use std::time::Duration;
use tracing::warn;
use super::man_page::strip_overstrike;
use super::pipeline::ParserPipeline;
use crate::models::ScannedCommand;
pub struct SubcommandDiscovery<'a> {
pipeline: &'a ParserPipeline,
max_depth: u32,
timeout: Duration,
}
impl<'a> SubcommandDiscovery<'a> {
pub fn new(pipeline: &'a ParserPipeline, max_depth: u32, timeout: Duration) -> Self {
Self {
pipeline,
max_depth,
timeout,
}
}
fn scan_subcommand(
&self,
tool_name: &str,
parent_command: &[String],
sub_name: &str,
depth: u32,
) -> Option<ScannedCommand> {
let mut full_cmd: Vec<String> = parent_command.to_vec();
full_cmd.push(sub_name.to_string());
let help_text = self.run_help(tool_name, &full_cmd)?;
let parsed = self.pipeline.parse(&help_text, tool_name);
let nested = if parsed.subcommand_names.is_empty() {
Vec::new()
} else {
self.discover(tool_name, &full_cmd, &parsed.subcommand_names, depth + 1)
};
Some(ScannedCommand {
name: sub_name.to_string(),
full_command: full_cmd.join(" "),
description: parsed.description,
flags: parsed.flags,
positional_args: parsed.positional_args,
subcommands: nested,
examples: parsed.examples,
help_format: parsed.help_format,
structured_output: parsed.structured_output,
end_of_options: false,
raw_help: help_text,
})
}
pub fn discover(
&self,
tool_name: &str,
parent_command: &[String],
subcommand_names: &[String],
depth: u32,
) -> Vec<ScannedCommand> {
if depth >= self.max_depth {
warn!(
tool = tool_name,
depth = depth,
"Max subcommand depth reached"
);
return Vec::new();
}
subcommand_names
.iter()
.filter_map(|sub_name| self.scan_subcommand(tool_name, parent_command, sub_name, depth))
.collect()
}
pub fn run_help(&self, tool_name: &str, full_cmd: &[String]) -> Option<String> {
let mut args: Vec<&str> = full_cmd[1..].iter().map(|s| s.as_str()).collect();
args.push("--help");
let output = super::exec::run_with_timeout(tool_name, &args, self.timeout).ok()?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let text = if stdout.trim().is_empty() && !stderr.trim().is_empty() {
stderr
} else if !stdout.trim().is_empty() {
stdout
} else {
warn!(command = %full_cmd.join(" "), "Empty help output");
return None;
};
Some(strip_overstrike(&text).into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_discovery_max_depth_zero_returns_empty() {
let pipeline = ParserPipeline::new(None);
let discovery = SubcommandDiscovery::new(&pipeline, 0, std::time::Duration::from_secs(5));
let result = discovery.discover("tool", &["tool".into()], &["sub1".into()], 0);
assert!(result.is_empty());
}
#[test]
fn test_discovery_respects_max_depth() {
let pipeline = ParserPipeline::new(None);
let discovery = SubcommandDiscovery::new(&pipeline, 1, std::time::Duration::from_secs(5));
let result = discovery.discover("tool", &["tool".into()], &["sub1".into()], 1);
assert!(result.is_empty());
}
#[test]
fn test_run_help_captures_stdout() {
let pipeline = ParserPipeline::new(None);
let discovery = SubcommandDiscovery::new(&pipeline, 2, std::time::Duration::from_secs(5));
let result = discovery.run_help("echo", &["echo".into(), "hello".into()]);
assert!(result.is_some());
}
#[test]
fn test_run_help_strips_man_page_overstrike() {
let pipeline = ParserPipeline::new(None);
let discovery = SubcommandDiscovery::new(&pipeline, 2, std::time::Duration::from_secs(5));
let result = discovery
.run_help(
"printf",
&["printf".into(), "N\\bNA\\bAM\\bME\\bE\\n".into()],
)
.expect("printf writes to stdout");
assert!(
result.contains("NAME"),
"overstrike not collapsed: {result:?}"
);
assert!(!result.contains('\u{8}'), "backspace survived: {result:?}");
}
#[test]
fn test_run_help_nonexistent_tool() {
let pipeline = ParserPipeline::new(None);
let discovery = SubcommandDiscovery::new(&pipeline, 2, std::time::Duration::from_secs(5));
let result = discovery.run_help("zzz_no_such_tool_xyz", &["zzz_no_such_tool_xyz".into()]);
assert!(result.is_none());
}
}