use anyhow::{Context, Result};
use regex::Regex;
use std::path::PathBuf;
use std::process::Command;
pub fn truncate(s: &str, max_len: usize) -> String {
let char_count = s.chars().count();
if char_count <= max_len {
s.to_string()
} else if max_len < 3 {
"...".to_string()
} else {
format!("{}...", s.chars().take(max_len - 3).collect::<String>())
}
}
pub fn strip_ansi(text: &str) -> String {
lazy_static::lazy_static! {
static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
}
ANSI_RE.replace_all(text, "").to_string()
}
pub fn format_tokens(n: usize) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
format!("{}", n)
}
}
pub fn format_usd(amount: f64) -> String {
if !amount.is_finite() {
return "$0.00".to_string();
}
if amount >= 0.01 {
format!("${:.2}", amount)
} else {
format!("${:.4}", amount)
}
}
pub fn format_cpt(cpt: f64) -> String {
if !cpt.is_finite() || cpt <= 0.0 {
return "$0.00/MTok".to_string();
}
let cpt_per_million = cpt * 1_000_000.0;
format!("${:.2}/MTok", cpt_per_million)
}
pub fn join_with_overflow(items: &[String], total: usize, max: usize, label: &str) -> String {
let mut out = items.join("\n");
if total > max {
out.push_str(&format!("\n… +{} more {}", total - max, label));
}
out
}
pub fn truncate_iso_date(date: &str) -> &str {
if date.len() >= 10 {
&date[..10]
} else {
date
}
}
pub fn ok_confirmation(action: &str, detail: &str) -> String {
if detail.is_empty() {
format!("ok {}", action)
} else {
format!("ok {} {}", action, detail)
}
}
pub fn exit_code_from_output(output: &std::process::Output, label: &str) -> i32 {
match output.status.code() {
Some(code) => code,
None => {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = output.status.signal() {
eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
return 128 + sig;
}
}
eprintln!("[rtk] {}: process terminated by signal", label);
1
}
}
}
pub fn exit_code_from_status(status: &std::process::ExitStatus, label: &str) -> i32 {
match status.code() {
Some(code) => code,
None => {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
if let Some(sig) = status.signal() {
eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
return 128 + sig;
}
}
eprintln!("[rtk] {}: process terminated by signal", label);
1
}
}
}
pub fn fallback_tail(output: &str, label: &str, n: usize) -> String {
eprintln!(
"[rtk] {}: output format not recognized, showing last {} lines",
label, n
);
let lines: Vec<&str> = output.lines().collect();
let start = lines.len().saturating_sub(n);
lines[start..].join("\n")
}
pub fn ruby_exec(tool: &str) -> Command {
if std::path::Path::new("Gemfile").exists() {
let mut c = Command::new("bundle");
c.arg("exec").arg(tool);
return c;
}
Command::new(tool)
}
#[cfg(test)]
pub fn count_tokens(text: &str) -> usize {
text.split_whitespace().count()
}
#[allow(dead_code)]
pub fn detect_package_manager() -> &'static str {
if std::path::Path::new("pnpm-lock.yaml").exists() {
"pnpm"
} else if std::path::Path::new("yarn.lock").exists() {
"yarn"
} else {
"npm"
}
}
pub fn package_manager_exec(tool: &str) -> Command {
if tool_exists(tool) {
resolved_command(tool)
} else {
let pm = detect_package_manager();
match pm {
"pnpm" => {
let mut c = resolved_command("pnpm");
c.arg("exec").arg("--").arg(tool);
c
}
"yarn" => {
let mut c = resolved_command("yarn");
c.arg("exec").arg("--").arg(tool);
c
}
_ => {
let mut c = resolved_command("npx");
c.arg("--no-install").arg("--").arg(tool);
c
}
}
}
}
pub fn resolve_binary(name: &str) -> Result<PathBuf> {
which::which(name).context(format!("Binary '{}' not found on PATH", name))
}
pub fn resolved_command(name: &str) -> Command {
match resolve_binary(name) {
Ok(path) => Command::new(path),
Err(e) => {
if cfg!(any(target_os = "windows", debug_assertions)) {
eprintln!(
"rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}",
name, e
);
}
Command::new(name)
}
}
}
pub fn tool_exists(name: &str) -> bool {
which::which(name).is_ok()
}
pub fn shorten_arn(arn: &str) -> &str {
let slash_result = arn.rsplit('/').next().unwrap_or(arn);
if slash_result == arn {
arn.rsplit(':').next().unwrap_or(arn)
} else {
slash_result
}
}
pub fn human_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
const TB: u64 = GB * 1024;
if bytes >= TB {
format!("{:.1} TB", bytes as f64 / TB as f64)
} else if bytes >= GB {
format!("{:.1} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_long_string() {
let result = truncate("hello world", 8);
assert_eq!(result, "hello...");
}
#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_edge_case() {
assert_eq!(truncate("hello", 2), "...");
assert_eq!(truncate("abc", 3), "abc");
assert_eq!(truncate("hello world", 3), "...");
}
#[test]
fn test_strip_ansi_simple() {
let input = "\x1b[31mError\x1b[0m";
assert_eq!(strip_ansi(input), "Error");
}
#[test]
fn test_strip_ansi_multiple() {
let input = "\x1b[1m\x1b[32mSuccess\x1b[0m\x1b[0m";
assert_eq!(strip_ansi(input), "Success");
}
#[test]
fn test_strip_ansi_no_codes() {
assert_eq!(strip_ansi("plain text"), "plain text");
}
#[test]
fn test_strip_ansi_complex() {
let input = "\x1b[32mGreen\x1b[0m normal \x1b[31mRed\x1b[0m";
assert_eq!(strip_ansi(input), "Green normal Red");
}
#[test]
fn test_format_tokens_millions() {
assert_eq!(format_tokens(1_234_567), "1.2M");
assert_eq!(format_tokens(12_345_678), "12.3M");
}
#[test]
fn test_format_tokens_thousands() {
assert_eq!(format_tokens(59_234), "59.2K");
assert_eq!(format_tokens(1_000), "1.0K");
}
#[test]
fn test_format_tokens_small() {
assert_eq!(format_tokens(694), "694");
assert_eq!(format_tokens(0), "0");
}
#[test]
fn test_format_usd_large() {
assert_eq!(format_usd(1234.567), "$1234.57");
assert_eq!(format_usd(1000.0), "$1000.00");
}
#[test]
fn test_format_usd_medium() {
assert_eq!(format_usd(12.345), "$12.35");
assert_eq!(format_usd(0.99), "$0.99");
}
#[test]
fn test_format_usd_small() {
assert_eq!(format_usd(0.0096), "$0.0096");
assert_eq!(format_usd(0.0001), "$0.0001");
}
#[test]
fn test_format_usd_edge() {
assert_eq!(format_usd(0.01), "$0.01");
assert_eq!(format_usd(0.009), "$0.0090");
}
#[test]
fn test_ok_confirmation_with_detail() {
assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42");
assert_eq!(
ok_confirmation("created", "PR #5 https://github.com/foo/bar/pull/5"),
"ok created PR #5 https://github.com/foo/bar/pull/5"
);
}
#[test]
fn test_ok_confirmation_no_detail() {
assert_eq!(ok_confirmation("commented", ""), "ok commented");
}
#[test]
fn test_format_cpt_normal() {
assert_eq!(format_cpt(0.000003), "$3.00/MTok");
assert_eq!(format_cpt(0.0000038), "$3.80/MTok");
assert_eq!(format_cpt(0.00000386), "$3.86/MTok");
}
#[test]
fn test_format_cpt_edge_cases() {
assert_eq!(format_cpt(0.0), "$0.00/MTok");
assert_eq!(format_cpt(-0.000001), "$0.00/MTok");
assert_eq!(format_cpt(f64::INFINITY), "$0.00/MTok");
assert_eq!(format_cpt(f64::NAN), "$0.00/MTok");
}
#[test]
fn test_detect_package_manager_default() {
let pm = detect_package_manager();
assert!(["pnpm", "yarn", "npm"].contains(&pm));
}
#[test]
fn test_truncate_multibyte_thai() {
let thai = "สวัสดีครับ";
let result = truncate(thai, 5);
assert!(result.len() <= thai.len());
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_multibyte_emoji() {
let emoji = "🎉🎊🎈🎁🎂🎄🎃🎆🎇✨";
let result = truncate(emoji, 5);
assert!(result.ends_with("..."));
}
#[test]
fn test_truncate_multibyte_cjk() {
let cjk = "你好世界测试字符串";
let result = truncate(cjk, 6);
assert!(result.ends_with("..."));
}
#[test]
fn test_resolve_binary_finds_known_command() {
let result = resolve_binary("cargo");
assert!(
result.is_ok(),
"resolve_binary('cargo') should succeed, got: {:?}",
result.err()
);
}
#[test]
fn test_resolve_binary_returns_absolute_path() {
let path = resolve_binary("cargo").expect("cargo should be resolvable");
assert!(
path.is_absolute(),
"resolve_binary should return absolute path, got: {:?}",
path
);
}
#[test]
fn test_resolve_binary_fails_for_unknown() {
let result = resolve_binary("nonexistent_binary_xyz_99999");
assert!(
result.is_err(),
"resolve_binary should fail for nonexistent binary"
);
}
#[test]
fn test_resolve_binary_path_contains_binary_name() {
let path = resolve_binary("cargo").expect("cargo should be resolvable");
let filename = path
.file_name()
.expect("should have filename")
.to_string_lossy();
assert!(
filename.starts_with("cargo"),
"resolved path filename should start with 'cargo', got: {}",
filename
);
}
#[test]
fn test_resolved_command_executes_known_command() {
let output = resolved_command("cargo")
.arg("--version")
.output()
.expect("resolved_command('cargo') should execute");
assert!(
output.status.success(),
"cargo --version should succeed via resolved_command"
);
}
#[test]
fn test_tool_exists_finds_cargo() {
assert!(
tool_exists("cargo"),
"tool_exists('cargo') should return true"
);
}
#[test]
fn test_tool_exists_rejects_unknown() {
assert!(
!tool_exists("nonexistent_binary_xyz_99999"),
"tool_exists should return false for nonexistent binary"
);
}
#[test]
fn test_tool_exists_finds_git() {
assert!(tool_exists("git"), "tool_exists('git') should return true");
}
#[cfg(target_os = "windows")]
mod windows_tests {
use super::super::*;
use std::fs;
fn create_temp_cmd_wrapper(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
let cmd_path = dir.join(format!("{}.cmd", name));
fs::write(&cmd_path, "@echo off\r\necho fake-tool-output\r\n")
.expect("failed to create .cmd wrapper");
cmd_path
}
fn path_with_dir(dir: &std::path::Path) -> std::ffi::OsString {
let original = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(dir.as_os_str());
new_path.push(";");
new_path.push(&original);
new_path
}
#[test]
fn test_resolve_binary_finds_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-tool-test");
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-tool-test",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .cmd wrapper on Windows, got: {:?}",
result.err()
);
let path = result.unwrap();
let ext = path
.extension()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
assert!(
ext == "cmd" || ext == "bat",
"resolved path should have .cmd/.bat extension, got: {:?}",
path
);
}
#[test]
fn test_resolve_binary_finds_bat_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let bat_path = temp_dir.path().join("fake-bat-tool.bat");
fs::write(&bat_path, "@echo off\r\necho bat-output\r\n")
.expect("failed to create .bat wrapper");
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-bat-tool",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .bat wrapper on Windows, got: {:?}",
result.err()
);
}
#[test]
fn test_resolved_command_executes_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-exec-test");
let search_path = path_with_dir(temp_dir.path());
let resolved = which::which_in(
"fake-exec-test",
Some(search_path),
std::env::current_dir().unwrap(),
)
.expect("should resolve fake-exec-test");
let output = Command::new(&resolved).output();
assert!(
output.is_ok(),
"Command with resolved path should execute .cmd wrapper on Windows"
);
let output = output.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("fake-tool-output"),
"should get output from .cmd wrapper, got: {}",
stdout
);
}
#[test]
fn test_resolved_command_fallback_on_unknown_binary() {
let mut cmd = resolved_command("nonexistent_binary_xyz_99999");
let result = cmd.output();
assert!(
result.is_err() || !result.unwrap().status.success(),
"nonexistent binary should fail to execute, but resolved_command must not panic"
);
}
#[test]
fn test_tool_exists_finds_cmd_wrapper() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
create_temp_cmd_wrapper(temp_dir.path(), "fake-exists-test");
let search_path = path_with_dir(temp_dir.path());
let result = which::which_in(
"fake-exists-test",
Some(search_path),
std::env::current_dir().unwrap(),
);
assert!(
result.is_ok(),
"which_in should find .cmd wrapper on Windows"
);
}
}
#[test]
fn test_shorten_arn_ecs_service() {
assert_eq!(
shorten_arn("arn:aws:ecs:us-east-1:123:service/cluster/api-service"),
"api-service"
);
}
#[test]
fn test_shorten_arn_iam_user() {
assert_eq!(shorten_arn("arn:aws:iam::123456789012:user/alice"), "alice");
}
#[test]
fn test_shorten_arn_lambda() {
assert_eq!(
shorten_arn("arn:aws:lambda:us-west-2:123:function:my-function"),
"my-function"
);
}
#[test]
fn test_shorten_arn_fallback() {
assert_eq!(shorten_arn("simple-name"), "simple-name");
}
#[test]
fn test_human_bytes_bytes() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(1023), "1023 B");
}
#[test]
fn test_human_bytes_kb() {
assert_eq!(human_bytes(1024), "1.0 KB");
assert_eq!(human_bytes(2048), "2.0 KB");
assert_eq!(human_bytes(1536), "1.5 KB");
}
#[test]
fn test_human_bytes_mb() {
assert_eq!(human_bytes(1_048_576), "1.0 MB");
assert_eq!(human_bytes(5_242_880), "5.0 MB");
}
#[test]
fn test_human_bytes_gb() {
assert_eq!(human_bytes(1_073_741_824), "1.0 GB");
assert_eq!(human_bytes(2_147_483_648), "2.0 GB");
}
#[test]
fn test_human_bytes_tb() {
assert_eq!(human_bytes(1_099_511_627_776), "1.0 TB");
}
#[test]
fn test_count_tokens_basic() {
assert_eq!(count_tokens("hello world"), 2);
assert_eq!(count_tokens("one two three four"), 4);
}
#[test]
fn test_count_tokens_empty() {
assert_eq!(count_tokens(""), 0);
assert_eq!(count_tokens(" "), 0);
}
#[test]
fn test_count_tokens_multiple_spaces() {
assert_eq!(count_tokens("hello world"), 2);
assert_eq!(count_tokens(" hello world "), 2);
}
}