use crate::audit::Audit;
#[cfg(test)]
use crate::audits::behavioral::subcommand_examples::has_example_line;
use crate::audits::behavioral::subcommand_help::probe_subcommands;
use crate::project::Project;
use crate::runner::HelpOutput;
use crate::types::{AuditGroup, AuditLayer, AuditResult, AuditStatus, Confidence};
const PAIR_WINDOW_LINES: usize = 5;
pub struct PairedExamplesAudit;
impl Audit for PairedExamplesAudit {
fn id(&self) -> &str {
"p3-paired-examples"
}
fn label(&self) -> &'static str {
"Help text pairs human and `--output json` example invocations"
}
fn group(&self) -> AuditGroup {
AuditGroup::P3
}
fn layer(&self) -> AuditLayer {
AuditLayer::Behavioral
}
fn covers(&self) -> &'static [&'static str] {
&["p3-should-paired-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) => {
let binary_name = project
.binary_paths
.first()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str());
let runner = project.runner_ref();
let subhelp = probe_subcommands(runner, top_help);
audit_paired_examples(binary_name, top_help, &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_paired_examples(
binary_name: Option<&str>,
top_help: &HelpOutput,
subhelp: &[(String, HelpOutput)],
) -> AuditStatus {
if has_paired_example(top_help.raw(), binary_name) {
return AuditStatus::Pass;
}
for (_, help) in subhelp {
if has_paired_example(help.raw(), binary_name) {
return AuditStatus::Pass;
}
}
AuditStatus::Warn(format!(
"no paired text + `--output json` example found within {PAIR_WINDOW_LINES} \
lines in top-level or any subcommand `--help`. Pairing keeps agents \
from reverse-engineering the JSON invocation from the text one."
))
}
fn has_paired_example(raw: &str, binary_name: Option<&str>) -> bool {
let lines: Vec<&str> = raw.lines().collect();
for (i, line) in lines.iter().enumerate() {
if !line_is_example(line, binary_name) {
continue;
}
let end = (i + 1 + PAIR_WINDOW_LINES).min(lines.len());
for follow in &lines[i + 1..end] {
if mentions_json_output(follow) {
return true;
}
}
}
false
}
fn line_is_example(line: &str, binary_name: Option<&str>) -> bool {
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()
&& trimmed[name.len()..].starts_with(' ')
{
return true;
}
false
}
fn mentions_json_output(line: &str) -> bool {
let lower = line.to_lowercase();
lower.contains("--output json")
|| lower.contains("--output=json")
|| lower.contains("--json")
|| lower.contains("-o json")
}
#[cfg(test)]
fn line_is_example_for_test(line: &str, binary_name: Option<&str>) -> bool {
let saw = line_is_example(line, binary_name);
if saw {
assert!(has_example_line(line, binary_name));
}
saw
}
#[cfg(test)]
mod tests {
use super::*;
fn hp(raw: &str) -> HelpOutput {
HelpOutput::from_raw(raw)
}
#[test]
fn pass_when_top_help_has_pair() {
let top = hp("\
Examples:
$ tool list
$ tool list --output json
");
let subhelp: Vec<(String, HelpOutput)> = Vec::new();
assert_eq!(
audit_paired_examples(Some("tool"), &top, &subhelp),
AuditStatus::Pass
);
}
#[test]
fn pass_when_subcommand_has_pair() {
let top = hp("Usage: tool [COMMAND]\n");
let subhelp = vec![(
"list".to_string(),
hp("\
Examples:
tool list
tool list --json
"),
)];
assert_eq!(
audit_paired_examples(Some("tool"), &top, &subhelp),
AuditStatus::Pass
);
}
#[test]
fn pass_when_pair_uses_output_equals() {
let top = hp("\
Examples:
$ tool query 'name'
$ tool query 'name' --output=json
");
assert_eq!(
audit_paired_examples(Some("tool"), &top, &[]),
AuditStatus::Pass
);
}
#[test]
fn warn_when_only_text_example() {
let top = hp("\
Examples:
$ tool list
");
match audit_paired_examples(Some("tool"), &top, &[]) {
AuditStatus::Warn(msg) => assert!(msg.contains("paired")),
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn warn_when_pair_outside_window() {
let mut text = String::from("$ tool list\n");
for _ in 0..(PAIR_WINDOW_LINES + 2) {
text.push_str("filler line\n");
}
text.push_str("$ tool list --output json\n");
let top = hp(&text);
match audit_paired_examples(Some("tool"), &top, &[]) {
AuditStatus::Warn(_) => {}
other => panic!("expected Warn, got {other:?}"),
}
}
#[test]
fn line_predicate_agrees_with_body_predicate() {
assert!(line_is_example_for_test("$ tool list", Some("tool")));
assert!(line_is_example_for_test(" tool audit .", Some("tool")));
assert!(!line_is_example_for_test("plain text", Some("tool")));
}
}