use crate::pipeline::LoadedTool;
use std::fmt::Write as _;
pub fn print_report(loaded: &LoadedTool) {
print!("{}{}", build_report(loaded), report_hint(&loaded.tool));
}
pub fn build_report(loaded: &LoadedTool) -> String {
let mut out = String::new();
writeln!(out, "mandible --doctor {}", loaded.tool).unwrap();
writeln!(out).unwrap();
let resolved = mandible_extract::resolve_tool(&loaded.tool);
let framework = mandible_extract::framework::identify(&resolved);
writeln!(out, "framework: {}", framework.describe()).unwrap();
writeln!(out).unwrap();
writeln!(out, "tiers:").unwrap();
for status in &loaded.tier_statuses {
let state = if !status.detected {
"not detected".to_string()
} else if let Some(err) = &status.error {
format!("FAILED: {err}")
} else {
"ok".to_string()
};
writeln!(out, " {:<28} {}", status.tier, state).unwrap();
}
writeln!(out).unwrap();
match &loaded.root {
Some(_) => {
let nodes = loaded.node_count();
let flags = loaded.flag_count();
let describable = loaded.describable_flag_count();
let pct = if describable == 0 {
"—".to_string()
} else {
format!("{:.1}%", loaded.flag_description_ratio() * 100.0)
};
writeln!(out, "nodes: {nodes}").unwrap();
writeln!(out, "flags: {flags} ({pct} flags with text)").unwrap();
let modifiers = loaded.modifier_count();
if modifiers > 0 {
writeln!(out, "modifiers: {modifiers}").unwrap();
}
let env_vars = loaded.env_var_count();
if env_vars > 0 {
writeln!(out, "env vars: {env_vars}").unwrap();
}
}
None => {
writeln!(
out,
"result: no tier produced a root node for {:?}",
loaded.tool
)
.unwrap();
}
}
writeln!(out).unwrap();
writeln!(out, "accuracy: unmeasured").unwrap();
writeln!(
out,
"elapsed: {:.2}ms",
loaded.elapsed.as_secs_f64() * 1000.0
)
.unwrap();
out
}
pub fn report_hint(tool: &str) -> String {
format!("\nFound a bad parse? Run `mandible --report {tool}` for a paste-ready bug report.\n")
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::{CommandNode, Entity, Provenance, Source};
use mandible_extract::{ExtractionResult, TierStatus};
#[test]
fn reports_an_em_dash_not_zero_percent_when_nothing_is_describable() {
let mut root = CommandNode::new("git", Provenance::single(Source::HelpText));
for name in ["paginate", "git-dir", "no-pager"] {
root.entities.push(Entity::flag_long(
name,
Provenance::single(Source::HelpTextSynopsis),
));
}
let loaded = ExtractionResult {
tool: "git".to_string(),
root: Some(root),
tier_statuses: vec![TierStatus {
tier: "help_text",
detected: true,
error: None,
}],
elapsed: std::time::Duration::default(),
};
let report = build_report(&loaded);
assert!(
report.contains("flags: 3 (— flags with text)"),
"expected the em-dash line, got:\n{report}"
);
assert!(
!report.contains("0.0%"),
"must never fall back to the old described/total arithmetic:\n{report}"
);
}
#[test]
fn reports_a_percentage_when_some_flags_are_describable() {
let mut root = CommandNode::new("sometool", Provenance::single(Source::HelpText));
let mut f = Entity::flag_long("verbose", Provenance::single(Source::HelpText));
f.description = Some(mandible_core::Text::sanitize("be more talkative"));
root.entities.push(f);
let loaded = ExtractionResult {
tool: "sometool".to_string(),
root: Some(root),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::default(),
};
let report = build_report(&loaded);
assert!(
report.contains("flags: 1 (100.0% flags with text)"),
"expected a real percentage, got:\n{report}"
);
}
#[test]
fn modifiers_are_counted_separately_and_only_shown_when_present() {
let mut root = CommandNode::new("ar", Provenance::single(Source::HelpText));
let mut f = Entity::flag_long("thin", Provenance::single(Source::HelpText));
f.description = Some(mandible_core::Text::sanitize("make a thin archive"));
root.entities.push(f);
let flags_only = ExtractionResult {
tool: "ar".to_string(),
root: Some(root.clone()),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::default(),
};
assert!(
!build_report(&flags_only).contains("modifiers:"),
"a tool with no modifier table must not grow a row for it"
);
for letter in ['v', 'S'] {
root.entities.push(Entity::modifier(
letter,
Provenance::single(Source::HelpText),
));
}
let with_modifiers = ExtractionResult {
tool: "ar".to_string(),
root: Some(root),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::default(),
};
let report = build_report(&with_modifiers);
assert!(report.contains("modifiers: 2"), "{report}");
assert!(
report.contains("flags: 1 (100.0% flags with text)"),
"{report}"
);
}
#[test]
fn env_vars_are_counted_separately_and_only_shown_when_present() {
let mut root = CommandNode::new("node", Provenance::single(Source::HelpText));
let mut f = Entity::flag_long("version", Provenance::single(Source::HelpText));
f.description = Some(mandible_core::Text::sanitize("print node's version"));
root.entities.push(f);
let flags_only = ExtractionResult {
tool: "node".to_string(),
root: Some(root.clone()),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::default(),
};
assert!(
!build_report(&flags_only).contains("env vars:"),
"a tool with no environment section must not grow a row for it"
);
for name in ["NODE_DEBUG", "NO_COLOR"] {
root.entities.push(Entity::env_var_item(
name,
Provenance::single(Source::HelpText),
));
}
let with_env_vars = ExtractionResult {
tool: "node".to_string(),
root: Some(root),
tier_statuses: Vec::new(),
elapsed: std::time::Duration::default(),
};
let report = build_report(&with_env_vars);
assert!(report.contains("env vars: 2"), "{report}");
assert!(
report.contains("flags: 1 (100.0% flags with text)"),
"{report}"
);
}
}