Skip to main content

xtask/
cargo_generate.rs

1use anyhow::{bail, Context, Result};
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::time::{SystemTime, UNIX_EPOCH};
7use walkdir::WalkDir;
8
9use crate::{cargo_generate_post, command_exists};
10
11#[derive(Debug)]
12struct Case {
13    name: &'static str,
14    values: BTreeMap<&'static str, &'static str>,
15    feature_checks: &'static [&'static str],
16}
17
18pub(crate) fn run(args: &[String]) -> Result<()> {
19    let mut cargo_check = true;
20    for arg in args {
21        match arg.as_str() {
22            "--no-cargo-check" => cargo_check = false,
23            "--help" | "-h" => {
24                println!("Usage: cargo xtask cargo-generate [--no-cargo-check]");
25                return Ok(());
26            }
27            unknown => bail!("Unknown cargo-generate option: {unknown}"),
28        }
29    }
30
31    if !command_exists("cargo-generate") {
32        bail!("cargo-generate is not installed; run `mise install cargo:cargo-generate`");
33    }
34
35    let repo = std::env::current_dir().context("failed to read current directory")?;
36    let temp = TempDir::new("soma-cargo-generate")?;
37    let template = temp.path().join("_template");
38    let cargo_home = stage_cargo_home(temp.path())?;
39    stage_template(&repo, &template)?;
40
41    for case in cases() {
42        println!("== {} ==", case.name);
43        generate_case(&template, temp.path(), &cargo_home, &case, cargo_check)?;
44    }
45
46    Ok(())
47}
48
49fn cases() -> Vec<Case> {
50    vec![
51        Case {
52            name: "simple",
53            values: BTreeMap::from([
54                ("package_name", "myservice-mcp"),
55                ("crate_prefix", "myservice"),
56                ("binary_name", "myservice"),
57                ("service_slug", "myservice"),
58                ("type_prefix", "MyService"),
59                ("env_prefix", "MYSERVICE"),
60                ("scope_prefix", "myservice"),
61                ("default_port", "40123"),
62                ("github_owner", "jmagar"),
63                ("github_repo", "myservice-mcp"),
64                ("default_features", "full"),
65            ]),
66            feature_checks: &[
67                "cli",
68                "mcp-stdio",
69                "local-adapter",
70                "api,cli,web,oauth,observability",
71                "server",
72                "full",
73            ],
74        },
75        Case {
76            name: "hyphenated-packages",
77            values: BTreeMap::from([
78                ("package_name", "foo-bar-mcp"),
79                ("crate_prefix", "foo-bar"),
80                ("binary_name", "foo-bar"),
81                ("service_slug", "foo_bar"),
82                ("type_prefix", "FooBar"),
83                ("env_prefix", "FOOBAR"),
84                ("scope_prefix", "foo-bar"),
85                ("default_port", "40124"),
86                ("github_owner", "jmagar"),
87                ("github_repo", "foo-bar-mcp"),
88                ("default_features", "server,web,oauth,observability"),
89            ]),
90            feature_checks: &[
91                "cli",
92                "mcp-stdio",
93                "local-adapter",
94                "api,cli,web,oauth,observability",
95                "server,web,oauth,observability",
96            ],
97        },
98        Case {
99            name: "upstream-client-local-adapter",
100            values: BTreeMap::from([
101                ("package_name", "lean-mcp"),
102                ("crate_prefix", "lean"),
103                ("binary_name", "lean"),
104                ("service_slug", "lean"),
105                ("type_prefix", "Lean"),
106                ("env_prefix", "LEAN"),
107                ("scope_prefix", "lean"),
108                ("default_port", "40090"),
109                ("github_owner", "jmagar"),
110                ("github_repo", "lean-mcp"),
111                ("default_features", "local-adapter"),
112            ]),
113            feature_checks: &["local-adapter", "cli", "mcp-stdio"],
114        },
115    ]
116}
117
118pub(crate) fn stage_template(repo: &Path, template: &Path) -> Result<()> {
119    for entry in WalkDir::new(repo).into_iter().filter_entry(|entry| {
120        let relative = match entry.path().strip_prefix(repo) {
121            Ok(path) => path,
122            Err(_) => return true,
123        };
124        !is_ignored(relative)
125    }) {
126        let entry = entry.context("failed to walk Soma scaffold source")?;
127        let relative = entry
128            .path()
129            .strip_prefix(repo)
130            .context("walk entry was outside repo")?;
131        if relative.as_os_str().is_empty() {
132            continue;
133        }
134
135        let destination = template.join(relative);
136        if entry.file_type().is_dir() {
137            fs::create_dir_all(&destination)
138                .with_context(|| format!("failed to create {}", destination.display()))?;
139        } else if entry.file_type().is_file() || entry.file_type().is_symlink() {
140            if let Some(parent) = destination.parent() {
141                fs::create_dir_all(parent)
142                    .with_context(|| format!("failed to create {}", parent.display()))?;
143            }
144            fs::copy(entry.path(), &destination).with_context(|| {
145                format!(
146                    "failed to copy {} to {}",
147                    entry.path().display(),
148                    destination.display()
149                )
150            })?;
151        }
152    }
153
154    let root_bin = template.join("bin");
155    if root_bin.exists() {
156        fs::remove_dir_all(&root_bin)
157            .with_context(|| format!("failed to remove {}", root_bin.display()))?;
158    }
159
160    Ok(())
161}
162
163fn is_ignored(relative: &Path) -> bool {
164    const IGNORED_NAMES: &[&str] = &[
165        ".beads",
166        ".cache",
167        ".dolt",
168        ".full-review",
169        ".git",
170        ".lavra",
171        ".next",
172        ".serena",
173        ".superpowers",
174        ".worktrees",
175        "__pycache__",
176        "node_modules",
177        "target",
178        "dist",
179        "storage",
180    ];
181    const IGNORED_PATHS: &[&str] = &["docs/references", "mcp-server-inventory.md"];
182
183    let normalized = relative.to_string_lossy().replace('\\', "/");
184    if IGNORED_PATHS.iter().any(|path| normalized == *path) {
185        return true;
186    }
187    if normalized.ends_with(".pyc") {
188        return true;
189    }
190    relative.components().any(|component| {
191        let name = component.as_os_str().to_string_lossy();
192        IGNORED_NAMES.iter().any(|ignored| name == *ignored)
193    })
194}
195
196fn generate_case(
197    template: &Path,
198    temp: &Path,
199    cargo_home: &Path,
200    case: &Case,
201    cargo_check: bool,
202) -> Result<()> {
203    let destination = temp.join(case.name);
204    fs::create_dir_all(&destination)
205        .with_context(|| format!("failed to create {}", destination.display()))?;
206
207    let mut args = vec![
208        "generate".to_string(),
209        "--path".to_string(),
210        template.display().to_string(),
211        "--name".to_string(),
212        value(case, "package_name")?.to_string(),
213        "--destination".to_string(),
214        destination.display().to_string(),
215        "--vcs".to_string(),
216        "none".to_string(),
217        "--silent".to_string(),
218    ];
219    for (key, value) in &case.values {
220        args.push("--define".to_string());
221        args.push(format!("{key}={value}"));
222    }
223
224    let cargo_args: Vec<&str> = args.iter().map(String::as_str).collect();
225    run_cmd_in("cargo", &cargo_args, Path::new("."), cargo_home)?;
226
227    let project = destination.join(value(case, "package_name")?);
228    cargo_generate_post::run(&[project.display().to_string()])?;
229    assert_generated_shape(&project, case)?;
230
231    if cargo_check {
232        run_cmd_in(
233            "cargo",
234            &["check", "--workspace", "--all-targets"],
235            &project,
236            cargo_home,
237        )
238        .with_context(|| format!("cargo check failed in {}", project.display()))?;
239
240        for features in case.feature_checks {
241            run_cmd_in(
242                "cargo",
243                &[
244                    "check",
245                    "-p",
246                    value(case, "package_name")?,
247                    "--no-default-features",
248                    "--features",
249                    features,
250                ],
251                &project,
252                cargo_home,
253            )
254            .with_context(|| {
255                format!(
256                    "cargo check failed in {} for features {features}",
257                    project.display()
258                )
259            })?;
260        }
261    }
262
263    Ok(())
264}
265
266fn assert_generated_shape(project: &Path, case: &Case) -> Result<()> {
267    let surface_name = format!("{}-mcp-surface", value(case, "crate_prefix")?);
268
269    assert_missing(project.join("cargo-generate.toml"))?;
270    assert_missing(project.join(".cargo-generate-values.toml"))?;
271    assert_missing(project.join("scaffold"))?;
272    assert_missing(project.join("docs/CARGO_GENERATE.md"))?;
273
274    let readme = read_to_string(project.join("README.md"))?;
275    if readme.contains("Generate a New Server") {
276        bail!("generated README still contains scaffold generation instructions");
277    }
278    if readme.contains(&format!(
279        "https://github.com/{}/{}",
280        value(case, "github_owner")?,
281        surface_name
282    )) {
283        bail!("generated README points at the internal MCP surface crate repo");
284    }
285
286    let package_crate = format!("crates/{}", value(case, "package_name")?.replace('-', "_"));
287    let manifest = read_to_string(project.join(&package_crate).join("Cargo.toml"))?;
288    let expected_default = format!(
289        "default = [{}]",
290        value(case, "default_features")?
291            .split(',')
292            .filter(|feature| !feature.trim().is_empty())
293            .map(|feature| format!("\"{}\"", feature.trim()))
294            .collect::<Vec<_>>()
295            .join(", ")
296    );
297    if !manifest.contains(&expected_default) {
298        bail!("generated Cargo.toml does not contain {expected_default}");
299    }
300
301    let web_crate = format!(
302        "crates/{}-web",
303        value(case, "package_name")?.replace('-', "_")
304    );
305    for bundled_source in [
306        "assets/source/package.json",
307        "assets/source/components/aurora.css",
308        "assets/source/app/page.tsx",
309    ] {
310        assert_exists(project.join(&web_crate).join(bundled_source))?;
311    }
312    for generated_artifact in [
313        "assets/source/node_modules",
314        "assets/source/.next",
315        "assets/source/out",
316        "assets/source/tsconfig.tsbuildinfo",
317    ] {
318        assert_missing(project.join(&web_crate).join(generated_artifact))?;
319    }
320
321    let expected_repo = format!(
322        "https://github.com/{}/{}",
323        value(case, "github_owner")?,
324        value(case, "github_repo")?
325    );
326    for plugin_file in [
327        project.join(format!(
328            "plugins/{}/.claude-plugin/plugin.json",
329            value(case, "binary_name")?
330        )),
331        project.join(format!(
332            "plugins/{}/.codex-plugin/plugin.json",
333            value(case, "binary_name")?
334        )),
335        project.join(format!(
336            "plugins/{}/gemini-extension.json",
337            value(case, "binary_name")?
338        )),
339    ] {
340        let body = read_to_string(&plugin_file)?;
341        if body.contains(&surface_name) {
342            bail!(
343                "{} leaks internal crate name {surface_name:?}",
344                plugin_file.display()
345            );
346        }
347        if !body.contains(&expected_repo) {
348            bail!("{} does not contain {expected_repo}", plugin_file.display());
349        }
350    }
351
352    Ok(())
353}
354
355fn assert_exists(path: PathBuf) -> Result<()> {
356    if !path.exists() {
357        bail!("generated project is missing {}", path.display());
358    }
359    Ok(())
360}
361
362fn assert_missing(path: PathBuf) -> Result<()> {
363    if path.exists() {
364        bail!("generated project still contains {}", path.display());
365    }
366    Ok(())
367}
368
369fn read_to_string(path: impl AsRef<Path>) -> Result<String> {
370    let path = path.as_ref();
371    fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
372}
373
374fn value<'a>(case: &'a Case, key: &str) -> Result<&'a str> {
375    case.values
376        .get(key)
377        .copied()
378        .with_context(|| format!("case {} is missing {key}", case.name))
379}
380
381fn stage_cargo_home(temp: &Path) -> Result<PathBuf> {
382    let cargo_home = temp.join("cargo-home");
383    fs::create_dir_all(&cargo_home)
384        .with_context(|| format!("failed to create {}", cargo_home.display()))?;
385
386    let source_home = std::env::var_os("CARGO_HOME")
387        .map(PathBuf::from)
388        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")));
389    let Some(source_home) = source_home else {
390        return Ok(cargo_home);
391    };
392
393    for cache_name in ["registry", "git"] {
394        let source = source_home.join(cache_name);
395        let destination = cargo_home.join(cache_name);
396        if source.exists() {
397            symlink_dir(&source, &destination).with_context(|| {
398                format!(
399                    "failed to link Cargo cache {} to {}",
400                    source.display(),
401                    destination.display()
402                )
403            })?;
404        }
405    }
406
407    Ok(cargo_home)
408}
409
410#[cfg(unix)]
411fn symlink_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
412    std::os::unix::fs::symlink(source, destination)
413}
414
415#[cfg(windows)]
416fn symlink_dir(source: &Path, destination: &Path) -> std::io::Result<()> {
417    std::os::windows::fs::symlink_dir(source, destination)
418}
419
420fn run_cmd_in(program: &str, args: &[&str], cwd: &Path, cargo_home: &Path) -> Result<()> {
421    let mut command = Command::new(program);
422    command.args(args).current_dir(cwd).stdin(Stdio::null());
423    if program == "cargo" {
424        command.env("CARGO_HOME", cargo_home);
425        for (key, _) in std::env::vars_os() {
426            if key.to_string_lossy().starts_with("CARGO_PROFILE") {
427                command.env_remove(key);
428            }
429        }
430    }
431    let status = command
432        .status()
433        .with_context(|| format!("failed to spawn `{program}` in {}", cwd.display()))?;
434    if !status.success() {
435        bail!(
436            "`{program} {}` exited with status {status} in {}",
437            args.join(" "),
438            cwd.display()
439        );
440    }
441    Ok(())
442}
443
444pub(crate) struct TempDir {
445    path: PathBuf,
446}
447
448impl TempDir {
449    pub(crate) fn new(prefix: &str) -> Result<Self> {
450        let mut path = std::env::temp_dir();
451        let now = SystemTime::now()
452            .duration_since(UNIX_EPOCH)
453            .context("system time is before Unix epoch")?
454            .as_nanos();
455        path.push(format!("{prefix}-{}-{now}", std::process::id()));
456        fs::create_dir_all(&path)
457            .with_context(|| format!("failed to create temp dir {}", path.display()))?;
458        Ok(Self { path })
459    }
460
461    pub(crate) fn path(&self) -> &Path {
462        &self.path
463    }
464}
465
466impl Drop for TempDir {
467    fn drop(&mut self) {
468        let _ = fs::remove_dir_all(&self.path);
469    }
470}