1use anyhow::{Context, Result};
9use regex::Regex;
10use std::path::PathBuf;
11use std::process::Command;
12
13pub 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 "...".to_string()
32 } else {
33 format!("{}...", s.chars().take(max_len - 3).collect::<String>())
34 }
35}
36
37pub 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
55pub 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
88pub 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
115pub 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
138pub 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
155pub fn truncate_iso_date(date: &str) -> &str {
165 if date.len() >= 10 {
166 &date[..10]
167 } else {
168 date
169 }
170}
171
172pub 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
189pub 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
210pub 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
231pub 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
243pub 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#[cfg(test)]
258pub fn count_tokens(text: &str) -> usize {
259 text.split_whitespace().count()
260}
261
262#[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
282pub 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
309pub fn resolve_binary(name: &str) -> Result<PathBuf> {
323 which::which(name).context(format!("Binary '{}' not found on PATH", name))
324}
325
326pub fn resolved_command(name: &str) -> Command {
340 match resolve_binary(name) {
341 Ok(path) => Command::new(path),
342 Err(e) => {
343 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
358pub fn tool_exists(name: &str) -> bool {
362 which::which(name).is_ok()
363}
364
365pub fn shorten_arn(arn: &str) -> &str {
369 let slash_result = arn.rsplit('/').next().unwrap_or(arn);
372 if slash_result == arn {
374 arn.rsplit(':').next().unwrap_or(arn)
375 } else {
376 slash_result
377 }
378}
379
380pub 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 assert_eq!(truncate("hello", 2), "...");
425 assert_eq!(truncate("abc", 3), "abc");
427 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"); 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"); }
524
525 #[test]
526 fn test_detect_package_manager_default() {
527 let pm = detect_package_manager();
530 assert!(["pnpm", "yarn", "npm"].contains(&pm));
531 }
532
533 #[test]
534 fn test_truncate_multibyte_thai() {
535 let thai = "สวัสดีครับ";
537 let result = truncate(thai, 5);
538 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 #[test]
560 fn test_resolve_binary_finds_known_command() {
561 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 assert!(
598 filename.starts_with("cargo"),
599 "resolved path filename should start with 'cargo', got: {}",
600 filename
601 );
602 }
603
604 #[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 #[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 #[cfg(target_os = "windows")]
644 mod windows_tests {
645 use super::super::*;
646 use std::fs;
647
648 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 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 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 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 let mut cmd = resolved_command("nonexistent_binary_xyz_99999");
753 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 #[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 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}