Robot mode: TTY auto-detect, env var, --no-robot flag, robot-docs command
Robot mode resolution is now a 4-tier cascade: 1. --no-robot (explicit off, conflicts_with robot) 2. --robot / --json (explicit on) 3. SWAGGER_CLI_ROBOT=1|true env var 4. TTY auto-detection (non-TTY stdout -> robot mode) Pre-scan updated to match the same resolution logic so parse errors get the correct output format before clap finishes parsing. New robot-docs subcommand (alias: docs) provides machine-readable self-documentation for AI agents: guide, commands, exit-codes, and workflows topics. Designed for LLM consumption — structured JSON with all flags, aliases, and usage patterns in one call. robot_success_pretty added for --pretty support on robot-docs output. Visible aliases added to commands: list->ls, show->info, search->find.
This commit is contained in:
@@ -4,6 +4,7 @@ pub mod diff;
|
||||
pub mod doctor;
|
||||
pub mod fetch;
|
||||
pub mod list;
|
||||
pub mod robot_docs;
|
||||
pub mod schemas;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
@@ -21,10 +22,14 @@ pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// Output machine-readable JSON
|
||||
#[arg(long, global = true)]
|
||||
/// Output machine-readable JSON (alias: --json)
|
||||
#[arg(long, global = true, visible_alias = "json")]
|
||||
pub robot: bool,
|
||||
|
||||
/// Force human-readable output (overrides TTY auto-detection and env var)
|
||||
#[arg(long, global = true, conflicts_with = "robot")]
|
||||
pub no_robot: bool,
|
||||
|
||||
/// Pretty-print JSON output
|
||||
#[arg(long, global = true)]
|
||||
pub pretty: bool,
|
||||
@@ -44,12 +49,15 @@ pub enum Commands {
|
||||
Fetch(fetch::Args),
|
||||
|
||||
/// List endpoints from a cached spec
|
||||
#[command(visible_alias = "ls")]
|
||||
List(list::Args),
|
||||
|
||||
/// Show details of a specific endpoint
|
||||
#[command(visible_alias = "info")]
|
||||
Show(show::Args),
|
||||
|
||||
/// Search endpoints by keyword
|
||||
#[command(visible_alias = "find")]
|
||||
Search(search::Args),
|
||||
|
||||
/// List or show schemas from a cached spec
|
||||
@@ -72,4 +80,8 @@ pub enum Commands {
|
||||
|
||||
/// Compare two versions of a spec
|
||||
Diff(diff::Args),
|
||||
|
||||
/// Machine-readable documentation for AI agents
|
||||
#[command(visible_alias = "docs")]
|
||||
RobotDocs(robot_docs::Args),
|
||||
}
|
||||
|
||||
611
src/cli/robot_docs.rs
Normal file
611
src/cli/robot_docs.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use clap::Args as ClapArgs;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
use crate::output::robot;
|
||||
|
||||
/// Machine-readable documentation for AI agents
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Topic: guide (default), commands, exit-codes, workflows
|
||||
#[arg(default_value = "guide")]
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — guide
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GuideOutput {
|
||||
tool: &'static str,
|
||||
version: &'static str,
|
||||
about: &'static str,
|
||||
robot_activation: RobotActivation,
|
||||
commands: Vec<CommandBrief>,
|
||||
exit_codes: Vec<(u8, &'static str)>,
|
||||
quick_start: &'static str,
|
||||
output_envelope: &'static str,
|
||||
topics: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RobotActivation {
|
||||
flags: Vec<&'static str>,
|
||||
env: &'static str,
|
||||
auto: &'static str,
|
||||
disable: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandBrief {
|
||||
cmd: &'static str,
|
||||
does: &'static str,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
aliases: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandsOutput {
|
||||
commands: Vec<CommandDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandDetail {
|
||||
name: &'static str,
|
||||
usage: &'static str,
|
||||
description: &'static str,
|
||||
key_flags: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — exit-codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExitCodesOutput {
|
||||
exit_codes: Vec<ExitCodeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExitCodeEntry {
|
||||
code: u8,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkflowsOutput {
|
||||
workflows: Vec<Workflow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Workflow {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
steps: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_guide() -> GuideOutput {
|
||||
GuideOutput {
|
||||
tool: "swagger-cli",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
about: "Cache-first CLI for exploring OpenAPI specs. <50ms queries after initial fetch.",
|
||||
robot_activation: RobotActivation {
|
||||
flags: vec!["--robot", "--json"],
|
||||
env: "SWAGGER_CLI_ROBOT=1",
|
||||
auto: "JSON when stdout is not a TTY",
|
||||
disable: "--no-robot",
|
||||
},
|
||||
commands: vec![
|
||||
CommandBrief {
|
||||
cmd: "fetch <url> --alias NAME",
|
||||
does: "Download + cache a spec",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "list [ALIAS]",
|
||||
does: "List endpoints (filterable by method/tag/path)",
|
||||
aliases: vec!["ls"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "show ALIAS PATH [-m METHOD]",
|
||||
does: "Endpoint detail with params/body/responses",
|
||||
aliases: vec!["info"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "search ALIAS QUERY",
|
||||
does: "Full-text search endpoints + schemas",
|
||||
aliases: vec!["find"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "schemas ALIAS [--show NAME]",
|
||||
does: "List or inspect schema definitions",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "tags ALIAS",
|
||||
does: "List tags with endpoint counts",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "aliases [--list|--show|--rename|--delete|--set-default]",
|
||||
does: "Manage cached spec aliases",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "sync [ALIAS|--all]",
|
||||
does: "Re-fetch + update cached specs",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "doctor [--fix]",
|
||||
does: "Check + repair cache health",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "cache [--stats|--path|--prune-stale]",
|
||||
does: "Cache statistics + cleanup",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "diff LEFT RIGHT",
|
||||
does: "Compare two spec versions",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "robot-docs [TOPIC]",
|
||||
does: "This documentation (always JSON)",
|
||||
aliases: vec!["docs"],
|
||||
},
|
||||
],
|
||||
exit_codes: vec![
|
||||
(0, "SUCCESS"),
|
||||
(2, "USAGE_ERROR"),
|
||||
(4, "NETWORK_ERROR"),
|
||||
(5, "INVALID_SPEC"),
|
||||
(6, "ALIAS_EXISTS"),
|
||||
(7, "AUTH_ERROR"),
|
||||
(8, "ALIAS_NOT_FOUND"),
|
||||
(9, "CACHE_LOCKED"),
|
||||
(10, "CACHE_ERROR"),
|
||||
(11, "CONFIG_ERROR"),
|
||||
(12, "IO_ERROR"),
|
||||
(13, "JSON_ERROR"),
|
||||
(14, "CACHE_INTEGRITY"),
|
||||
(15, "OFFLINE_MODE"),
|
||||
(16, "POLICY_BLOCKED"),
|
||||
],
|
||||
quick_start: "swagger-cli fetch URL --alias NAME && swagger-cli list NAME",
|
||||
output_envelope: "{ok,data?,error?{code,message,suggestion?},meta{schema_version,tool_version,command,duration_ms}}",
|
||||
topics: vec!["guide", "commands", "exit-codes", "workflows"],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_commands() -> CommandsOutput {
|
||||
CommandsOutput {
|
||||
commands: vec![
|
||||
CommandDetail {
|
||||
name: "fetch",
|
||||
usage: "fetch <url|file|-> --alias <name>",
|
||||
description: "Download and cache an OpenAPI spec from URL, local file, or stdin",
|
||||
key_flags: vec![
|
||||
"--alias NAME (required)",
|
||||
"--force: overwrite existing",
|
||||
"--bearer TOKEN: auth header",
|
||||
"--auth-profile NAME: config auth profile",
|
||||
"-H 'Key: Val': extra headers (repeatable)",
|
||||
"--timeout-ms N (default: 10000)",
|
||||
"--retries N (default: 2)",
|
||||
"--resolve-external-refs: inline external $ref entries",
|
||||
"--allow-private-host HOST (repeatable)",
|
||||
"--allow-insecure-http",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "list",
|
||||
usage: "list [ALIAS] [--all-aliases]",
|
||||
description: "List endpoints from cached spec(s) with filtering and sorting",
|
||||
key_flags: vec![
|
||||
"-m METHOD: filter by HTTP method",
|
||||
"-t TAG: filter by tag",
|
||||
"-p REGEX: filter by path pattern",
|
||||
"--sort path|method|tag (default: path)",
|
||||
"-n LIMIT (default: 50)",
|
||||
"-a/--all: show all (no limit)",
|
||||
"--all-aliases: query every cached spec",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "show",
|
||||
usage: "show <ALIAS> <PATH> [-m METHOD]",
|
||||
description: "Show full endpoint detail: parameters, request body, responses",
|
||||
key_flags: vec![
|
||||
"-m METHOD: required when path has multiple methods",
|
||||
"--expand-refs: inline $ref entries",
|
||||
"--max-depth N (default: 3)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "search",
|
||||
usage: "search <ALIAS> <QUERY> | search --all-aliases <QUERY>",
|
||||
description: "Full-text search across endpoints and schemas with ranked results",
|
||||
key_flags: vec![
|
||||
"--all-aliases: search all cached specs",
|
||||
"--case-sensitive",
|
||||
"--exact: match as exact phrase",
|
||||
"--in all|paths|descriptions|schemas",
|
||||
"--limit N (default: 20)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "schemas",
|
||||
usage: "schemas <ALIAS> [--show NAME]",
|
||||
description: "List schema names or show a specific schema definition",
|
||||
key_flags: vec![
|
||||
"--name REGEX: filter names",
|
||||
"--list: list mode (default)",
|
||||
"--show NAME: show specific schema",
|
||||
"--expand-refs: inline $ref",
|
||||
"--max-depth N (default: 3)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "tags",
|
||||
usage: "tags <ALIAS>",
|
||||
description: "List tags from spec with endpoint counts",
|
||||
key_flags: vec![],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "aliases",
|
||||
usage: "aliases [--list|--show A|--rename OLD NEW|--delete A|--set-default A]",
|
||||
description: "Manage cached spec aliases",
|
||||
key_flags: vec![
|
||||
"--list (default)",
|
||||
"--show ALIAS: full details",
|
||||
"--rename OLD NEW",
|
||||
"--delete ALIAS",
|
||||
"--set-default ALIAS",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "sync",
|
||||
usage: "sync [ALIAS] [--all]",
|
||||
description: "Re-fetch and update cached specs from upstream",
|
||||
key_flags: vec![
|
||||
"--all: sync every alias",
|
||||
"--dry-run: check without writing",
|
||||
"--force: re-fetch regardless of freshness",
|
||||
"--details: include change lists",
|
||||
"--auth PROFILE",
|
||||
"--jobs N (default: 4)",
|
||||
"--per-host N (default: 2)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "doctor",
|
||||
usage: "doctor [--fix] [--alias ALIAS]",
|
||||
description: "Check cache integrity and diagnose issues",
|
||||
key_flags: vec![
|
||||
"--fix: attempt automatic repair",
|
||||
"--alias ALIAS: check one alias only",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "cache",
|
||||
usage: "cache [--stats|--path|--prune-stale|--max-total-mb N]",
|
||||
description: "Cache statistics, location, and cleanup",
|
||||
key_flags: vec![
|
||||
"--stats (default)",
|
||||
"--path: print cache directory",
|
||||
"--prune-stale: remove stale aliases",
|
||||
"--prune-threshold DAYS (default: 90)",
|
||||
"--max-total-mb N: LRU eviction",
|
||||
"--dry-run: report without deleting",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "diff",
|
||||
usage: "diff <LEFT> <RIGHT>",
|
||||
description: "Compare two cached spec versions for breaking/non-breaking changes",
|
||||
key_flags: vec![
|
||||
"--fail-on breaking|non-breaking: exit non-zero on changes",
|
||||
"--details: per-item change descriptions",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exit_codes() -> ExitCodesOutput {
|
||||
ExitCodesOutput {
|
||||
exit_codes: vec![
|
||||
ExitCodeEntry {
|
||||
code: 0,
|
||||
name: "SUCCESS",
|
||||
description: "Operation completed successfully",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 2,
|
||||
name: "USAGE_ERROR",
|
||||
description: "Invalid arguments or flags",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 4,
|
||||
name: "NETWORK_ERROR",
|
||||
description: "HTTP request failed",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 5,
|
||||
name: "INVALID_SPEC",
|
||||
description: "Not a valid OpenAPI 3.x spec",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 6,
|
||||
name: "ALIAS_EXISTS",
|
||||
description: "Alias already cached (use --force)",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 7,
|
||||
name: "AUTH_ERROR",
|
||||
description: "Authentication failed",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 8,
|
||||
name: "ALIAS_NOT_FOUND",
|
||||
description: "No cached spec with that alias",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 9,
|
||||
name: "CACHE_LOCKED",
|
||||
description: "Another process holds the lock",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 10,
|
||||
name: "CACHE_ERROR",
|
||||
description: "Cache read/write failure",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 11,
|
||||
name: "CONFIG_ERROR",
|
||||
description: "Config file invalid",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 12,
|
||||
name: "IO_ERROR",
|
||||
description: "Filesystem I/O failure",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 13,
|
||||
name: "JSON_ERROR",
|
||||
description: "JSON parse/serialize failure",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 14,
|
||||
name: "CACHE_INTEGRITY",
|
||||
description: "Cache data corrupted (run doctor --fix)",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 15,
|
||||
name: "OFFLINE_MODE",
|
||||
description: "Network required but offline mode active",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 16,
|
||||
name: "POLICY_BLOCKED",
|
||||
description: "Request blocked by network policy",
|
||||
retryable: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_workflows() -> WorkflowsOutput {
|
||||
WorkflowsOutput {
|
||||
workflows: vec![
|
||||
Workflow {
|
||||
name: "first-use",
|
||||
description: "Fetch a spec and start exploring",
|
||||
steps: vec![
|
||||
"swagger-cli fetch https://petstore3.swagger.io/api/v3/openapi.json --alias petstore",
|
||||
"swagger-cli list petstore",
|
||||
"swagger-cli search petstore 'pet'",
|
||||
"swagger-cli show petstore /pet/{petId} -m GET",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "api-discovery",
|
||||
description: "Explore an unfamiliar API systematically",
|
||||
steps: vec![
|
||||
"swagger-cli tags ALIAS",
|
||||
"swagger-cli list ALIAS -t TAG_NAME",
|
||||
"swagger-cli show ALIAS PATH -m METHOD --expand-refs",
|
||||
"swagger-cli schemas ALIAS --show SCHEMA_NAME",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "multi-spec",
|
||||
description: "Work across multiple cached APIs",
|
||||
steps: vec![
|
||||
"swagger-cli aliases --list",
|
||||
"swagger-cli search --all-aliases 'user'",
|
||||
"swagger-cli list --all-aliases -m POST",
|
||||
"swagger-cli diff api-v1 api-v2",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "maintenance",
|
||||
description: "Keep cache healthy and up-to-date",
|
||||
steps: vec![
|
||||
"swagger-cli doctor",
|
||||
"swagger-cli sync --all --dry-run",
|
||||
"swagger-cli sync --all",
|
||||
"swagger-cli cache --stats",
|
||||
"swagger-cli cache --prune-stale --prune-threshold 60",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn execute(args: &Args, pretty: bool) -> Result<(), SwaggerCliError> {
|
||||
let start = Instant::now();
|
||||
let topic = args.topic.as_str();
|
||||
|
||||
match topic {
|
||||
"guide" => emit(build_guide(), pretty, start),
|
||||
"commands" => emit(build_commands(), pretty, start),
|
||||
"exit-codes" => emit(build_exit_codes(), pretty, start),
|
||||
"workflows" => emit(build_workflows(), pretty, start),
|
||||
unknown => Err(SwaggerCliError::Usage(format!(
|
||||
"Unknown robot-docs topic '{unknown}'. Valid: guide, commands, exit-codes, workflows"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit<T: Serialize>(data: T, pretty: bool, start: Instant) -> Result<(), SwaggerCliError> {
|
||||
let duration = start.elapsed();
|
||||
if pretty {
|
||||
robot::robot_success_pretty(data, "robot-docs", duration);
|
||||
} else {
|
||||
robot::robot_success(data, "robot-docs", duration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guide_serializes_without_panic() {
|
||||
let guide = build_guide();
|
||||
let json = serde_json::to_string(&guide).unwrap();
|
||||
assert!(json.contains("swagger-cli"));
|
||||
assert!(json.contains("robot_activation"));
|
||||
assert!(json.contains("exit_codes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_serializes_without_panic() {
|
||||
let cmds = build_commands();
|
||||
let json = serde_json::to_string(&cmds).unwrap();
|
||||
assert!(json.contains("\"fetch\""));
|
||||
assert!(json.contains("\"list\""));
|
||||
assert!(json.contains("\"show\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_serializes_without_panic() {
|
||||
let codes = build_exit_codes();
|
||||
let json = serde_json::to_string(&codes).unwrap();
|
||||
assert!(json.contains("USAGE_ERROR"));
|
||||
assert!(json.contains("NETWORK_ERROR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflows_serializes_without_panic() {
|
||||
let wf = build_workflows();
|
||||
let json = serde_json::to_string(&wf).unwrap();
|
||||
assert!(json.contains("first-use"));
|
||||
assert!(json.contains("api-discovery"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_command_count_matches_enum() {
|
||||
let guide = build_guide();
|
||||
// 11 main commands + robot-docs itself = 12
|
||||
assert_eq!(guide.commands.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_has_no_gaps_in_critical_range() {
|
||||
let codes = build_exit_codes();
|
||||
// Verify code 0 and 2 exist (most important for agents)
|
||||
assert!(codes.exit_codes.iter().any(|c| c.code == 0));
|
||||
assert!(codes.exit_codes.iter().any(|c| c.code == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_aliases_populated_for_aliased_commands() {
|
||||
let guide = build_guide();
|
||||
let list_cmd = guide
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.starts_with("list"))
|
||||
.unwrap();
|
||||
assert!(list_cmd.aliases.contains(&"ls"));
|
||||
|
||||
let search_cmd = guide
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.starts_with("search"))
|
||||
.unwrap();
|
||||
assert!(search_cmd.aliases.contains(&"find"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_topic_returns_usage_error() {
|
||||
let args = Args {
|
||||
topic: "nonexistent".to_string(),
|
||||
};
|
||||
let result = execute(&args, false);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
SwaggerCliError::Usage(msg) => {
|
||||
assert!(msg.contains("nonexistent"));
|
||||
assert!(msg.contains("guide"));
|
||||
}
|
||||
other => panic!("expected Usage error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_token_efficiency() {
|
||||
let guide = build_guide();
|
||||
let json = serde_json::to_string(&guide).unwrap();
|
||||
// Compact JSON should be under 2500 chars (~400 tokens)
|
||||
assert!(
|
||||
json.len() < 2500,
|
||||
"guide JSON is {} chars, should be <2500 for token efficiency",
|
||||
json.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/main.rs
58
src/main.rs
@@ -1,5 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -9,8 +10,51 @@ use swagger_cli::cli::{Cli, Commands};
|
||||
use swagger_cli::errors::SwaggerCliError;
|
||||
use swagger_cli::output::robot;
|
||||
|
||||
/// Pre-scan for robot mode before clap parses, so parse errors get the right
|
||||
/// output format. Mirrors the resolution logic in `resolve_robot_mode`.
|
||||
fn pre_scan_robot() -> bool {
|
||||
std::env::args().any(|a| a == "--robot")
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// --no-robot always wins
|
||||
if args.iter().any(|a| a == "--no-robot") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit --robot or --json
|
||||
if args.iter().any(|a| a == "--robot" || a == "--json") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Environment variable
|
||||
if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// TTY auto-detection: JSON when stdout is not a TTY
|
||||
!std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// Resolve robot mode after clap parses. Resolution order:
|
||||
/// 1. --no-robot (explicit off)
|
||||
/// 2. --robot / --json (explicit on)
|
||||
/// 3. SWAGGER_CLI_ROBOT env var
|
||||
/// 4. TTY auto-detection (not a TTY → robot mode)
|
||||
fn resolve_robot_mode(cli: &Cli) -> bool {
|
||||
if cli.no_robot {
|
||||
return false;
|
||||
}
|
||||
|
||||
if cli.robot {
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
!std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
fn command_name(cli: &Cli) -> &'static str {
|
||||
@@ -26,6 +70,7 @@ fn command_name(cli: &Cli) -> &'static str {
|
||||
Commands::Doctor(_) => "doctor",
|
||||
Commands::Cache(_) => "cache",
|
||||
Commands::Diff(_) => "diff",
|
||||
Commands::RobotDocs(_) => "robot-docs",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +96,12 @@ async fn main() -> ExitCode {
|
||||
let cli = match Cli::try_parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(err) => {
|
||||
if is_robot {
|
||||
// Help and version requests always use human output, even when piped
|
||||
let is_info = matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
|
||||
);
|
||||
if is_robot && !is_info {
|
||||
let parse_err = SwaggerCliError::Usage(err.to_string());
|
||||
output_robot_error(&parse_err, "unknown", start.elapsed());
|
||||
return parse_err.to_exit_code();
|
||||
@@ -61,7 +111,8 @@ async fn main() -> ExitCode {
|
||||
};
|
||||
|
||||
let cmd = command_name(&cli);
|
||||
let robot = cli.robot;
|
||||
let robot = resolve_robot_mode(&cli);
|
||||
let pretty = cli.pretty;
|
||||
|
||||
let network_flag = cli.network.as_str();
|
||||
let config_override = cli.config.as_deref();
|
||||
@@ -82,6 +133,7 @@ async fn main() -> ExitCode {
|
||||
Commands::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::execute(args, robot).await,
|
||||
Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||
Commands::RobotDocs(args) => swagger_cli::cli::robot_docs::execute(args, pretty),
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
@@ -30,6 +30,13 @@ pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
pub fn robot_success_pretty<T: Serialize>(data: T, command: &str, duration: Duration) {
|
||||
let meta = build_meta(command, duration);
|
||||
let envelope = RobotEnvelope::success(data, meta);
|
||||
let json = serde_json::to_string_pretty(&envelope).expect("serialization should not fail");
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
pub fn robot_error(
|
||||
code: &str,
|
||||
message: &str,
|
||||
|
||||
Reference in New Issue
Block a user