use harn_parser::{parse_source, peel_attributes, Node};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct DiscoveredCommand {
pub name: String,
pub pipeline_name: String,
pub description: String,
pub hint: Option<String>,
}
pub(super) fn discover_commands(source: &str) -> Vec<DiscoveredCommand> {
let Ok(program) = parse_source(source) else {
return Vec::new();
};
let mut commands: Vec<DiscoveredCommand> = Vec::new();
for sn in &program {
let (attrs, inner) = peel_attributes(sn);
let Node::Pipeline {
name: pipeline_name,
..
} = &inner.node
else {
continue;
};
let Some(attr) = attrs.iter().find(|a| a.name == "command") else {
continue;
};
let cmd_name = attr
.string_arg("name")
.unwrap_or_else(|| pipeline_name.clone());
if commands.iter().any(|c| c.name == cmd_name) {
continue;
}
let description = attr.string_arg("description").unwrap_or_default();
let hint = attr.string_arg("hint");
commands.push(DiscoveredCommand {
name: cmd_name,
pipeline_name: pipeline_name.clone(),
description,
hint,
});
}
commands
}
pub(super) fn render_available_commands(commands: &[DiscoveredCommand]) -> serde_json::Value {
let items: Vec<serde_json::Value> = commands
.iter()
.map(|cmd| {
let mut entry = serde_json::json!({
"name": cmd.name,
"description": cmd.description,
});
if let Some(hint) = &cmd.hint {
entry["input"] = serde_json::json!({ "hint": hint });
}
entry
})
.collect();
serde_json::Value::Array(items)
}
pub(super) fn parse_slash_invocation(prompt_text: &str) -> Option<(&str, &str)> {
let rest = prompt_text.trim_start().strip_prefix('/')?;
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
.unwrap_or(rest.len());
if end == 0 {
return None;
}
let (name, after) = rest.split_at(end);
let args = after.trim_start();
Some((name, args))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_commands_finds_attributed_pipelines() {
let source = r#"
@command(name: "review", description: "Run a code review", hint: "focus area")
pipeline review_branch(harness: Harness, task: unknown) { harness.stdio.println("review") }
pipeline default(harness: Harness, task: unknown) { harness.stdio.println("default") }
@command(description: "Plan the work")
pipeline plan(harness: Harness, task: unknown) { harness.stdio.println("plan") }
"#;
let commands = discover_commands(source);
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].name, "review");
assert_eq!(commands[0].pipeline_name, "review_branch");
assert_eq!(commands[0].description, "Run a code review");
assert_eq!(commands[0].hint.as_deref(), Some("focus area"));
assert_eq!(commands[1].name, "plan");
assert_eq!(commands[1].pipeline_name, "plan");
assert_eq!(commands[1].description, "Plan the work");
assert!(commands[1].hint.is_none());
}
#[test]
fn discover_commands_skips_unparseable_source() {
assert!(discover_commands("@command pipeline broken( {").is_empty());
}
#[test]
fn discover_commands_dedupes_by_advertised_name() {
let source = r#"
@command(name: "foo")
pipeline first(task: unknown) { 1 }
@command(name: "foo")
pipeline second(task: unknown) { 2 }
"#;
let commands = discover_commands(source);
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].pipeline_name, "first");
}
#[test]
fn discover_commands_returns_empty_when_no_attribute_present() {
let source =
"pipeline main(harness: Harness, task: unknown) { harness.stdio.println(\"hi\") }";
assert!(discover_commands(source).is_empty());
}
#[test]
fn render_available_commands_matches_acp_wire_shape() {
let commands = vec![
DiscoveredCommand {
name: "review".to_string(),
pipeline_name: "review_branch".to_string(),
description: "Review the diff".to_string(),
hint: Some("focus area".to_string()),
},
DiscoveredCommand {
name: "plan".to_string(),
pipeline_name: "plan".to_string(),
description: "Plan the work".to_string(),
hint: None,
},
];
let json = render_available_commands(&commands);
assert_eq!(
json,
serde_json::json!([
{
"name": "review",
"description": "Review the diff",
"input": {"hint": "focus area"},
},
{
"name": "plan",
"description": "Plan the work",
},
])
);
}
#[test]
fn parse_slash_invocation_extracts_name_and_args() {
assert_eq!(
parse_slash_invocation("/review src/lib.rs"),
Some(("review", "src/lib.rs"))
);
assert_eq!(parse_slash_invocation("/plan"), Some(("plan", "")));
assert_eq!(
parse_slash_invocation("/plan-it now"),
Some(("plan-it", "now"))
);
assert_eq!(
parse_slash_invocation("/plan_it now"),
Some(("plan_it", "now"))
);
assert_eq!(
parse_slash_invocation("/plan\nstep two"),
Some(("plan", "step two"))
);
}
#[test]
fn parse_slash_invocation_rejects_non_slash_prompts() {
assert_eq!(parse_slash_invocation("review the diff"), None);
assert_eq!(parse_slash_invocation(""), None);
assert_eq!(parse_slash_invocation("/"), None);
assert_eq!(parse_slash_invocation("/ leading space"), None);
assert_eq!(parse_slash_invocation("//comment"), None);
}
}