use crate::output::{acli_error, acli_ok};
use serde_json::json;
struct Playbook {
key: &'static str,
body: &'static str,
}
const PLAYBOOKS: &[Playbook] = &[
Playbook {
key: "compose-a-graph",
body: include_str!("../../../../docs/agents/compose-a-graph.md"),
},
Playbook {
key: "find-an-existing-stage",
body: include_str!("../../../../docs/agents/find-an-existing-stage.md"),
},
Playbook {
key: "synthesize-a-new-stage",
body: include_str!("../../../../docs/agents/synthesize-a-new-stage.md"),
},
Playbook {
key: "express-a-property",
body: include_str!("../../../../docs/agents/express-a-property.md"),
},
Playbook {
key: "debug-a-failed-graph",
body: include_str!("../../../../docs/agents/debug-a-failed-graph.md"),
},
];
pub fn cmd_agent_docs(key: Option<&str>, search: Option<&str>) {
if let Some(q) = search {
let lower = q.to_ascii_lowercase();
let hits: Vec<_> = PLAYBOOKS
.iter()
.filter(|p| p.body.to_ascii_lowercase().contains(&lower))
.map(playbook_summary)
.collect();
println!(
"{}",
acli_ok(json!({
"query": q,
"hits": hits,
}))
);
return;
}
match key {
None => {
let list: Vec<_> = PLAYBOOKS.iter().map(playbook_summary).collect();
println!(
"{}",
acli_ok(json!({
"playbooks": list,
"usage": "noether agent-docs <key> # dump one playbook\n\
noether agent-docs --search <term> # search by keyword",
}))
);
}
Some(k) => match PLAYBOOKS.iter().find(|p| p.key == k) {
Some(p) => {
println!(
"{}",
acli_ok(json!({
"key": p.key,
"title": extract_title(p.body),
"intent": extract_intent(p.body),
"body": p.body,
}))
);
}
None => {
let available: Vec<&str> = PLAYBOOKS.iter().map(|p| p.key).collect();
eprintln!(
"{}",
acli_error(&format!(
"no playbook with key `{k}`. Available: {}",
available.join(", ")
))
);
std::process::exit(2);
}
},
}
}
fn playbook_summary(p: &Playbook) -> serde_json::Value {
json!({
"key": p.key,
"title": extract_title(p.body),
"intent": extract_intent(p.body),
})
}
fn extract_title(body: &str) -> String {
body.lines()
.find_map(|l| l.strip_prefix("# ").map(|s| s.trim().to_string()))
.unwrap_or_default()
}
fn extract_intent(body: &str) -> String {
let mut lines = body.lines();
let mut found_header = false;
let mut buf = String::new();
for line in lines.by_ref() {
if !found_header {
if line.trim().eq_ignore_ascii_case("## intent") {
found_header = true;
}
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("## ") {
break;
}
if trimmed.is_empty() && !buf.is_empty() {
break;
}
if !trimmed.is_empty() {
if !buf.is_empty() {
buf.push(' ');
}
buf.push_str(trimmed);
}
}
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_playbook_has_title_and_intent() {
for p in PLAYBOOKS {
assert!(
!extract_title(p.body).is_empty(),
"playbook {} has no H1 title",
p.key
);
assert!(
!extract_intent(p.body).is_empty(),
"playbook {} has no '## Intent' section",
p.key
);
}
}
#[test]
fn playbook_keys_match_file_headers() {
for p in PLAYBOOKS {
let title = extract_title(p.body);
let expected = format!("Playbook: {}", p.key);
assert_eq!(
title, expected,
"playbook {} H1 should be `# {expected}`, got `# {title}`",
p.key
);
}
}
#[test]
fn extract_intent_handles_blank_lines() {
let body = "# t\n\n## Intent\n\nfirst line.\nsecond line.\n\n## Preconditions\n\nignored\n";
assert_eq!(
extract_intent(body),
"first line. second line.",
"intent paragraph should terminate at the blank line before the next ## header"
);
}
}