Skip to main content

xtask/patterns/
actions.rs

1use super::{reporter::PatternReporter, util::read_file};
2
3const ACTION_TEST_COVERAGE_EXCEPTIONS: &[&str] = &[
4    // Requires a live MCP Peer<RoleServer>; covered by parser/schema/help checks instead.
5    "elicit_name",
6];
7
8// ACTION_SPECS moved from crates/soma/contracts/src/actions.rs to
9// crates/soma/domain/src/actions.rs (plan section 6.2 "From soma-contracts";
10// PR 13). crates/soma/contracts was deleted in PR 19.
11const ACTION_SPECS_PATH: &str = "crates/soma/domain/src/actions.rs";
12
13pub(super) fn action_surfaces(reporter: &mut PatternReporter) {
14    let actions_text = read_file(ACTION_SPECS_PATH);
15    let action_specs = action_specs_body(&actions_text).unwrap_or(&actions_text);
16    let action_names = extract_action_names(action_specs);
17    let mcp_only = extract_mcp_only_actions(action_specs);
18
19    if action_names.is_empty() {
20        reporter.fail(
21            "actions",
22            format!("could not parse ACTION_SPECS from {ACTION_SPECS_PATH}"),
23        );
24        return;
25    }
26
27    let schema = read_file("crates/soma/mcp/src/schemas.rs");
28    let tools = read_file("crates/soma/mcp/src/tools.rs");
29    let tests = read_file("apps/soma/tests/tool_dispatch.rs");
30    let cli = read_file("crates/soma/cli/src/lib.rs");
31
32    let schema_uses_metadata = schema.contains("tool_definitions_for_catalogs")
33        && schema.contains("action_names(catalogs)");
34    let missing_schema = if schema_uses_metadata {
35        Vec::new()
36    } else {
37        action_names
38            .iter()
39            .filter(|action| !schema.contains(&format!("\"{action}\"")))
40            .cloned()
41            .collect::<Vec<_>>()
42    };
43    let help_uses_metadata =
44        tools.contains("for spec in ACTION_SPECS") && tools.contains("spec.description");
45    let missing_help = if help_uses_metadata {
46        Vec::new()
47    } else {
48        action_names
49            .iter()
50            .filter(|action| {
51                !tools.contains(&format!("### {action}")) && !tools.contains(&format!("`{action}`"))
52            })
53            .cloned()
54            .collect::<Vec<_>>()
55    };
56    let missing_tests = action_names
57        .iter()
58        .filter(|action| {
59            action.as_str() != "help"
60                && !ACTION_TEST_COVERAGE_EXCEPTIONS.contains(&action.as_str())
61                && !tests.contains(action.as_str())
62        })
63        .cloned()
64        .collect::<Vec<_>>();
65    let missing_cli = action_names
66        .iter()
67        .filter(|action| action.as_str() != "help" && !mcp_only.contains(action))
68        .filter(|action| {
69            !cli.contains(&format!("\"{action}\"")) && !cli.contains(&variant_name(action))
70        })
71        .cloned()
72        .collect::<Vec<_>>();
73
74    if !missing_schema.is_empty() {
75        reporter.fail(
76            "actions",
77            format!(
78                "schemas.rs missing action(s): {}",
79                missing_schema.join(", ")
80            ),
81        );
82    }
83    if !missing_help.is_empty() {
84        reporter.fail(
85            "actions",
86            format!(
87                "mcp/tools.rs HELP_TEXT missing action(s): {}. Hint: add `### <action>` docs to HELP_TEXT.",
88                missing_help.join(", ")
89            ),
90        );
91    }
92    if !missing_tests.is_empty() {
93        reporter.warn(
94            "actions",
95            format!(
96                "apps/soma/tests/tool_dispatch.rs may be missing action coverage: {}. Hint: add a direct dispatch/service test or an explicit exception.",
97                missing_tests.join(", ")
98            ),
99        );
100    }
101    if !missing_cli.is_empty() {
102        reporter.warn(
103            "cli-mcp-parity",
104            format!(
105                "CLI may be missing non-MCP-only action(s): {}. Hint: add a Command variant, parse arm, and dispatch arm.",
106                missing_cli.join(", ")
107            ),
108        );
109    }
110    if missing_schema.is_empty()
111        && missing_help.is_empty()
112        && missing_tests.is_empty()
113        && missing_cli.is_empty()
114    {
115        reporter.ok(
116            "actions",
117            format!(
118                "{} actions appear in schema/help/tests/CLI surfaces",
119                action_names.len()
120            ),
121        );
122    }
123}
124
125fn action_specs_body(text: &str) -> Option<&str> {
126    let start = text.find("ACTION_SPECS")?;
127    let after_start = &text[start..];
128    let end = after_start.find("];")?;
129    Some(&after_start[..end])
130}
131
132fn extract_action_names(text: &str) -> Vec<String> {
133    text.lines()
134        .filter_map(|line| {
135            let (_, after_name) = line.split_once("name:")?;
136            let start = after_name.find('"')? + 1;
137            let rest = &after_name[start..];
138            let end = rest.find('"')?;
139            Some(rest[..end].to_string())
140        })
141        .collect()
142}
143
144fn extract_mcp_only_actions(text: &str) -> Vec<String> {
145    let mut actions = Vec::new();
146    for block in text.split("ActionSpec").skip(1) {
147        let Some(end) = block.find('}') else {
148            continue;
149        };
150        let block = &block[..end];
151        if !block.contains("ActionTransport::McpOnly") {
152            continue;
153        }
154        if let Some(name) = extract_action_names(block).into_iter().next() {
155            actions.push(name);
156        }
157    }
158    actions
159}
160
161fn variant_name(action: &str) -> String {
162    action
163        .split('_')
164        .filter(|part| !part.is_empty())
165        .map(|part| {
166            let mut chars = part.chars();
167            match chars.next() {
168                Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
169                None => String::new(),
170            }
171        })
172        .collect::<String>()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    const ACTIONS: &str = r#"
180pub const ACTION_SPECS: &[ActionSpec] = &[
181    ActionSpec {
182        name: "greet",
183        required_scope: Some(READ_SCOPE),
184        transport: ActionTransport::Any,
185    },
186    ActionSpec {
187        name: "elicit_name",
188        required_scope: Some(READ_SCOPE),
189        transport: ActionTransport::McpOnly,
190    },
191];
192
193pub fn rest_help() {
194    let example = "Alice";
195}
196"#;
197
198    #[test]
199    fn action_specs_body_limits_parsing_to_metadata_block() {
200        let body = action_specs_body(ACTIONS).expect("ACTION_SPECS body should parse");
201        assert!(body.contains("greet"));
202        assert!(!body.contains("Alice"));
203    }
204
205    #[test]
206    fn action_name_parser_ignores_non_metadata_names() {
207        let body = action_specs_body(ACTIONS).unwrap();
208        assert_eq!(extract_action_names(body), vec!["greet", "elicit_name"]);
209    }
210
211    #[test]
212    fn mcp_only_parser_detects_transport_restriction() {
213        let body = action_specs_body(ACTIONS).unwrap();
214        assert_eq!(extract_mcp_only_actions(body), vec!["elicit_name"]);
215    }
216
217    #[test]
218    fn variant_name_matches_cli_enum_style() {
219        assert_eq!(variant_name("elicit_name"), "ElicitName");
220    }
221}