diff --git a/src/claude_code.rs b/src/claude_code.rs index 884a5df..cd0db88 100644 --- a/src/claude_code.rs +++ b/src/claude_code.rs @@ -1,32 +1,11 @@ use crate::config::Config; +use crate::output; use crate::provider::{AgentSession, BootstrapMessage, OutputChunk, ProviderError, StepMessage}; use serde::Deserialize; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; -/// ANSI color codes for verbose output. -mod color { - // Label prefix — dim grey - pub const DIM: &str = "\x1b[38;5;243m"; - // Content text — lighter grey - pub const LIGHT: &str = "\x1b[38;5;250m"; - // Tool names — soft blue - pub const TOOL: &str = "\x1b[38;5;111m"; - // Thinking — soft purple - pub const THINKING: &str = "\x1b[38;5;183m"; - // Tool result — soft green - pub const RESULT: &str = "\x1b[38;5;151m"; - // Message/prompt — soft yellow - pub const PROMPT: &str = "\x1b[38;5;223m"; - // Launch command — soft cyan - pub const CMD: &str = "\x1b[38;5;152m"; - // Separator — very dim - pub const SEP: &str = "\x1b[38;5;238m"; - // Reset - pub const RESET: &str = "\x1b[0m"; -} - /// Claude Code CLI provider adapter. /// /// Implements the `AgentSession` trait by driving a single long-lived `claude` @@ -167,25 +146,26 @@ impl ClaudeCodeAdapter { .stderr(Stdio::piped()); if self.verbose { + let c = output::stderr_colors(); let args: Vec<_> = cmd .get_args() .map(|a| a.to_string_lossy().to_string()) .collect(); eprintln!( "{}[verbose]{} {}launch:{} {} {}{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::CMD, + c.dim, + c.reset, + c.dim, + c.reset, + c.cmd, args.join(" "), - color::RESET + c.reset ); eprintln!( "{} binary: {}{}", - color::DIM, + c.dim, cmd.get_program().to_string_lossy(), - color::RESET + c.reset ); } @@ -226,16 +206,17 @@ impl ClaudeCodeAdapter { let input_line = format_stream_input(message); if self.verbose { + let c = output::stderr_colors(); eprintln!( "{}[verbose]{} {}prompt ({} bytes):{}", - color::DIM, - color::RESET, - color::DIM, + c.dim, + c.reset, + c.dim, message.len(), - color::RESET + c.reset ); - eprintln!("{}{}{}", color::PROMPT, message, color::RESET); - eprintln!("{}───{}", color::SEP, color::RESET); + eprintln!("{}{}{}", c.prompt, message, c.reset); + eprintln!("{}───{}", c.sep, c.reset); } stdin @@ -257,6 +238,11 @@ impl ClaudeCodeAdapter { reader, done: false, verbose: self.verbose, + colors: if self.verbose { + Some(output::stderr_colors()) + } else { + None + }, })) } } @@ -350,6 +336,7 @@ struct StreamTurnIterator<'a> { reader: &'a mut BufReader, done: bool, verbose: bool, + colors: Option<&'static output::Colors>, } impl<'a> Iterator for StreamTurnIterator<'a> { @@ -360,6 +347,8 @@ impl<'a> Iterator for StreamTurnIterator<'a> { return None; } + let c = if self.verbose { self.colors } else { None }; + loop { let mut line = String::new(); match self.reader.read_line(&mut line) { @@ -392,7 +381,7 @@ impl<'a> Iterator for StreamTurnIterator<'a> { } } "tool_use" => { - if self.verbose { + if let Some(c) = c { let name = block.name.as_deref().unwrap_or("unknown"); let input_preview = block @@ -427,25 +416,32 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .chars() .take(12) .collect::(); - eprintln!("{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", color::DIM, color::RESET, color::DIM, color::RESET, color::TOOL, name, color::RESET, color::LIGHT, input_preview, color::RESET, color::DIM, id_short, color::RESET); + eprintln!( + "{}[verbose]{} {}tool:{} {}{}{} {}{}{} {}({}){}", + c.dim, + c.reset, + c.dim, + c.reset, + c.tool, + name, + c.reset, + c.light, + input_preview, + c.reset, + c.dim, + id_short, + c.reset + ); } } "thinking" => { - if self.verbose { + if let Some(c) = c { if let Some(thinking) = &block.thinking { eprintln!( "{}[verbose]{} {}thinking:{}", - color::DIM, - color::RESET, - color::DIM, - color::RESET - ); - eprintln!( - "{}{}{}", - color::THINKING, - thinking, - color::RESET + c.dim, c.reset, c.dim, c.reset ); + eprintln!("{}{}{}", c.thinking, thinking, c.reset); } } } @@ -457,7 +453,7 @@ impl<'a> Iterator for StreamTurnIterator<'a> { } "user" => { // Tool results — log in verbose mode - if self.verbose { + if let Some(c) = c { if let Some(msg) = &event.message { for block in &msg.content { if block.block_type == "tool_result" { @@ -479,20 +475,15 @@ impl<'a> Iterator for StreamTurnIterator<'a> { .collect::(); eprintln!( "{}[verbose]{} {}result:{} {}({}){}", - color::DIM, - color::RESET, - color::DIM, - color::RESET, - color::DIM, + c.dim, + c.reset, + c.dim, + c.reset, + c.dim, id_short, - color::RESET - ); - eprintln!( - "{}{}{}", - color::RESULT, - result_text, - color::RESET + c.reset ); + eprintln!("{}{}{}", c.result, result_text, c.reset); } } } @@ -654,6 +645,7 @@ mod tests { reader: unsafe { &mut *(&mut reader as *mut BufReader) }, done: false, verbose: false, + colors: None, }; let mut collected = Vec::new(); @@ -686,6 +678,7 @@ mod tests { reader: unsafe { &mut *(&mut reader as *mut BufReader) }, done: false, verbose: false, + colors: None, }; let result = iter.next(); @@ -715,6 +708,7 @@ mod tests { reader: unsafe { &mut *(&mut reader as *mut BufReader) }, done: false, verbose: false, + colors: None, }; let mut texts = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index 11b6d36..91d098d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod discovery; pub mod executor; pub mod exit_code; pub mod expand; +pub mod output; pub mod provider; pub mod report; pub mod run; diff --git a/src/main.rs b/src/main.rs index e6ece35..30e543c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ use bugatti::exit_code::{ EXIT_STEP_ERROR, }; use bugatti::expand; +use bugatti::output; use bugatti::provider::AgentSession; use bugatti::report::{self, ReportInput}; use bugatti::run::{self, ArtifactDir, EffectiveConfigSummary}; @@ -35,6 +36,39 @@ fn relative_display(path: &Path) -> String { .unwrap_or_else(|| path.display().to_string()) } +/// Build shorthand test file candidate by appending `.test.toml`. +/// +/// Returns `None` when the input already ends with `.test.toml`. +fn shorthand_test_path(input: &str) -> Option { + if input.ends_with(".test.toml") { + return None; + } + + let mut candidate = String::with_capacity(input.len() + ".test.toml".len()); + candidate.push_str(input); + candidate.push_str(".test.toml"); + Some(PathBuf::from(candidate)) +} + +/// Resolve a user-provided test path. +/// +/// Resolution order: +/// 1) exact path as provided +/// 2) shorthand fallback `.test.toml` +fn resolve_test_path(input: &str) -> Option { + let direct = PathBuf::from(input); + if direct.is_file() { + return Some(direct); + } + + let shorthand = shorthand_test_path(input)?; + if shorthand.is_file() { + Some(shorthand) + } else { + None + } +} + /// Check whether the run has been interrupted by Ctrl+C. pub fn is_interrupted() -> bool { INTERRUPTED.load(Ordering::Relaxed) @@ -72,9 +106,14 @@ fn main() { let cli = Cli::parse(); + let c = output::stdout_colors(); println!( - "\x1b[1mbugatti\x1b[0m \x1b[38;5;243mv{}\x1b[0m", - env!("CARGO_PKG_VERSION") + "{}bugatti{} {}v{}{}", + c.bold, + c.reset, + c.dim, + env!("CARGO_PKG_VERSION"), + c.reset ); println!(); @@ -105,12 +144,8 @@ fn main() { println!("Skipping commands: {}", skip_cmds.join(", ")); } match path { - Some(p) => { - let test_path = PathBuf::from(&p); - if !test_path.exists() { - eprintln!("ERROR: test file not found: {p}"); - EXIT_CONFIG_ERROR - } else { + Some(p) => match resolve_test_path(&p) { + Some(test_path) => { let result = run_test_pipeline( &project_root, &test_path, @@ -134,7 +169,18 @@ fn main() { ); result.exit_code } - } + None => { + if let Some(shorthand) = shorthand_test_path(&p) { + eprintln!( + "ERROR: test file not found: {p} (also tried {})", + shorthand.display() + ); + } else { + eprintln!("ERROR: test file not found: {p}"); + } + EXIT_CONFIG_ERROR + } + }, None => run_discovery( &project_root, &skip_cmds, @@ -384,9 +430,10 @@ fn run_test_with_artifacts( ); // Print per-test run info - let dim = "\x1b[38;5;243m"; - let light = "\x1b[38;5;250m"; - let reset = "\x1b[0m"; + let c = output::stdout_colors(); + let dim = c.dim; + let light = c.light; + let reset = c.reset; if ctx.effective.provider.agent_args.is_empty() { println!(" {dim}Provider:{reset} {}", ctx.effective.provider.name); } else { @@ -785,9 +832,12 @@ fn print_run_references(results: &[TestRunResult]) { #[cfg(test)] mod tests { use clap::Parser; + use std::path::PathBuf; use bugatti::cli::Cli; + use crate::{resolve_test_path, shorthand_test_path}; + #[test] fn test_subcommand_no_path() { let cli = Cli::parse_from(["bugatti", "test"]); @@ -943,4 +993,53 @@ mod tests { Ok(_) => panic!("--help should produce an error-like result from clap"), } } + + #[test] + fn test_shorthand_test_path_appends_suffix() { + let candidate = shorthand_test_path("ftue").expect("candidate should exist"); + assert_eq!(candidate, PathBuf::from("ftue.test.toml")); + } + + #[test] + fn test_shorthand_test_path_skips_when_already_full_name() { + assert!(shorthand_test_path("ftue.test.toml").is_none()); + } + + #[test] + fn test_resolve_test_path_prefers_exact_match() { + let tmp = tempfile::tempdir().expect("tempdir"); + let exact = tmp.path().join("login.test.toml"); + std::fs::write(&exact, "name = \"login\"\nsteps = []\n").expect("write file"); + + let resolved = resolve_test_path(exact.to_string_lossy().as_ref()); + assert_eq!(resolved, Some(exact)); + } + + #[test] + fn test_resolve_test_path_uses_shorthand_fallback() { + let tmp = tempfile::tempdir().expect("tempdir"); + let fallback = tmp.path().join("ftue.test.toml"); + std::fs::write(&fallback, "name = \"ftue\"\nsteps = []\n").expect("write file"); + + let shorthand_input = tmp.path().join("ftue"); + let resolved = resolve_test_path(shorthand_input.to_string_lossy().as_ref()); + assert_eq!(resolved, Some(fallback)); + } + + #[test] + fn test_resolve_test_path_missing_returns_none() { + let tmp = tempfile::tempdir().expect("tempdir"); + let shorthand_input = tmp.path().join("does-not-exist"); + assert!(resolve_test_path(shorthand_input.to_string_lossy().as_ref()).is_none()); + } + + #[test] + fn test_resolve_test_path_rejects_directories() { + let tmp = tempfile::tempdir().expect("tempdir"); + let direct_dir = tmp.path().join("folder-as-input"); + std::fs::create_dir_all(&direct_dir).expect("create dir"); + + let resolved = resolve_test_path(direct_dir.to_string_lossy().as_ref()); + assert!(resolved.is_none()); + } } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..c5d351a --- /dev/null +++ b/src/output.rs @@ -0,0 +1,110 @@ +use std::io::IsTerminal; +use std::sync::OnceLock; + +#[derive(Clone, Copy, Debug)] +pub enum Stream { + Stdout, + Stderr, +} + +/// Shared ANSI palette used by terminal output formatting. +#[derive(Debug)] +pub struct Colors { + pub enabled: bool, + pub bold: &'static str, + pub dim: &'static str, + pub light: &'static str, + pub tool: &'static str, + pub thinking: &'static str, + pub result: &'static str, + pub prompt: &'static str, + pub cmd: &'static str, + pub sep: &'static str, + pub reset: &'static str, +} + +static COLORS: OnceLock = OnceLock::new(); +static STDERR_COLORS: OnceLock = OnceLock::new(); + +fn detect_color_enabled(stream: Stream) -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + + match stream { + Stream::Stdout => std::io::stdout().is_terminal(), + Stream::Stderr => std::io::stderr().is_terminal(), + } +} + +fn build_colors(enabled: bool) -> Colors { + let code = |value| if enabled { value } else { "" }; + + Colors { + enabled, + bold: code("\x1b[1m"), + dim: code("\x1b[38;5;243m"), + light: code("\x1b[38;5;250m"), + tool: code("\x1b[38;5;111m"), + thinking: code("\x1b[38;5;183m"), + result: code("\x1b[38;5;151m"), + prompt: code("\x1b[38;5;223m"), + cmd: code("\x1b[38;5;152m"), + sep: code("\x1b[38;5;238m"), + reset: code("\x1b[0m"), + } +} + +/// Returns a lazily initialized singleton color palette. +pub fn colors() -> &'static Colors { + stdout_colors() +} + +/// Returns a lazily initialized stdout color palette. +pub fn stdout_colors() -> &'static Colors { + COLORS.get_or_init(|| build_colors(detect_color_enabled(Stream::Stdout))) +} + +/// Returns a lazily initialized stderr color palette. +pub fn stderr_colors() -> &'static Colors { + STDERR_COLORS.get_or_init(|| build_colors(detect_color_enabled(Stream::Stderr))) +} + +/// Returns whether ANSI color output should be enabled. +/// +/// Color is disabled when: +/// - `NO_COLOR` is set to any value +/// - stdout is not a terminal (e.g. piped/redirected) +pub fn color_enabled() -> bool { + stdout_colors().enabled +} + +/// Returns whether ANSI color output should be enabled for stderr. +pub fn color_enabled_stderr() -> bool { + stderr_colors().enabled +} + +/// Returns `code` when color is enabled, otherwise an empty string. +pub fn ansi(code: &'static str) -> &'static str { + if stdout_colors().enabled { + code + } else { + "" + } +} + +/// Returns `code` when stderr color is enabled, otherwise an empty string. +pub fn ansi_stderr(code: &'static str) -> &'static str { + if stderr_colors().enabled { + code + } else { + "" + } +} + +pub mod prelude { + pub use super::{ + ansi, ansi_stderr, color_enabled, color_enabled_stderr, colors, stderr_colors, + stdout_colors, Colors, Stream, + }; +}