956 lines
30 KiB
Rust
956 lines
30 KiB
Rust
//! Utility functions for text processing and command execution.
|
|
//!
|
|
//! Provides common helpers used across rtk commands:
|
|
//! - ANSI color code stripping
|
|
//! - Text truncation
|
|
//! - Command execution with error context
|
|
|
|
use anyhow::{Context, Result};
|
|
use regex::Regex;
|
|
use serde_json::Value;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::OnceLock;
|
|
|
|
/// Truncates a string to `max_len` characters, appending `...` if needed.
|
|
///
|
|
/// # Arguments
|
|
/// * `s` - The string to truncate
|
|
/// * `max_len` - Maximum length before truncation (minimum 3 to include "...")
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::truncate;
|
|
/// assert_eq!(truncate("hello world", 8), "hello...");
|
|
/// assert_eq!(truncate("hi", 10), "hi");
|
|
/// ```
|
|
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 {
|
|
// If max_len is too small, just return "..."
|
|
"...".to_string()
|
|
} else {
|
|
format!("{}...", s.chars().take(max_len - 3).collect::<String>())
|
|
}
|
|
}
|
|
|
|
/// Strip ANSI escape codes (colors, styles) from a string.
|
|
///
|
|
/// # Arguments
|
|
/// * `text` - Text potentially containing ANSI escape codes
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::strip_ansi;
|
|
/// let colored = "\x1b[31mError\x1b[0m";
|
|
/// assert_eq!(strip_ansi(colored), "Error");
|
|
/// ```
|
|
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()
|
|
}
|
|
|
|
/// Executes a command and returns cleaned stdout/stderr.
|
|
///
|
|
/// # Arguments
|
|
/// * `cmd` - Command to execute (e.g., "eslint")
|
|
/// * `args` - Command arguments
|
|
///
|
|
/// # Returns
|
|
/// `(stdout: String, stderr: String, exit_code: i32)`
|
|
/// Formats a token count with K/M suffixes for readability.
|
|
///
|
|
/// # Arguments
|
|
/// * `n` - Number of tokens
|
|
///
|
|
/// # Returns
|
|
/// Formatted string (e.g., "1.2M", "59.2K", "694")
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::format_tokens;
|
|
/// assert_eq!(format_tokens(1_234_567), "1.2M");
|
|
/// assert_eq!(format_tokens(59_234), "59.2K");
|
|
/// assert_eq!(format_tokens(694), "694");
|
|
/// ```
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Formats a USD amount with adaptive precision.
|
|
///
|
|
/// # Arguments
|
|
/// * `amount` - Amount in dollars
|
|
///
|
|
/// # Returns
|
|
/// Formatted string with $ prefix
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::format_usd;
|
|
/// assert_eq!(format_usd(1234.567), "$1234.57");
|
|
/// assert_eq!(format_usd(12.345), "$12.35");
|
|
/// assert_eq!(format_usd(0.123), "$0.12");
|
|
/// assert_eq!(format_usd(0.0096), "$0.0096");
|
|
/// ```
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// Format cost-per-token as $/MTok (e.g., "$3.86/MTok")
|
|
///
|
|
/// # Arguments
|
|
/// * `cpt` - Cost per token (not per million tokens)
|
|
///
|
|
/// # Returns
|
|
/// Formatted string like "$3.86/MTok"
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::format_cpt;
|
|
/// 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");
|
|
/// ```
|
|
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)
|
|
}
|
|
|
|
/// Join items into a newline-separated string, appending an overflow hint when total > max.
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::join_with_overflow;
|
|
/// let items = vec!["a".to_string(), "b".to_string()];
|
|
/// assert_eq!(join_with_overflow(&items, 5, 3, "items"), "a\nb\n... +2 more items");
|
|
/// assert_eq!(join_with_overflow(&items, 2, 3, "items"), "a\nb");
|
|
/// ```
|
|
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
|
|
}
|
|
|
|
/// Truncate an ISO 8601 datetime string to just the date portion (first 10 chars).
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::truncate_iso_date;
|
|
/// assert_eq!(truncate_iso_date("2024-01-15T10:30:00Z"), "2024-01-15");
|
|
/// assert_eq!(truncate_iso_date("2024-01-15"), "2024-01-15");
|
|
/// assert_eq!(truncate_iso_date("short"), "short");
|
|
/// ```
|
|
pub fn truncate_iso_date(date: &str) -> &str {
|
|
if date.len() >= 10 {
|
|
&date[..10]
|
|
} else {
|
|
date
|
|
}
|
|
}
|
|
|
|
/// Format a confirmation message: "ok \<action\> \<detail\>"
|
|
/// Used for write operations (merge, create, comment, edit, etc.)
|
|
///
|
|
/// # Examples
|
|
/// ```
|
|
/// use rtk::utils::ok_confirmation;
|
|
/// assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42");
|
|
/// assert_eq!(ok_confirmation("created", "PR #5 https://..."), "ok created PR #5 https://...");
|
|
/// ```
|
|
pub fn ok_confirmation(action: &str, detail: &str) -> String {
|
|
if detail.is_empty() {
|
|
format!("ok {}", action)
|
|
} else {
|
|
format!("ok {} {}", action, detail)
|
|
}
|
|
}
|
|
|
|
/// Extract exit code from a process output. Returns the actual exit code, or
|
|
/// `128 + signal` per Unix convention when terminated by a signal (no exit code
|
|
/// available). Falls back to 1 on non-Unix platforms.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract exit code from an ExitStatus (for `.status()` calls, not `.output()`).
|
|
/// Returns the actual exit code, or `128 + signal` per Unix convention when
|
|
/// terminated by a signal. Falls back to 1 on non-Unix platforms.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return the last `n` lines of output with a label, for use as a fallback
|
|
/// when filter parsing fails. Logs a diagnostic to stderr.
|
|
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")
|
|
}
|
|
|
|
/// Build a Command for Ruby tools, auto-detecting bundle exec.
|
|
/// Uses `bundle exec <tool>` when a Gemfile exists (transitive deps like rake
|
|
/// won't appear in the Gemfile but still need bundler for version isolation).
|
|
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)
|
|
}
|
|
|
|
/// Count whitespace-delimited tokens in text. Used by filter tests to verify
|
|
/// token savings claims.
|
|
#[cfg(test)]
|
|
pub fn count_tokens(text: &str) -> usize {
|
|
text.split_whitespace().count()
|
|
}
|
|
|
|
/// Detect the package manager used in the current directory.
|
|
/// Returns "pnpm", "yarn", or "npm" based on lockfile presence.
|
|
///
|
|
/// # Examples
|
|
/// ```no_run
|
|
/// use rtk::utils::detect_package_manager;
|
|
/// let pm = detect_package_manager();
|
|
/// // Returns "pnpm" if pnpm-lock.yaml exists, "yarn" if yarn.lock, else "npm"
|
|
/// ```
|
|
#[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"
|
|
}
|
|
}
|
|
|
|
/// Build a Command using the detected package manager's exec mechanism.
|
|
/// Returns a Command ready to have tool-specific args appended.
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve a binary name to its full path, honoring PATHEXT on Windows.
|
|
///
|
|
/// On Windows, Node.js tools are installed as `.CMD`/`.BAT`/`.PS1` shims.
|
|
/// Rust's `std::process::Command::new()` does NOT honor PATHEXT, so
|
|
/// `Command::new("vitest")` fails even when `vitest.CMD` is on PATH.
|
|
///
|
|
/// This function uses the `which` crate to perform proper PATH+PATHEXT resolution.
|
|
///
|
|
/// # Arguments
|
|
/// * `name` - Binary name (e.g., "vitest", "eslint", "tsc")
|
|
///
|
|
/// # Returns
|
|
/// Full path to the resolved binary, or error if not found.
|
|
pub fn resolve_binary(name: &str) -> Result<PathBuf> {
|
|
which::which(name).context(format!("Binary '{}' not found on PATH", name))
|
|
}
|
|
|
|
/// Create a `Command` with PATHEXT-aware binary resolution.
|
|
///
|
|
/// Drop-in replacement for `Command::new(name)` that works on Windows
|
|
/// with `.CMD`/`.BAT`/`.PS1` wrappers.
|
|
///
|
|
/// Falls back to `Command::new(name)` if resolution fails, so native
|
|
/// commands (git, cargo) still work even if `which` can't find them.
|
|
///
|
|
/// # Arguments
|
|
/// * `name` - Binary name (e.g., "vitest", "eslint")
|
|
///
|
|
/// # Returns
|
|
/// A `Command` configured with the resolved binary path.
|
|
pub fn resolved_command(name: &str) -> Command {
|
|
match resolve_binary(name) {
|
|
Ok(path) => Command::new(path),
|
|
Err(e) => {
|
|
// On Windows, resolution failure likely means a .CMD/.BAT wrapper
|
|
// wasn't found — always warn so users have a signal.
|
|
// On Unix, this is less common; only log in debug builds.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return Composer bin directories in precedence order.
|
|
///
|
|
/// Composer allows overriding the default `vendor/bin` via `COMPOSER_BIN_DIR`
|
|
/// or `composer.json` `config.bin-dir`. Keep the default as a fallback so we
|
|
/// continue recognizing the common layout even when the repo is not configured.
|
|
pub fn composer_bin_dirs() -> Vec<PathBuf> {
|
|
// Resolution depends only on the process's env + cwd composer.json, both
|
|
// constant for a single rtk invocation. The rewrite hot path queries this
|
|
// several times per command segment, so read the file once and cache.
|
|
static CACHE: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
|
CACHE
|
|
.get_or_init(|| {
|
|
let env_bin_dir = std::env::var("COMPOSER_BIN_DIR").ok();
|
|
let composer_json = fs::read_to_string("composer.json").ok();
|
|
composer_bin_dirs_from(env_bin_dir.as_deref(), composer_json.as_deref())
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
pub fn composer_tool_paths(tool: &str) -> Vec<PathBuf> {
|
|
composer_bin_dirs()
|
|
.into_iter()
|
|
.map(|dir| dir.join(tool))
|
|
.collect()
|
|
}
|
|
|
|
fn composer_bin_dirs_from(env_bin_dir: Option<&str>, composer_json: Option<&str>) -> Vec<PathBuf> {
|
|
let mut dirs = Vec::new();
|
|
|
|
if let Some(dir) = env_bin_dir
|
|
.map(str::trim)
|
|
.filter(|dir| !dir.is_empty())
|
|
.map(PathBuf::from)
|
|
.or_else(|| composer_json.and_then(read_composer_bin_dir))
|
|
{
|
|
dirs.push(dir);
|
|
}
|
|
|
|
let default_dir = PathBuf::from("vendor/bin");
|
|
if !dirs.iter().any(|dir| dir == &default_dir) {
|
|
dirs.push(default_dir);
|
|
}
|
|
|
|
dirs
|
|
}
|
|
|
|
fn read_composer_bin_dir(composer_json: &str) -> Option<PathBuf> {
|
|
let parsed: Value = serde_json::from_str(composer_json).ok()?;
|
|
let bin_dir = parsed.get("config")?.get("bin-dir")?.as_str()?.trim();
|
|
if bin_dir.is_empty() {
|
|
None
|
|
} else {
|
|
Some(PathBuf::from(bin_dir))
|
|
}
|
|
}
|
|
|
|
/// Check if a tool exists on PATH (PATHEXT-aware on Windows).
|
|
///
|
|
/// Replaces manual `Command::new("which").arg(tool)` checks that fail on Windows.
|
|
pub fn tool_exists(name: &str) -> bool {
|
|
which::which(name).is_ok()
|
|
}
|
|
|
|
/// Extract short name from AWS ARN.
|
|
/// Example: `arn:aws:ecs:region:acct:service/cluster/name` -> `name`
|
|
/// For simple ARNs like `arn:aws:iam::123:user/alice`, returns `alice`.
|
|
pub fn shorten_arn(arn: &str) -> &str {
|
|
// ARNs use "/" or ":" as separators. Try "/" first (service/cluster/name pattern),
|
|
// then fall back to ":" for Lambda/IAM ARNs.
|
|
let slash_result = arn.rsplit('/').next().unwrap_or(arn);
|
|
// If rsplit('/') returned the whole string (no '/' found), try ':'
|
|
if slash_result == arn {
|
|
arn.rsplit(':').next().unwrap_or(arn)
|
|
} else {
|
|
slash_result
|
|
}
|
|
}
|
|
|
|
/// Convert bytes to human-readable format (KB, MB, GB, TB).
|
|
/// Used for S3 object sizes.
|
|
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() {
|
|
// max_len < 3 returns just "..."
|
|
assert_eq!(truncate("hello", 2), "...");
|
|
// When string length equals max_len, return as is
|
|
assert_eq!(truncate("abc", 3), "abc");
|
|
// When string is longer and max_len is exactly 3, return "..."
|
|
assert_eq!(truncate("hello world", 3), "...");
|
|
}
|
|
|
|
#[test]
|
|
fn test_composer_bin_dirs_use_default_when_unconfigured() {
|
|
assert_eq!(
|
|
composer_bin_dirs_from(None, None),
|
|
vec![PathBuf::from("vendor/bin")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_composer_bin_dirs_prefer_env_override() {
|
|
let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#;
|
|
assert_eq!(
|
|
composer_bin_dirs_from(Some("custom/bin"), Some(composer_json)),
|
|
vec![PathBuf::from("custom/bin"), PathBuf::from("vendor/bin")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_composer_bin_dirs_read_composer_config() {
|
|
let composer_json = r#"{"config":{"bin-dir":"tools/bin"}}"#;
|
|
assert_eq!(
|
|
composer_bin_dirs_from(None, Some(composer_json)),
|
|
vec![PathBuf::from("tools/bin"), PathBuf::from("vendor/bin")]
|
|
);
|
|
}
|
|
|
|
#[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"); // zero
|
|
assert_eq!(format_cpt(-0.000001), "$0.00/MTok"); // negative
|
|
assert_eq!(format_cpt(f64::INFINITY), "$0.00/MTok"); // infinite
|
|
assert_eq!(format_cpt(f64::NAN), "$0.00/MTok"); // NaN
|
|
}
|
|
|
|
#[test]
|
|
fn test_detect_package_manager_default() {
|
|
// In the test environment (rtk repo), there's no JS lockfile
|
|
// so it should default to "npm"
|
|
let pm = detect_package_manager();
|
|
assert!(["pnpm", "yarn", "npm"].contains(&pm));
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_multibyte_thai() {
|
|
// Thai characters are 3 bytes each
|
|
let thai = "สวัสดีครับ";
|
|
let result = truncate(thai, 5);
|
|
// Should not panic, should produce valid UTF-8
|
|
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_truncate_multibyte_cyrillic_issue_2787() {
|
|
// Regression: `rtk gain --history` panicked slicing this at byte 22,
|
|
// which falls inside 'н' (Cyrillic chars are 2 bytes each)
|
|
let cmd = "rtk ls -la Ародинамический расчёт Новый";
|
|
let result = truncate(cmd, 25);
|
|
assert_eq!(result.chars().count(), 25);
|
|
assert!(result.ends_with("..."));
|
|
assert_eq!(result, "rtk ls -la Ародинамиче...");
|
|
}
|
|
|
|
// ===== resolve_binary tests (issue #212) =====
|
|
|
|
#[test]
|
|
fn test_resolve_binary_finds_known_command() {
|
|
// "cargo" must be on PATH in any Rust dev environment
|
|
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();
|
|
// On Windows this could be "cargo.exe", on Unix just "cargo"
|
|
assert!(
|
|
filename.starts_with("cargo"),
|
|
"resolved path filename should start with 'cargo', got: {}",
|
|
filename
|
|
);
|
|
}
|
|
|
|
// ===== resolved_command tests (issue #212) =====
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
// ===== tool_exists tests (issue #212) =====
|
|
|
|
#[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");
|
|
}
|
|
|
|
// ===== Windows-specific PATHEXT resolution tests (issue #212) =====
|
|
|
|
#[cfg(target_os = "windows")]
|
|
mod windows_tests {
|
|
use super::super::*;
|
|
use std::fs;
|
|
|
|
/// Create a temporary .cmd wrapper to simulate Node.js tool installation
|
|
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
|
|
}
|
|
|
|
/// Build a PATH string that includes the temp dir
|
|
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");
|
|
|
|
// Use which::which_in to avoid mutating global PATH (thread-safe)
|
|
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");
|
|
|
|
// Resolve the full path, then execute it directly (no PATH mutation)
|
|
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() {
|
|
// When resolve_binary fails, resolved_command should fall back to
|
|
// Command::new(name) instead of panicking. On Windows this also
|
|
// prints a warning to stderr.
|
|
let mut cmd = resolved_command("nonexistent_binary_xyz_99999");
|
|
// The Command should be created (not panic). Attempting to run it
|
|
// will fail, but that's expected — we just verify the fallback path
|
|
// produces a usable Command.
|
|
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"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ===== AWS helper function tests =====
|
|
|
|
#[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() {
|
|
// Non-ARN string - return as-is
|
|
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);
|
|
}
|
|
}
|