mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Extract terminal crate and prettify attractor CLI output
Move ANSI Styles struct from agent/cli.rs into a shared terminal crate so both binaries can use it. Add green and yellow color codes. Prettify all attractor CLI output: bold headers, colored diagnostics by severity, green/red status, yellow warnings, dimmed event details, and styled interviewer prompts. Move pipeline status output from stdout to stderr. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b4397564ec
commit
dddc6df8c8
12 changed files with 251 additions and 103 deletions
6
Cargo.lock
generated
6
Cargo.lock
generated
|
|
@ -18,6 +18,7 @@ dependencies = [
|
|||
"llm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"terminal",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
|
|
@ -170,6 +171,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"terminal",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
|
|
@ -2109,6 +2111,10 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminal"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ clap.workspace = true
|
|||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
llm = { path = "../llm" }
|
||||
terminal = { path = "../terminal" }
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -8,38 +8,7 @@ use std::io::{IsTerminal, Write};
|
|||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Pre-resolved ANSI escape codes for styled terminal output.
|
||||
/// All fields are empty strings when color is disabled (non-TTY stderr).
|
||||
struct Styles {
|
||||
bold: &'static str,
|
||||
dim: &'static str,
|
||||
cyan: &'static str,
|
||||
red: &'static str,
|
||||
reset: &'static str,
|
||||
}
|
||||
|
||||
impl Styles {
|
||||
fn new(use_color: bool) -> Self {
|
||||
if use_color {
|
||||
Self {
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m",
|
||||
red: "\x1b[31m",
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
bold: "",
|
||||
dim: "",
|
||||
cyan: "",
|
||||
red: "",
|
||||
reset: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use terminal::Styles;
|
||||
|
||||
/// Minimal CLI for the agent agentic loop.
|
||||
#[derive(Parser)]
|
||||
|
|
@ -316,8 +285,7 @@ pub async fn run() -> anyhow::Result<()> {
|
|||
let cli = Cli::parse();
|
||||
|
||||
// Resolve color support once, leak to get 'static lifetime for use across threads
|
||||
let styles: &'static Styles =
|
||||
Box::leak(Box::new(Styles::new(std::io::stderr().is_terminal())));
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
|
||||
// Validate provider API key
|
||||
if !validate_api_key(&cli.provider) {
|
||||
|
|
@ -428,13 +396,7 @@ mod tests {
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
static NO_COLOR: Styles = Styles {
|
||||
bold: "",
|
||||
dim: "",
|
||||
cyan: "",
|
||||
red: "",
|
||||
reset: "",
|
||||
};
|
||||
static NO_COLOR: Styles = Styles::new(false);
|
||||
|
||||
// tool_category tests
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ clap.workspace = true
|
|||
anyhow.workspace = true
|
||||
dotenvy.workspace = true
|
||||
agent = { path = "../agent" }
|
||||
terminal = { path = "../terminal" }
|
||||
llm = { path = "../llm" }
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use std::path::Path;
|
|||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
use terminal::Styles;
|
||||
|
||||
use crate::event::PipelineEvent;
|
||||
use crate::validation::{Diagnostic, Severity};
|
||||
|
|
@ -75,27 +76,38 @@ pub fn read_dot_file(path: &Path) -> anyhow::Result<String> {
|
|||
.map_err(|e| anyhow::anyhow!("Failed to read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Print diagnostics to stderr, grouped by severity.
|
||||
pub fn print_diagnostics(diagnostics: &[Diagnostic]) {
|
||||
/// Print diagnostics to stderr, colored by severity.
|
||||
pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) {
|
||||
for d in diagnostics {
|
||||
let prefix = match d.severity {
|
||||
Severity::Error => "error",
|
||||
Severity::Warning => "warning",
|
||||
Severity::Info => "info",
|
||||
};
|
||||
let location = match (&d.node_id, &d.edge) {
|
||||
(Some(node), _) => format!(" [node: {node}]"),
|
||||
(_, Some((from, to))) => format!(" [edge: {from} -> {to}]"),
|
||||
_ => String::new(),
|
||||
};
|
||||
eprintln!("{prefix}{location}: {} ({})", d.message, d.rule);
|
||||
match d.severity {
|
||||
Severity::Error => eprintln!(
|
||||
"{red}error{reset}{location}: {} ({dim}{}{reset})",
|
||||
d.message, d.rule,
|
||||
red = styles.red, dim = styles.dim, reset = styles.reset,
|
||||
),
|
||||
Severity::Warning => eprintln!(
|
||||
"{yellow}warning{reset}{location}: {} ({dim}{}{reset})",
|
||||
d.message, d.rule,
|
||||
yellow = styles.yellow, dim = styles.dim, reset = styles.reset,
|
||||
),
|
||||
Severity::Info => eprintln!(
|
||||
"{dim}info{location}: {} ({}){reset}",
|
||||
d.message, d.rule,
|
||||
dim = styles.dim, reset = styles.reset,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line summary of a pipeline event for `-v` output.
|
||||
/// One-line summary of a pipeline event for `-v` output (dimmed).
|
||||
#[must_use]
|
||||
pub fn format_event_summary(event: &PipelineEvent) -> String {
|
||||
match event {
|
||||
pub fn format_event_summary(event: &PipelineEvent, styles: &Styles) -> String {
|
||||
let body = match event {
|
||||
PipelineEvent::PipelineStarted { name, id } => {
|
||||
format!("[PIPELINE_STARTED] name={name} id={id}")
|
||||
}
|
||||
|
|
@ -179,30 +191,35 @@ pub fn format_event_summary(event: &PipelineEvent) -> String {
|
|||
PipelineEvent::CheckpointSaved { node_id } => {
|
||||
format!("[CHECKPOINT_SAVED] node={node_id}")
|
||||
}
|
||||
}
|
||||
};
|
||||
format!("{dim}{body}{reset}", dim = styles.dim, reset = styles.reset)
|
||||
}
|
||||
|
||||
/// Multi-line detail view of a pipeline event for `-vv` output.
|
||||
/// Box-drawing is dimmed; values are normal.
|
||||
#[must_use]
|
||||
pub fn format_event_detail(event: &PipelineEvent) -> String {
|
||||
pub fn format_event_detail(event: &PipelineEvent, styles: &Styles) -> String {
|
||||
let d = styles.dim;
|
||||
let r = styles.reset;
|
||||
|
||||
match event {
|
||||
PipelineEvent::PipelineStarted { name, id } => {
|
||||
format!(
|
||||
"── PIPELINE_STARTED ─────────────────────────\n name: {name}\n id: {id}\n"
|
||||
"{d}── PIPELINE_STARTED ─────────────────────────{r}\n {d}name:{r} {name}\n {d}id:{r} {id}\n"
|
||||
)
|
||||
}
|
||||
PipelineEvent::PipelineCompleted {
|
||||
duration_ms,
|
||||
artifact_count,
|
||||
} => {
|
||||
format!("── PIPELINE_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n artifact_count: {artifact_count}\n")
|
||||
format!("{d}── PIPELINE_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}artifact_count:{r} {artifact_count}\n")
|
||||
}
|
||||
PipelineEvent::PipelineFailed { error, duration_ms } => {
|
||||
format!("── PIPELINE_FAILED ──────────────────────────\n error: {error}\n duration_ms: {duration_ms}\n")
|
||||
format!("{d}── PIPELINE_FAILED ──────────────────────────{r}\n {d}error:{r} {error}\n {d}duration_ms:{r} {duration_ms}\n")
|
||||
}
|
||||
PipelineEvent::StageStarted { name, index } => {
|
||||
format!(
|
||||
"── STAGE_STARTED ────────────────────────────\n name: {name}\n index: {index}\n"
|
||||
"{d}── STAGE_STARTED ────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n"
|
||||
)
|
||||
}
|
||||
PipelineEvent::StageCompleted {
|
||||
|
|
@ -210,7 +227,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
|
|||
index,
|
||||
duration_ms,
|
||||
} => {
|
||||
format!("── STAGE_COMPLETED ──────────────────────────\n name: {name}\n index: {index}\n duration_ms: {duration_ms}\n")
|
||||
format!("{d}── STAGE_COMPLETED ──────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n")
|
||||
}
|
||||
PipelineEvent::StageFailed {
|
||||
name,
|
||||
|
|
@ -218,7 +235,7 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
|
|||
error,
|
||||
will_retry,
|
||||
} => {
|
||||
format!("── STAGE_FAILED ─────────────────────────────\n name: {name}\n index: {index}\n error: {error}\n will_retry: {will_retry}\n")
|
||||
format!("{d}── STAGE_FAILED ─────────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}error:{r} {error}\n {d}will_retry:{r} {will_retry}\n")
|
||||
}
|
||||
PipelineEvent::StageRetrying {
|
||||
name,
|
||||
|
|
@ -226,13 +243,13 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
|
|||
attempt,
|
||||
delay_ms,
|
||||
} => {
|
||||
format!("── STAGE_RETRYING ───────────────────────────\n name: {name}\n index: {index}\n attempt: {attempt}\n delay_ms: {delay_ms}\n")
|
||||
format!("{d}── STAGE_RETRYING ───────────────────────────{r}\n {d}name:{r} {name}\n {d}index:{r} {index}\n {d}attempt:{r} {attempt}\n {d}delay_ms:{r} {delay_ms}\n")
|
||||
}
|
||||
PipelineEvent::ParallelStarted { branch_count } => {
|
||||
format!("── PARALLEL_STARTED ─────────────────────────\n branch_count: {branch_count}\n")
|
||||
format!("{d}── PARALLEL_STARTED ─────────────────────────{r}\n {d}branch_count:{r} {branch_count}\n")
|
||||
}
|
||||
PipelineEvent::ParallelBranchStarted { branch, index } => {
|
||||
format!("── PARALLEL_BRANCH_STARTED ──────────────────\n branch: {branch}\n index: {index}\n")
|
||||
format!("{d}── PARALLEL_BRANCH_STARTED ──────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n")
|
||||
}
|
||||
PipelineEvent::ParallelBranchCompleted {
|
||||
branch,
|
||||
|
|
@ -240,35 +257,35 @@ pub fn format_event_detail(event: &PipelineEvent) -> String {
|
|||
duration_ms,
|
||||
success,
|
||||
} => {
|
||||
format!("── PARALLEL_BRANCH_COMPLETED ────────────────\n branch: {branch}\n index: {index}\n duration_ms: {duration_ms}\n success: {success}\n")
|
||||
format!("{d}── PARALLEL_BRANCH_COMPLETED ────────────────{r}\n {d}branch:{r} {branch}\n {d}index:{r} {index}\n {d}duration_ms:{r} {duration_ms}\n {d}success:{r} {success}\n")
|
||||
}
|
||||
PipelineEvent::ParallelCompleted {
|
||||
duration_ms,
|
||||
success_count,
|
||||
failure_count,
|
||||
} => {
|
||||
format!("── PARALLEL_COMPLETED ───────────────────────\n duration_ms: {duration_ms}\n success_count: {success_count}\n failure_count: {failure_count}\n")
|
||||
format!("{d}── PARALLEL_COMPLETED ───────────────────────{r}\n {d}duration_ms:{r} {duration_ms}\n {d}success_count:{r} {success_count}\n {d}failure_count:{r} {failure_count}\n")
|
||||
}
|
||||
PipelineEvent::InterviewStarted { question, stage } => {
|
||||
format!("── INTERVIEW_STARTED ────────────────────────\n stage: {stage}\n question: {question}\n")
|
||||
format!("{d}── INTERVIEW_STARTED ────────────────────────{r}\n {d}stage:{r} {stage}\n {d}question:{r} {question}\n")
|
||||
}
|
||||
PipelineEvent::InterviewCompleted {
|
||||
question,
|
||||
answer,
|
||||
duration_ms,
|
||||
} => {
|
||||
format!("── INTERVIEW_COMPLETED ──────────────────────\n question: {question}\n answer: {answer}\n duration_ms: {duration_ms}\n")
|
||||
format!("{d}── INTERVIEW_COMPLETED ──────────────────────{r}\n {d}question:{r} {question}\n {d}answer:{r} {answer}\n {d}duration_ms:{r} {duration_ms}\n")
|
||||
}
|
||||
PipelineEvent::InterviewTimeout {
|
||||
question,
|
||||
stage,
|
||||
duration_ms,
|
||||
} => {
|
||||
format!("── INTERVIEW_TIMEOUT ────────────────────────\n question: {question}\n stage: {stage}\n duration_ms: {duration_ms}\n")
|
||||
format!("{d}── INTERVIEW_TIMEOUT ────────────────────────{r}\n {d}question:{r} {question}\n {d}stage:{r} {stage}\n {d}duration_ms:{r} {duration_ms}\n")
|
||||
}
|
||||
PipelineEvent::CheckpointSaved { node_id } => {
|
||||
format!(
|
||||
"── CHECKPOINT_SAVED ─────────────────────────\n node_id: {node_id}\n"
|
||||
"{d}── CHECKPOINT_SAVED ─────────────────────────{r}\n {d}node_id:{r} {node_id}\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::bail;
|
||||
use chrono::Local;
|
||||
use terminal::Styles;
|
||||
|
||||
use crate::checkpoint::Checkpoint;
|
||||
use crate::engine::{PipelineEngine, RunConfig};
|
||||
|
|
@ -24,24 +25,25 @@ use super::{format_event_detail, format_event_summary, print_diagnostics, read_d
|
|||
///
|
||||
/// Returns an error if the pipeline cannot be read, parsed, validated, or executed.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
|
||||
pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Result<()> {
|
||||
// 1. Parse and validate pipeline
|
||||
let source = read_dot_file(&args.pipeline)?;
|
||||
let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?;
|
||||
|
||||
println!(
|
||||
"Parsed pipeline: {} ({} nodes, {} edges)",
|
||||
eprintln!(
|
||||
"{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})",
|
||||
graph.name,
|
||||
graph.nodes.len(),
|
||||
graph.edges.len(),
|
||||
bold = styles.bold, dim = styles.dim, reset = styles.reset,
|
||||
);
|
||||
|
||||
let goal = graph.goal();
|
||||
if !goal.is_empty() {
|
||||
println!("Goal: {goal}");
|
||||
eprintln!("{bold}Goal:{reset} {goal}", bold = styles.bold, reset = styles.reset);
|
||||
}
|
||||
|
||||
print_diagnostics(&diagnostics);
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
|
|
@ -59,12 +61,12 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
|
|||
// 3. Build event emitter
|
||||
let mut emitter = EventEmitter::new();
|
||||
if args.verbose >= 2 {
|
||||
emitter.on_event(|event| {
|
||||
eprint!("{}", format_event_detail(event));
|
||||
emitter.on_event(move |event| {
|
||||
eprint!("{}", format_event_detail(event, styles));
|
||||
});
|
||||
} else if args.verbose >= 1 {
|
||||
emitter.on_event(|event| {
|
||||
eprintln!("{}", format_event_summary(event));
|
||||
emitter.on_event(move |event| {
|
||||
eprintln!("{}", format_event_summary(event, styles));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +74,7 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
|
|||
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
|
||||
Arc::new(AutoApproveInterviewer)
|
||||
} else {
|
||||
Arc::new(ConsoleInterviewer)
|
||||
Arc::new(ConsoleInterviewer::new(styles))
|
||||
};
|
||||
|
||||
// 5. Resolve backend, model, and provider
|
||||
|
|
@ -81,12 +83,18 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
|
|||
} else {
|
||||
match llm::client::Client::from_env().await {
|
||||
Ok(c) if c.provider_names().is_empty() => {
|
||||
eprintln!("Warning: No LLM providers configured. Running in dry-run mode.");
|
||||
eprintln!(
|
||||
"{yellow}Warning:{reset} No LLM providers configured. Running in dry-run mode.",
|
||||
yellow = styles.yellow, reset = styles.reset,
|
||||
);
|
||||
true
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to initialize LLM client: {e}. Running in dry-run mode.");
|
||||
eprintln!(
|
||||
"{yellow}Warning:{reset} Failed to initialize LLM client: {e}. Running in dry-run mode.",
|
||||
yellow = styles.yellow, reset = styles.reset,
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -144,15 +152,32 @@ pub async fn run_command(args: RunArgs) -> anyhow::Result<()> {
|
|||
};
|
||||
|
||||
// 8. Print result
|
||||
println!("\n=== Pipeline Result ===");
|
||||
println!("Status: {}", outcome.status.to_string().to_uppercase());
|
||||
eprintln!(
|
||||
"\n{bold}=== Pipeline Result ==={reset}",
|
||||
bold = styles.bold, reset = styles.reset,
|
||||
);
|
||||
|
||||
let status_str = outcome.status.to_string().to_uppercase();
|
||||
let status_color = match outcome.status {
|
||||
StageStatus::Success | StageStatus::PartialSuccess => styles.green,
|
||||
_ => styles.red,
|
||||
};
|
||||
eprintln!("Status: {status_color}{status_str}{reset}", reset = styles.reset);
|
||||
|
||||
if let Some(notes) = &outcome.notes {
|
||||
println!("Notes: {notes}");
|
||||
eprintln!("Notes: {notes}");
|
||||
}
|
||||
if let Some(failure) = &outcome.failure_reason {
|
||||
println!("Failure: {failure}");
|
||||
eprintln!(
|
||||
"{red}Failure: {failure}{reset}",
|
||||
red = styles.red, reset = styles.reset,
|
||||
);
|
||||
}
|
||||
println!("Logs: {}", logs_dir.display());
|
||||
eprintln!(
|
||||
"{dim}Logs: {}{reset}",
|
||||
logs_dir.display(),
|
||||
dim = styles.dim, reset = styles.reset,
|
||||
);
|
||||
|
||||
// 9. Exit code
|
||||
match outcome.status {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::bail;
|
||||
use terminal::Styles;
|
||||
|
||||
use crate::pipeline::PipelineBuilder;
|
||||
use crate::validation::Severity;
|
||||
|
|
@ -10,23 +11,27 @@ use super::{print_diagnostics, read_dot_file, ValidateArgs};
|
|||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the file cannot be read, parsed, or has validation errors.
|
||||
pub fn validate_command(args: &ValidateArgs) -> anyhow::Result<()> {
|
||||
pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result<()> {
|
||||
let source = read_dot_file(&args.pipeline)?;
|
||||
let (graph, diagnostics) = PipelineBuilder::new().prepare(&source)?;
|
||||
|
||||
println!(
|
||||
"Parsed pipeline: {} ({} nodes, {} edges)",
|
||||
eprintln!(
|
||||
"{bold}Parsed pipeline:{reset} {} ({dim}{} nodes, {} edges{reset})",
|
||||
graph.name,
|
||||
graph.nodes.len(),
|
||||
graph.edges.len(),
|
||||
bold = styles.bold, dim = styles.dim, reset = styles.reset,
|
||||
);
|
||||
|
||||
print_diagnostics(&diagnostics);
|
||||
print_diagnostics(&diagnostics, styles);
|
||||
|
||||
if diagnostics.iter().any(|d| d.severity == Severity::Error) {
|
||||
bail!("Validation failed");
|
||||
}
|
||||
|
||||
println!("Validation: OK");
|
||||
eprintln!(
|
||||
"Validation: {green}OK{reset}",
|
||||
green = styles.green, reset = styles.reset,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
use async_trait::async_trait;
|
||||
use terminal::Styles;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
use super::{Answer, AnswerValue, Interviewer, Question, QuestionType};
|
||||
|
||||
/// Reads from stdin to collect answers. Displays formatted prompts per spec 6.4.
|
||||
pub struct ConsoleInterviewer;
|
||||
pub struct ConsoleInterviewer {
|
||||
styles: &'static Styles,
|
||||
}
|
||||
|
||||
impl ConsoleInterviewer {
|
||||
#[must_use]
|
||||
pub const fn new(styles: &'static Styles) -> Self {
|
||||
Self { styles }
|
||||
}
|
||||
}
|
||||
|
||||
fn find_matching_option(
|
||||
response: &str,
|
||||
|
|
@ -48,12 +58,21 @@ async fn read_line(prompt: &str) -> std::io::Result<String> {
|
|||
#[async_trait]
|
||||
impl Interviewer for ConsoleInterviewer {
|
||||
async fn ask(&self, question: Question) -> Answer {
|
||||
eprintln!("[?] {}", question.text);
|
||||
let s = self.styles;
|
||||
eprintln!(
|
||||
"{bold}{cyan}?{reset} {}",
|
||||
question.text,
|
||||
bold = s.bold, cyan = s.cyan, reset = s.reset,
|
||||
);
|
||||
|
||||
match question.question_type {
|
||||
QuestionType::MultipleChoice => {
|
||||
for (i, opt) in question.options.iter().enumerate() {
|
||||
eprintln!(" [{}] {} - {}", i + 1, opt.key, opt.label);
|
||||
eprintln!(
|
||||
" {dim}[{reset}{bold}{}{reset}{dim}]{reset} {} - {}",
|
||||
i + 1, opt.key, opt.label,
|
||||
dim = s.dim, bold = s.bold, reset = s.reset,
|
||||
);
|
||||
}
|
||||
if question.allow_freeform {
|
||||
eprintln!(" Or type a free-text response");
|
||||
|
|
@ -86,7 +105,11 @@ impl Interviewer for ConsoleInterviewer {
|
|||
}
|
||||
|
||||
async fn inform(&self, message: &str, stage: &str) {
|
||||
eprintln!("[{stage}] {message}");
|
||||
let s = self.styles;
|
||||
eprintln!(
|
||||
"{dim}[{stage}]{reset} {message}",
|
||||
dim = s.dim, reset = s.reset,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,25 @@
|
|||
use clap::Parser;
|
||||
use terminal::Styles;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let cli = attractor::cli::Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args).await,
|
||||
attractor::cli::Command::Validate(args) => attractor::cli::validate::validate_command(&args),
|
||||
attractor::cli::Command::Run(args) => attractor::cli::run::run_command(args, styles).await,
|
||||
attractor::cli::Command::Validate(args) => {
|
||||
attractor::cli::validate::validate_command(&args, styles)
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e:#}");
|
||||
eprintln!(
|
||||
"{red}Error:{reset} {e:#}",
|
||||
red = styles.red, reset = styles.reset,
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ fn validate_simple() {
|
|||
.args(["validate", "../../test/simple.dot"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Validation: OK"));
|
||||
.stderr(predicate::str::contains("Validation: OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -23,7 +23,7 @@ fn validate_branching() {
|
|||
.args(["validate", "../../test/branching.dot"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Validation: OK"));
|
||||
.stderr(predicate::str::contains("Validation: OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -32,7 +32,7 @@ fn validate_conditions() {
|
|||
.args(["validate", "../../test/conditions.dot"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Validation: OK"));
|
||||
.stderr(predicate::str::contains("Validation: OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -41,7 +41,7 @@ fn validate_parallel() {
|
|||
.args(["validate", "../../test/parallel.dot"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Validation: OK"));
|
||||
.stderr(predicate::str::contains("Validation: OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -50,7 +50,7 @@ fn validate_styled() {
|
|||
.args(["validate", "../../test/styled.dot"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Validation: OK"));
|
||||
.stderr(predicate::str::contains("Validation: OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
12
crates/terminal/Cargo.toml
Normal file
12
crates/terminal/Cargo.toml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "terminal"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Shared ANSI terminal styling for CLI binaries"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
89
crates/terminal/src/lib.rs
Normal file
89
crates/terminal/src/lib.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::io::IsTerminal;
|
||||
|
||||
/// Pre-resolved ANSI escape codes for styled terminal output.
|
||||
/// All fields are empty strings when color is disabled (non-TTY or `NO_COLOR`).
|
||||
pub struct Styles {
|
||||
pub bold: &'static str,
|
||||
pub dim: &'static str,
|
||||
pub cyan: &'static str,
|
||||
pub green: &'static str,
|
||||
pub yellow: &'static str,
|
||||
pub red: &'static str,
|
||||
pub reset: &'static str,
|
||||
}
|
||||
|
||||
// SAFETY: Styles contains only `&'static str` fields, which are inherently Send + Sync.
|
||||
unsafe impl Send for Styles {}
|
||||
unsafe impl Sync for Styles {}
|
||||
|
||||
impl Styles {
|
||||
#[must_use]
|
||||
pub const fn new(use_color: bool) -> Self {
|
||||
if use_color {
|
||||
Self {
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
cyan: "\x1b[36m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
red: "\x1b[31m",
|
||||
reset: "\x1b[0m",
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
bold: "",
|
||||
dim: "",
|
||||
cyan: "",
|
||||
green: "",
|
||||
yellow: "",
|
||||
red: "",
|
||||
reset: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create styles based on whether stderr is a TTY.
|
||||
/// Respects `NO_COLOR` environment variable.
|
||||
#[must_use]
|
||||
pub fn detect_stderr() -> Self {
|
||||
let use_color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none();
|
||||
Self::new(use_color)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn styles_with_color() {
|
||||
let s = Styles::new(true);
|
||||
assert_eq!(s.bold, "\x1b[1m");
|
||||
assert_eq!(s.dim, "\x1b[2m");
|
||||
assert_eq!(s.cyan, "\x1b[36m");
|
||||
assert_eq!(s.green, "\x1b[32m");
|
||||
assert_eq!(s.yellow, "\x1b[33m");
|
||||
assert_eq!(s.red, "\x1b[31m");
|
||||
assert_eq!(s.reset, "\x1b[0m");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_without_color() {
|
||||
let s = Styles::new(false);
|
||||
assert!(s.bold.is_empty());
|
||||
assert!(s.dim.is_empty());
|
||||
assert!(s.cyan.is_empty());
|
||||
assert!(s.green.is_empty());
|
||||
assert!(s.yellow.is_empty());
|
||||
assert!(s.red.is_empty());
|
||||
assert!(s.reset.is_empty());
|
||||
}
|
||||
|
||||
static NO_COLOR: Styles = Styles::new(false);
|
||||
|
||||
#[test]
|
||||
fn no_color_static_is_empty() {
|
||||
assert!(NO_COLOR.bold.is_empty());
|
||||
assert!(NO_COLOR.reset.is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue