use std::collections::HashSet;
use std::path::PathBuf;
use std::process::Command;
fn asd_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_asd"))
}
fn extract_asd_subcommands(text: &str) -> HashSet<String> {
let mut found: HashSet<String> = HashSet::new();
for span in text.split('`').enumerate().filter_map(|(i, s)| {
if i % 2 == 1 { Some(s) } else { None }
}) {
let trimmed = span.trim();
let rest = match trimmed.strip_prefix("asd ") {
Some(r) => r.trim_start(),
None => continue,
};
let token = match rest.split_whitespace().next() {
Some(t) => t,
None => continue,
};
if token.starts_with('-')
|| token.starts_with('<')
|| token.starts_with('[')
|| token.contains('<')
|| token.contains('[')
{
continue;
}
let cleaned =
token.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
if !cleaned.is_empty() {
found.insert(cleaned.to_string());
}
}
found
}
fn assert_subcommand_resolves(sub: &str) -> Result<(), String> {
let out = Command::new(asd_bin())
.args([sub, "--help"])
.output()
.map_err(|e| format!("spawn failed: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(format!(
"`asd {sub} --help` exited non-zero\nstderr: {}",
String::from_utf8_lossy(&out.stderr).trim()
))
}
}
fn run_cmd_capture_stdout(args: &[&str]) -> String {
let out = Command::new(asd_bin())
.args(args)
.output()
.unwrap_or_else(|e| panic!("spawn `asd {}`: {e}", args.join(" ")));
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn top_level_help_long_about_examples_resolve() {
let text = run_cmd_capture_stdout(&["--help"]);
let subs = extract_asd_subcommands(&text);
assert!(
!subs.is_empty(),
"extractor should find at least one `asd <sub>` in --help; got empty set\n--- help text ---\n{text}"
);
let mut broken: Vec<String> = Vec::new();
for sub in &subs {
if let Err(msg) = assert_subcommand_resolves(sub) {
broken.push(format!(" - {sub}: {msg}"));
}
}
assert!(
broken.is_empty(),
"`asd --help` references {} broken subcommand(s):\n{}\n(all extracted: {subs:?})",
broken.len(),
broken.join("\n")
);
}
#[test]
fn think_bootstrap_human_output_examples_resolve() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
let text = run_cmd_capture_stdout(&["--db", db.to_str().unwrap(), "think", "bootstrap"]);
let subs = extract_asd_subcommands(&text);
let mut broken: Vec<String> = Vec::new();
for sub in &subs {
if let Err(msg) = assert_subcommand_resolves(sub) {
broken.push(format!(" - {sub}: {msg}"));
}
}
assert!(
broken.is_empty(),
"`asd think bootstrap` references {} broken subcommand(s):\n{}\n(all extracted: {subs:?})\n--- bootstrap text ---\n{text}",
broken.len(),
broken.join("\n")
);
}
#[test]
fn think_bootstrap_json_output_examples_resolve() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join(".asd-state.db");
let text =
run_cmd_capture_stdout(&["--db", db.to_str().unwrap(), "think", "bootstrap", "--json"]);
let subs = extract_asd_subcommands(&text);
let mut broken: Vec<String> = Vec::new();
for sub in &subs {
if let Err(msg) = assert_subcommand_resolves(sub) {
broken.push(format!(" - {sub}: {msg}"));
}
}
assert!(
broken.is_empty(),
"`asd think bootstrap --json` references {} broken subcommand(s):\n{}\n(all extracted: {subs:?})",
broken.len(),
broken.join("\n")
);
}
#[test]
fn extractor_unit_skips_placeholders_and_flags() {
let text = "Try `asd index .` or `asd <subcommand> --help` or `asd --help` or `asd [OPTIONS]`.";
let subs = extract_asd_subcommands(text);
assert!(subs.contains("index"), "must extract literal 'index'");
assert!(!subs.contains("<subcommand>"), "must skip placeholder");
assert!(!subs.iter().any(|s| s.starts_with('-')), "must skip flags");
assert!(
!subs.iter().any(|s| s.starts_with('[')),
"must skip clap templates"
);
}
#[test]
fn extractor_unit_handles_markdown_trailing_punctuation() {
let text = "Run `asd init` to start.";
let subs = extract_asd_subcommands(text);
assert!(subs.contains("init"), "got: {subs:?}");
}
#[test]
fn extractor_unit_returns_empty_for_no_backticks() {
let text = "This text has no backticks and mentions asd index by name.";
let subs = extract_asd_subcommands(text);
assert!(subs.is_empty(), "got: {subs:?}");
}
#[test]
fn design_md_and_changelog_backtick_examples_resolve() {
const KNOWN_PLACEHOLDERS: &[&str] = &[
"health",
"pull-meta",
"plan",
"savings",
"enrich",
"serve",
"sweep",
"query-example",
"remote",
];
let cli_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let repo_root = cli_dir
.parent() .and_then(|p| p.parent()) .expect("repo root must be two levels above cli crate");
let mut all_text = String::new();
for fname in &["DESIGN.md", "CHANGELOG.md"] {
let path = repo_root.join(fname);
match std::fs::read_to_string(&path) {
Ok(s) => all_text.push_str(&s),
Err(e) => {
if *fname == "DESIGN.md" {
panic!("required doc {} unreadable: {e}", path.display());
}
}
}
all_text.push('\n');
}
let extracted = extract_asd_subcommands(&all_text);
let known: std::collections::HashSet<&str> = KNOWN_PLACEHOLDERS.iter().copied().collect();
let mut broken: Vec<String> = Vec::new();
for sub in &extracted {
if known.contains(sub.as_str()) {
continue;
}
if let Err(msg) = assert_subcommand_resolves(sub) {
broken.push(format!(" - {sub}: {msg}"));
}
}
assert!(
broken.is_empty(),
"DESIGN.md/CHANGELOG.md reference {} broken `asd <sub>` example(s):\n{}\n(extracted set: {extracted:?})\n\nIf the broken example is intentional (hypothetical, deprecated, forward-looking), add the subcommand token to KNOWN_PLACEHOLDERS in this test file.",
broken.len(),
broken.join("\n")
);
}