Skip to main content

xtask/
cargo_generate_post.rs

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}