Skip to main content

xtask/
scaffold.rs

1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8use crate::{cargo_generate, cargo_generate_post, command_exists};
9
10const RESERVED_RMCP_PORTS: &[u16] = &[40010, 40020, 40030, 40040, 40050, 40060, 40070, 40080];
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub(crate) enum ServerCategory {
14    UpstreamClient,
15    ApplicationPlatform,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub(crate) enum PortSelection {
20    Auto,
21    Port(u16),
22}
23
24#[derive(Debug)]
25pub(crate) struct ScaffoldCliInput {
26    pub name: String,
27    pub category: ServerCategory,
28    pub port: PortSelection,
29    pub github_owner: String,
30    pub github_repo: Option<String>,
31}
32
33#[derive(Debug)]
34pub(crate) struct ScaffoldPlan {
35    pub defines: BTreeMap<String, String>,
36    pub report: String,
37    action_snippets: Option<String>,
38}
39
40#[derive(Debug, Deserialize)]
41struct IntentPayload {
42    server_category: String,
43    project: IntentProject,
44    #[serde(default)]
45    runtime: IntentRuntime,
46    #[serde(default)]
47    upstream: IntentUpstream,
48    #[serde(default)]
49    required_surfaces: Vec<String>,
50    #[serde(default)]
51    mcp_primitives: Vec<String>,
52    #[serde(default)]
53    deployment: String,
54    #[serde(default)]
55    plugins: Vec<String>,
56    #[serde(default)]
57    publish_mcp: bool,
58    #[serde(default)]
59    crawl_docs: CrawlDocs,
60}
61
62#[derive(Debug, Deserialize)]
63struct IntentProject {
64    #[serde(default)]
65    display_name: String,
66    crate_name: String,
67    binary_name: String,
68    service_name: String,
69    env_prefix: String,
70}
71
72#[derive(Debug, Default, Deserialize)]
73struct IntentRuntime {
74    #[serde(default = "default_host")]
75    host: String,
76    #[serde(default)]
77    port: u16,
78    #[serde(default)]
79    binary_profile: String,
80    #[serde(default = "default_mcp_transport")]
81    mcp_transport: String,
82}
83
84#[derive(Debug, Default, Deserialize)]
85struct IntentUpstream {
86    #[serde(default)]
87    auth_kind: String,
88}
89
90#[derive(Debug, Default, Deserialize)]
91struct CrawlDocs {
92    #[serde(default)]
93    urls: Vec<String>,
94    #[serde(default)]
95    repos: Vec<String>,
96    #[serde(default)]
97    search_topics: Vec<String>,
98}
99
100#[derive(Debug, Deserialize)]
101pub(crate) struct ActionManifest {
102    actions: Vec<ActionDraft>,
103}
104
105#[derive(Debug, Deserialize)]
106struct ActionDraft {
107    name: String,
108    #[serde(default)]
109    description: String,
110    #[serde(default = "default_read_scope")]
111    scope: String,
112    #[serde(default)]
113    params: Vec<ActionParam>,
114}
115
116#[derive(Debug, Deserialize)]
117struct ActionParam {
118    name: String,
119    #[serde(rename = "type", default = "default_string_type")]
120    ty: String,
121    #[serde(default)]
122    required: bool,
123    #[serde(default)]
124    description: String,
125}
126
127pub(crate) fn run(args: &[String]) -> Result<()> {
128    let options = Options::parse(args)?;
129    match options.mode {
130        Mode::Help => {
131            print_help();
132            Ok(())
133        }
134        Mode::Verify { root } => {
135            verify_generated_project(&root)?;
136            if options.cargo_check {
137                run_cargo_check(&root)?;
138            }
139            println!("Scaffold verification passed for {}", root.display());
140            Ok(())
141        }
142        Mode::AdaptPlan { ref root } => {
143            print!("{}", render_adapt_plan(root)?);
144            Ok(())
145        }
146        Mode::WriteActionStarters { ref root } => {
147            let actions = options
148                .actions
149                .as_ref()
150                .context("--actions is required with --write-action-starters")?;
151            let text = fs::read_to_string(actions)
152                .with_context(|| format!("failed to read action manifest {}", actions.display()))?;
153            let manifest = ActionManifest::from_json(&text)?;
154            write_action_starters(root, &manifest)?;
155            println!(
156                "Wrote action starter artifacts to {}",
157                root.join("docs/action-starters").display()
158            );
159            Ok(())
160        }
161        Mode::Plan => {
162            let plan = build_plan(&options)?;
163            print!("{}", plan.render());
164            Ok(())
165        }
166        Mode::Apply { ref parent } => {
167            let plan = build_plan(&options)?;
168            apply_plan(&plan, parent, options.cargo_check)?;
169            Ok(())
170        }
171    }
172}
173
174impl ScaffoldPlan {
175    pub(crate) fn from_intent_json(input: &str) -> Result<Self> {
176        let payload: IntentPayload =
177            serde_json::from_str(input).context("failed to parse scaffold intent JSON")?;
178        Self::from_intent(payload)
179    }
180
181    pub(crate) fn from_cli(input: ScaffoldCliInput) -> Result<Self> {
182        let raw_name = normalize_name(&input.name)?;
183        let crate_prefix = raw_name
184            .strip_suffix("-mcp")
185            .unwrap_or(&raw_name)
186            .to_owned();
187        let service_slug = snake_slug(&crate_prefix)?;
188        let package_name = if raw_name.ends_with("-mcp") {
189            raw_name
190        } else {
191            format!("{raw_name}-mcp")
192        };
193        let default_port = match input.port {
194            PortSelection::Auto => next_scaffold_port(40010, RESERVED_RMCP_PORTS).to_string(),
195            PortSelection::Port(port) => validate_port(port)?.to_string(),
196        };
197        let github_repo = input.github_repo.unwrap_or_else(|| package_name.clone());
198
199        let mut defines = BTreeMap::new();
200        defines.insert("package_name".to_owned(), package_name.clone());
201        defines.insert("crate_prefix".to_owned(), crate_prefix.clone());
202        defines.insert("binary_name".to_owned(), crate_prefix.clone());
203        defines.insert("service_slug".to_owned(), service_slug.clone());
204        defines.insert("type_prefix".to_owned(), pascal_case(&service_slug));
205        defines.insert("env_prefix".to_owned(), env_prefix(&service_slug));
206        defines.insert("scope_prefix".to_owned(), crate_prefix.replace('_', "-"));
207        defines.insert("default_port".to_owned(), default_port);
208        defines.insert("github_owner".to_owned(), input.github_owner);
209        defines.insert("github_repo".to_owned(), github_repo);
210        defines.insert(
211            "default_features".to_owned(),
212            default_features_for(input.category).to_owned(),
213        );
214
215        let empty_surfaces = Vec::new();
216        let empty_primitives = Vec::new();
217        let empty_plugins = Vec::new();
218        let empty_crawl_docs = CrawlDocs::default();
219        let report = render_report(ReportInput {
220            category: input.category,
221            defines: &defines,
222            required_surfaces: &empty_surfaces,
223            host: "127.0.0.1",
224            mcp_transport: "dual",
225            mcp_primitives: &empty_primitives,
226            deployment: "none",
227            plugins: &empty_plugins,
228            publish_mcp: false,
229            crawl_docs: &empty_crawl_docs,
230            display_name: "",
231            auth_kind: "",
232        });
233        Ok(Self {
234            defines,
235            report,
236            action_snippets: None,
237        })
238    }
239
240    fn from_intent(payload: IntentPayload) -> Result<Self> {
241        let category = parse_category(&payload.server_category)?;
242        let crate_prefix = payload
243            .project
244            .crate_name
245            .strip_suffix("-mcp")
246            .unwrap_or(&payload.project.crate_name)
247            .to_owned();
248        let service_slug = snake_slug(&payload.project.service_name)?;
249        let binary_profile = normalize_binary_profile(&payload.runtime.binary_profile, category);
250        let default_port = validate_port(if payload.runtime.port == 0 {
251            next_scaffold_port(40010, RESERVED_RMCP_PORTS)
252        } else {
253            payload.runtime.port
254        })?
255        .to_string();
256
257        let mut defines = BTreeMap::new();
258        defines.insert(
259            "package_name".to_owned(),
260            payload.project.crate_name.clone(),
261        );
262        defines.insert("crate_prefix".to_owned(), crate_prefix.clone());
263        defines.insert(
264            "binary_name".to_owned(),
265            payload.project.binary_name.clone(),
266        );
267        defines.insert("service_slug".to_owned(), service_slug.clone());
268        defines.insert("type_prefix".to_owned(), pascal_case(&service_slug));
269        defines.insert("env_prefix".to_owned(), payload.project.env_prefix.clone());
270        defines.insert("scope_prefix".to_owned(), crate_prefix.replace('_', "-"));
271        defines.insert("default_port".to_owned(), default_port);
272        defines.insert("github_owner".to_owned(), "jmagar".to_owned());
273        defines.insert("github_repo".to_owned(), payload.project.crate_name.clone());
274        defines.insert("default_features".to_owned(), binary_profile);
275
276        let report = render_report(ReportInput {
277            category,
278            defines: &defines,
279            required_surfaces: &payload.required_surfaces,
280            host: &payload.runtime.host,
281            mcp_transport: &payload.runtime.mcp_transport,
282            mcp_primitives: &payload.mcp_primitives,
283            deployment: &payload.deployment,
284            plugins: &payload.plugins,
285            publish_mcp: payload.publish_mcp,
286            crawl_docs: &payload.crawl_docs,
287            display_name: &payload.project.display_name,
288            auth_kind: &payload.upstream.auth_kind,
289        });
290        Ok(Self {
291            defines,
292            report,
293            action_snippets: None,
294        })
295    }
296
297    fn with_action_snippets(mut self, snippets: Option<String>) -> Self {
298        self.action_snippets = snippets;
299        self
300    }
301
302    fn render(&self) -> String {
303        let mut output = self.report.clone();
304        if let Some(snippets) = &self.action_snippets {
305            output.push_str("\n## Action Starter Snippets\n\n");
306            output.push_str(snippets);
307            output.push('\n');
308        }
309        output
310    }
311}
312
313impl ActionManifest {
314    pub(crate) fn from_json(input: &str) -> Result<Self> {
315        let manifest: Self =
316            serde_json::from_str(input).context("failed to parse action manifest JSON")?;
317        if manifest.actions.is_empty() {
318            bail!("action manifest must contain at least one action");
319        }
320        for action in &manifest.actions {
321            validate_identifier("action.name", &action.name)?;
322            for param in &action.params {
323                validate_identifier("param.name", &param.name)?;
324            }
325        }
326        Ok(manifest)
327    }
328
329    pub(crate) fn render_snippets(&self, service_type: &str) -> String {
330        let mut output = String::new();
331        output.push_str("### crates/soma/domain/src/actions.rs\n\n```rust\n");
332        output.push_str(&self.render_action_specs_snippet());
333        output.push_str("```\n\n### crates/soma/mcp/src/tools.rs\n\n```rust\n");
334        output.push_str(&self.render_tools_snippet());
335        output.push_str("```\n\n### crates/soma/cli/src/lib.rs\n\n```rust\n");
336        output.push_str(&self.render_cli_snippet());
337        output.push_str("```\n\n### crates/soma/application/src/service.rs\n\n```rust\n");
338        output.push_str(&self.render_service_snippet(service_type));
339        output.push_str("```\n\n### tests\n\n");
340        output.push_str(&self.render_tests_guide());
341        output
342    }
343
344    fn render_action_specs_snippet(&self) -> String {
345        let mut output = String::new();
346        for action in &self.actions {
347            output.push_str("ActionSpec {\n");
348            output.push_str(&format!("    name: \"{}\",\n", action.name));
349            output.push_str(&format!(
350                "    description: \"{}\",\n",
351                escape_rust_string(description_or_default(&action.description, &action.name))
352            ));
353            output.push_str(&format!(
354                "    required_scope: Some({}_SCOPE),\n",
355                action.scope.to_ascii_uppercase()
356            ));
357            output.push_str("    transport: ActionTransport::Any,\n");
358            output.push_str("    rest_method: Some(\"POST\"),\n");
359            output.push_str(&format!("    rest_path: Some(\"/v1/{}\"),\n", action.name));
360            output.push_str("    destructive: false,\n");
361            output.push_str("    requires_admin: false,\n");
362            output.push_str("    cost: ActionCost::Cheap,\n");
363            output.push_str("    params: &[\n");
364            for param in &action.params {
365                output.push_str(&format!(
366                    "        ParamSpec {{ name: \"{}\", ty: ParamType::String, required: {}, description: \"{}\", max_len: Some(4096), enum_values: &[] }},\n",
367                    param.name,
368                    param.required,
369                    escape_rust_string(description_or_default(&param.description, &param.name))
370                ));
371            }
372            output.push_str("    ],\n");
373            output.push_str("    returns: \"JSON payload from the service layer.\",\n");
374            output.push_str("    cli: None,\n");
375            output.push_str("},\n\n");
376        }
377        output
378    }
379
380    fn render_tools_snippet(&self) -> String {
381        let mut output = String::new();
382        for action in &self.actions {
383            output.push_str(&format!("\"{}\" => {{\n", action.name));
384            for param in &action.params {
385                if param.ty == "string" {
386                    output.push_str(&format!(
387                        "    let {} = string_arg(&args, \"{}\");\n",
388                        param.name, param.name
389                    ));
390                }
391            }
392            output.push_str(&format!("    state.service.{}(", action.name));
393            output.push_str(
394                &action
395                    .params
396                    .iter()
397                    .map(|param| param.name.as_str())
398                    .collect::<Vec<_>>()
399                    .join(", "),
400            );
401            output.push_str(").await\n},\n");
402        }
403        output
404    }
405
406    fn render_cli_snippet(&self) -> String {
407        let mut output = String::new();
408        for action in &self.actions {
409            output.push_str(&format!("Command::{},\n", pascal_case(&action.name)));
410        }
411        output
412    }
413
414    fn render_service_snippet(&self, service_type: &str) -> String {
415        let mut output = String::new();
416        output.push_str(&format!("impl {service_type} {{\n"));
417        for action in &self.actions {
418            output.push_str(&format!("    pub async fn {}(", action.name));
419            output.push_str(
420                &action
421                    .params
422                    .iter()
423                    .map(|param| format!("{}: Option<String>", param.name))
424                    .collect::<Vec<_>>()
425                    .join(", "),
426            );
427            output.push_str(") -> anyhow::Result<serde_json::Value> {\n");
428            output.push_str("        todo!(\"replace with service implementation\")\n");
429            output.push_str("    }\n\n");
430        }
431        output.push_str("}\n");
432        output
433    }
434
435    fn render_tests_guide(&self) -> String {
436        let mut output = String::new();
437        output.push_str("Add service and tool_dispatch coverage for every action.\n");
438        output
439            .push_str("- `apps/soma/tests/tool_dispatch.rs`: MCP success and validation paths.\n");
440        output.push_str("- `apps/soma/tests/cli_parse.rs`: CLI command/flag parsing.\n");
441        output.push_str(
442            "- Service-layer tests near `crates/soma/application/src/service.rs` or focused modules.\n",
443        );
444        output.push_str(&format!(
445            "- Actions: {}\n",
446            self.actions
447                .iter()
448                .map(|action| action.name.as_str())
449                .collect::<Vec<_>>()
450                .join(", ")
451        ));
452        output
453    }
454
455    fn render_starter_readme(&self) -> String {
456        let mut output = String::new();
457        output.push_str("# Action Starters\n\n");
458        output.push_str("Generated by `cargo xtask scaffold --write-action-starters`.\n");
459        output.push_str("Review and move these snippets into the real source files; they are intentionally not applied automatically.\n\n");
460        output.push_str("Actions:\n");
461        for action in &self.actions {
462            output.push_str(&format!("- `{}` - {}\n", action.name, action.description));
463        }
464        output
465    }
466}
467
468fn build_plan(options: &Options) -> Result<ScaffoldPlan> {
469    let plan = if let Some(path) = &options.intent {
470        let text = fs::read_to_string(path)
471            .with_context(|| format!("failed to read intent JSON {}", path.display()))?;
472        ScaffoldPlan::from_intent_json(&text)?
473    } else {
474        let name = options
475            .name
476            .clone()
477            .context("--name is required when --intent is not provided")?;
478        ScaffoldPlan::from_cli(ScaffoldCliInput {
479            name,
480            category: options.category.unwrap_or(ServerCategory::UpstreamClient),
481            port: options.port.unwrap_or(PortSelection::Auto),
482            github_owner: options
483                .github_owner
484                .clone()
485                .unwrap_or_else(|| "jmagar".to_owned()),
486            github_repo: options.github_repo.clone(),
487        })?
488    };
489
490    let action_snippets = options
491        .actions
492        .as_ref()
493        .map(|path| -> Result<String> {
494            let text = fs::read_to_string(path)
495                .with_context(|| format!("failed to read action manifest {}", path.display()))?;
496            Ok(ActionManifest::from_json(&text)?.render_snippets("SomaService"))
497        })
498        .transpose()?;
499    Ok(plan.with_action_snippets(action_snippets))
500}
501
502fn apply_plan(plan: &ScaffoldPlan, parent: &Path, cargo_check: bool) -> Result<()> {
503    if !command_exists("cargo-generate") {
504        bail!("cargo-generate is not installed; run `cargo install cargo-generate`");
505    }
506    fs::create_dir_all(parent)
507        .with_context(|| format!("failed to create output parent {}", parent.display()))?;
508    let package_name = plan
509        .defines
510        .get("package_name")
511        .context("scaffold plan is missing package_name")?;
512    let repo = std::env::current_dir().context("failed to read current directory")?;
513    let temp = cargo_generate::TempDir::new("soma-scaffold")?;
514    let template = temp.path().join("_template");
515    cargo_generate::stage_template(&repo, &template)?;
516    let mut args = vec![
517        "generate".to_owned(),
518        "--silent".to_owned(),
519        "--path".to_owned(),
520        template.display().to_string(),
521        "--name".to_owned(),
522        package_name.clone(),
523        "--destination".to_owned(),
524        parent.display().to_string(),
525    ];
526    for (key, value) in &plan.defines {
527        args.push("--define".to_owned());
528        args.push(format!("{key}={value}"));
529    }
530    run_command("cargo", &args, Path::new("."))?;
531
532    let project = parent.join(package_name);
533    cargo_generate_post::run(&[project.display().to_string()])?;
534
535    fs::create_dir_all(project.join("docs"))
536        .with_context(|| format!("failed to create {}", project.join("docs").display()))?;
537    fs::write(project.join("docs/scaffold-report.md"), plan.render())
538        .with_context(|| "failed to write scaffold report".to_owned())?;
539    verify_generated_project(&project)?;
540    if cargo_check {
541        run_cargo_check(&project)?;
542    }
543    println!("Scaffolded {}", project.display());
544    println!(
545        "Report: {}",
546        project.join("docs/scaffold-report.md").display()
547    );
548    Ok(())
549}
550
551pub(crate) fn verify_generated_project(root: &Path) -> Result<()> {
552    let mut errors = Vec::new();
553    for relative in [
554        ".cargo-generate-values.toml",
555        "cargo-generate.toml",
556        "scaffold",
557        "docs/CARGO_GENERATE.md",
558    ] {
559        if root.join(relative).exists() {
560            errors.push(format!("generated project still contains {relative}"));
561        }
562    }
563    if root.join("CLAUDE.md").exists() {
564        check_agent_symlink(root, "AGENTS.md", &mut errors);
565        check_agent_symlink(root, "GEMINI.md", &mut errors);
566    }
567    check_plugin_manifest_versions(root, &mut errors)?;
568    if errors.is_empty() {
569        Ok(())
570    } else {
571        bail!(errors.join("\n"))
572    }
573}
574
575fn check_plugin_manifest_versions(root: &Path, errors: &mut Vec<String>) -> Result<()> {
576    for entry in walkdir::WalkDir::new(root) {
577        let entry = entry?;
578        if !entry.file_type().is_file() {
579            continue;
580        }
581        let path = entry.path();
582        let normalized = path.to_string_lossy().replace('\\', "/");
583        if !(normalized.ends_with(".claude-plugin/plugin.json")
584            || normalized.ends_with(".codex-plugin/plugin.json")
585            || normalized.ends_with("gemini-extension.json"))
586        {
587            continue;
588        }
589        let text = fs::read_to_string(path)
590            .with_context(|| format!("failed to read {}", path.display()))?;
591        let value: serde_json::Value = serde_json::from_str(&text)
592            .with_context(|| format!("failed to parse {}", path.display()))?;
593        if json_contains_key(&value, "version") {
594            errors.push(format!(
595                "{} must not contain a version field",
596                path.display()
597            ));
598        }
599    }
600    Ok(())
601}
602
603fn check_agent_symlink(root: &Path, name: &str, errors: &mut Vec<String>) {
604    let path = root.join(name);
605    match fs::read_link(&path) {
606        Ok(target) if target == Path::new("CLAUDE.md") => {}
607        Ok(target) => errors.push(format!(
608            "{} must be a symlink to CLAUDE.md, found {}",
609            path.display(),
610            target.display()
611        )),
612        Err(_) => errors.push(format!("{} must be a symlink to CLAUDE.md", path.display())),
613    }
614}
615
616fn json_contains_key(value: &serde_json::Value, key: &str) -> bool {
617    match value {
618        serde_json::Value::Object(map) => {
619            map.contains_key(key) || map.values().any(|value| json_contains_key(value, key))
620        }
621        serde_json::Value::Array(values) => {
622            values.iter().any(|value| json_contains_key(value, key))
623        }
624        _ => false,
625    }
626}
627
628fn run_cargo_check(root: &Path) -> Result<()> {
629    run_command(
630        "cargo",
631        &[
632            "check".to_owned(),
633            "--workspace".to_owned(),
634            "--all-targets".to_owned(),
635        ],
636        root,
637    )
638}
639
640fn run_command(program: &str, args: &[String], cwd: &Path) -> Result<()> {
641    let mut command = Command::new(program);
642    command.args(args).current_dir(cwd).stdin(Stdio::null());
643    if program == "cargo" {
644        for (key, _) in std::env::vars_os() {
645            if key.to_string_lossy().starts_with("CARGO_PROFILE") {
646                command.env_remove(key);
647            }
648        }
649    }
650    let status = command
651        .status()
652        .with_context(|| format!("failed to spawn `{program}` in {}", cwd.display()))?;
653    if !status.success() {
654        bail!(
655            "`{program} {}` exited with status {status} in {}",
656            args.join(" "),
657            cwd.display()
658        );
659    }
660    Ok(())
661}
662
663struct ReportInput<'a> {
664    category: ServerCategory,
665    defines: &'a BTreeMap<String, String>,
666    required_surfaces: &'a [String],
667    host: &'a str,
668    mcp_transport: &'a str,
669    mcp_primitives: &'a [String],
670    deployment: &'a str,
671    plugins: &'a [String],
672    publish_mcp: bool,
673    crawl_docs: &'a CrawlDocs,
674    display_name: &'a str,
675    auth_kind: &'a str,
676}
677
678fn render_report(input: ReportInput<'_>) -> String {
679    let surfaces = if input.required_surfaces.is_empty() {
680        surfaces_for(input.category)
681            .iter()
682            .map(|value| value.to_string())
683            .collect::<Vec<_>>()
684    } else {
685        input.required_surfaces.to_vec()
686    };
687    let title = if input.display_name.is_empty() {
688        input
689            .defines
690            .get("package_name")
691            .map(String::as_str)
692            .unwrap_or("Generated MCP server")
693    } else {
694        input.display_name
695    };
696    let mut report = String::new();
697    report.push_str(&format!("# Scaffold Plan: {title}\n\n"));
698    report.push_str(&format!("Category: {}\n", input.category.as_str()));
699    report.push_str(&format!("Required surfaces: {}\n", surfaces.join(", ")));
700    report.push_str(&format!(
701        "Default Cargo features: {}\n",
702        input
703            .defines
704            .get("default_features")
705            .map(String::as_str)
706            .unwrap_or("")
707    ));
708    report.push_str(&format!("Host: {}\n", input.host));
709    report.push_str(&format!("MCP transport: {}\n", input.mcp_transport));
710    report.push_str(&format!(
711        "MCP primitives: {}\n",
712        join_or_none(input.mcp_primitives)
713    ));
714    report.push_str(&format!(
715        "Deployment: {}\n",
716        empty_as_none(input.deployment)
717    ));
718    report.push_str(&format!("Plugins: {}\n", join_or_none(input.plugins)));
719    report.push_str(&format!("MCP registry publishing: {}\n", input.publish_mcp));
720    report.push_str(&format!(
721        "Upstream auth: {}\n\n",
722        empty_as_none(input.auth_kind)
723    ));
724    report.push_str("## cargo-generate Defines\n\n");
725    for (key, value) in input.defines {
726        report.push_str(&format!("- `{key}` = `{value}`\n"));
727    }
728    report.push_str("\n## One-command Paths\n\n");
729    report.push_str("- Plan only: `cargo xtask scaffold --intent scaffold-intent.json --plan`\n");
730    report.push_str(
731        "- Apply: `cargo xtask scaffold --intent scaffold-intent.json --apply ../generated`\n",
732    );
733    report.push_str(
734        "- Prove generated output: `cargo xtask scaffold --verify ../generated/<package>`\n",
735    );
736    if has_crawl_docs(input.crawl_docs) {
737        report.push_str("\n## Axon research inputs\n\n");
738        for url in &input.crawl_docs.urls {
739            report.push_str(&format!("- URL: {url}\n"));
740        }
741        for repo in &input.crawl_docs.repos {
742            report.push_str(&format!("- Repo: {repo}\n"));
743        }
744        for topic in &input.crawl_docs.search_topics {
745            report.push_str(&format!("- Search: {topic}\n"));
746        }
747        report.push_str(
748            "\nRun the approved Axon crawl/research step before replacing stub client methods.\n",
749        );
750    }
751    report.push_str("\n## Remaining Human Work\n\n");
752    report.push_str("- Replace the stub client with real upstream/platform calls.\n");
753    report.push_str("- Add each real action through service, MCP, CLI, tests, and docs.\n");
754    report.push_str("- Run `cargo xtask scaffold --verify <generated-root>` before publishing.\n");
755    report
756}
757
758fn has_crawl_docs(crawl_docs: &CrawlDocs) -> bool {
759    !crawl_docs.urls.is_empty()
760        || !crawl_docs.repos.is_empty()
761        || !crawl_docs.search_topics.is_empty()
762}
763
764fn surfaces_for(category: ServerCategory) -> &'static [&'static str] {
765    match category {
766        ServerCategory::UpstreamClient => &["mcp", "cli"],
767        ServerCategory::ApplicationPlatform => &["api", "cli", "mcp", "web"],
768    }
769}
770
771fn default_features_for(category: ServerCategory) -> &'static str {
772    match category {
773        ServerCategory::UpstreamClient => "local-adapter",
774        ServerCategory::ApplicationPlatform => "full",
775    }
776}
777
778fn normalize_binary_profile(value: &str, category: ServerCategory) -> String {
779    match value {
780        "local-adapter" | "cli-mcp" => "local-adapter".to_owned(),
781        "server-full" | "full" => "full".to_owned(),
782        "" => default_features_for(category).to_owned(),
783        other => other.to_owned(),
784    }
785}
786
787fn parse_category(value: &str) -> Result<ServerCategory> {
788    match value {
789        "upstream-client" => Ok(ServerCategory::UpstreamClient),
790        "application-platform" => Ok(ServerCategory::ApplicationPlatform),
791        other => bail!("unknown server category {other:?}"),
792    }
793}
794
795impl ServerCategory {
796    fn as_str(self) -> &'static str {
797        match self {
798            Self::UpstreamClient => "upstream-client",
799            Self::ApplicationPlatform => "application-platform",
800        }
801    }
802}
803
804fn next_scaffold_port(start: u16, reserved: &[u16]) -> u16 {
805    let reserved = reserved.iter().copied().collect::<BTreeSet<_>>();
806    let mut port = start;
807    loop {
808        if !reserved.contains(&port) {
809            return port;
810        }
811        port = port.saturating_add(10);
812    }
813}
814
815fn normalize_name(value: &str) -> Result<String> {
816    let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
817    validate_slug("name", &normalized)?;
818    Ok(normalized)
819}
820
821fn snake_slug(value: &str) -> Result<String> {
822    let slug = value.replace('-', "_");
823    validate_identifier("service_slug", &slug)?;
824    Ok(slug)
825}
826
827fn pascal_case(value: &str) -> String {
828    value
829        .split(['_', '-'])
830        .filter(|part| !part.is_empty())
831        .map(|part| {
832            let mut chars = part.chars();
833            match chars.next() {
834                Some(first) => {
835                    first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
836                }
837                None => String::new(),
838            }
839        })
840        .collect::<String>()
841}
842
843fn env_prefix(value: &str) -> String {
844    value
845        .chars()
846        .map(|ch| {
847            if ch.is_ascii_alphanumeric() {
848                ch.to_ascii_uppercase()
849            } else {
850                '_'
851            }
852        })
853        .collect()
854}
855
856fn validate_slug(name: &str, value: &str) -> Result<()> {
857    let mut chars = value.chars();
858    let Some(first) = chars.next() else {
859        bail!("{name} must not be empty");
860    };
861    if !first.is_ascii_lowercase()
862        || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
863    {
864        bail!("{name} must match ^[a-z][a-z0-9-]*$: {value:?}");
865    }
866    Ok(())
867}
868
869fn validate_identifier(name: &str, value: &str) -> Result<()> {
870    let mut chars = value.chars();
871    let Some(first) = chars.next() else {
872        bail!("{name} must not be empty");
873    };
874    if !first.is_ascii_lowercase()
875        || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
876    {
877        bail!("{name} must be Rust identifier-safe: {value:?}");
878    }
879    Ok(())
880}
881
882fn validate_port(port: u16) -> Result<u16> {
883    if port == 0 {
884        bail!("port must be between 1 and 65535");
885    }
886    Ok(port)
887}
888
889fn description_or_default<'a>(description: &'a str, name: &'a str) -> &'a str {
890    if description.is_empty() {
891        name
892    } else {
893        description
894    }
895}
896
897fn escape_rust_string(value: &str) -> String {
898    value.replace('\\', "\\\\").replace('"', "\\\"")
899}
900
901fn join_or_none(values: &[String]) -> String {
902    if values.is_empty() {
903        "none".to_owned()
904    } else {
905        values.join(", ")
906    }
907}
908
909fn empty_as_none(value: &str) -> &str {
910    if value.is_empty() {
911        "none"
912    } else {
913        value
914    }
915}
916
917fn default_host() -> String {
918    "127.0.0.1".to_owned()
919}
920
921fn default_mcp_transport() -> String {
922    "dual".to_owned()
923}
924
925fn default_read_scope() -> String {
926    "read".to_owned()
927}
928
929fn default_string_type() -> String {
930    "string".to_owned()
931}
932
933#[derive(Debug)]
934struct Options {
935    mode: Mode,
936    intent: Option<PathBuf>,
937    name: Option<String>,
938    category: Option<ServerCategory>,
939    port: Option<PortSelection>,
940    github_owner: Option<String>,
941    github_repo: Option<String>,
942    actions: Option<PathBuf>,
943    cargo_check: bool,
944}
945
946#[derive(Debug)]
947enum Mode {
948    Help,
949    Plan,
950    Apply { parent: PathBuf },
951    Verify { root: PathBuf },
952    AdaptPlan { root: PathBuf },
953    WriteActionStarters { root: PathBuf },
954}
955
956impl Options {
957    fn parse(args: &[String]) -> Result<Self> {
958        if args
959            .iter()
960            .any(|arg| arg == "--help" || arg == "-h" || arg == "help")
961        {
962            return Ok(Self::help());
963        }
964        let mut options = Self {
965            mode: Mode::Plan,
966            intent: None,
967            name: None,
968            category: None,
969            port: None,
970            github_owner: None,
971            github_repo: None,
972            actions: None,
973            cargo_check: true,
974        };
975        let mut index = 0usize;
976        while index < args.len() {
977            match args[index].as_str() {
978                "--plan" => options.mode = Mode::Plan,
979                "--apply" => {
980                    index += 1;
981                    options.mode = Mode::Apply {
982                        parent: PathBuf::from(value_arg(args, index, "--apply")?),
983                    };
984                }
985                "--verify" => {
986                    index += 1;
987                    options.mode = Mode::Verify {
988                        root: PathBuf::from(value_arg(args, index, "--verify")?),
989                    };
990                }
991                "--adapt-plan" => {
992                    index += 1;
993                    options.mode = Mode::AdaptPlan {
994                        root: PathBuf::from(value_arg(args, index, "--adapt-plan")?),
995                    };
996                }
997                "--write-action-starters" => {
998                    index += 1;
999                    options.mode = Mode::WriteActionStarters {
1000                        root: PathBuf::from(value_arg(args, index, "--write-action-starters")?),
1001                    };
1002                }
1003                "--intent" => {
1004                    index += 1;
1005                    options.intent = Some(PathBuf::from(value_arg(args, index, "--intent")?));
1006                }
1007                "--name" => {
1008                    index += 1;
1009                    options.name = Some(value_arg(args, index, "--name")?.to_owned());
1010                }
1011                "--category" => {
1012                    index += 1;
1013                    options.category = Some(parse_category(value_arg(args, index, "--category")?)?);
1014                }
1015                "--port" => {
1016                    index += 1;
1017                    options.port = Some(parse_port_selection(value_arg(args, index, "--port")?)?);
1018                }
1019                "--github-owner" => {
1020                    index += 1;
1021                    options.github_owner =
1022                        Some(value_arg(args, index, "--github-owner")?.to_owned());
1023                }
1024                "--github-repo" => {
1025                    index += 1;
1026                    options.github_repo = Some(value_arg(args, index, "--github-repo")?.to_owned());
1027                }
1028                "--actions" => {
1029                    index += 1;
1030                    options.actions = Some(PathBuf::from(value_arg(args, index, "--actions")?));
1031                }
1032                "--no-cargo-check" => options.cargo_check = false,
1033                unknown => bail!("unknown scaffold option {unknown:?}"),
1034            }
1035            index += 1;
1036        }
1037        Ok(options)
1038    }
1039
1040    fn help() -> Self {
1041        Self {
1042            mode: Mode::Help,
1043            intent: None,
1044            name: None,
1045            category: None,
1046            port: None,
1047            github_owner: None,
1048            github_repo: None,
1049            actions: None,
1050            cargo_check: true,
1051        }
1052    }
1053}
1054
1055fn value_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str> {
1056    args.get(index)
1057        .map(String::as_str)
1058        .with_context(|| format!("{flag} requires a value"))
1059}
1060
1061fn parse_port_selection(value: &str) -> Result<PortSelection> {
1062    if value == "auto" {
1063        return Ok(PortSelection::Auto);
1064    }
1065    let port = value
1066        .parse::<u16>()
1067        .with_context(|| format!("port must be an integer or auto: {value:?}"))?;
1068    Ok(PortSelection::Port(validate_port(port)?))
1069}
1070
1071fn print_help() {
1072    println!(
1073        "Usage:
1074  cargo xtask scaffold --name <service> [--category upstream-client|application-platform] [--port auto|PORT] --plan
1075  cargo xtask scaffold --intent scaffold-intent.json [--actions actions.json] --plan
1076  cargo xtask scaffold --intent scaffold-intent.json --apply <output-parent> [--no-cargo-check]
1077  cargo xtask scaffold --verify <generated-root> [--no-cargo-check]
1078  cargo xtask scaffold --adapt-plan <generated-root>
1079  cargo xtask scaffold --write-action-starters <generated-root> --actions actions.json"
1080    );
1081}
1082
1083fn write_action_starters(root: &Path, manifest: &ActionManifest) -> Result<()> {
1084    if !root.exists() {
1085        bail!("generated root does not exist: {}", root.display());
1086    }
1087    let dir = root.join("docs/action-starters");
1088    fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1089    fs::write(dir.join("README.md"), manifest.render_starter_readme())
1090        .context("failed to write action starter README")?;
1091    fs::write(
1092        dir.join("actions.rs.snippet"),
1093        manifest.render_action_specs_snippet(),
1094    )
1095    .context("failed to write actions.rs snippet")?;
1096    fs::write(
1097        dir.join("tools.rs.snippet"),
1098        manifest.render_tools_snippet(),
1099    )
1100    .context("failed to write tools.rs snippet")?;
1101    fs::write(dir.join("cli.rs.snippet"), manifest.render_cli_snippet())
1102        .context("failed to write cli.rs snippet")?;
1103    fs::write(
1104        dir.join("service.rs.snippet"),
1105        manifest.render_service_snippet("SomaService"),
1106    )
1107    .context("failed to write service.rs snippet")?;
1108    fs::write(dir.join("tests.md"), manifest.render_tests_guide())
1109        .context("failed to write tests guide")?;
1110    Ok(())
1111}
1112
1113fn render_adapt_plan(root: &Path) -> Result<String> {
1114    if !root.exists() {
1115        bail!("generated root does not exist: {}", root.display());
1116    }
1117    let report_path = root.join("docs/scaffold-report.md");
1118    let report = fs::read_to_string(&report_path).unwrap_or_default();
1119    let profile = report_value(&report, "Default Cargo features").unwrap_or("unknown");
1120    let category = report_value(&report, "Category").unwrap_or("unknown");
1121    let surfaces = report_value(&report, "Required surfaces").unwrap_or("unknown");
1122    let has_api = profile_contains(profile, "full")
1123        || profile_contains(profile, "server")
1124        || surface_contains(surfaces, "api");
1125    let has_web = profile_contains(profile, "full") || surface_contains(surfaces, "web");
1126    let has_plugin = profile_contains(profile, "full") || profile_contains(profile, "plugin");
1127
1128    let mut output = String::new();
1129    output.push_str("# Adaptation Plan\n\n");
1130    output.push_str(&format!("Root: {}\n", root.display()));
1131    output.push_str(&format!("Category: {category}\n"));
1132    output.push_str(&format!("Profile: {profile}\n"));
1133    output.push_str(&format!("Surfaces: {surfaces}\n"));
1134    if report.is_empty() {
1135        output.push_str(&format!(
1136            "Scaffold report: missing ({})\n",
1137            report_path.display()
1138        ));
1139    } else {
1140        output.push_str(&format!("Scaffold report: {}\n", report_path.display()));
1141    }
1142
1143    output.push_str("\n## 1. Domain and config\n\n");
1144    output.push_str("- Replace the stub client in `crates/soma/client/src/client.rs`.\n");
1145    output.push_str("- Put validation, defaults, retries, caching, and domain rules in `crates/soma/application/src/service.rs` or focused modules under `crates/soma/application/src/`.\n");
1146    output.push_str(
1147        "- Update config structs and env prefixes in `crates/soma/config/src/config.rs`.\n",
1148    );
1149    output.push_str("- Update `.env.example` and `config.soma.toml` with real required credentials and non-secret defaults.\n");
1150
1151    output.push_str("\n## 2. Business actions\n\n");
1152    output.push_str("- Add action metadata in `crates/soma/domain/src/actions.rs`.\n");
1153    output.push_str("- Regenerate MCP schema docs and OpenAPI after changing action metadata.\n");
1154    output.push_str("- Keep MCP, CLI, and REST shims registry-driven.\n");
1155    if has_api {
1156        output.push_str("- Add REST handlers/routes in `crates/soma/api/src/api.rs` and `apps/soma/src/routes.rs`.\n");
1157    } else {
1158        output.push_str("- REST handlers are optional for this profile; add them only if the project needs an API surface.\n");
1159    }
1160
1161    output.push_str("\n## 3. Optional surfaces\n\n");
1162    if has_web {
1163        output.push_str("- Replace or remove the bundled web app under `apps/web`.\n");
1164        output.push_str("- Run `cargo xtask sync-web-source` after web source changes.\n");
1165    } else {
1166        output.push_str("- Web is not selected by this profile; remove web-specific assumptions if you keep the scaffold lean.\n");
1167    }
1168    if has_plugin {
1169        output.push_str("- Update plugin options, skills, and setup mappings under `plugins/soma/` and `crates/soma/cli/src/setup.rs`.\n");
1170    } else {
1171        output.push_str("- Plugin support is not selected by this profile; keep plugin files only if you plan to publish editor integrations.\n");
1172    }
1173    output.push_str("- Update `server.json`, repository URLs, Docker labels, release metadata, and package names before publishing.\n");
1174
1175    output.push_str("\n## 4. Tests and verification\n\n");
1176    output.push_str("- Add service behavior tests near the service modules.\n");
1177    output.push_str("- Add MCP dispatch coverage in `apps/soma/tests/tool_dispatch.rs`.\n");
1178    output.push_str("- Add CLI parsing coverage in `apps/soma/tests/cli_parse.rs`.\n");
1179    if has_api {
1180        output.push_str("- Add REST route coverage for every API action.\n");
1181    }
1182    output.push_str("- Run `cargo xtask scaffold --verify <generated-root>`.\n");
1183    output.push_str("- Run `cargo xtask check-docs`, `cargo xtask check-schema-docs --check`, `cargo xtask check-openapi --check`, and `just verify`.\n");
1184    output.push_str("\nUse this plan as an implementation checklist; it does not mutate files.\n");
1185    Ok(output)
1186}
1187
1188fn report_value<'a>(report: &'a str, label: &str) -> Option<&'a str> {
1189    report.lines().find_map(|line| {
1190        let (key, value) = line.split_once(':')?;
1191        if key.trim() == label {
1192            Some(value.trim())
1193        } else {
1194            None
1195        }
1196    })
1197}
1198
1199fn profile_contains(profile: &str, needle: &str) -> bool {
1200    profile.split(',').map(str::trim).any(|part| part == needle)
1201}
1202
1203fn surface_contains(surfaces: &str, needle: &str) -> bool {
1204    surfaces
1205        .split(',')
1206        .map(str::trim)
1207        .any(|part| part == needle)
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use std::fs;
1214    use tempfile::TempDir;
1215
1216    #[test]
1217    fn intent_json_derives_local_adapter_defines_and_research_plan() {
1218        let intent = r#"{
1219          "kind": "soma_scaffold_intent",
1220          "schema_version": 1,
1221          "server_category": "upstream-client",
1222          "required_surfaces": ["mcp", "cli"],
1223          "project": {
1224            "display_name": "unraid-rmcp",
1225            "crate_name": "unraid-rmcp",
1226            "binary_name": "runraid",
1227            "service_name": "unraid",
1228            "env_prefix": "UNRAID"
1229          },
1230          "upstream": { "base_url_env": "UNRAID_API_URL", "auth_kind": "api-key" },
1231          "runtime": {
1232            "host": "127.0.0.1",
1233            "port": 40010,
1234            "binary_profile": "local-adapter",
1235            "mcp_transport": "dual"
1236          },
1237          "mcp_primitives": ["tools", "resources"],
1238          "deployment": "none",
1239          "plugins": ["claude", "codex"],
1240          "publish_mcp": true,
1241          "crawl_docs": {
1242            "urls": ["https://docs.unraid.net/"],
1243            "repos": [],
1244            "search_topics": ["Unraid API authentication"]
1245          },
1246          "handoff": { "recommended_skill": "scaffold-project", "instructions": "Plan only." },
1247          "policy": {
1248            "business_action_minimum_surfaces": ["mcp", "cli"],
1249            "upstream_client_surfaces": ["mcp", "cli"],
1250            "application_platform_surfaces": ["api", "cli", "mcp", "web"],
1251            "binary_profiles": {
1252              "upstream_client_default": "local-adapter",
1253              "application_platform_default": "server-full",
1254              "gateway_shared_default": "server-full"
1255            }
1256          }
1257        }"#;
1258
1259        let plan = ScaffoldPlan::from_intent_json(intent).expect("plan");
1260
1261        assert_eq!(plan.defines.get("package_name").unwrap(), "unraid-rmcp");
1262        assert_eq!(plan.defines.get("crate_prefix").unwrap(), "unraid-rmcp");
1263        assert_eq!(plan.defines.get("binary_name").unwrap(), "runraid");
1264        assert_eq!(
1265            plan.defines.get("default_features").unwrap(),
1266            "local-adapter"
1267        );
1268        assert!(plan.report.contains("Required surfaces: mcp, cli"));
1269        assert!(plan.report.contains("Axon research inputs"));
1270        assert!(plan.report.contains("https://docs.unraid.net/"));
1271    }
1272
1273    #[test]
1274    fn cli_name_auto_port_derives_defaults() {
1275        let plan = ScaffoldPlan::from_cli(ScaffoldCliInput {
1276            name: "rustfoo".to_owned(),
1277            category: ServerCategory::UpstreamClient,
1278            port: PortSelection::Auto,
1279            github_owner: "jmagar".to_owned(),
1280            github_repo: None,
1281        })
1282        .expect("plan");
1283
1284        assert_eq!(plan.defines.get("package_name").unwrap(), "rustfoo-mcp");
1285        assert_eq!(plan.defines.get("crate_prefix").unwrap(), "rustfoo");
1286        assert_eq!(plan.defines.get("type_prefix").unwrap(), "Rustfoo");
1287        assert_eq!(plan.defines.get("env_prefix").unwrap(), "RUSTFOO");
1288        assert_eq!(
1289            plan.defines.get("default_features").unwrap(),
1290            "local-adapter"
1291        );
1292        assert_eq!(plan.defines.get("default_port").unwrap(), "40090");
1293    }
1294
1295    #[test]
1296    fn action_manifest_renders_starter_snippets() {
1297        let manifest = r#"{
1298          "actions": [
1299            {
1300              "name": "list_things",
1301              "description": "List visible things.",
1302              "scope": "read",
1303              "params": [
1304                { "name": "kind", "type": "string", "required": false }
1305              ]
1306            }
1307          ]
1308        }"#;
1309
1310        let snippets = ActionManifest::from_json(manifest)
1311            .expect("manifest")
1312            .render_snippets("SomaService");
1313
1314        assert!(snippets.contains("ActionSpec"));
1315        assert!(snippets.contains("name: \"list_things\""));
1316        assert!(snippets.contains("string_arg(&args, \"kind\")"));
1317        assert!(snippets.contains("Command::ListThings"));
1318        assert!(snippets.contains("tool_dispatch"));
1319    }
1320
1321    #[test]
1322    fn generated_project_verify_rejects_plugin_manifest_versions() {
1323        let fixture = TempDir::new().unwrap();
1324        fs::write(fixture.path().join("Cargo.toml"), "[workspace]\n").unwrap();
1325        fs::write(fixture.path().join("CLAUDE.md"), "# Memory\n").unwrap();
1326        #[cfg(unix)]
1327        {
1328            std::os::unix::fs::symlink("CLAUDE.md", fixture.path().join("AGENTS.md")).unwrap();
1329            std::os::unix::fs::symlink("CLAUDE.md", fixture.path().join("GEMINI.md")).unwrap();
1330        }
1331        fs::create_dir_all(fixture.path().join("plugins/soma-fixture/.codex-plugin")).unwrap();
1332        fs::write(
1333            fixture
1334                .path()
1335                .join("plugins/soma-fixture/.codex-plugin/plugin.json"),
1336            r#"{"name":"soma","version":"1.0.0"}"#,
1337        )
1338        .unwrap();
1339
1340        let errors = verify_generated_project(fixture.path()).expect_err("version rejected");
1341        assert!(errors
1342            .to_string()
1343            .contains("must not contain a version field"));
1344    }
1345
1346    #[test]
1347    fn adapt_plan_is_profile_aware_and_path_specific() {
1348        let fixture = TempDir::new().unwrap();
1349        fs::create_dir_all(fixture.path().join("docs")).unwrap();
1350        fs::write(
1351            fixture.path().join("docs/scaffold-report.md"),
1352            "Category: application-platform\nRequired surfaces: api, cli, mcp, web\nDefault Cargo features: full\n",
1353        )
1354        .unwrap();
1355
1356        let plan = render_adapt_plan(fixture.path()).expect("adapt plan");
1357
1358        assert!(plan.contains("# Adaptation Plan"));
1359        assert!(plan.contains("Profile: full"));
1360        assert!(plan.contains("crates/soma/client/src/client.rs"));
1361        assert!(plan.contains("crates/soma/api/src/api.rs"));
1362        assert!(plan.contains("apps/web"));
1363        assert!(plan.contains("server.json"));
1364        assert!(plan.contains("cargo xtask scaffold --verify"));
1365    }
1366
1367    #[test]
1368    fn action_manifest_writes_starter_artifacts_into_generated_project() {
1369        let fixture = TempDir::new().unwrap();
1370        let manifest = ActionManifest::from_json(
1371            r#"{
1372              "actions": [
1373                {
1374                  "name": "list_things",
1375                  "description": "List visible things.",
1376                  "scope": "read",
1377                  "params": [
1378                    { "name": "kind", "type": "string", "required": false }
1379                  ]
1380                }
1381              ]
1382            }"#,
1383        )
1384        .expect("manifest");
1385
1386        write_action_starters(fixture.path(), &manifest).expect("write starters");
1387
1388        let readme = fs::read_to_string(fixture.path().join("docs/action-starters/README.md"))
1389            .expect("readme");
1390        let actions = fs::read_to_string(
1391            fixture
1392                .path()
1393                .join("docs/action-starters/actions.rs.snippet"),
1394        )
1395        .expect("actions snippet");
1396        let service = fs::read_to_string(
1397            fixture
1398                .path()
1399                .join("docs/action-starters/service.rs.snippet"),
1400        )
1401        .expect("service snippet");
1402        let tests = fs::read_to_string(fixture.path().join("docs/action-starters/tests.md"))
1403            .expect("tests guide");
1404
1405        assert!(readme.contains("list_things"));
1406        assert!(actions.contains("ActionSpec"));
1407        assert!(actions.contains("name: \"list_things\""));
1408        assert!(service.contains("pub async fn list_things"));
1409        assert!(tests.contains("tool_dispatch"));
1410    }
1411}