Files
rtk-ai__rtk/src/utils.rs
T
Florian BRUNIAUX b13d92c9ea fix(docs): escape HTML tags in rustdoc comments
Fixes 6 rustdoc warnings:
- Escape <action>, <detail>, <hash>, <branch>, <stats> tags
- Use backticks for [RTK:PASSTHROUGH] marker

All rustdoc warnings now resolved.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 11:33:40 +01:00

337 lines
9.2 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 std::process::Command;
/// Tronque une chaîne à `max_len` caractères avec "..." si nécessaire.
///
/// # Arguments
/// * `s` - La chaîne à tronquer
/// * `max_len` - Longueur maximale avant troncature (minimum 3 pour inclure "...")
///
/// # 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 {
if s.len() <= max_len {
s.to_string()
} else if max_len < 3 {
// If max_len is too small, just return "..."
"...".to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}
/// Supprime les codes ANSI d'une chaîne (couleurs, styles).
///
/// # Arguments
/// * `text` - Texte contenant potentiellement des codes ANSI
///
/// # 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()
}
/// Exécute une commande et retourne stdout/stderr nettoyés.
///
/// # Arguments
/// * `cmd` - Commande à exécuter (ex: "eslint")
/// * `args` - Arguments de la commande
///
/// # Returns
/// `(stdout: String, stderr: String, exit_code: i32)`
///
/// # Examples
/// ```no_run
/// use rtk::utils::execute_command;
/// let (stdout, stderr, code) = execute_command("echo", &["test"]).unwrap();
/// assert_eq!(code, 0);
/// ```
#[allow(dead_code)]
pub fn execute_command(cmd: &str, args: &[&str]) -> Result<(String, String, i32)> {
let output = Command::new(cmd)
.args(args)
.output()
.context(format!("Failed to execute {}", cmd))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let exit_code = output.status.code().unwrap_or(-1);
Ok((stdout, stderr, exit_code))
}
/// Formate un nombre de tokens avec suffixes K/M pour lisibilité.
///
/// # Arguments
/// * `n` - Nombre de tokens
///
/// # Returns
/// String formaté (ex: "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)
}
}
/// Formate un montant USD avec précision adaptée.
///
/// # Arguments
/// * `amount` - Montant en dollars
///
/// # Returns
/// String formaté avec $ 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 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)
}
}
/// 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.
#[allow(dead_code)]
pub fn package_manager_exec(tool: &str) -> Command {
let tool_exists = Command::new("which")
.arg(tool)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if tool_exists {
Command::new(tool)
} else {
let pm = detect_package_manager();
match pm {
"pnpm" => {
let mut c = Command::new("pnpm");
c.arg("exec").arg("--").arg(tool);
c
}
"yarn" => {
let mut c = Command::new("yarn");
c.arg("exec").arg("--").arg(tool);
c
}
_ => {
let mut c = Command::new("npx");
c.arg("--no-install").arg("--").arg(tool);
c
}
}
}
}
#[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_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_execute_command_success() {
let result = execute_command("echo", &["test"]);
assert!(result.is_ok());
let (stdout, _, code) = result.unwrap();
assert_eq!(code, 0);
assert!(stdout.contains("test"));
}
#[test]
fn test_execute_command_failure() {
let result = execute_command("nonexistent_command_xyz_12345", &[]);
assert!(result.is_err());
}
#[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_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));
}
}