use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ValueType {
String,
Integer,
Float,
Boolean,
Path,
Enum,
Url,
#[default]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FlagSource {
Help,
ManPage,
Completion,
Overlay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Confidence {
#[default]
Low,
Medium,
High,
Verified,
}
impl Confidence {
pub fn from_sources(sources: &[FlagSource]) -> Self {
if sources.contains(&FlagSource::Overlay) {
Confidence::Verified
} else if sources.contains(&FlagSource::Completion) {
Confidence::High
} else if sources.len() >= 2 {
Confidence::Medium
} else {
Confidence::Low
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HelpFormat {
Gnu,
Click,
Argparse,
Cobra,
Clap,
#[default]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScannedFlag {
pub long_name: Option<String>,
pub short_name: Option<String>,
pub description: String,
pub value_type: ValueType,
pub required: bool,
pub default: Option<String>,
pub enum_values: Option<Vec<String>>,
pub repeatable: bool,
pub value_name: Option<String>,
#[serde(default)]
pub long_running: bool,
#[serde(default)]
pub conflicts_with: Vec<String>,
#[serde(default)]
pub sources: Vec<FlagSource>,
#[serde(default)]
pub confidence: Confidence,
}
impl ScannedFlag {
pub fn canonical_name(&self) -> String {
if let Some(ref long) = self.long_name {
long.trim_start_matches('-').replace('-', "_")
} else if let Some(ref short) = self.short_name {
short.trim_start_matches('-').to_string()
} else {
"unknown".to_string()
}
}
pub fn add_source(&mut self, source: FlagSource) {
if !self.sources.contains(&source) {
self.sources.push(source);
}
self.confidence = Confidence::from_sources(&self.sources);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScannedArg {
pub name: String,
pub description: String,
pub value_type: ValueType,
pub required: bool,
pub variadic: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct StructuredOutputInfo {
pub supported: bool,
pub flag: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScannedCommand {
pub name: String,
pub full_command: String,
pub description: String,
pub flags: Vec<ScannedFlag>,
pub positional_args: Vec<ScannedArg>,
pub subcommands: Vec<ScannedCommand>,
pub examples: Vec<String>,
pub help_format: HelpFormat,
pub structured_output: StructuredOutputInfo,
pub raw_help: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ScannedCLITool {
pub name: String,
#[serde(default)]
pub description: String,
pub binary_path: String,
pub version: Option<String>,
#[serde(default)]
pub variant: ToolVariant,
#[serde(default)]
pub overlay: Option<String>,
pub subcommands: Vec<ScannedCommand>,
pub global_flags: Vec<ScannedFlag>,
#[serde(default)]
pub positional_args: Vec<ScannedArg>,
pub structured_output: StructuredOutputInfo,
#[serde(default)]
pub annotation_overrides: AnnotationOverrides,
pub scan_tier: u32,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ToolVariant {
Bsd,
Gnu,
Apple,
Busybox,
#[default]
Unknown,
}
impl ToolVariant {
pub fn as_str(self) -> &'static str {
match self {
ToolVariant::Bsd => "bsd",
ToolVariant::Gnu => "gnu",
ToolVariant::Apple => "apple",
ToolVariant::Busybox => "busybox",
ToolVariant::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnnotationOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub readonly: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub destructive: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotent: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requires_approval: Option<bool>,
}
impl AnnotationOverrides {
pub fn is_empty(&self) -> bool {
*self == Self::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_value_type_serde_round_trip() {
let types = vec![
(ValueType::String, "\"string\""),
(ValueType::Integer, "\"integer\""),
(ValueType::Float, "\"float\""),
(ValueType::Boolean, "\"boolean\""),
(ValueType::Path, "\"path\""),
(ValueType::Enum, "\"enum\""),
(ValueType::Url, "\"url\""),
(ValueType::Unknown, "\"unknown\""),
];
for (variant, expected_json) in types {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json, "Serialize {variant:?}");
let back: ValueType = serde_json::from_str(&json).unwrap();
assert_eq!(back, variant, "Deserialize {variant:?}");
}
}
#[test]
fn test_value_type_debug_clone() {
let v = ValueType::Boolean;
let cloned = v;
assert_eq!(format!("{:?}", cloned), "Boolean");
}
#[test]
fn test_help_format_serde_round_trip() {
let formats = vec![
(HelpFormat::Gnu, "\"gnu\""),
(HelpFormat::Click, "\"click\""),
(HelpFormat::Argparse, "\"argparse\""),
(HelpFormat::Cobra, "\"cobra\""),
(HelpFormat::Clap, "\"clap\""),
(HelpFormat::Unknown, "\"unknown\""),
];
for (variant, expected_json) in formats {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected_json, "Serialize {variant:?}");
let back: HelpFormat = serde_json::from_str(&json).unwrap();
assert_eq!(back, variant, "Deserialize {variant:?}");
}
}
#[test]
fn test_confidence_from_sources_ranks_overlay_highest() {
assert_eq!(
Confidence::from_sources(&[FlagSource::Help, FlagSource::Overlay]),
Confidence::Verified
);
assert_eq!(
Confidence::from_sources(&[FlagSource::Help, FlagSource::Completion]),
Confidence::High
);
assert_eq!(
Confidence::from_sources(&[FlagSource::Help, FlagSource::ManPage]),
Confidence::Medium
);
assert_eq!(
Confidence::from_sources(&[FlagSource::Help]),
Confidence::Low
);
assert_eq!(Confidence::from_sources(&[]), Confidence::Low);
}
#[test]
fn test_confidence_is_ordered_worst_to_best() {
assert!(Confidence::Low < Confidence::Medium);
assert!(Confidence::Medium < Confidence::High);
assert!(Confidence::High < Confidence::Verified);
}
#[test]
fn test_add_source_is_idempotent() {
let mut flag = ScannedFlag::default();
flag.add_source(FlagSource::Help);
flag.add_source(FlagSource::Help);
assert_eq!(flag.sources, vec![FlagSource::Help]);
assert_eq!(flag.confidence, Confidence::Low);
flag.add_source(FlagSource::ManPage);
assert_eq!(flag.confidence, Confidence::Medium);
}
#[test]
fn test_tool_variant_tokens_are_stable() {
assert_eq!(ToolVariant::Bsd.as_str(), "bsd");
assert_eq!(ToolVariant::Gnu.as_str(), "gnu");
assert_eq!(ToolVariant::Apple.as_str(), "apple");
assert_eq!(ToolVariant::Busybox.as_str(), "busybox");
assert_eq!(ToolVariant::Unknown.as_str(), "unknown");
assert_eq!(ToolVariant::default(), ToolVariant::Unknown);
}
#[test]
fn test_tool_variant_serde_matches_tokens() {
for variant in [
ToolVariant::Bsd,
ToolVariant::Gnu,
ToolVariant::Apple,
ToolVariant::Busybox,
ToolVariant::Unknown,
] {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, format!("\"{}\"", variant.as_str()));
let back: ToolVariant = serde_json::from_str(&json).unwrap();
assert_eq!(back, variant);
}
}
#[test]
fn test_annotation_overrides_empty_by_default() {
assert!(AnnotationOverrides::default().is_empty());
assert!(!AnnotationOverrides {
readonly: Some(true),
..Default::default()
}
.is_empty());
}
#[test]
fn test_scanned_flag_provenance_defaults_on_legacy_json() {
let legacy = r#"{"long_name":"--all","short_name":"-a","description":"",
"value_type":"boolean","required":false,"default":null,
"enum_values":null,"repeatable":false,"value_name":null}"#;
let flag: ScannedFlag = serde_json::from_str(legacy).unwrap();
assert!(flag.sources.is_empty());
assert!(flag.conflicts_with.is_empty());
assert_eq!(flag.confidence, Confidence::Low);
assert!(!flag.long_running);
}
#[test]
fn test_canonical_name_long() {
let flag = ScannedFlag {
long_name: Some("--message".into()),
short_name: Some("-m".into()),
description: String::new(),
value_type: ValueType::String,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
};
assert_eq!(flag.canonical_name(), "message");
}
#[test]
fn test_canonical_name_dry_run() {
let flag = ScannedFlag {
long_name: Some("--dry-run".into()),
short_name: None,
description: String::new(),
value_type: ValueType::Boolean,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
};
assert_eq!(flag.canonical_name(), "dry_run");
}
#[test]
fn test_canonical_name_short_only() {
let flag = ScannedFlag {
long_name: None,
short_name: Some("-m".into()),
description: String::new(),
value_type: ValueType::String,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
};
assert_eq!(flag.canonical_name(), "m");
}
#[test]
fn test_canonical_name_neither() {
let flag = ScannedFlag {
long_name: None,
short_name: None,
description: String::new(),
value_type: ValueType::Unknown,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
};
assert_eq!(flag.canonical_name(), "unknown");
}
#[test]
fn test_scanned_flag_serde_all_fields() {
let flag = ScannedFlag {
long_name: Some("--format".into()),
short_name: Some("-f".into()),
description: "Output format".into(),
value_type: ValueType::Enum,
required: true,
default: Some("json".into()),
enum_values: Some(vec!["json".into(), "text".into()]),
repeatable: false,
value_name: Some("FMT".into()),
..Default::default()
};
let json = serde_json::to_string(&flag).unwrap();
let back: ScannedFlag = serde_json::from_str(&json).unwrap();
assert_eq!(back.long_name, flag.long_name);
assert_eq!(back.short_name, flag.short_name);
assert_eq!(back.value_type, flag.value_type);
assert_eq!(back.required, flag.required);
assert_eq!(back.enum_values, flag.enum_values);
}
#[test]
fn test_scanned_flag_serde_optional_none() {
let flag = ScannedFlag {
long_name: None,
short_name: Some("-v".into()),
description: "Verbose".into(),
value_type: ValueType::Boolean,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
};
let json = serde_json::to_string(&flag).unwrap();
let back: ScannedFlag = serde_json::from_str(&json).unwrap();
assert!(back.long_name.is_none());
assert!(back.default.is_none());
assert!(back.enum_values.is_none());
}
#[test]
fn test_scanned_arg_serde_round_trip() {
let arg = ScannedArg {
name: "file".into(),
description: "Input file".into(),
value_type: ValueType::Path,
required: true,
variadic: false,
};
let json = serde_json::to_string(&arg).unwrap();
let back: ScannedArg = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "file");
assert_eq!(back.value_type, ValueType::Path);
assert!(back.required);
assert!(!back.variadic);
}
#[test]
fn test_scanned_arg_variadic() {
let arg = ScannedArg {
name: "files".into(),
description: "Input files".into(),
value_type: ValueType::Path,
required: false,
variadic: true,
};
let json = serde_json::to_string(&arg).unwrap();
let back: ScannedArg = serde_json::from_str(&json).unwrap();
assert!(back.variadic);
}
#[test]
fn test_structured_output_info_default() {
let info = StructuredOutputInfo::default();
assert!(!info.supported);
assert!(info.flag.is_none());
assert!(info.format.is_none());
}
#[test]
fn test_structured_output_info_serde() {
let info = StructuredOutputInfo {
supported: true,
flag: Some("--json".into()),
format: Some("json".into()),
};
let json = serde_json::to_string(&info).unwrap();
let back: StructuredOutputInfo = serde_json::from_str(&json).unwrap();
assert!(back.supported);
assert_eq!(back.flag.as_deref(), Some("--json"));
assert_eq!(back.format.as_deref(), Some("json"));
}
#[test]
fn test_scanned_command_nested_serde() {
let inner = ScannedCommand {
name: "ls".into(),
full_command: "docker container ls".into(),
description: "List containers".into(),
flags: vec![],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: HelpFormat::Cobra,
structured_output: StructuredOutputInfo::default(),
raw_help: String::new(),
};
let mid = ScannedCommand {
name: "container".into(),
full_command: "docker container".into(),
description: "Manage containers".into(),
flags: vec![],
positional_args: vec![],
subcommands: vec![inner],
examples: vec![],
help_format: HelpFormat::Cobra,
structured_output: StructuredOutputInfo::default(),
raw_help: String::new(),
};
let json = serde_json::to_string(&mid).unwrap();
let back: ScannedCommand = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "container");
assert_eq!(back.subcommands.len(), 1);
assert_eq!(back.subcommands[0].name, "ls");
}
#[test]
fn test_scanned_command_empty_subcommands() {
let cmd = ScannedCommand {
name: "status".into(),
full_command: "git status".into(),
description: "Show status".into(),
flags: vec![],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: HelpFormat::Gnu,
structured_output: StructuredOutputInfo::default(),
raw_help: String::new(),
};
let json = serde_json::to_string(&cmd).unwrap();
let back: ScannedCommand = serde_json::from_str(&json).unwrap();
assert!(back.subcommands.is_empty());
}
#[test]
fn test_scanned_cli_tool_serde_round_trip() {
let tool = ScannedCLITool {
name: "git".into(),
description: String::new(),
binary_path: "/usr/bin/git".into(),
version: Some("2.43.0".into()),
subcommands: vec![ScannedCommand {
name: "commit".into(),
full_command: "git commit".into(),
description: "Record changes".into(),
flags: vec![ScannedFlag {
long_name: Some("--message".into()),
short_name: Some("-m".into()),
description: "Commit message".into(),
value_type: ValueType::String,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: Some("MSG".into()),
..Default::default()
}],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: HelpFormat::Gnu,
structured_output: StructuredOutputInfo::default(),
raw_help: String::new(),
}],
global_flags: vec![ScannedFlag {
long_name: Some("--version".into()),
short_name: None,
description: "Print version".into(),
value_type: ValueType::Boolean,
required: false,
default: None,
enum_values: None,
repeatable: false,
value_name: None,
..Default::default()
}],
structured_output: StructuredOutputInfo::default(),
scan_tier: 1,
warnings: vec!["some warning".into()],
..Default::default()
};
let json = serde_json::to_string_pretty(&tool).unwrap();
let back: ScannedCLITool = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "git");
assert_eq!(back.version, Some("2.43.0".into()));
assert_eq!(back.subcommands.len(), 1);
assert_eq!(back.global_flags.len(), 1);
assert_eq!(back.warnings, vec!["some warning"]);
}
#[test]
fn test_scanned_cli_tool_version_none() {
let tool = ScannedCLITool {
name: "mytool".into(),
description: String::new(),
binary_path: "/usr/local/bin/mytool".into(),
version: None,
subcommands: vec![],
global_flags: vec![],
structured_output: StructuredOutputInfo::default(),
scan_tier: 1,
warnings: vec![],
..Default::default()
};
let json = serde_json::to_string(&tool).unwrap();
let back: ScannedCLITool = serde_json::from_str(&json).unwrap();
assert!(back.version.is_none());
}
}