use crate::audit::Audit;
use crate::audits::behavioral::subcommand_help::probe_subcommands;
use crate::project::Project;
use crate::runner::HelpOutput;
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
pub struct SubcommandExamplesAudit;
impl Audit for SubcommandExamplesAudit {
fn id(&self) -> &str {
"p3-subcommand-examples"
}
fn label(&self) -> &'static str {
"Each subcommand's `--help` ships at least one invocation example"
}
fn group(&self) -> AuditGroup {
AuditGroup::P3
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p3-must-subcommand-examples"]
}
fn applicable(&self, project: &Project) -> bool {
project.runner.is_some()
}
fn run(&self, project: &Project) -> anyhow::Result<AuditResult> {
let status = match project.help_output() {
None => AuditStatus::Skip("could not probe --help".into()),
Some(top_help) if top_help.subcommands().is_empty() => AuditStatus::Skip(
"binary has no subcommands; MUST applies conditionally to CLIs that use them."
.into(),
),
Some(top_help) => {
let runner = project.runner_ref();
let subhelp = probe_subcommands(runner, top_help);
let binary_name = project
.binary_paths
.first()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str());
audit_subcommand_examples(binary_name, &subhelp)
}
};
Ok(AuditResult {
id: self.id().to_string(),
label: self.label().into(),
group: self.group(),
layer: self.layer(),
status,
confidence: Confidence::Medium,
})
}
}
pub(crate) fn audit_subcommand_examples(
binary_name: Option<&str>,
subhelp: &[(String, HelpOutput)],
) -> AuditStatus {
if subhelp.is_empty() {
return AuditStatus::Skip(
"no subcommands responded to `--help`; nothing to inspect.".into(),
);
}
let missing: Vec<&str> = subhelp
.iter()
.filter(|(_, help)| !has_example_line(help.raw(), binary_name))
.map(|(name, _)| name.as_str())
.collect();
if missing.is_empty() {
AuditStatus::Pass
} else {
AuditStatus::Fail(format!(
"subcommands missing example invocations in their `--help`: {}. \
Examples teach agents the call shape faster than option tables; \
use clap's `after_help` or a dedicated `Examples:` block.",
missing.join(", ")
))
}
}
pub(crate) fn has_example_line(raw: &str, binary_name: Option<&str>) -> bool {
let lower = raw.to_lowercase();
if lower.contains("examples:") || lower.contains("\nexamples\n") {
return true;
}
if raw.contains("```") {
return true;
}
for line in raw.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("$ ") {
return true;
}
if let Some(name) = binary_name
&& !name.is_empty()
&& trimmed.starts_with(name)
&& trimmed.len() > name.len()
{
let next = &trimmed[name.len()..];
if next.starts_with(' ') {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn hp(raw: &str) -> HelpOutput {
HelpOutput::from_raw(raw)
}
#[test]
fn pass_with_dollar_prompt() {
let subhelp = vec![(
"audit".to_string(),
hp("Usage: tool audit\n\n $ tool audit .\n"),
)];
assert_eq!(
audit_subcommand_examples(Some("tool"), &subhelp),
AuditStatus::Pass
);
}
#[test]
fn pass_with_examples_header() {
let subhelp = vec![(
"audit".to_string(),
hp("Usage: tool audit\n\nExamples:\n tool audit .\n"),
)];
assert_eq!(
audit_subcommand_examples(Some("tool"), &subhelp),
AuditStatus::Pass
);
}
#[test]
fn pass_with_binary_prefix() {
let subhelp = vec![(
"audit".to_string(),
hp("Usage: tool audit\n\nDescription...\n tool audit . --output json\n"),
)];
assert_eq!(
audit_subcommand_examples(Some("tool"), &subhelp),
AuditStatus::Pass
);
}
#[test]
fn pass_with_fenced_block() {
let subhelp = vec![(
"audit".to_string(),
hp("Usage: tool audit\n\n```\ntool audit .\n```\n"),
)];
assert_eq!(
audit_subcommand_examples(Some("tool"), &subhelp),
AuditStatus::Pass
);
}
#[test]
fn fail_when_subcommand_missing_example() {
let subhelp = vec![
(
"audit".to_string(),
hp("Usage: tool audit\n\n$ tool audit .\n"),
),
(
"generate".to_string(),
hp("Usage: tool generate\n\nOptions: ...\n"),
),
];
match audit_subcommand_examples(Some("tool"), &subhelp) {
AuditStatus::Fail(msg) => {
assert!(msg.contains("generate"));
assert!(!msg.contains(" audit,"), "audit should pass: {msg}");
}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn skip_when_no_subhelp() {
let subhelp: Vec<(String, HelpOutput)> = Vec::new();
match audit_subcommand_examples(Some("tool"), &subhelp) {
AuditStatus::Skip(msg) => assert!(msg.contains("no subcommands")),
other => panic!("expected Skip, got {other:?}"),
}
}
#[test]
fn no_false_positive_on_bare_name_line() {
assert!(!has_example_line("Usage: tool\n\n tool\n", Some("tool")));
}
}