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", ¶m.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(¶m.description, ¶m.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}