1use anyhow::{bail, Context, Result};
2use serde::Deserialize;
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7const TEXT_SUFFIXES: &[&str] = &[
8 "css", "html", "json", "lock", "md", "mjs", "py", "rs", "rhai", "sh", "toml", "ts", "tsx",
9 "txt", "yml", "yaml",
10];
11
12const SKIP_DIRS: &[&str] = &[
13 ".git",
14 ".beads",
15 ".cache",
16 ".dolt",
17 ".full-review",
18 ".lavra",
19 ".next",
20 ".serena",
21 ".superpowers",
22 ".worktrees",
23 "node_modules",
24 "target",
25 "dist",
26];
27
28const SKIP_FILES: &[&str] = &["cargo-generate.toml", "Cargo.lock"];
29
30#[derive(Debug)]
31struct Values {
32 crate_name: String,
33 crate_name_snake: String,
34 crate_prefix: String,
35 crate_prefix_snake: String,
36 binary_name: String,
37 service_slug: String,
38 type_prefix: String,
39 env_prefix: String,
40 scope_prefix: String,
41 default_port: String,
42 default_feature_array: String,
43 github_slug: String,
44 github_url: String,
45 github_ssh: String,
46 mcp_surface_crate: String,
47 mcp_surface_crate_snake: String,
48}
49
50#[derive(Debug, Deserialize)]
51struct GeneratedValues {
52 package_name: String,
53 crate_prefix: String,
54 binary_name: String,
55 service_slug: String,
56 type_prefix: String,
57 env_prefix: String,
58 scope_prefix: String,
59 default_port: String,
60 github_owner: String,
61 github_repo: String,
62 default_features: String,
63}
64
65pub(crate) fn run(args: &[String]) -> Result<()> {
66 if args.len() != 1 {
67 bail!("Usage: cargo xtask cargo-generate-post <generated-root>");
68 }
69
70 let root = PathBuf::from(&args[0]);
71 if !root.is_dir() {
72 bail!("generated-root is not a directory: {}", root.display());
73 }
74 ensure_unprocessed_template_root(&root)?;
75 let values = Values::from_generated_file(&root)?;
76 let pairs = replacements(&values);
77 rewrite_tree(&root, &pairs)?;
78 rename_paths(&root, &values)?;
79 cleanup_template_files(&root)?;
80 cleanup_generated_readme(&root)?;
81 ensure_agent_memory_symlinks(&root)?;
82 Ok(())
83}
84
85impl Values {
86 fn from_generated_file(root: &Path) -> Result<Self> {
87 let path = root.join(".cargo-generate-values.toml");
88 let text = fs::read_to_string(&path)
89 .with_context(|| format!("failed to read {}", path.display()))?;
90 let values = toml::from_str::<GeneratedValues>(&text)
91 .with_context(|| format!("failed to parse {}", path.display()))?;
92 Self::parse(values)
93 }
94
95 fn parse(input: GeneratedValues) -> Result<Self> {
96 validate_slug("package_name", &input.package_name)?;
97 validate_slug("crate_prefix", &input.crate_prefix)?;
98 validate_slug("binary_name", &input.binary_name)?;
99 validate_identifier("service_slug", &input.service_slug)?;
100 validate_type_prefix(&input.type_prefix)?;
101 validate_env_prefix(&input.env_prefix)?;
102 validate_scope_prefix(&input.scope_prefix)?;
103 validate_port(&input.default_port)?;
104 validate_github_name("github_owner", &input.github_owner)?;
105 validate_github_name("github_repo", &input.github_repo)?;
106 let features = validate_default_features(&input.default_features)?;
107 let mcp_surface_crate = format!("{}-mcp-surface", input.crate_prefix);
108
109 Ok(Self {
110 crate_name_snake: snake(&input.package_name),
111 crate_prefix_snake: snake(&input.crate_prefix),
112 default_feature_array: cargo_feature_array(&features),
113 github_slug: format!("{}/{}", input.github_owner, input.github_repo),
114 github_url: format!(
115 "https://github.com/{}/{}",
116 input.github_owner, input.github_repo
117 ),
118 github_ssh: format!(
119 "github.com:{}/{}.git",
120 input.github_owner, input.github_repo
121 ),
122 mcp_surface_crate_snake: snake(&mcp_surface_crate),
123 crate_name: input.package_name,
124 crate_prefix: input.crate_prefix,
125 binary_name: input.binary_name,
126 service_slug: input.service_slug,
127 type_prefix: input.type_prefix,
128 env_prefix: input.env_prefix,
129 scope_prefix: input.scope_prefix,
130 default_port: input.default_port,
131 mcp_surface_crate,
132 })
133 }
134}
135
136fn ensure_unprocessed_template_root(root: &Path) -> Result<()> {
137 for relative in [
138 "Cargo.toml",
139 ".cargo-generate-values.toml",
140 "apps/soma/Cargo.toml",
141 ] {
142 let path = root.join(relative);
143 if !path.exists() {
144 bail!(
145 "generated-root does not look like an unprocessed soma output; missing {}",
146 path.display()
147 );
148 }
149 }
150 Ok(())
151}
152
153fn snake(value: &str) -> String {
154 value.replace('-', "_")
155}
156
157fn validate_slug(name: &str, value: &str) -> Result<()> {
158 let mut chars = value.chars();
159 let Some(first) = chars.next() else {
160 bail!("{name} must not be empty");
161 };
162 if !first.is_ascii_lowercase()
163 || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
164 {
165 bail!("{name} must match ^[a-z][a-z0-9_-]*$: {value:?}");
166 }
167 Ok(())
168}
169
170fn validate_identifier(name: &str, value: &str) -> Result<()> {
171 let mut chars = value.chars();
172 let Some(first) = chars.next() else {
173 bail!("{name} must not be empty");
174 };
175 if !first.is_ascii_lowercase()
176 || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
177 {
178 bail!("{name} must be Rust identifier-safe: {value:?}");
179 }
180 Ok(())
181}
182
183fn validate_type_prefix(value: &str) -> Result<()> {
184 let mut chars = value.chars();
185 let Some(first) = chars.next() else {
186 bail!("type_prefix must not be empty");
187 };
188 if !first.is_ascii_uppercase() || !chars.all(|ch| ch.is_ascii_alphanumeric()) {
189 bail!("type_prefix must match ^[A-Z][A-Za-z0-9]*$: {value:?}");
190 }
191 Ok(())
192}
193
194fn validate_env_prefix(value: &str) -> Result<()> {
195 let mut chars = value.chars();
196 let Some(first) = chars.next() else {
197 bail!("env_prefix must not be empty");
198 };
199 if !first.is_ascii_uppercase()
200 || !chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
201 {
202 bail!("env_prefix must match ^[A-Z][A-Z0-9_]*$: {value:?}");
203 }
204 Ok(())
205}
206
207fn validate_scope_prefix(value: &str) -> Result<()> {
208 let mut chars = value.chars();
209 let Some(first) = chars.next() else {
210 bail!("scope_prefix must not be empty");
211 };
212 if !first.is_ascii_lowercase()
213 || !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
214 {
215 bail!("scope_prefix must match ^[a-z][a-z0-9-]*$: {value:?}");
216 }
217 Ok(())
218}
219
220fn validate_port(value: &str) -> Result<()> {
221 let port: u16 = value
222 .parse()
223 .with_context(|| format!("default_port must be an integer: {value:?}"))?;
224 if port == 0 {
225 bail!("default_port must be between 1 and 65535: {value:?}");
226 }
227 Ok(())
228}
229
230fn validate_github_name(name: &str, value: &str) -> Result<()> {
231 if value.is_empty()
232 || !value
233 .chars()
234 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
235 {
236 bail!("{name} must match ^[A-Za-z0-9_.-]+$: {value:?}");
237 }
238 Ok(())
239}
240
241fn validate_default_features(value: &str) -> Result<Vec<String>> {
242 const ALLOWED: &[&str] = &[
243 "cli",
244 "mcp",
245 "mcp-stdio",
246 "api",
247 "auth",
248 "oauth",
249 "observability",
250 "plugin",
251 "mcp-http",
252 "web",
253 "local-adapter",
254 "server",
255 "full",
256 "test-support",
257 ];
258
259 let features = value
260 .split(',')
261 .map(str::trim)
262 .filter(|feature| !feature.is_empty())
263 .map(str::to_owned)
264 .collect::<Vec<_>>();
265 if features.is_empty() {
266 bail!("default_features must include at least one feature");
267 }
268 let unknown = features
269 .iter()
270 .filter(|feature| !ALLOWED.contains(&feature.as_str()))
271 .cloned()
272 .collect::<Vec<_>>();
273 if !unknown.is_empty() {
274 bail!(
275 "default_features contains unknown feature(s): {}",
276 unknown.join(", ")
277 );
278 }
279 Ok(features)
280}
281
282fn cargo_feature_array(features: &[String]) -> String {
283 features
284 .iter()
285 .map(|feature| format!("\"{feature}\""))
286 .collect::<Vec<_>>()
287 .join(", ")
288}
289
290fn replacements(values: &Values) -> Vec<(String, String)> {
291 vec![
292 (
293 "https://github.com/your-org/soma-mcp".into(),
294 values.github_url.clone(),
295 ),
296 (
297 "https://github.com/dinglebear-ai/soma".into(),
298 values.github_url.clone(),
299 ),
300 (
301 "https://github.com/jmagar/soma".into(),
302 values.github_url.clone(),
303 ),
304 (
305 "https://github.com/jmagar/soma-mcp".into(),
306 values.github_url.clone(),
307 ),
308 (
309 "github.com:dinglebear-ai/soma.git".into(),
310 values.github_ssh.clone(),
311 ),
312 (
313 "github.com:jmagar/soma-mcp.git".into(),
314 values.github_ssh.clone(),
315 ),
316 ("dinglebear-ai/soma".into(), values.github_slug.clone()),
317 ("jmagar/soma".into(), values.github_slug.clone()),
318 ("jmagar/soma-mcp".into(), values.github_slug.clone()),
319 (
320 "\"name\": \"soma-mcp\"".into(),
321 format!("\"name\": \"{}\"", values.crate_name),
322 ),
323 (
324 "[package]\nname = \"soma\"".into(),
325 format!("[package]\nname = \"{}\"", values.crate_name),
326 ),
327 (
328 "name = \"soma\"\npath = \"src/bin/soma.rs\"".into(),
329 format!(
330 "name = \"{}\"\npath = \"src/bin/{}.rs\"",
331 values.binary_name, values.binary_name
332 ),
333 ),
334 (
335 "soma = { path = \".\", package = \"soma\", features = [\"test-support\"] }".into(),
336 format!(
337 "{} = {{ path = \".\", package = \"{}\", features = [\"test-support\"] }}",
338 values.crate_name_snake, values.crate_name
339 ),
340 ),
341 (
342 "CARGO_BIN_EXE_soma".into(),
343 format!("CARGO_BIN_EXE_{}", values.binary_name),
344 ),
345 (
346 "default = [\"full\"]".into(),
347 format!("default = [{}]", values.default_feature_array),
348 ),
349 ("soma".into(), values.crate_name_snake.clone()),
350 ("soma_mcp".into(), values.mcp_surface_crate_snake.clone()),
351 ("soma-mcp".into(), values.mcp_surface_crate.clone()),
352 ("soma_".into(), format!("{}_", values.crate_prefix_snake)),
353 ("soma-".into(), format!("{}-", values.crate_prefix)),
354 ("soma".into(), values.binary_name.clone()),
355 ("soma".into(), values.crate_name.clone()),
356 ("SOMA".into(), values.env_prefix.clone()),
357 (
358 "SomaRmcpServer".into(),
359 format!("{}RmcpServer", values.type_prefix),
360 ),
361 (
362 "SomaService".into(),
363 format!("{}Service", values.type_prefix),
364 ),
365 ("SomaClient".into(), format!("{}Client", values.type_prefix)),
366 ("SomaConfig".into(), format!("{}Config", values.type_prefix)),
367 ("SomaAction".into(), format!("{}Action", values.type_prefix)),
368 (
369 "apps/soma/src/bin/soma.rs".into(),
370 format!(
371 "apps/{}/src/bin/{}.rs",
372 values.crate_name, values.binary_name
373 ),
374 ),
375 ("soma:read".into(), format!("{}:read", values.scope_prefix)),
376 (
377 "soma:write".into(),
378 format!("{}:write", values.scope_prefix),
379 ),
380 (
381 "soma:__deny__".into(),
382 format!("{}:__deny__", values.scope_prefix),
383 ),
384 ("soma".into(), values.service_slug.clone()),
385 ("40060".into(), values.default_port.clone()),
386 ("40000".into(), values.default_port.clone()),
387 ("MyService".into(), values.type_prefix.clone()),
388 ("myservice-mcp".into(), values.crate_name.clone()),
389 ("myservice".into(), values.service_slug.clone()),
390 ("MYSERVICE".into(), values.env_prefix.clone()),
391 ]
392}
393
394fn rewrite_tree(root: &Path, pairs: &[(String, String)]) -> Result<()> {
395 for entry in WalkDir::new(root).into_iter().filter_entry(|entry| {
396 let relative = entry.path().strip_prefix(root).unwrap_or(entry.path());
397 !should_skip_dir(relative)
398 }) {
399 let entry = entry?;
400 let path = entry.path();
401 if !entry.file_type().is_file() || !should_rewrite(path) {
402 continue;
403 }
404 let Ok(original) = fs::read_to_string(path) else {
405 continue;
406 };
407 let mut updated = original.clone();
408 for (old, new) in pairs {
409 updated = updated.replace(old, new);
410 }
411 if updated != original {
412 fs::write(path, updated)
413 .with_context(|| format!("failed to rewrite {}", path.display()))?;
414 }
415 }
416 Ok(())
417}
418
419fn rename_paths(root: &Path, values: &Values) -> Result<()> {
420 let mut renames = Vec::new();
421 for entry in WalkDir::new(root)
422 .contents_first(true)
423 .into_iter()
424 .filter_entry(|entry| {
425 let relative = entry.path().strip_prefix(root).unwrap_or(entry.path());
426 !should_skip_dir(relative)
427 })
428 {
429 let entry = entry?;
430 let path = entry.path();
431 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
432 continue;
433 };
434 let new_name = rename_path_segment(path, values);
435 if new_name != name {
436 renames.push((path.to_path_buf(), path.with_file_name(new_name)));
437 }
438 }
439
440 for (src, dst) in renames {
441 if src.exists() && !dst.exists() {
442 fs::rename(&src, &dst).with_context(|| {
443 format!("failed to rename {} to {}", src.display(), dst.display())
444 })?;
445 }
446 }
447 Ok(())
448}
449
450fn rename_path_segment(path: &Path, values: &Values) -> String {
451 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
452 return path.display().to_string();
453 };
454 match name {
455 "soma" if path_parent_name(path).is_some_and(|parent| parent == "plugins") => {
456 values.binary_name.clone()
457 }
458 "soma" if path_parent_name(path).is_some_and(|parent| parent == "skills") => {
459 values.binary_name.clone()
460 }
461 "soma" if path_parent_name(path).is_some_and(|parent| parent == "crates") => {
462 values.crate_name_snake.clone()
463 }
464 "soma" => values.crate_name.clone(),
465 "soma.rs" if path_parent_name(path).is_some_and(|parent| parent == "bin") => {
466 format!("{}.rs", values.binary_name)
467 }
468 "soma.rs" => format!("{}.rs", values.crate_name_snake),
469 "soma-rmcp" => values.crate_name.clone(),
470 "soma-rmcp.js" => format!("{}.js", values.crate_name),
471 "soma-mcp" => format!("{}-mcp", values.crate_name_snake),
472 "soma_mcp" => format!("{}_mcp", values.crate_name_snake),
473 _ if name.starts_with("soma-") => {
474 format!("{}-{}", values.crate_name_snake, &name["soma-".len()..])
475 }
476 _ if name.starts_with("soma_") => {
477 format!("{}_{}", values.crate_name_snake, &name["soma_".len()..])
478 }
479 _ => name.to_owned(),
480 }
481}
482
483fn path_parent_name(path: &Path) -> Option<&str> {
484 path.parent()
485 .and_then(Path::file_name)
486 .and_then(|name| name.to_str())
487}
488
489fn cleanup_template_files(root: &Path) -> Result<()> {
490 for relative in [
491 ".cargo-generate-values.toml",
492 "cargo-generate.toml",
493 "scaffold",
494 "docs/CARGO_GENERATE.md",
495 ] {
496 let path = root.join(relative);
497 if path.is_dir() {
498 fs::remove_dir_all(&path)
499 .with_context(|| format!("failed to remove {}", path.display()))?;
500 } else if path.exists() {
501 fs::remove_file(&path)
502 .with_context(|| format!("failed to remove {}", path.display()))?;
503 }
504 }
505 let target = root.join("target");
506 if target.exists() {
507 let _ = fs::remove_dir_all(target);
508 }
509 Ok(())
510}
511
512fn cleanup_generated_readme(root: &Path) -> Result<()> {
513 let path = root.join("README.md");
514 if !path.exists() {
515 return Ok(());
516 }
517 let text = fs::read_to_string(&path)?;
518 let Some(start) = text.find("\n## Generate a New Server\n") else {
519 return Ok(());
520 };
521 let next = text[start + 1..]
522 .find("\n## ")
523 .map(|offset| start + 1 + offset)
524 .unwrap_or(text.len());
525 let mut updated = String::with_capacity(text.len());
526 updated.push_str(&text[..start]);
527 updated.push('\n');
528 updated.push_str(&text[next..]);
529 fs::write(path, updated)?;
530 Ok(())
531}
532
533fn ensure_agent_memory_symlinks(root: &Path) -> Result<()> {
534 for entry in WalkDir::new(root).into_iter().filter_entry(|entry| {
535 let relative = entry.path().strip_prefix(root).unwrap_or(entry.path());
536 !should_skip_dir(relative)
537 }) {
538 let entry = entry?;
539 if !entry.file_type().is_file() || entry.file_name() != "CLAUDE.md" {
540 continue;
541 }
542 let dir = entry
543 .path()
544 .parent()
545 .with_context(|| format!("{} has no parent", entry.path().display()))?;
546 for link_name in ["AGENTS.md", "GEMINI.md"] {
547 let link = dir.join(link_name);
548 if link.exists() || link.symlink_metadata().is_ok() {
549 continue;
550 }
551 #[cfg(unix)]
552 std::os::unix::fs::symlink("CLAUDE.md", &link)
553 .with_context(|| format!("failed to create {}", link.display()))?;
554 #[cfg(windows)]
555 std::os::windows::fs::symlink_file("CLAUDE.md", &link)
556 .with_context(|| format!("failed to create {}", link.display()))?;
557 }
558 }
559 Ok(())
560}
561
562fn should_skip_dir(path: &Path) -> bool {
563 path.components().any(|component| {
564 let name = component.as_os_str().to_string_lossy();
565 SKIP_DIRS.contains(&name.as_ref())
566 })
567}
568
569fn should_rewrite(path: &Path) -> bool {
570 if path
571 .file_name()
572 .and_then(|name| name.to_str())
573 .is_some_and(|name| SKIP_FILES.contains(&name))
574 {
575 return false;
576 }
577 if path
578 .extension()
579 .and_then(|extension| extension.to_str())
580 .is_some_and(|extension| TEXT_SUFFIXES.contains(&extension))
581 {
582 return true;
583 }
584 path.file_name()
585 .and_then(|name| name.to_str())
586 .is_some_and(|name| {
587 matches!(
588 name,
589 "Dockerfile"
590 | "Justfile"
591 | "LICENSE"
592 | "README"
593 | "CLAUDE.md"
594 | "AGENTS.md"
595 | "GEMINI.md"
596 )
597 })
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603 use tempfile::TempDir;
604
605 fn test_values() -> Values {
606 Values {
607 crate_name: "myservice-mcp".to_owned(),
608 crate_name_snake: "myservice_mcp".to_owned(),
609 crate_prefix: "myservice".to_owned(),
610 crate_prefix_snake: "myservice".to_owned(),
611 binary_name: "myservice".to_owned(),
612 service_slug: "myservice".to_owned(),
613 type_prefix: "MyService".to_owned(),
614 env_prefix: "MYSERVICE".to_owned(),
615 scope_prefix: "myservice".to_owned(),
616 default_port: "41234".to_owned(),
617 default_feature_array: "\"full\"".to_owned(),
618 github_slug: "jmagar/myservice-mcp".to_owned(),
619 github_url: "https://github.com/jmagar/myservice-mcp".to_owned(),
620 github_ssh: "github.com:jmagar/myservice-mcp.git".to_owned(),
621 mcp_surface_crate: "myservice-mcp-surface".to_owned(),
622 mcp_surface_crate_snake: "myservice_mcp_surface".to_owned(),
623 }
624 }
625
626 #[test]
627 fn rename_paths_maps_soma_root_crate_and_support_packages() {
628 let fixture = TempDir::new().unwrap();
629 fs::create_dir_all(fixture.path().join("apps/soma/src/bin")).unwrap();
630 fs::create_dir_all(fixture.path().join("crates/soma/api")).unwrap();
631 fs::create_dir_all(fixture.path().join("packages/soma-rmcp/bin")).unwrap();
632 fs::create_dir_all(fixture.path().join("plugins/soma/skills/soma")).unwrap();
633 fs::write(fixture.path().join("apps/soma/Cargo.toml"), "").unwrap();
634 fs::write(fixture.path().join("apps/soma/src/bin/soma.rs"), "").unwrap();
635 fs::write(fixture.path().join("crates/soma/api/Cargo.toml"), "").unwrap();
636 fs::write(fixture.path().join("packages/soma-rmcp/package.json"), "").unwrap();
637 fs::write(
638 fixture.path().join("packages/soma-rmcp/bin/soma-rmcp.js"),
639 "",
640 )
641 .unwrap();
642 fs::write(fixture.path().join("plugins/soma/.claude-plugin.json"), "").unwrap();
643 fs::write(fixture.path().join("plugins/soma/skills/soma/SKILL.md"), "").unwrap();
644
645 rename_paths(fixture.path(), &test_values()).unwrap();
646
647 assert!(fixture
648 .path()
649 .join("apps/myservice-mcp/Cargo.toml")
650 .exists());
651 assert!(fixture
652 .path()
653 .join("apps/myservice-mcp/src/bin/myservice.rs")
654 .exists());
655 assert!(fixture
656 .path()
657 .join("crates/myservice_mcp/api/Cargo.toml")
658 .exists());
659 assert!(fixture
660 .path()
661 .join("packages/myservice-mcp/package.json")
662 .exists());
663 assert!(fixture
664 .path()
665 .join("packages/myservice-mcp/bin/myservice-mcp.js")
666 .exists());
667 assert!(fixture
668 .path()
669 .join("plugins/myservice/.claude-plugin.json")
670 .exists());
671 assert!(fixture
672 .path()
673 .join("plugins/myservice/skills/myservice/SKILL.md")
674 .exists());
675 }
676
677 #[test]
678 fn agent_memory_symlinks_are_recreated_after_cargo_generate_skips_them() {
679 let fixture = TempDir::new().unwrap();
680 fs::write(fixture.path().join("CLAUDE.md"), "# Root\n").unwrap();
681 fs::create_dir_all(fixture.path().join("docs")).unwrap();
682 fs::write(fixture.path().join("docs/CLAUDE.md"), "# Docs\n").unwrap();
683
684 ensure_agent_memory_symlinks(fixture.path()).unwrap();
685
686 assert_eq!(
687 fs::read_link(fixture.path().join("AGENTS.md")).unwrap(),
688 Path::new("CLAUDE.md")
689 );
690 assert_eq!(
691 fs::read_link(fixture.path().join("GEMINI.md")).unwrap(),
692 Path::new("CLAUDE.md")
693 );
694 assert_eq!(
695 fs::read_link(fixture.path().join("docs/AGENTS.md")).unwrap(),
696 Path::new("CLAUDE.md")
697 );
698 }
699}