use crate::doctor;
use crate::pipeline::LoadedTool;
use mandible_extract::{help_text, resolve_tool, NodeHints};
pub fn print_report(loaded: &LoadedTool) {
print!("{}", build_report(loaded));
}
fn build_report(loaded: &LoadedTool) -> String {
let tool = &loaded.tool;
let resolved = resolve_tool(tool);
let raw = raw_help(&resolved, tool);
let mut body = String::new();
body.push_str(&format!("mandible: v{}\n", env!("CARGO_PKG_VERSION")));
body.push_str(&match scrape_version(raw.as_ref().ok(), tool) {
Some(version) => format!("{tool} version: {version}\n"),
None => format!(
"{tool} version: (not printed in --help — please paste `{tool} --version` here)\n"
),
});
body.push('\n');
body.push_str("--- mandible --doctor ");
body.push_str(tool);
body.push_str(" ---\n");
body.push_str(&doctor::build_report(loaded));
body.push('\n');
body.push_str("--- ");
body.push_str(tool);
body.push_str(" --help (raw) ---\n");
body.push_str(&raw_help_block(tool, raw.as_ref()));
let mut out = String::new();
out.push_str(
"Paste-ready bug report. Note: a GitHub issue textarea is not byte-exact \
(trailing whitespace is dropped by markdown rendering) — that's fine for \
a first report; exact-byte re-capture is the maintainer's job if the fix \
needs it.\n\n",
);
out.push_str("```console\n");
out.push_str(&body);
while out.ends_with('\n') {
out.pop();
}
out.push('\n');
out.push_str("```\n\n");
out.push_str(&format!(
"File it at: {}/issues\n",
env!("CARGO_PKG_REPOSITORY")
));
out
}
fn raw_help(
resolved: &mandible_extract::ResolvedTool,
tool: &str,
) -> Result<(Vec<mandible_core::Text>, String), mandible_extract::ExtractError> {
let root_path = vec![tool.to_string()];
let hints = NodeHints {
heading_attested: true,
};
help_text::raw_help(resolved, &root_path, hints)
}
fn raw_help_block(
tool: &str,
raw: Result<&(Vec<mandible_core::Text>, String), &mandible_extract::ExtractError>,
) -> String {
match raw {
Ok((lines, flag)) => {
let mut s = format!("$ {tool} {flag}\n");
for line in lines {
s.push_str(line.as_str());
s.push('\n');
}
s
}
Err(e) => format!(
"(unavailable: {e})\n\
Reasons this can happen: the tool isn't on PATH, the probe \
errored or timed out, or (spec §6 rule 0) it's a tool restricted \
to exactly `--help` whose own output came back empty on both \
stdout and stderr — that would ordinarily fall back to `-h`, \
which rule 0 refuses for those tools. Either way, mandible will \
not try a different argv shape to fill this in.\n"
),
}
}
fn scrape_version(raw: Option<&(Vec<mandible_core::Text>, String)>, tool: &str) -> Option<String> {
let (lines, _flag) = raw?;
const BANNER_SCAN_LINES: usize = 5;
lines
.iter()
.take(BANNER_SCAN_LINES)
.find_map(|line| version_from_banner_line(line.as_str(), tool))
}
fn version_from_banner_line(line: &str, tool: &str) -> Option<String> {
let mut words = line.split_whitespace();
let name = words.next()?;
let version = words.next()?;
if words.next().is_some() {
return None; }
if !name.eq_ignore_ascii_case(tool) {
return None;
}
version
.starts_with(|c: char| c.is_ascii_digit())
.then(|| version.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use mandible_core::Text;
fn raw(lines: &[&str]) -> (Vec<Text>, String) {
(
lines.iter().map(|l| Text::sanitize(l)).collect(),
"--help".to_string(),
)
}
#[test]
fn scrapes_a_clap_style_name_version_banner() {
let r = raw(&["zoxide 0.9.9", "Ajeet D'Souza <email>", "", "A smarter cd"]);
assert_eq!(
scrape_version(Some(&r), "zoxide"),
Some("0.9.9".to_string())
);
}
#[test]
fn does_not_misread_an_ordinary_two_word_usage_line() {
let r = raw(&["Usage: git"]);
assert_eq!(scrape_version(Some(&r), "git"), None);
}
#[test]
fn requires_the_name_to_match_the_tool() {
let r = raw(&["gitk 1.2.3"]);
assert_eq!(scrape_version(Some(&r), "git"), None);
}
#[test]
fn does_not_scan_past_the_leading_lines() {
let mut lines = vec!["real help text"; 10];
lines.push("git 9.9.9");
let r = raw(&lines);
assert_eq!(scrape_version(Some(&r), "git"), None);
}
#[test]
fn no_raw_text_scrapes_to_none() {
assert_eq!(scrape_version(None, "ghost"), None);
}
#[test]
fn report_never_panics_for_an_unresolvable_tool() {
let loaded = crate::pipeline::load("definitely-not-a-real-tool-xyz-123");
let report = build_report(&loaded);
assert!(
report.contains("--version` here"),
"the version line must tell the reporter what to paste: {report}"
);
assert!(report.contains("/issues"));
}
#[test]
fn the_report_block_does_not_carry_doctors_report_hint() {
let loaded = crate::pipeline::load("definitely-not-a-real-tool-xyz-123");
assert!(!build_report(&loaded).contains("Found a bad parse?"));
}
}