1use anyhow::{Context, Result, bail};
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() { "none" } else { value }
911}
912
913fn default_host() -> String {
914 "127.0.0.1".to_owned()
915}
916
917fn default_mcp_transport() -> String {
918 "dual".to_owned()
919}
920
921fn default_read_scope() -> String {
922 "read".to_owned()
923}
924
925fn default_string_type() -> String {
926 "string".to_owned()
927}
928
929#[derive(Debug)]
930struct Options {
931 mode: Mode,
932 intent: Option<PathBuf>,
933 name: Option<String>,
934 category: Option<ServerCategory>,
935 port: Option<PortSelection>,
936 github_owner: Option<String>,
937 github_repo: Option<String>,
938 actions: Option<PathBuf>,
939 cargo_check: bool,
940}
941
942#[derive(Debug)]
943enum Mode {
944 Help,
945 Plan,
946 Apply { parent: PathBuf },
947 Verify { root: PathBuf },
948 AdaptPlan { root: PathBuf },
949 WriteActionStarters { root: PathBuf },
950}
951
952impl Options {
953 fn parse(args: &[String]) -> Result<Self> {
954 if args
955 .iter()
956 .any(|arg| arg == "--help" || arg == "-h" || arg == "help")
957 {
958 return Ok(Self::help());
959 }
960 let mut options = Self {
961 mode: Mode::Plan,
962 intent: None,
963 name: None,
964 category: None,
965 port: None,
966 github_owner: None,
967 github_repo: None,
968 actions: None,
969 cargo_check: true,
970 };
971 let mut index = 0usize;
972 while index < args.len() {
973 match args[index].as_str() {
974 "--plan" => options.mode = Mode::Plan,
975 "--apply" => {
976 index += 1;
977 options.mode = Mode::Apply {
978 parent: PathBuf::from(value_arg(args, index, "--apply")?),
979 };
980 }
981 "--verify" => {
982 index += 1;
983 options.mode = Mode::Verify {
984 root: PathBuf::from(value_arg(args, index, "--verify")?),
985 };
986 }
987 "--adapt-plan" => {
988 index += 1;
989 options.mode = Mode::AdaptPlan {
990 root: PathBuf::from(value_arg(args, index, "--adapt-plan")?),
991 };
992 }
993 "--write-action-starters" => {
994 index += 1;
995 options.mode = Mode::WriteActionStarters {
996 root: PathBuf::from(value_arg(args, index, "--write-action-starters")?),
997 };
998 }
999 "--intent" => {
1000 index += 1;
1001 options.intent = Some(PathBuf::from(value_arg(args, index, "--intent")?));
1002 }
1003 "--name" => {
1004 index += 1;
1005 options.name = Some(value_arg(args, index, "--name")?.to_owned());
1006 }
1007 "--category" => {
1008 index += 1;
1009 options.category = Some(parse_category(value_arg(args, index, "--category")?)?);
1010 }
1011 "--port" => {
1012 index += 1;
1013 options.port = Some(parse_port_selection(value_arg(args, index, "--port")?)?);
1014 }
1015 "--github-owner" => {
1016 index += 1;
1017 options.github_owner =
1018 Some(value_arg(args, index, "--github-owner")?.to_owned());
1019 }
1020 "--github-repo" => {
1021 index += 1;
1022 options.github_repo = Some(value_arg(args, index, "--github-repo")?.to_owned());
1023 }
1024 "--actions" => {
1025 index += 1;
1026 options.actions = Some(PathBuf::from(value_arg(args, index, "--actions")?));
1027 }
1028 "--no-cargo-check" => options.cargo_check = false,
1029 unknown => bail!("unknown scaffold option {unknown:?}"),
1030 }
1031 index += 1;
1032 }
1033 Ok(options)
1034 }
1035
1036 fn help() -> Self {
1037 Self {
1038 mode: Mode::Help,
1039 intent: None,
1040 name: None,
1041 category: None,
1042 port: None,
1043 github_owner: None,
1044 github_repo: None,
1045 actions: None,
1046 cargo_check: true,
1047 }
1048 }
1049}
1050
1051fn value_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str> {
1052 args.get(index)
1053 .map(String::as_str)
1054 .with_context(|| format!("{flag} requires a value"))
1055}
1056
1057fn parse_port_selection(value: &str) -> Result<PortSelection> {
1058 if value == "auto" {
1059 return Ok(PortSelection::Auto);
1060 }
1061 let port = value
1062 .parse::<u16>()
1063 .with_context(|| format!("port must be an integer or auto: {value:?}"))?;
1064 Ok(PortSelection::Port(validate_port(port)?))
1065}
1066
1067fn print_help() {
1068 println!(
1069 "Usage:
1070 cargo xtask scaffold --name <service> [--category upstream-client|application-platform] [--port auto|PORT] --plan
1071 cargo xtask scaffold --intent scaffold-intent.json [--actions actions.json] --plan
1072 cargo xtask scaffold --intent scaffold-intent.json --apply <output-parent> [--no-cargo-check]
1073 cargo xtask scaffold --verify <generated-root> [--no-cargo-check]
1074 cargo xtask scaffold --adapt-plan <generated-root>
1075 cargo xtask scaffold --write-action-starters <generated-root> --actions actions.json"
1076 );
1077}
1078
1079fn write_action_starters(root: &Path, manifest: &ActionManifest) -> Result<()> {
1080 if !root.exists() {
1081 bail!("generated root does not exist: {}", root.display());
1082 }
1083 let dir = root.join("docs/action-starters");
1084 fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
1085 fs::write(dir.join("README.md"), manifest.render_starter_readme())
1086 .context("failed to write action starter README")?;
1087 fs::write(
1088 dir.join("actions.rs.snippet"),
1089 manifest.render_action_specs_snippet(),
1090 )
1091 .context("failed to write actions.rs snippet")?;
1092 fs::write(
1093 dir.join("tools.rs.snippet"),
1094 manifest.render_tools_snippet(),
1095 )
1096 .context("failed to write tools.rs snippet")?;
1097 fs::write(dir.join("cli.rs.snippet"), manifest.render_cli_snippet())
1098 .context("failed to write cli.rs snippet")?;
1099 fs::write(
1100 dir.join("service.rs.snippet"),
1101 manifest.render_service_snippet("SomaService"),
1102 )
1103 .context("failed to write service.rs snippet")?;
1104 fs::write(dir.join("tests.md"), manifest.render_tests_guide())
1105 .context("failed to write tests guide")?;
1106 Ok(())
1107}
1108
1109fn render_adapt_plan(root: &Path) -> Result<String> {
1110 if !root.exists() {
1111 bail!("generated root does not exist: {}", root.display());
1112 }
1113 let report_path = root.join("docs/scaffold-report.md");
1114 let report = fs::read_to_string(&report_path).unwrap_or_default();
1115 let profile = report_value(&report, "Default Cargo features").unwrap_or("unknown");
1116 let category = report_value(&report, "Category").unwrap_or("unknown");
1117 let surfaces = report_value(&report, "Required surfaces").unwrap_or("unknown");
1118 let has_api = profile_contains(profile, "full")
1119 || profile_contains(profile, "server")
1120 || surface_contains(surfaces, "api");
1121 let has_web = profile_contains(profile, "full") || surface_contains(surfaces, "web");
1122 let has_plugin = profile_contains(profile, "full") || profile_contains(profile, "plugin");
1123
1124 let mut output = String::new();
1125 output.push_str("# Adaptation Plan\n\n");
1126 output.push_str(&format!("Root: {}\n", root.display()));
1127 output.push_str(&format!("Category: {category}\n"));
1128 output.push_str(&format!("Profile: {profile}\n"));
1129 output.push_str(&format!("Surfaces: {surfaces}\n"));
1130 if report.is_empty() {
1131 output.push_str(&format!(
1132 "Scaffold report: missing ({})\n",
1133 report_path.display()
1134 ));
1135 } else {
1136 output.push_str(&format!("Scaffold report: {}\n", report_path.display()));
1137 }
1138
1139 output.push_str("\n## 1. Domain and config\n\n");
1140 output.push_str("- Replace the stub client in `crates/soma/client/src/client.rs`.\n");
1141 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");
1142 output.push_str(
1143 "- Update config structs and env prefixes in `crates/soma/config/src/config.rs`.\n",
1144 );
1145 output.push_str("- Update `.env.example` and `config.soma.toml` with real required credentials and non-secret defaults.\n");
1146
1147 output.push_str("\n## 2. Business actions\n\n");
1148 output.push_str("- Add action metadata in `crates/soma/domain/src/actions.rs`.\n");
1149 output.push_str("- Regenerate MCP schema docs and OpenAPI after changing action metadata.\n");
1150 output.push_str("- Keep MCP, CLI, and REST shims registry-driven.\n");
1151 if has_api {
1152 output.push_str("- Add REST handlers/routes in `crates/soma/api/src/api.rs` and `apps/soma/src/routes.rs`.\n");
1153 } else {
1154 output.push_str("- REST handlers are optional for this profile; add them only if the project needs an API surface.\n");
1155 }
1156
1157 output.push_str("\n## 3. Optional surfaces\n\n");
1158 if has_web {
1159 output.push_str("- Replace or remove the bundled web app under `apps/web`.\n");
1160 output.push_str("- Run `cargo xtask sync-web-source` after web source changes.\n");
1161 } else {
1162 output.push_str("- Web is not selected by this profile; remove web-specific assumptions if you keep the scaffold lean.\n");
1163 }
1164 if has_plugin {
1165 output.push_str("- Update plugin options, skills, and setup mappings under `plugins/soma/` and `crates/soma/cli/src/setup.rs`.\n");
1166 } else {
1167 output.push_str("- Plugin support is not selected by this profile; keep plugin files only if you plan to publish editor integrations.\n");
1168 }
1169 output.push_str("- Update `server.json`, repository URLs, Docker labels, release metadata, and package names before publishing.\n");
1170
1171 output.push_str("\n## 4. Tests and verification\n\n");
1172 output.push_str("- Add service behavior tests near the service modules.\n");
1173 output.push_str("- Add MCP dispatch coverage in `apps/soma/tests/tool_dispatch.rs`.\n");
1174 output.push_str("- Add CLI parsing coverage in `apps/soma/tests/cli_parse.rs`.\n");
1175 if has_api {
1176 output.push_str("- Add REST route coverage for every API action.\n");
1177 }
1178 output.push_str("- Run `cargo xtask scaffold --verify <generated-root>`.\n");
1179 output.push_str("- Run `cargo xtask check-docs`, `cargo xtask check-schema-docs --check`, `cargo xtask check-openapi --check`, and `just verify`.\n");
1180 output.push_str("\nUse this plan as an implementation checklist; it does not mutate files.\n");
1181 Ok(output)
1182}
1183
1184fn report_value<'a>(report: &'a str, label: &str) -> Option<&'a str> {
1185 report.lines().find_map(|line| {
1186 let (key, value) = line.split_once(':')?;
1187 if key.trim() == label {
1188 Some(value.trim())
1189 } else {
1190 None
1191 }
1192 })
1193}
1194
1195fn profile_contains(profile: &str, needle: &str) -> bool {
1196 profile.split(',').map(str::trim).any(|part| part == needle)
1197}
1198
1199fn surface_contains(surfaces: &str, needle: &str) -> bool {
1200 surfaces
1201 .split(',')
1202 .map(str::trim)
1203 .any(|part| part == needle)
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209 use std::fs;
1210 use tempfile::TempDir;
1211
1212 #[test]
1213 fn intent_json_derives_local_adapter_defines_and_research_plan() {
1214 let intent = r#"{
1215 "kind": "soma_scaffold_intent",
1216 "schema_version": 1,
1217 "server_category": "upstream-client",
1218 "required_surfaces": ["mcp", "cli"],
1219 "project": {
1220 "display_name": "unraid-rmcp",
1221 "crate_name": "unraid-rmcp",
1222 "binary_name": "runraid",
1223 "service_name": "unraid",
1224 "env_prefix": "UNRAID"
1225 },
1226 "upstream": { "base_url_env": "UNRAID_API_URL", "auth_kind": "api-key" },
1227 "runtime": {
1228 "host": "127.0.0.1",
1229 "port": 40010,
1230 "binary_profile": "local-adapter",
1231 "mcp_transport": "dual"
1232 },
1233 "mcp_primitives": ["tools", "resources"],
1234 "deployment": "none",
1235 "plugins": ["claude", "codex"],
1236 "publish_mcp": true,
1237 "crawl_docs": {
1238 "urls": ["https://docs.unraid.net/"],
1239 "repos": [],
1240 "search_topics": ["Unraid API authentication"]
1241 },
1242 "handoff": { "recommended_skill": "scaffold-project", "instructions": "Plan only." },
1243 "policy": {
1244 "business_action_minimum_surfaces": ["mcp", "cli"],
1245 "upstream_client_surfaces": ["mcp", "cli"],
1246 "application_platform_surfaces": ["api", "cli", "mcp", "web"],
1247 "binary_profiles": {
1248 "upstream_client_default": "local-adapter",
1249 "application_platform_default": "server-full",
1250 "gateway_shared_default": "server-full"
1251 }
1252 }
1253 }"#;
1254
1255 let plan = ScaffoldPlan::from_intent_json(intent).expect("plan");
1256
1257 assert_eq!(plan.defines.get("package_name").unwrap(), "unraid-rmcp");
1258 assert_eq!(plan.defines.get("crate_prefix").unwrap(), "unraid-rmcp");
1259 assert_eq!(plan.defines.get("binary_name").unwrap(), "runraid");
1260 assert_eq!(
1261 plan.defines.get("default_features").unwrap(),
1262 "local-adapter"
1263 );
1264 assert!(plan.report.contains("Required surfaces: mcp, cli"));
1265 assert!(plan.report.contains("Axon research inputs"));
1266 assert!(plan.report.contains("https://docs.unraid.net/"));
1267 }
1268
1269 #[test]
1270 fn cli_name_auto_port_derives_defaults() {
1271 let plan = ScaffoldPlan::from_cli(ScaffoldCliInput {
1272 name: "rustfoo".to_owned(),
1273 category: ServerCategory::UpstreamClient,
1274 port: PortSelection::Auto,
1275 github_owner: "jmagar".to_owned(),
1276 github_repo: None,
1277 })
1278 .expect("plan");
1279
1280 assert_eq!(plan.defines.get("package_name").unwrap(), "rustfoo-mcp");
1281 assert_eq!(plan.defines.get("crate_prefix").unwrap(), "rustfoo");
1282 assert_eq!(plan.defines.get("type_prefix").unwrap(), "Rustfoo");
1283 assert_eq!(plan.defines.get("env_prefix").unwrap(), "RUSTFOO");
1284 assert_eq!(
1285 plan.defines.get("default_features").unwrap(),
1286 "local-adapter"
1287 );
1288 assert_eq!(plan.defines.get("default_port").unwrap(), "40090");
1289 }
1290
1291 #[test]
1292 fn action_manifest_renders_starter_snippets() {
1293 let manifest = r#"{
1294 "actions": [
1295 {
1296 "name": "list_things",
1297 "description": "List visible things.",
1298 "scope": "read",
1299 "params": [
1300 { "name": "kind", "type": "string", "required": false }
1301 ]
1302 }
1303 ]
1304 }"#;
1305
1306 let snippets = ActionManifest::from_json(manifest)
1307 .expect("manifest")
1308 .render_snippets("SomaService");
1309
1310 assert!(snippets.contains("ActionSpec"));
1311 assert!(snippets.contains("name: \"list_things\""));
1312 assert!(snippets.contains("string_arg(&args, \"kind\")"));
1313 assert!(snippets.contains("Command::ListThings"));
1314 assert!(snippets.contains("tool_dispatch"));
1315 }
1316
1317 #[test]
1318 fn generated_project_verify_rejects_plugin_manifest_versions() {
1319 let fixture = TempDir::new().unwrap();
1320 fs::write(fixture.path().join("Cargo.toml"), "[workspace]\n").unwrap();
1321 fs::write(fixture.path().join("CLAUDE.md"), "# Memory\n").unwrap();
1322 #[cfg(unix)]
1323 {
1324 std::os::unix::fs::symlink("CLAUDE.md", fixture.path().join("AGENTS.md")).unwrap();
1325 std::os::unix::fs::symlink("CLAUDE.md", fixture.path().join("GEMINI.md")).unwrap();
1326 }
1327 fs::create_dir_all(fixture.path().join("plugins/soma-fixture/.codex-plugin")).unwrap();
1328 fs::write(
1329 fixture
1330 .path()
1331 .join("plugins/soma-fixture/.codex-plugin/plugin.json"),
1332 r#"{"name":"soma","version":"1.0.0"}"#,
1333 )
1334 .unwrap();
1335
1336 let errors = verify_generated_project(fixture.path()).expect_err("version rejected");
1337 assert!(
1338 errors
1339 .to_string()
1340 .contains("must not contain a version field")
1341 );
1342 }
1343
1344 #[test]
1345 fn adapt_plan_is_profile_aware_and_path_specific() {
1346 let fixture = TempDir::new().unwrap();
1347 fs::create_dir_all(fixture.path().join("docs")).unwrap();
1348 fs::write(
1349 fixture.path().join("docs/scaffold-report.md"),
1350 "Category: application-platform\nRequired surfaces: api, cli, mcp, web\nDefault Cargo features: full\n",
1351 )
1352 .unwrap();
1353
1354 let plan = render_adapt_plan(fixture.path()).expect("adapt plan");
1355
1356 assert!(plan.contains("# Adaptation Plan"));
1357 assert!(plan.contains("Profile: full"));
1358 assert!(plan.contains("crates/soma/client/src/client.rs"));
1359 assert!(plan.contains("crates/soma/api/src/api.rs"));
1360 assert!(plan.contains("apps/web"));
1361 assert!(plan.contains("server.json"));
1362 assert!(plan.contains("cargo xtask scaffold --verify"));
1363 }
1364
1365 #[test]
1366 fn action_manifest_writes_starter_artifacts_into_generated_project() {
1367 let fixture = TempDir::new().unwrap();
1368 let manifest = ActionManifest::from_json(
1369 r#"{
1370 "actions": [
1371 {
1372 "name": "list_things",
1373 "description": "List visible things.",
1374 "scope": "read",
1375 "params": [
1376 { "name": "kind", "type": "string", "required": false }
1377 ]
1378 }
1379 ]
1380 }"#,
1381 )
1382 .expect("manifest");
1383
1384 write_action_starters(fixture.path(), &manifest).expect("write starters");
1385
1386 let readme = fs::read_to_string(fixture.path().join("docs/action-starters/README.md"))
1387 .expect("readme");
1388 let actions = fs::read_to_string(
1389 fixture
1390 .path()
1391 .join("docs/action-starters/actions.rs.snippet"),
1392 )
1393 .expect("actions snippet");
1394 let service = fs::read_to_string(
1395 fixture
1396 .path()
1397 .join("docs/action-starters/service.rs.snippet"),
1398 )
1399 .expect("service snippet");
1400 let tests = fs::read_to_string(fixture.path().join("docs/action-starters/tests.md"))
1401 .expect("tests guide");
1402
1403 assert!(readme.contains("list_things"));
1404 assert!(actions.contains("ActionSpec"));
1405 assert!(actions.contains("name: \"list_things\""));
1406 assert!(service.contains("pub async fn list_things"));
1407 assert!(tests.contains("tool_dispatch"));
1408 }
1409}