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}