Skip to main content

rtk/core/
utils.rs

1//! Utility functions for text processing and command execution.
2//!
3//! Provides common helpers used across rtk commands:
4//! - ANSI color code stripping
5//! - Text truncation
6//! - Command execution with error context
7
8use anyhow::{Context, Result};
9use regex::Regex;
10use std::path::PathBuf;
11use std::process::Command;
12
13/// Truncates a string to `max_len` characters, appending `...` if needed.
14///
15/// # Arguments
16/// * `s` - The string to truncate
17/// * `max_len` - Maximum length before truncation (minimum 3 to include "...")
18///
19/// # Examples
20/// ```
21/// use rtk::utils::truncate;
22/// assert_eq!(truncate("hello world", 8), "hello...");
23/// assert_eq!(truncate("hi", 10), "hi");
24/// ```
25pub fn truncate(s: &str, max_len: usize) -> String {
26    let char_count = s.chars().count();
27    if char_count <= max_len {
28        s.to_string()
29    } else if max_len < 3 {
30        // If max_len is too small, just return "..."
31        "...".to_string()
32    } else {
33        format!("{}...", s.chars().take(max_len - 3).collect::<String>())
34    }
35}
36
37/// Strip ANSI escape codes (colors, styles) from a string.
38///
39/// # Arguments
40/// * `text` - Text potentially containing ANSI escape codes
41///
42/// # Examples
43/// ```
44/// use rtk::utils::strip_ansi;
45/// let colored = "\x1b[31mError\x1b[0m";
46/// assert_eq!(strip_ansi(colored), "Error");
47/// ```
48pub fn strip_ansi(text: &str) -> String {
49    lazy_static::lazy_static! {
50        static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
51    }
52    ANSI_RE.replace_all(text, "").to_string()
53}
54
55/// Executes a command and returns cleaned stdout/stderr.
56///
57/// # Arguments
58/// * `cmd` - Command to execute (e.g., "eslint")
59/// * `args` - Command arguments
60///
61/// # Returns
62/// `(stdout: String, stderr: String, exit_code: i32)`
63/// Formats a token count with K/M suffixes for readability.
64///
65/// # Arguments
66/// * `n` - Number of tokens
67///
68/// # Returns
69/// Formatted string (e.g., "1.2M", "59.2K", "694")
70///
71/// # Examples
72/// ```
73/// use rtk::utils::format_tokens;
74/// assert_eq!(format_tokens(1_234_567), "1.2M");
75/// assert_eq!(format_tokens(59_234), "59.2K");
76/// assert_eq!(format_tokens(694), "694");
77/// ```
78pub fn format_tokens(n: usize) -> String {
79    if n >= 1_000_000 {
80        format!("{:.1}M", n as f64 / 1_000_000.0)
81    } else if n >= 1_000 {
82        format!("{:.1}K", n as f64 / 1_000.0)
83    } else {
84        format!("{}", n)
85    }
86}
87
88/// Formats a USD amount with adaptive precision.
89///
90/// # Arguments
91/// * `amount` - Amount in dollars
92///
93/// # Returns
94/// Formatted string with $ prefix
95///
96/// # Examples
97/// ```
98/// use rtk::utils::format_usd;
99/// assert_eq!(format_usd(1234.567), "$1234.57");
100/// assert_eq!(format_usd(12.345), "$12.35");
101/// assert_eq!(format_usd(0.123), "$0.12");
102/// assert_eq!(format_usd(0.0096), "$0.0096");
103/// ```
104pub fn format_usd(amount: f64) -> String {
105    if !amount.is_finite() {
106        return "$0.00".to_string();
107    }
108    if amount >= 0.01 {
109        format!("${:.2}", amount)
110    } else {
111        format!("${:.4}", amount)
112    }
113}
114
115/// Format cost-per-token as $/MTok (e.g., "$3.86/MTok")
116///
117/// # Arguments
118/// * `cpt` - Cost per token (not per million tokens)
119///
120/// # Returns
121/// Formatted string like "$3.86/MTok"
122///
123/// # Examples
124/// ```
125/// use rtk::utils::format_cpt;
126/// assert_eq!(format_cpt(0.000003), "$3.00/MTok");
127/// assert_eq!(format_cpt(0.0000038), "$3.80/MTok");
128/// assert_eq!(format_cpt(0.00000386), "$3.86/MTok");
129/// ```
130pub fn format_cpt(cpt: f64) -> String {
131    if !cpt.is_finite() || cpt <= 0.0 {
132        return "$0.00/MTok".to_string();
133    }
134    let cpt_per_million = cpt * 1_000_000.0;
135    format!("${:.2}/MTok", cpt_per_million)
136}
137
138/// Join items into a newline-separated string, appending an overflow hint when total > max.
139///
140/// # Examples
141/// ```
142/// use rtk::utils::join_with_overflow;
143/// let items = vec!["a".to_string(), "b".to_string()];
144/// assert_eq!(join_with_overflow(&items, 5, 3, "items"), "a\nb\n… +2 more items");
145/// assert_eq!(join_with_overflow(&items, 2, 3, "items"), "a\nb");
146/// ```
147pub fn join_with_overflow(items: &[String], total: usize, max: usize, label: &str) -> String {
148    let mut out = items.join("\n");
149    if total > max {
150        out.push_str(&format!("\n… +{} more {}", total - max, label));
151    }
152    out
153}
154
155/// Truncate an ISO 8601 datetime string to just the date portion (first 10 chars).
156///
157/// # Examples
158/// ```
159/// use rtk::utils::truncate_iso_date;
160/// assert_eq!(truncate_iso_date("2024-01-15T10:30:00Z"), "2024-01-15");
161/// assert_eq!(truncate_iso_date("2024-01-15"), "2024-01-15");
162/// assert_eq!(truncate_iso_date("short"), "short");
163/// ```
164pub fn truncate_iso_date(date: &str) -> &str {
165    if date.len() >= 10 {
166        &date[..10]
167    } else {
168        date
169    }
170}
171
172/// Format a confirmation message: "ok \<action\> \<detail\>"
173/// Used for write operations (merge, create, comment, edit, etc.)
174///
175/// # Examples
176/// ```
177/// use rtk::utils::ok_confirmation;
178/// assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42");
179/// assert_eq!(ok_confirmation("created", "PR #5 https://..."), "ok created PR #5 https://...");
180/// ```
181pub fn ok_confirmation(action: &str, detail: &str) -> String {
182    if detail.is_empty() {
183        format!("ok {}", action)
184    } else {
185        format!("ok {} {}", action, detail)
186    }
187}
188
189/// Extract exit code from a process output. Returns the actual exit code, or
190/// `128 + signal` per Unix convention when terminated by a signal (no exit code
191/// available). Falls back to 1 on non-Unix platforms.
192pub fn exit_code_from_output(output: &std::process::Output, label: &str) -> i32 {
193    match output.status.code() {
194        Some(code) => code,
195        None => {
196            #[cfg(unix)]
197            {
198                use std::os::unix::process::ExitStatusExt;
199                if let Some(sig) = output.status.signal() {
200                    eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
201                    return 128 + sig;
202                }
203            }
204            eprintln!("[rtk] {}: process terminated by signal", label);
205            1
206        }
207    }
208}
209
210/// Extract exit code from an ExitStatus (for `.status()` calls, not `.output()`).
211/// Returns the actual exit code, or `128 + signal` per Unix convention when
212/// terminated by a signal. Falls back to 1 on non-Unix platforms.
213pub fn exit_code_from_status(status: &std::process::ExitStatus, label: &str) -> i32 {
214    match status.code() {
215        Some(code) => code,
216        None => {
217            #[cfg(unix)]
218            {
219                use std::os::unix::process::ExitStatusExt;
220                if let Some(sig) = status.signal() {
221                    eprintln!("[rtk] {}: process terminated by signal {}", label, sig);
222                    return 128 + sig;
223                }
224            }
225            eprintln!("[rtk] {}: process terminated by signal", label);
226            1
227        }
228    }
229}
230
231/// Return the last `n` lines of output with a label, for use as a fallback
232/// when filter parsing fails. Logs a diagnostic to stderr.
233pub fn fallback_tail(output: &str, label: &str, n: usize) -> String {
234    eprintln!(
235        "[rtk] {}: output format not recognized, showing last {} lines",
236        label, n
237    );
238    let lines: Vec<&str> = output.lines().collect();
239    let start = lines.len().saturating_sub(n);
240    lines[start..].join("\n")
241}
242
243/// Build a Command for Ruby tools, auto-detecting bundle exec.
244/// Uses `bundle exec <tool>` when a Gemfile exists (transitive deps like rake
245/// won't appear in the Gemfile but still need bundler for version isolation).
246pub fn ruby_exec(tool: &str) -> Command {
247    if std::path::Path::new("Gemfile").exists() {
248        let mut c = Command::new("bundle");
249        c.arg("exec").arg(tool);
250        return c;
251    }
252    Command::new(tool)
253}
254
255/// Count whitespace-delimited tokens in text. Used by filter tests to verify
256/// token savings claims.
257#[cfg(test)]
258pub fn count_tokens(text: &str) -> usize {
259    text.split_whitespace().count()
260}
261
262/// Detect the package manager used in the current directory.
263/// Returns "pnpm", "yarn", or "npm" based on lockfile presence.
264///
265/// # Examples
266/// ```no_run
267/// use rtk::utils::detect_package_manager;
268/// let pm = detect_package_manager();
269/// // Returns "pnpm" if pnpm-lock.yaml exists, "yarn" if yarn.lock, else "npm"
270/// ```
271#[allow(dead_code)]
272pub fn detect_package_manager() -> &'static str {
273    if std::path::Path::new("pnpm-lock.yaml").exists() {
274        "pnpm"
275    } else if std::path::Path::new("yarn.lock").exists() {
276        "yarn"
277    } else {
278        "npm"
279    }
280}
281
282/// Build a Command using the detected package manager's exec mechanism.
283/// Returns a Command ready to have tool-specific args appended.
284pub fn package_manager_exec(tool: &str) -> Command {
285    if tool_exists(tool) {
286        resolved_command(tool)
287    } else {
288        let pm = detect_package_manager();
289        match pm {
290            "pnpm" => {
291                let mut c = resolved_command("pnpm");
292                c.arg("exec").arg("--").arg(tool);
293                c
294            }
295            "yarn" => {
296                let mut c = resolved_command("yarn");
297                c.arg("exec").arg("--").arg(tool);
298                c
299            }
300            _ => {
301                let mut c = resolved_command("npx");
302                c.arg("--no-install").arg("--").arg(tool);
303                c
304            }
305        }
306    }
307}
308
309/// Resolve a binary name to its full path, honoring PATHEXT on Windows.
310///
311/// On Windows, Node.js tools are installed as `.CMD`/`.BAT`/`.PS1` shims.
312/// Rust's `std::process::Command::new()` does NOT honor PATHEXT, so
313/// `Command::new("vitest")` fails even when `vitest.CMD` is on PATH.
314///
315/// This function uses the `which` crate to perform proper PATH+PATHEXT resolution.
316///
317/// # Arguments
318/// * `name` - Binary name (e.g., "vitest", "eslint", "tsc")
319///
320/// # Returns
321/// Full path to the resolved binary, or error if not found.
322pub fn resolve_binary(name: &str) -> Result<PathBuf> {
323    which::which(name).context(format!("Binary '{}' not found on PATH", name))
324}
325
326/// Create a `Command` with PATHEXT-aware binary resolution.
327///
328/// Drop-in replacement for `Command::new(name)` that works on Windows
329/// with `.CMD`/`.BAT`/`.PS1` wrappers.
330///
331/// Falls back to `Command::new(name)` if resolution fails, so native
332/// commands (git, cargo) still work even if `which` can't find them.
333///
334/// # Arguments
335/// * `name` - Binary name (e.g., "vitest", "eslint")
336///
337/// # Returns
338/// A `Command` configured with the resolved binary path.
339pub fn resolved_command(name: &str) -> Command {
340    match resolve_binary(name) {
341        Ok(path) => Command::new(path),
342        Err(e) => {
343            // On Windows, resolution failure likely means a .CMD/.BAT wrapper
344            // wasn't found — always warn so users have a signal.
345            // On Unix, this is less common; only log in debug builds.
346            if cfg!(any(target_os = "windows", debug_assertions)) {
347                eprintln!(
348                    "rtk: Failed to resolve '{}' via PATH, falling back to direct exec: {}",
349                    name, e
350                );
351            }
352
353            Command::new(name)
354        }
355    }
356}
357
358/// Check if a tool exists on PATH (PATHEXT-aware on Windows).
359///
360/// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows.
361pub fn tool_exists(name: &str) -> bool {
362    which::which(name).is_ok()
363}
364
365/// Extract short name from AWS ARN.
366/// Example: `arn:aws:ecs:region:acct:service/cluster/name` -> `name`
367/// For simple ARNs like `arn:aws:iam::123:user/alice`, returns `alice`.
368pub fn shorten_arn(arn: &str) -> &str {
369    // ARNs use "/" or ":" as separators. Try "/" first (service/cluster/name pattern),
370    // then fall back to ":" for Lambda/IAM ARNs.
371    let slash_result = arn.rsplit('/').next().unwrap_or(arn);
372    // If rsplit('/') returned the whole string (no '/' found), try ':'
373    if slash_result == arn {
374        arn.rsplit(':').next().unwrap_or(arn)
375    } else {
376        slash_result
377    }
378}
379
380/// Convert bytes to human-readable format (KB, MB, GB, TB).
381/// Used for S3 object sizes.
382pub fn human_bytes(bytes: u64) -> String {
383    const KB: u64 = 1024;
384    const MB: u64 = KB * 1024;
385    const GB: u64 = MB * 1024;
386    const TB: u64 = GB * 1024;
387
388    if bytes >= TB {
389        format!("{:.1} TB", bytes as f64 / TB as f64)
390    } else if bytes >= GB {
391        format!("{:.1} GB", bytes as f64 / GB as f64)
392    } else if bytes >= MB {
393        format!("{:.1} MB", bytes as f64 / MB as f64)
394    } else if bytes >= KB {
395        format!("{:.1} KB", bytes as f64 / KB as f64)
396    } else {
397        format!("{} B", bytes)
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_truncate_short_string() {
407        assert_eq!(truncate("hello", 10), "hello");
408    }
409
410    #[test]
411    fn test_truncate_long_string() {
412        let result = truncate("hello world", 8);
413        assert_eq!(result, "hello...");
414    }
415
416    #[test]
417    fn test_truncate_exact_length() {
418        assert_eq!(truncate("hello", 5), "hello");
419    }
420
421    #[test]
422    fn test_truncate_edge_case() {
423        // max_len < 3 returns just "..."
424        assert_eq!(truncate("hello", 2), "...");
425        // When string length equals max_len, return as is
426        assert_eq!(truncate("abc", 3), "abc");
427        // When string is longer and max_len is exactly 3, return "..."
428        assert_eq!(truncate("hello world", 3), "...");
429    }
430
431    #[test]
432    fn test_strip_ansi_simple() {
433        let input = "\x1b[31mError\x1b[0m";
434        assert_eq!(strip_ansi(input), "Error");
435    }
436
437    #[test]
438    fn test_strip_ansi_multiple() {
439        let input = "\x1b[1m\x1b[32mSuccess\x1b[0m\x1b[0m";
440        assert_eq!(strip_ansi(input), "Success");
441    }
442
443    #[test]
444    fn test_strip_ansi_no_codes() {
445        assert_eq!(strip_ansi("plain text"), "plain text");
446    }
447
448    #[test]
449    fn test_strip_ansi_complex() {
450        let input = "\x1b[32mGreen\x1b[0m normal \x1b[31mRed\x1b[0m";
451        assert_eq!(strip_ansi(input), "Green normal Red");
452    }
453
454    #[test]
455    fn test_format_tokens_millions() {
456        assert_eq!(format_tokens(1_234_567), "1.2M");
457        assert_eq!(format_tokens(12_345_678), "12.3M");
458    }
459
460    #[test]
461    fn test_format_tokens_thousands() {
462        assert_eq!(format_tokens(59_234), "59.2K");
463        assert_eq!(format_tokens(1_000), "1.0K");
464    }
465
466    #[test]
467    fn test_format_tokens_small() {
468        assert_eq!(format_tokens(694), "694");
469        assert_eq!(format_tokens(0), "0");
470    }
471
472    #[test]
473    fn test_format_usd_large() {
474        assert_eq!(format_usd(1234.567), "$1234.57");
475        assert_eq!(format_usd(1000.0), "$1000.00");
476    }
477
478    #[test]
479    fn test_format_usd_medium() {
480        assert_eq!(format_usd(12.345), "$12.35");
481        assert_eq!(format_usd(0.99), "$0.99");
482    }
483
484    #[test]
485    fn test_format_usd_small() {
486        assert_eq!(format_usd(0.0096), "$0.0096");
487        assert_eq!(format_usd(0.0001), "$0.0001");
488    }
489
490    #[test]
491    fn test_format_usd_edge() {
492        assert_eq!(format_usd(0.01), "$0.01");
493        assert_eq!(format_usd(0.009), "$0.0090");
494    }
495
496    #[test]
497    fn test_ok_confirmation_with_detail() {
498        assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42");
499        assert_eq!(
500            ok_confirmation("created", "PR #5 https://github.com/foo/bar/pull/5"),
501            "ok created PR #5 https://github.com/foo/bar/pull/5"
502        );
503    }
504
505    #[test]
506    fn test_ok_confirmation_no_detail() {
507        assert_eq!(ok_confirmation("commented", ""), "ok commented");
508    }
509
510    #[test]
511    fn test_format_cpt_normal() {
512        assert_eq!(format_cpt(0.000003), "$3.00/MTok");
513        assert_eq!(format_cpt(0.0000038), "$3.80/MTok");
514        assert_eq!(format_cpt(0.00000386), "$3.86/MTok");
515    }
516
517    #[test]
518    fn test_format_cpt_edge_cases() {
519        assert_eq!(format_cpt(0.0), "$0.00/MTok"); // zero
520        assert_eq!(format_cpt(-0.000001), "$0.00/MTok"); // negative
521        assert_eq!(format_cpt(f64::INFINITY), "$0.00/MTok"); // infinite
522        assert_eq!(format_cpt(f64::NAN), "$0.00/MTok"); // NaN
523    }
524
525    #[test]
526    fn test_detect_package_manager_default() {
527        // In the test environment (rtk repo), there's no JS lockfile
528        // so it should default to "npm"
529        let pm = detect_package_manager();
530        assert!(["pnpm", "yarn", "npm"].contains(&pm));
531    }
532
533    #[test]
534    fn test_truncate_multibyte_thai() {
535        // Thai characters are 3 bytes each
536        let thai = "สวัสดีครับ";
537        let result = truncate(thai, 5);
538        // Should not panic, should produce valid UTF-8
539        assert!(result.len() <= thai.len());
540        assert!(result.ends_with("..."));
541    }
542
543    #[test]
544    fn test_truncate_multibyte_emoji() {
545        let emoji = "🎉🎊🎈🎁🎂🎄🎃🎆🎇✨";
546        let result = truncate(emoji, 5);
547        assert!(result.ends_with("..."));
548    }
549
550    #[test]
551    fn test_truncate_multibyte_cjk() {
552        let cjk = "你好世界测试字符串";
553        let result = truncate(cjk, 6);
554        assert!(result.ends_with("..."));
555    }
556
557    // ===== resolve_binary tests (issue #212) =====
558
559    #[test]
560    fn test_resolve_binary_finds_known_command() {
561        // "cargo" must be on PATH in any Rust dev environment
562        let result = resolve_binary("cargo");
563        assert!(
564            result.is_ok(),
565            "resolve_binary('cargo') should succeed, got: {:?}",
566            result.err()
567        );
568    }
569
570    #[test]
571    fn test_resolve_binary_returns_absolute_path() {
572        let path = resolve_binary("cargo").expect("cargo should be resolvable");
573        assert!(
574            path.is_absolute(),
575            "resolve_binary should return absolute path, got: {:?}",
576            path
577        );
578    }
579
580    #[test]
581    fn test_resolve_binary_fails_for_unknown() {
582        let result = resolve_binary("nonexistent_binary_xyz_99999");
583        assert!(
584            result.is_err(),
585            "resolve_binary should fail for nonexistent binary"
586        );
587    }
588
589    #[test]
590    fn test_resolve_binary_path_contains_binary_name() {
591        let path = resolve_binary("cargo").expect("cargo should be resolvable");
592        let filename = path
593            .file_name()
594            .expect("should have filename")
595            .to_string_lossy();
596        // On Windows this could be "cargo.exe", on Unix just "cargo"
597        assert!(
598            filename.starts_with("cargo"),
599            "resolved path filename should start with 'cargo', got: {}",
600            filename
601        );
602    }
603
604    // ===== resolved_command tests (issue #212) =====
605
606    #[test]
607    fn test_resolved_command_executes_known_command() {
608        let output = resolved_command("cargo")
609            .arg("--version")
610            .output()
611            .expect("resolved_command('cargo') should execute");
612        assert!(
613            output.status.success(),
614            "cargo --version should succeed via resolved_command"
615        );
616    }
617
618    // ===== tool_exists tests (issue #212) =====
619
620    #[test]
621    fn test_tool_exists_finds_cargo() {
622        assert!(
623            tool_exists("cargo"),
624            "tool_exists('cargo') should return true"
625        );
626    }
627
628    #[test]
629    fn test_tool_exists_rejects_unknown() {
630        assert!(
631            !tool_exists("nonexistent_binary_xyz_99999"),
632            "tool_exists should return false for nonexistent binary"
633        );
634    }
635
636    #[test]
637    fn test_tool_exists_finds_git() {
638        assert!(tool_exists("git"), "tool_exists('git') should return true");
639    }
640
641    // ===== Windows-specific PATHEXT resolution tests (issue #212) =====
642
643    #[cfg(target_os = "windows")]
644    mod windows_tests {
645        use super::super::*;
646        use std::fs;
647
648        /// Create a temporary .cmd wrapper to simulate Node.js tool installation
649        fn create_temp_cmd_wrapper(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
650            let cmd_path = dir.join(format!("{}.cmd", name));
651            fs::write(&cmd_path, "@echo off\r\necho fake-tool-output\r\n")
652                .expect("failed to create .cmd wrapper");
653            cmd_path
654        }
655
656        /// Build a PATH string that includes the temp dir
657        fn path_with_dir(dir: &std::path::Path) -> std::ffi::OsString {
658            let original = std::env::var_os("PATH").unwrap_or_default();
659            let mut new_path = std::ffi::OsString::from(dir.as_os_str());
660            new_path.push(";");
661            new_path.push(&original);
662            new_path
663        }
664
665        #[test]
666        fn test_resolve_binary_finds_cmd_wrapper() {
667            let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
668            create_temp_cmd_wrapper(temp_dir.path(), "fake-tool-test");
669
670            // Use which::which_in to avoid mutating global PATH (thread-safe)
671            let search_path = path_with_dir(temp_dir.path());
672            let result = which::which_in(
673                "fake-tool-test",
674                Some(search_path),
675                std::env::current_dir().unwrap(),
676            );
677
678            assert!(
679                result.is_ok(),
680                "which_in should find .cmd wrapper on Windows, got: {:?}",
681                result.err()
682            );
683
684            let path = result.unwrap();
685            let ext = path
686                .extension()
687                .unwrap_or_default()
688                .to_string_lossy()
689                .to_lowercase();
690            assert!(
691                ext == "cmd" || ext == "bat",
692                "resolved path should have .cmd/.bat extension, got: {:?}",
693                path
694            );
695        }
696
697        #[test]
698        fn test_resolve_binary_finds_bat_wrapper() {
699            let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
700            let bat_path = temp_dir.path().join("fake-bat-tool.bat");
701            fs::write(&bat_path, "@echo off\r\necho bat-output\r\n")
702                .expect("failed to create .bat wrapper");
703
704            let search_path = path_with_dir(temp_dir.path());
705            let result = which::which_in(
706                "fake-bat-tool",
707                Some(search_path),
708                std::env::current_dir().unwrap(),
709            );
710
711            assert!(
712                result.is_ok(),
713                "which_in should find .bat wrapper on Windows, got: {:?}",
714                result.err()
715            );
716        }
717
718        #[test]
719        fn test_resolved_command_executes_cmd_wrapper() {
720            let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
721            create_temp_cmd_wrapper(temp_dir.path(), "fake-exec-test");
722
723            // Resolve the full path, then execute it directly (no PATH mutation)
724            let search_path = path_with_dir(temp_dir.path());
725            let resolved = which::which_in(
726                "fake-exec-test",
727                Some(search_path),
728                std::env::current_dir().unwrap(),
729            )
730            .expect("should resolve fake-exec-test");
731
732            let output = Command::new(&resolved).output();
733
734            assert!(
735                output.is_ok(),
736                "Command with resolved path should execute .cmd wrapper on Windows"
737            );
738            let output = output.unwrap();
739            let stdout = String::from_utf8_lossy(&output.stdout);
740            assert!(
741                stdout.contains("fake-tool-output"),
742                "should get output from .cmd wrapper, got: {}",
743                stdout
744            );
745        }
746
747        #[test]
748        fn test_resolved_command_fallback_on_unknown_binary() {
749            // When resolve_binary fails, resolved_command should fall back to
750            // Command::new(name) instead of panicking.  On Windows this also
751            // prints a warning to stderr.
752            let mut cmd = resolved_command("nonexistent_binary_xyz_99999");
753            // The Command should be created (not panic).  Attempting to run it
754            // will fail, but that's expected — we just verify the fallback path
755            // produces a usable Command.
756            let result = cmd.output();
757            assert!(
758                result.is_err() || !result.unwrap().status.success(),
759                "nonexistent binary should fail to execute, but resolved_command must not panic"
760            );
761        }
762
763        #[test]
764        fn test_tool_exists_finds_cmd_wrapper() {
765            let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
766            create_temp_cmd_wrapper(temp_dir.path(), "fake-exists-test");
767
768            let search_path = path_with_dir(temp_dir.path());
769            let result = which::which_in(
770                "fake-exists-test",
771                Some(search_path),
772                std::env::current_dir().unwrap(),
773            );
774
775            assert!(
776                result.is_ok(),
777                "which_in should find .cmd wrapper on Windows"
778            );
779        }
780    }
781
782    // ===== AWS helper function tests =====
783
784    #[test]
785    fn test_shorten_arn_ecs_service() {
786        assert_eq!(
787            shorten_arn("arn:aws:ecs:us-east-1:123:service/cluster/api-service"),
788            "api-service"
789        );
790    }
791
792    #[test]
793    fn test_shorten_arn_iam_user() {
794        assert_eq!(shorten_arn("arn:aws:iam::123456789012:user/alice"), "alice");
795    }
796
797    #[test]
798    fn test_shorten_arn_lambda() {
799        assert_eq!(
800            shorten_arn("arn:aws:lambda:us-west-2:123:function:my-function"),
801            "my-function"
802        );
803    }
804
805    #[test]
806    fn test_shorten_arn_fallback() {
807        // Non-ARN string - return as-is
808        assert_eq!(shorten_arn("simple-name"), "simple-name");
809    }
810
811    #[test]
812    fn test_human_bytes_bytes() {
813        assert_eq!(human_bytes(0), "0 B");
814        assert_eq!(human_bytes(512), "512 B");
815        assert_eq!(human_bytes(1023), "1023 B");
816    }
817
818    #[test]
819    fn test_human_bytes_kb() {
820        assert_eq!(human_bytes(1024), "1.0 KB");
821        assert_eq!(human_bytes(2048), "2.0 KB");
822        assert_eq!(human_bytes(1536), "1.5 KB");
823    }
824
825    #[test]
826    fn test_human_bytes_mb() {
827        assert_eq!(human_bytes(1_048_576), "1.0 MB");
828        assert_eq!(human_bytes(5_242_880), "5.0 MB");
829    }
830
831    #[test]
832    fn test_human_bytes_gb() {
833        assert_eq!(human_bytes(1_073_741_824), "1.0 GB");
834        assert_eq!(human_bytes(2_147_483_648), "2.0 GB");
835    }
836
837    #[test]
838    fn test_human_bytes_tb() {
839        assert_eq!(human_bytes(1_099_511_627_776), "1.0 TB");
840    }
841
842    #[test]
843    fn test_count_tokens_basic() {
844        assert_eq!(count_tokens("hello world"), 2);
845        assert_eq!(count_tokens("one two three four"), 4);
846    }
847
848    #[test]
849    fn test_count_tokens_empty() {
850        assert_eq!(count_tokens(""), 0);
851        assert_eq!(count_tokens("   "), 0);
852    }
853
854    #[test]
855    fn test_count_tokens_multiple_spaces() {
856        assert_eq!(count_tokens("hello    world"), 2);
857        assert_eq!(count_tokens("  hello   world  "), 2);
858    }
859}