use std::time::Duration;
use tracing::{info, warn};
use super::cache::ScanCache;
use super::completion::CompletionParser;
use super::discovery::SubcommandDiscovery;
use super::man_page::ManPageParser;
use super::pipeline::ParserPipeline;
use super::resolver::ToolResolver;
use crate::config::ApexeConfig;
use crate::models::ScannedCLITool;
pub struct ScanOrchestrator {
config: ApexeConfig,
resolver: ToolResolver,
pipeline: ParserPipeline,
cache: ScanCache,
man_parser: ManPageParser,
completion_parser: CompletionParser,
}
impl ScanOrchestrator {
pub fn new(config: ApexeConfig) -> Self {
let cache = ScanCache::new(config.cache_dir.clone());
Self {
config,
resolver: ToolResolver,
pipeline: ParserPipeline::new(None),
cache,
man_parser: ManPageParser,
completion_parser: CompletionParser,
}
}
pub fn with_plugins(
config: ApexeConfig,
plugins: Vec<Box<dyn super::protocol::CliParser>>,
) -> Self {
let cache = ScanCache::new(config.cache_dir.clone());
Self {
config,
resolver: ToolResolver,
pipeline: ParserPipeline::new(Some(plugins)),
cache,
man_parser: ManPageParser,
completion_parser: CompletionParser,
}
}
pub fn scan(
&self,
tool_names: &[String],
no_cache: bool,
depth: u32,
) -> anyhow::Result<Vec<ScannedCLITool>> {
let mut results = Vec::new();
for tool_name in tool_names {
let tool = self.scan_single(tool_name, no_cache, depth)?;
results.push(tool);
}
Ok(results)
}
fn scan_single(
&self,
tool_name: &str,
no_cache: bool,
depth: u32,
) -> anyhow::Result<ScannedCLITool> {
let resolved = self
.resolver
.resolve(tool_name, Duration::from_secs(self.config.default_timeout))?;
if !no_cache {
if let Some(cached) = self.cache.get(tool_name, resolved.version.as_deref()) {
info!(tool = %tool_name, "Using cached scan result");
return Ok(cached);
}
}
let help_text = self.run_help_with_timeout(tool_name)?;
let mut warnings = Vec::new();
if help_text.trim().is_empty() {
warnings.push(format!("Empty help output from '{tool_name} --help'"));
}
let mut parsed = self.pipeline.parse(&help_text, tool_name, None);
if parsed.flags.len() < 3 {
if let Ok(expanded) = self.try_expanded_help(tool_name) {
if !expanded.trim().is_empty() {
let expanded_parsed = self.pipeline.parse(&expanded, tool_name, None);
if expanded_parsed.flags.len() > parsed.flags.len() {
parsed = expanded_parsed;
}
}
}
}
let discovery = SubcommandDiscovery::new(
&self.pipeline,
depth,
Duration::from_secs(self.config.default_timeout),
);
let subcommands = discovery.discover(
tool_name,
&[tool_name.to_string()],
&parsed.subcommand_names,
0,
);
let mut tool = ScannedCLITool {
name: tool_name.to_string(),
binary_path: resolved.binary_path,
version: resolved.version,
subcommands,
global_flags: parsed.flags,
structured_output: parsed.structured_output,
scan_tier: 1,
warnings,
};
self.enrich_with_man_page(&mut tool, tool_name);
self.enrich_with_completions(&mut tool, tool_name);
if let Err(e) = self.cache.put(&tool) {
warn!(tool = %tool_name, "Failed to cache scan result: {e}");
}
Ok(tool)
}
fn run_help_with_timeout(&self, tool_name: &str) -> anyhow::Result<String> {
let timeout = Duration::from_secs(self.config.default_timeout);
let output =
super::exec::run_with_timeout(tool_name, &["--help"], timeout).map_err(|e| match e
.kind()
{
std::io::ErrorKind::PermissionDenied => crate::errors::ApexeError::ScanPermission {
command: tool_name.to_string(),
}
.into(),
std::io::ErrorKind::TimedOut => crate::errors::ApexeError::ScanTimeout {
command: format!("{tool_name} --help"),
timeout: self.config.default_timeout,
}
.into(),
_ => anyhow::Error::from(e),
})?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stdout.trim().is_empty() && !stderr.trim().is_empty() {
Ok(stderr)
} else {
Ok(stdout)
}
}
fn try_expanded_help(&self, tool_name: &str) -> anyhow::Result<String> {
let timeout = Duration::from_secs(self.config.default_timeout);
let variants = [vec!["--help", "all"], vec!["-h"]];
for args in &variants {
if let Ok(out) = super::exec::run_with_timeout(tool_name, args, timeout) {
let text = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let result = if text.len() > stderr.len() {
text
} else {
stderr
};
if result.len() > 100 {
return Ok(result);
}
}
}
Ok(String::new())
}
fn enrich_with_man_page(&self, tool: &mut ScannedCLITool, tool_name: &str) {
if let Some(man_help) = self.man_parser.parse_man_page(tool_name) {
if !man_help.description.is_empty() {
tool.scan_tier = tool.scan_tier.max(2);
for cmd in &mut tool.subcommands {
if cmd.description.len() < 20 && !cmd.description.is_empty() {
cmd.description = format!("{} — {}", cmd.description, man_help.description);
}
}
if !man_help.flags.is_empty() {
for cmd in &mut tool.subcommands {
for flag in &mut cmd.flags {
if flag.description.len() < 10 {
let canonical = flag.canonical_name();
for man_flag in &man_help.flags {
let man_name = man_flag.canonical_name();
if canonical == man_name && !man_flag.description.is_empty() {
flag.description.clone_from(&man_flag.description);
}
}
}
}
}
}
}
}
}
fn enrich_with_completions(&self, tool: &mut ScannedCLITool, tool_name: &str) {
if let Some(comp_help) = self.completion_parser.parse_completions(tool_name) {
tool.scan_tier = tool.scan_tier.max(3);
let existing_names: std::collections::HashSet<String> =
tool.subcommands.iter().map(|c| c.name.clone()).collect();
for sub_name in &comp_help.subcommand_names {
if !existing_names.contains(sub_name) {
tool.subcommands.push(crate::models::ScannedCommand {
name: sub_name.clone(),
full_command: format!("{} {}", tool_name, sub_name),
description: format!("{tool_name} {sub_name}"),
flags: vec![],
positional_args: vec![],
subcommands: vec![],
examples: vec![],
help_format: crate::models::HelpFormat::Unknown,
structured_output: crate::models::StructuredOutputInfo::default(),
raw_help: String::new(),
});
tool.warnings.push(format!(
"Subcommand '{sub_name}' discovered via shell completion (stub only)"
));
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> ApexeConfig {
ApexeConfig {
modules_dir: tmp.path().join("modules"),
cache_dir: tmp.path().join("cache"),
config_dir: tmp.path().to_path_buf(),
audit_log: tmp.path().join("audit.jsonl"),
log_level: "warn".into(),
default_timeout: 10,
scan_depth: 2,
json_output_preference: true,
..ApexeConfig::default()
}
}
#[test]
fn test_orchestrator_new() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let _orchestrator = ScanOrchestrator::new(config);
}
#[test]
fn test_orchestrator_uses_config_cache_dir() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.cache.get("nonexistent", None);
assert!(result.is_none());
}
#[test]
fn test_scan_echo() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.scan(&["echo".into()], true, 1);
assert!(result.is_ok());
let tools = result.unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
assert!(!tools[0].binary_path.is_empty());
assert!(tools[0].scan_tier >= 1);
}
#[test]
fn test_scan_uses_cache() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result1 = orchestrator.scan(&["echo".into()], false, 1).unwrap();
assert_eq!(result1.len(), 1);
let result2 = orchestrator.scan(&["echo".into()], false, 1).unwrap();
assert_eq!(result2.len(), 1);
assert_eq!(result2[0].name, "echo");
}
#[test]
fn test_scan_no_cache_forces_rescan() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result1 = orchestrator.scan(&["echo".into()], false, 1).unwrap();
assert_eq!(result1.len(), 1);
let result2 = orchestrator.scan(&["echo".into()], true, 1).unwrap();
assert_eq!(result2.len(), 1);
}
#[test]
fn test_scan_multiple_tools() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.scan(&["echo".into(), "ls".into()], true, 1);
assert!(result.is_ok());
let tools = result.unwrap();
assert_eq!(tools.len(), 2);
}
#[test]
fn test_scan_nonexistent_tool_errors() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.scan(&["zzz_no_such_tool_xyz".into()], true, 1);
assert!(result.is_err());
}
#[test]
fn test_scan_tool_with_empty_help_adds_warning() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.scan(&["echo".into()], true, 1).unwrap();
assert_eq!(result[0].name, "echo");
}
#[test]
fn test_scan_tier_is_at_least_1() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let orchestrator = ScanOrchestrator::new(config);
let result = orchestrator.scan(&["echo".into()], true, 1).unwrap();
assert!(result[0].scan_tier >= 1);
}
}