use std::path::PathBuf;
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
use crate::scenarios::ScenarioCategory;
#[derive(Parser, Debug)]
#[command(name = "thoughtjack", author, version, about)]
#[command(propagate_version = true)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, action = ArgAction::Count, global = true, conflicts_with = "quiet")]
pub verbose: u8,
#[arg(short, long, global = true, conflicts_with = "verbose")]
pub quiet: bool,
#[arg(long, default_value = "auto", global = true, env = "THOUGHTJACK_COLOR")]
pub color: ColorChoice,
#[arg(
long,
default_value = "human",
global = true,
env = "THOUGHTJACK_LOG_FORMAT"
)]
pub log_format: LogFormatChoice,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Run(Box<RunArgs>),
Scenarios(ScenariosCommand),
Validate(ValidateArgs),
Version(VersionArgs),
}
#[derive(Args, Debug, Clone)]
pub struct ExecutionArgs {
#[arg(long, value_name = "ADDR:PORT")]
pub mcp_server: Option<String>,
#[arg(long, value_name = "CMD")]
pub mcp_client_command: Option<String>,
#[arg(long, value_name = "ARGS", requires = "mcp_client_command")]
pub mcp_client_args: Option<String>,
#[arg(long, value_name = "URL", conflicts_with = "mcp_client_command")]
pub mcp_client_endpoint: Option<String>,
#[arg(long, value_name = "URL")]
pub agui_client_endpoint: Option<String>,
#[arg(long, value_name = "ADDR:PORT")]
pub a2a_server: Option<String>,
#[arg(long, value_name = "URL")]
pub a2a_client_endpoint: Option<String>,
#[arg(long, value_name = "DURATION")]
pub grace_period: Option<humantime::Duration>,
#[arg(long, value_name = "DURATION", default_value = "5m")]
pub max_session: humantime::Duration,
#[arg(long, value_name = "DURATION", default_value = "30s")]
pub readiness_timeout: humantime::Duration,
#[arg(short, long, value_name = "PATH")]
pub output: Option<String>,
#[arg(long, value_name = "KEY:VALUE")]
pub header: Vec<String>,
#[arg(long)]
pub no_semantic: bool,
#[arg(long)]
pub raw_synthesize: bool,
#[arg(long, env = "THOUGHTJACK_METRICS_PORT")]
pub metrics_port: Option<u16>,
#[arg(long, env = "THOUGHTJACK_EVENTS_FILE")]
pub events_file: Option<PathBuf>,
#[arg(long, default_value = "auto", env = "THOUGHTJACK_PROGRESS")]
pub progress: ProgressLevel,
#[arg(long, value_name = "PATH", env = "THOUGHTJACK_EXPORT_TRACE")]
pub export_trace: Option<String>,
#[arg(long)]
pub context: bool,
#[arg(long, value_name = "MODEL", env = "THOUGHTJACK_CONTEXT_MODEL")]
pub context_model: Option<String>,
#[arg(long, value_name = "KEY", env = "THOUGHTJACK_CONTEXT_API_KEY")]
pub context_api_key: Option<String>,
#[arg(long, value_name = "URL", env = "THOUGHTJACK_CONTEXT_BASE_URL")]
pub context_base_url: Option<String>,
#[arg(
long,
value_name = "TYPE",
env = "THOUGHTJACK_CONTEXT_PROVIDER",
default_value = "openai"
)]
pub context_provider: String,
#[arg(long, value_name = "FLOAT", env = "THOUGHTJACK_CONTEXT_TEMPERATURE")]
pub context_temperature: Option<f32>,
#[arg(long, value_name = "TOKENS", env = "THOUGHTJACK_CONTEXT_MAX_TOKENS")]
pub context_max_tokens: Option<u32>,
#[arg(long, value_name = "PROMPT", env = "THOUGHTJACK_CONTEXT_SYSTEM_PROMPT")]
pub context_system_prompt: Option<String>,
#[arg(long, value_name = "SECONDS", env = "THOUGHTJACK_CONTEXT_TIMEOUT")]
pub context_timeout: Option<u64>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
pub max_turns: Option<u32>,
}
#[derive(Args, Debug)]
pub struct RunArgs {
#[arg(env = "THOUGHTJACK_SCENARIO", value_name = "SCENARIO")]
pub scenario: PathBuf,
#[command(flatten)]
pub execution: ExecutionArgs,
}
#[derive(Args, Debug)]
pub struct ScenariosCommand {
#[command(subcommand)]
pub subcommand: ScenariosSubcommand,
}
#[derive(Subcommand, Debug)]
pub enum ScenariosSubcommand {
List(ScenariosListArgs),
Show(ScenariosShowArgs),
Run(Box<ScenariosRunArgs>),
}
#[derive(Args, Debug)]
pub struct ScenariosListArgs {
#[arg(long)]
pub category: Option<ScenarioCategory>,
#[arg(long)]
pub tag: Option<String>,
#[arg(long, default_value = "human")]
pub format: OutputFormat,
}
#[derive(Args, Debug)]
pub struct ScenariosShowArgs {
pub name: String,
}
#[derive(Args, Debug)]
pub struct ScenariosRunArgs {
pub name: String,
#[command(flatten)]
pub execution: ExecutionArgs,
}
#[derive(Args, Debug)]
pub struct ValidateArgs {
pub path: PathBuf,
#[arg(long)]
pub normalize: bool,
}
#[derive(Args, Debug)]
pub struct VersionArgs {
#[arg(short, long, default_value = "human")]
pub format: OutputFormat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum LogFormatChoice {
#[default]
Human,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum ProgressLevel {
Off,
On,
#[default]
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Human,
Json,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_with_config() {
let cli = Cli::try_parse_from(["thoughtjack", "run", "test.yaml"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_help_output() {
let result = Cli::try_parse_from(["thoughtjack", "--help"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
}
#[test]
fn test_version_output() {
let result = Cli::try_parse_from(["thoughtjack", "--version"]);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
}
#[test]
fn test_color_choices_parse() {
for variant in ["auto", "always", "never"] {
let cli = Cli::try_parse_from(["thoughtjack", "--color", variant, "run", "x.yaml"]);
assert!(cli.is_ok(), "Failed to parse color={variant}");
}
}
#[test]
fn test_verbose_count() {
let cli = Cli::try_parse_from(["thoughtjack", "-vvv", "run", "x.yaml"]).unwrap();
assert_eq!(cli.verbose, 3);
}
#[test]
fn test_quiet_flag() {
let cli = Cli::try_parse_from(["thoughtjack", "--quiet", "run", "x.yaml"]).unwrap();
assert!(cli.quiet);
}
#[test]
fn test_no_args_fails() {
let result = Cli::try_parse_from(["thoughtjack"]);
assert!(result.is_err(), "Expected error when no subcommand given");
}
#[test]
fn test_unknown_subcommand_fails() {
let result = Cli::try_parse_from(["thoughtjack", "foobar"]);
assert!(result.is_err(), "Expected error for unknown subcommand");
}
#[test]
fn test_verbose_quiet_conflict() {
let result = Cli::try_parse_from(["thoughtjack", "--verbose", "--quiet", "run", "x.yaml"]);
assert!(result.is_err(), "Expected conflict error for -v + -q");
}
#[test]
fn test_excessive_verbosity_clamps() {
let cli = Cli::try_parse_from(["thoughtjack", "-vvvv", "run", "x.yaml"]).unwrap();
assert_eq!(cli.verbose, 4, "Expected verbosity count of 4");
}
#[test]
fn test_color_values() {
let expected = [
("auto", ColorChoice::Auto),
("always", ColorChoice::Always),
("never", ColorChoice::Never),
];
for (input, variant) in expected {
let cli =
Cli::try_parse_from(["thoughtjack", "--color", input, "run", "x.yaml"]).unwrap();
assert_eq!(cli.color, variant, "Unexpected color variant for {input}");
}
}
#[test]
fn test_invalid_color_value() {
let result = Cli::try_parse_from(["thoughtjack", "--color", "rainbow", "run", "x.yaml"]);
assert!(result.is_err(), "Expected error for invalid color value");
}
#[test]
fn test_scenarios_list_command() {
let cli = Cli::try_parse_from(["thoughtjack", "scenarios", "list"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_scenarios_list_with_category() {
let cli = Cli::try_parse_from([
"thoughtjack",
"scenarios",
"list",
"--category",
"injection",
]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_scenarios_show_command() {
let cli = Cli::try_parse_from(["thoughtjack", "scenarios", "show", "rug-pull"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_version_command() {
let cli = Cli::try_parse_from(["thoughtjack", "version"]).unwrap();
assert!(
matches!(cli.command, Commands::Version(_)),
"Expected Version command"
);
}
#[test]
fn test_log_format_values() {
let expected = [
("human", LogFormatChoice::Human),
("json", LogFormatChoice::Json),
];
for (input, variant) in expected {
let cli = Cli::try_parse_from(["thoughtjack", "--log-format", input, "run", "x.yaml"])
.unwrap();
assert_eq!(cli.log_format, variant, "Unexpected log-format for {input}");
}
}
#[test]
fn test_validate_command() {
let cli = Cli::try_parse_from(["thoughtjack", "validate", "scenario.yaml"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_validate_normalize() {
let cli = Cli::try_parse_from(["thoughtjack", "validate", "scenario.yaml", "--normalize"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_run_max_session() {
let cli = Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--max-session", "10m"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_run_grace_period() {
let cli = Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--grace-period", "30s"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_mcp_client_args_requires_command() {
let result =
Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--mcp-client-args", "foo"]);
assert!(
result.is_err(),
"Expected error: --mcp-client-args requires --mcp-client-command"
);
}
#[test]
fn test_mcp_client_command_endpoint_conflict() {
let result = Cli::try_parse_from([
"thoughtjack",
"run",
"x.yaml",
"--mcp-client-command",
"npx server",
"--mcp-client-endpoint",
"http://localhost:3000",
]);
assert!(
result.is_err(),
"Expected conflict: --mcp-client-command vs --mcp-client-endpoint"
);
}
#[test]
fn test_json_log_format() {
let cli =
Cli::try_parse_from(["thoughtjack", "--log-format", "json", "run", "x.yaml"]).unwrap();
assert_eq!(cli.log_format, LogFormatChoice::Json);
let invalid = Cli::try_parse_from(["thoughtjack", "--log-format", "xml", "run", "x.yaml"]);
assert!(
invalid.is_err(),
"Expected parse error for invalid log format 'xml'"
);
}
#[test]
fn test_scenarios_run_rejects_config() {
let result = Cli::try_parse_from(["thoughtjack", "scenarios", "run", "rug-pull", "x.yaml"]);
assert!(
result.is_err(),
"Expected clap parse error: scenarios run should not accept positional path"
);
}
#[test]
fn test_scenarios_run_without_config() {
let cli = Cli::try_parse_from(["thoughtjack", "scenarios", "run", "rug-pull"]);
assert!(cli.is_ok(), "Failed to parse: {cli:?}");
}
#[test]
fn test_run_without_config_fails() {
let cli = Cli::try_parse_from(["thoughtjack", "run"]);
assert!(cli.is_err(), "Expected clap parse error");
}
#[test]
fn test_max_turns_zero_rejected() {
let result = Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--max-turns", "0"]);
assert!(result.is_err(), "Expected error for --max-turns 0");
}
#[test]
fn test_max_turns_one_accepted() {
let cli = Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--max-turns", "1"]);
assert!(cli.is_ok(), "Failed to parse --max-turns 1");
}
#[test]
fn test_progress_values() {
let expected = [
("off", ProgressLevel::Off),
("on", ProgressLevel::On),
("auto", ProgressLevel::Auto),
];
for (input, variant) in expected {
let cli =
Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--progress", input]).unwrap();
match cli.command {
Commands::Run(args) => {
assert_eq!(args.execution.progress, variant, "for --progress {input}");
}
_ => panic!("Expected Run command"),
}
}
}
#[test]
fn test_invalid_progress_value() {
let result = Cli::try_parse_from(["thoughtjack", "run", "x.yaml", "--progress", "verbose"]);
assert!(result.is_err(), "Expected error for invalid progress value");
}
#[test]
fn test_progress_default_auto() {
let cli = Cli::try_parse_from(["thoughtjack", "run", "x.yaml"]).unwrap();
match cli.command {
Commands::Run(args) => {
assert_eq!(args.execution.progress, ProgressLevel::Auto);
}
_ => panic!("Expected Run command"),
}
}
}