Skip to main content

xtask/
scripts_lane_b.rs

1//! Lane B Rust ports for release and plugin-layout shell scripts.
2//!
3//! The shell scripts remain as thin wrappers; these functions are the
4//! canonical implementations.
5
6use anyhow::{Context, Result, bail};
7use serde_json::Value;
8use std::ffi::OsStr;
9use std::path::{Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use crate::release_versions::{self, BumpLevel};
14use crate::{scripts, scripts_lane_d};
15
16pub fn bump_version(root: &Path, args: &[String]) -> Result<()> {
17    let arg = args.first().map(String::as_str).unwrap_or("");
18    let level = parse_bump_wrapper_level(arg)?;
19    release_versions::bump(root, "soma", level)?;
20    println!("Done. This is an emergency/local helper; normal releases go through release-please.");
21    Ok(())
22}
23
24pub fn check_version_sync(root: &Path, args: &[String]) -> Result<()> {
25    if args.len() > 1 {
26        bail!("Usage: scripts/check-version-sync.sh [PROJECT_DIR]");
27    }
28    let project_dir = args
29        .first()
30        .map(PathBuf::from)
31        .unwrap_or_else(|| root.to_owned());
32    release_versions::check_version_sync(&project_dir)
33}
34
35pub fn pre_release_check(args: &[String]) -> Result<()> {
36    let options = PreReleaseOptions::parse(args)?;
37    let mut runner = CheckRunner::default();
38
39    runner.run("PATTERNS.md contracts", "cargo", &["xtask", "patterns"]);
40    runner.run("plugin layout", "just", &["validate-plugin"]);
41    runner.run(
42        "schema docs",
43        "cargo",
44        &["xtask", "check-schema-docs", "--check"],
45    );
46    runner.run(
47        "OpenAPI docs",
48        "cargo",
49        &["xtask", "check-openapi", "--check"],
50    );
51    runner.run(
52        "MCP registry manifest",
53        "cargo",
54        &["xtask", "check-mcp-registry"],
55    );
56    runner.run(
57        "scaffold intent contract",
58        "cargo",
59        &["xtask", "check-scaffold-intent-contract"],
60    );
61    runner.run(
62        "Soma feature smoke",
63        "cargo",
64        &["xtask", "test-soma-features"],
65    );
66    runner.run(
67        "release version sync",
68        "cargo",
69        &["xtask", "check-version-sync"],
70    );
71    runner.run("blob size", "cargo", &["xtask", "check-blob-size"]);
72    runner.run("ascii hygiene", "just", &["ascii-check"]);
73
74    if options.run_verify {
75        runner.run("quality gate", "just", &["verify"]);
76    }
77    if options.run_build_plugin {
78        runner.run("plugin package validation", "just", &["build-plugin"]);
79    }
80    if options.run_mcporter {
81        runner.run("mcporter integration", "just", &["test-mcporter"]);
82    }
83
84    runner.finish()
85}
86
87pub fn test_soma_features(repo_root: &Path) -> Result<()> {
88    let mut smoke = SmokeRunner::default();
89    let temp_root = TempDir::create("soma-feature-smoke")?;
90    let repo = temp_root.path().join("repo");
91
92    run_silent_in(repo_root, "git", &["init", "-q", path_str(&repo)?])
93        .context("failed to initialize temporary git repo")?;
94
95    run_silent_in(
96        &repo,
97        "git",
98        &["config", "user.email", "test@example.invalid"],
99    )?;
100    run_silent_in(&repo, "git", &["config", "user.name", "Soma Test"])?;
101    std::fs::write(repo.join(".env.example"), "safe=true\n")?;
102    std::fs::write(repo.join(".env"), "secret=true\n")?;
103    run_silent_in(&repo, "git", &["add", ".env.example"])?;
104    run_silent_in(&repo, "git", &["add", "-f", ".env"])?;
105
106    smoke.expect_fail("env guard blocks staged .env", run_env_guard_in(&repo));
107
108    run_silent_in(&repo, "git", &["reset", "-q", ".env"])?;
109    smoke.expect_ok("env guard allows .env.example", run_env_guard_in(&repo));
110
111    let nested = temp_root.path().join("docs/nested");
112    std::fs::create_dir_all(&nested)?;
113    std::fs::write(temp_root.path().join("CLAUDE.md"), "# Root\n")?;
114    std::fs::write(nested.join("CLAUDE.md"), "# Nested\n")?;
115    match create_agent_memory_symlinks(temp_root.path()) {
116        Ok(())
117            if temp_root.path().join("AGENTS.md").is_symlink()
118                && temp_root.path().join("GEMINI.md").is_symlink()
119                && nested.join("AGENTS.md").is_symlink()
120                && nested.join("GEMINI.md").is_symlink() =>
121        {
122            smoke.pass("symlink-docs inline pattern creates AGENTS/GEMINI links");
123        }
124        _ => smoke.fail("symlink-docs inline pattern creates AGENTS/GEMINI links"),
125    }
126
127    smoke.expect_ok(
128        "plugin layout validator passes",
129        validate_plugin_layout(repo_root, None),
130    );
131    smoke.expect_ok(
132        "schema docs checker passes",
133        scripts_lane_d::check_schema_docs(&["--check".to_owned()]),
134    );
135    smoke.expect_ok(
136        "ascii checker catches allowed repo glyphs cleanly",
137        run_ascii_checker(repo_root),
138    );
139
140    smoke.finish()
141}
142
143pub fn validate_plugin_layout(repo_root: &Path, plugin_root: Option<&Path>) -> Result<()> {
144    let plugin_root = plugin_root
145        .map(PathBuf::from)
146        .or_else(|| env_path("PLUGIN_ROOT"))
147        .unwrap_or_else(|| PathBuf::from("plugins/soma"));
148    let plugin_root = repo_root.join(plugin_root);
149    let layout = PluginLayout::new(&plugin_root);
150    let mut checks = PluginChecks::default();
151
152    println!("=== Validating soma Plugin Layout ===");
153    println!("Plugin root: {}", display_relative(repo_root, &plugin_root));
154    println!();
155
156    checks.check("jq is available", || command_on_path("jq"));
157
158    checks.check_result("Claude plugin manifest exists", || {
159        file_exists(&layout.claude)
160    });
161    checks.check_result("Claude plugin manifest is valid JSON", || {
162        read_json(&layout.claude).map(|_| ())
163    });
164    checks.check_result("Claude plugin name is soma", || {
165        json_field_eq(&layout.claude, "/name", "soma")
166    });
167    checks.check_result("Claude plugin has no version field", || {
168        json_has_no_version(&layout.claude)
169    });
170    checks.check_result("Claude plugin points to skills directory", || {
171        json_field_eq(&layout.claude, "/skills", "./skills")
172    });
173    checks.check_result(
174        "Claude plugin declares optional server_url userConfig",
175        || {
176            let value = read_json(&layout.claude)?;
177            require_json_bool(&value, "/userConfig/server_url/required", false)?;
178            require_json_str(&value, "/userConfig/server_url/default", "")?;
179            Ok(())
180        },
181    );
182    checks.check_result("Claude plugin declares api_token as sensitive", || {
183        let value = read_json(&layout.claude)?;
184        require_json_bool(&value, "/userConfig/api_token/sensitive", true)
185    });
186    checks.check_result("Claude plugin declares no_auth toggle", || {
187        json_field_eq(&layout.claude, "/userConfig/no_auth/type", "boolean")
188    });
189    checks.check_result("Claude plugin declares auth_mode default", || {
190        json_field_eq(&layout.claude, "/userConfig/auth_mode/default", "bearer")
191    });
192
193    checks.check_result("Codex plugin manifest exists", || {
194        file_exists(&layout.codex)
195    });
196    checks.check_result("Codex plugin manifest is valid JSON", || {
197        read_json(&layout.codex).map(|_| ())
198    });
199    checks.check_result("Codex plugin name is soma", || {
200        json_field_eq(&layout.codex, "/name", "soma")
201    });
202    checks.check_result("Codex plugin has no version field", || {
203        json_has_no_version(&layout.codex)
204    });
205    checks.check_result("Codex plugin points to skills directory", || {
206        json_field_eq(&layout.codex, "/skills", "./skills/")
207    });
208    checks.check_result("Codex plugin composer icon asset exists", || {
209        plugin_asset_exists(&plugin_root, &layout.codex, "/interface/composerIcon")
210    });
211    checks.check_result("Codex plugin logo asset exists", || {
212        plugin_asset_exists(&plugin_root, &layout.codex, "/interface/logo")
213    });
214
215    checks.check_result("Gemini extension manifest exists", || {
216        file_exists(&layout.gemini)
217    });
218    checks.check_result("Gemini extension manifest is valid JSON", || {
219        read_json(&layout.gemini).map(|_| ())
220    });
221    checks.check_result("Gemini extension name is soma", || {
222        json_field_eq(&layout.gemini, "/name", "soma")
223    });
224    checks.check_result("Gemini extension has no version field", || {
225        json_has_no_version(&layout.gemini)
226    });
227    checks.check_result("Gemini extension points to skills directory", || {
228        json_field_eq(&layout.gemini, "/skills", "./skills")
229    });
230
231    // Marketplace manifests intentionally do not bundle MCP server registration
232    // (see plugins/README.md): the server connects through the user's gateway or
233    // local MCP setup. No .mcp.json / mcpServers checks here by design.
234
235    // The plugin package intentionally ships no Claude Code lifecycle hooks:
236    // manifests declare no `hooks` key and there is no `hooks/hooks.json`.
237    checks.check_result("Claude plugin declares no hooks", || {
238        json_has_no_hooks(&layout.claude)
239    });
240    checks.check_result("Gemini extension declares no hooks", || {
241        json_has_no_hooks(&layout.gemini)
242    });
243
244    checks.check_result("skills directory exists", || dir_exists(&layout.skills));
245
246    let skill_files = skill_files(&layout.skills).unwrap_or_default();
247    for skill_file in &skill_files {
248        let skill_dir = skill_file
249            .parent()
250            .and_then(Path::file_name)
251            .and_then(OsStr::to_str)
252            .unwrap_or("<unknown>");
253        let expected_skill_name = if skill_dir == "soma" {
254            "soma"
255        } else {
256            skill_dir
257        };
258        checks.check_result(&format!("skill {skill_dir} has front matter name"), || {
259            skill_has_name(skill_file, expected_skill_name)
260        });
261        checks.check_result(&format!("skill {skill_dir} has description"), || {
262            skill_has_description(skill_file)
263        });
264    }
265
266    checks.check("at least one plugin skill exists", || {
267        !skill_files.is_empty()
268    });
269    checks.finish()
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273struct PreReleaseOptions {
274    run_verify: bool,
275    run_build_plugin: bool,
276    run_mcporter: bool,
277}
278
279impl Default for PreReleaseOptions {
280    fn default() -> Self {
281        Self {
282            run_verify: true,
283            run_build_plugin: true,
284            run_mcporter: false,
285        }
286    }
287}
288
289impl PreReleaseOptions {
290    fn parse(args: &[String]) -> Result<Self> {
291        let mut options = Self::default();
292        for arg in args {
293            match arg.as_str() {
294                "--skip-verify" => options.run_verify = false,
295                "--skip-build-plugin" => options.run_build_plugin = false,
296                "--mcporter" => options.run_mcporter = true,
297                "-h" | "--help" => {
298                    println!("{}", PRE_RELEASE_USAGE.trim_end());
299                    std::process::exit(0);
300                }
301                unknown => {
302                    eprintln!("unknown argument: {unknown}");
303                    eprintln!("{}", PRE_RELEASE_USAGE.trim_end());
304                    bail!("unknown argument: {unknown}");
305                }
306            }
307        }
308        Ok(options)
309    }
310}
311
312const PRE_RELEASE_USAGE: &str = r#"Usage: scripts/pre-release-check.sh [OPTIONS]
313
314Options:
315  --skip-verify        Skip `just verify`.
316  --skip-build-plugin  Skip `just build-plugin`.
317  --mcporter           Also run `just test-mcporter` (requires running server).
318  -h, --help           Show this help.
319"#;
320
321#[derive(Default)]
322struct CheckRunner {
323    pass: usize,
324    fail: usize,
325    failed_checks: Vec<String>,
326}
327
328impl CheckRunner {
329    fn run(&mut self, label: &str, program: &str, args: &[&str]) {
330        println!("\n==> {label}");
331        match run_status(program, args, true) {
332            Ok(status) if status.success() => {
333                println!("PASS {label}");
334                self.pass += 1;
335            }
336            _ => {
337                eprintln!("FAIL {label}");
338                self.failed_checks.push(label.to_owned());
339                self.fail += 1;
340            }
341        }
342    }
343
344    fn finish(self) -> Result<()> {
345        println!("\n== Results ==");
346        println!("Passed: {}", self.pass);
347        println!("Failed: {}", self.fail);
348        if self.fail > 0 {
349            eprintln!("Failed checks:");
350            for label in &self.failed_checks {
351                eprintln!("  - {label}");
352            }
353            bail!("pre-release check failed");
354        }
355        println!("Release gate passed.");
356        Ok(())
357    }
358}
359
360#[derive(Default)]
361struct SmokeRunner {
362    pass: usize,
363    fail: usize,
364}
365
366impl SmokeRunner {
367    fn pass(&mut self, label: &str) {
368        println!("PASS  {label}");
369        self.pass += 1;
370    }
371
372    fn fail(&mut self, label: &str) {
373        eprintln!("FAIL  {label}");
374        self.fail += 1;
375    }
376
377    fn expect_ok(&mut self, label: &str, result: Result<()>) {
378        match result {
379            Ok(()) => self.pass(label),
380            Err(error) => {
381                let output = error.to_string().replace('\n', "");
382                let output: String = output.chars().take(200).collect();
383                self.fail(&format!("{label} ({output})"));
384            }
385        }
386    }
387
388    fn expect_fail(&mut self, label: &str, result: Result<()>) {
389        match result {
390            Ok(()) => self.fail(&format!("{label} (unexpected success)")),
391            Err(_) => self.pass(label),
392        }
393    }
394
395    fn finish(self) -> Result<()> {
396        println!("\n{} passed, {} failed", self.pass, self.fail);
397        if self.fail == 0 {
398            Ok(())
399        } else {
400            bail!("Soma feature smoke failed")
401        }
402    }
403}
404
405#[derive(Default)]
406struct PluginChecks {
407    checks: usize,
408    passed: usize,
409    failed: usize,
410}
411
412impl PluginChecks {
413    fn check(&mut self, name: &str, test: impl FnOnce() -> bool) {
414        self.check_result(name, || {
415            if test() {
416                Ok(())
417            } else {
418                bail!("check returned false")
419            }
420        });
421    }
422
423    fn check_result(&mut self, name: &str, test: impl FnOnce() -> Result<()>) {
424        self.checks += 1;
425        print!("Checking: {name}... ");
426        if test().is_ok() {
427            println!("\x1b[0;32mPASS\x1b[0m");
428            self.passed += 1;
429        } else {
430            println!("\x1b[0;31mFAIL\x1b[0m");
431            self.failed += 1;
432        }
433    }
434
435    fn finish(self) -> Result<()> {
436        println!();
437        println!("=== Results ===");
438        println!("Total checks: {}", self.checks);
439        println!("\x1b[0;32mPassed: {}\x1b[0m", self.passed);
440        if self.failed > 0 {
441            println!("\x1b[0;31mFailed: {}\x1b[0m", self.failed);
442            bail!("plugin layout validation failed");
443        }
444        println!("\x1b[0;32mAll checks passed.\x1b[0m");
445        Ok(())
446    }
447}
448
449struct PluginLayout {
450    claude: PathBuf,
451    codex: PathBuf,
452    gemini: PathBuf,
453    skills: PathBuf,
454}
455
456impl PluginLayout {
457    fn new(plugin_root: &Path) -> Self {
458        Self {
459            claude: plugin_root.join(".claude-plugin/plugin.json"),
460            codex: plugin_root.join(".codex-plugin/plugin.json"),
461            gemini: plugin_root.join("gemini-extension.json"),
462            skills: plugin_root.join("skills"),
463        }
464    }
465}
466
467struct TempDir {
468    path: PathBuf,
469}
470
471impl TempDir {
472    fn create(prefix: &str) -> Result<Self> {
473        let nonce = SystemTime::now()
474            .duration_since(UNIX_EPOCH)
475            .context("system clock is before UNIX_EPOCH")?
476            .as_nanos();
477        let path = std::env::temp_dir().join(format!("{prefix}-{}-{nonce}", std::process::id()));
478        std::fs::create_dir_all(&path)
479            .with_context(|| format!("failed to create {}", path.display()))?;
480        Ok(Self { path })
481    }
482
483    fn path(&self) -> &Path {
484        &self.path
485    }
486}
487
488impl Drop for TempDir {
489    fn drop(&mut self) {
490        let _ = std::fs::remove_dir_all(&self.path);
491    }
492}
493
494fn parse_bump_wrapper_level(arg: &str) -> Result<BumpLevel> {
495    match arg {
496        "patch" => Ok(BumpLevel::Patch),
497        "minor" => Ok(BumpLevel::Minor),
498        "major" => Ok(BumpLevel::Major),
499        "" => bail!("Usage: scripts/bump-version.sh <major|minor|patch>"),
500        _ => bail!(
501            "scripts/bump-version.sh now accepts only major, minor, or patch; use cargo xtask for component-aware bumps."
502        ),
503    }
504}
505
506fn file_exists(path: &Path) -> Result<()> {
507    if path.is_file() {
508        Ok(())
509    } else {
510        bail!("missing file {}", path.display())
511    }
512}
513
514fn dir_exists(path: &Path) -> Result<()> {
515    if path.is_dir() {
516        Ok(())
517    } else {
518        bail!("missing directory {}", path.display())
519    }
520}
521
522fn read_json(path: &Path) -> Result<Value> {
523    let content = std::fs::read_to_string(path)
524        .with_context(|| format!("failed to read {}", path.display()))?;
525    serde_json::from_str(&content).with_context(|| format!("invalid JSON in {}", path.display()))
526}
527
528fn json_field_eq(path: &Path, pointer: &str, expected: &str) -> Result<()> {
529    let value = read_json(path)?;
530    require_json_str(&value, pointer, expected)
531}
532
533fn json_has_no_version(path: &Path) -> Result<()> {
534    let value = read_json(path)?;
535    if contains_json_key(&value, "version") {
536        bail!("must not contain a version key")
537    } else {
538        Ok(())
539    }
540}
541
542fn json_has_no_hooks(path: &Path) -> Result<()> {
543    let value = read_json(path)?;
544    if value.get("hooks").is_some() {
545        bail!("must not declare a hooks key; the plugin ships no lifecycle hooks")
546    } else {
547        Ok(())
548    }
549}
550
551fn require_json_str(value: &Value, pointer: &str, expected: &str) -> Result<()> {
552    let actual = value
553        .pointer(pointer)
554        .and_then(Value::as_str)
555        .with_context(|| format!("missing JSON string at {pointer}"))?;
556    if actual == expected {
557        Ok(())
558    } else {
559        bail!("expected {pointer} to be {expected:?}, found {actual:?}")
560    }
561}
562
563fn require_json_bool(value: &Value, pointer: &str, expected: bool) -> Result<()> {
564    let actual = value
565        .pointer(pointer)
566        .and_then(Value::as_bool)
567        .with_context(|| format!("missing JSON boolean at {pointer}"))?;
568    if actual == expected {
569        Ok(())
570    } else {
571        bail!("expected {pointer} to be {expected}, found {actual}")
572    }
573}
574
575fn plugin_asset_exists(plugin_root: &Path, manifest: &Path, pointer: &str) -> Result<()> {
576    let value = read_json(manifest)?;
577    let asset = value
578        .pointer(pointer)
579        .and_then(Value::as_str)
580        .with_context(|| format!("missing JSON asset path at {pointer}"))?;
581    let relative = asset.strip_prefix("./").unwrap_or(asset);
582    let path = plugin_root.join(relative);
583    file_exists(&path)
584}
585
586fn contains_json_key(value: &Value, key: &str) -> bool {
587    match value {
588        Value::Object(map) => {
589            map.contains_key(key) || map.values().any(|value| contains_json_key(value, key))
590        }
591        Value::Array(values) => values.iter().any(|value| contains_json_key(value, key)),
592        _ => false,
593    }
594}
595
596fn skill_files(skills_dir: &Path) -> Result<Vec<PathBuf>> {
597    if !skills_dir.is_dir() {
598        return Ok(Vec::new());
599    }
600    let mut files = Vec::new();
601    for entry in std::fs::read_dir(skills_dir)? {
602        let entry = entry?;
603        if !entry.file_type()?.is_dir() {
604            continue;
605        }
606        let skill = entry.path().join("SKILL.md");
607        if skill.is_file() {
608            files.push(skill);
609        }
610    }
611    files.sort();
612    Ok(files)
613}
614
615fn skill_has_name(skill_file: &Path, expected: &str) -> Result<()> {
616    let content = std::fs::read_to_string(skill_file)
617        .with_context(|| format!("failed to read {}", skill_file.display()))?;
618    let expected_line = format!("name: {expected}");
619    if content.lines().any(|line| line.trim_end() == expected_line) {
620        Ok(())
621    } else {
622        bail!("missing front matter name {expected}")
623    }
624}
625
626fn skill_has_description(skill_file: &Path) -> Result<()> {
627    let content = std::fs::read_to_string(skill_file)
628        .with_context(|| format!("failed to read {}", skill_file.display()))?;
629    if content.lines().any(|line| {
630        line.strip_prefix("description:")
631            .is_some_and(|value| !value.trim().is_empty())
632    }) {
633        Ok(())
634    } else {
635        bail!("missing description")
636    }
637}
638
639fn create_agent_memory_symlinks(root: &Path) -> Result<()> {
640    for claude in find_named_files(root, "CLAUDE.md")? {
641        let dir = claude
642            .parent()
643            .with_context(|| format!("{} has no parent", claude.display()))?;
644        for link_name in ["AGENTS.md", "GEMINI.md"] {
645            let link = dir.join(link_name);
646            if link.exists() || link.symlink_metadata().is_ok() {
647                continue;
648            }
649            #[cfg(unix)]
650            std::os::unix::fs::symlink("CLAUDE.md", &link)
651                .with_context(|| format!("failed to create {}", link.display()))?;
652            #[cfg(windows)]
653            std::os::windows::fs::symlink_file("CLAUDE.md", &link)
654                .with_context(|| format!("failed to create {}", link.display()))?;
655        }
656    }
657    Ok(())
658}
659
660fn find_named_files(root: &Path, name: &str) -> Result<Vec<PathBuf>> {
661    let mut files = Vec::new();
662    visit_dirs(root, &mut |path| {
663        if path.file_name().and_then(OsStr::to_str) == Some(name) {
664            files.push(path.to_owned());
665        }
666        Ok(())
667    })?;
668    Ok(files)
669}
670
671fn visit_dirs(dir: &Path, f: &mut impl FnMut(&Path) -> Result<()>) -> Result<()> {
672    if dir
673        .file_name()
674        .and_then(OsStr::to_str)
675        .is_some_and(|name| matches!(name, ".git" | "target"))
676    {
677        return Ok(());
678    }
679    for entry in
680        std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?
681    {
682        let entry = entry?;
683        let path = entry.path();
684        let file_type = entry.file_type()?;
685        if file_type.is_dir() {
686            visit_dirs(&path, f)?;
687        } else if file_type.is_file() {
688            f(&path)?;
689        }
690    }
691    Ok(())
692}
693
694fn run_ascii_checker(repo_root: &Path) -> Result<()> {
695    let output = run_output_in(
696        repo_root,
697        "git",
698        &[
699            "ls-files",
700            "*.md",
701            "*.rs",
702            "*.toml",
703            "*.json",
704            "*.yml",
705            "*.yaml",
706            "*.sh",
707            "*.py",
708            ":!:docs/references/**",
709            ":!:docs/sessions/**",
710            // Keep this list aligned with scripts::run_ascii_check. The locked
711            // Synapse donor snapshot intentionally contains Unicode UI glyphs.
712            ":!:crates/synapse/import/**",
713        ],
714    )?;
715    let mut files: Vec<String> = output
716        .lines()
717        .filter(|path| repo_root.join(path).is_file())
718        .map(ToOwned::to_owned)
719        .collect();
720    let mut args = Vec::new();
721    args.append(&mut files);
722    let original = std::env::current_dir().context("failed to capture current directory")?;
723    std::env::set_current_dir(repo_root)
724        .with_context(|| format!("failed to enter {}", repo_root.display()))?;
725    let result = scripts_lane_d::asciicheck(&args);
726    let restore = std::env::set_current_dir(&original)
727        .with_context(|| format!("failed to restore {}", original.display()));
728    restore?;
729    result
730}
731
732fn run_silent_in(cwd: &Path, program: &str, args: &[&str]) -> Result<()> {
733    let status = run_status_in(cwd, program, args, false)?;
734    if status.success() {
735        Ok(())
736    } else {
737        bail!("{program} exited with {status}")
738    }
739}
740
741fn run_env_guard_in(cwd: &Path) -> Result<()> {
742    let original = std::env::current_dir().context("failed to capture current directory")?;
743    std::env::set_current_dir(cwd).with_context(|| format!("failed to enter {}", cwd.display()))?;
744    let result = scripts::block_env_commits();
745    let restore = std::env::set_current_dir(&original)
746        .with_context(|| format!("failed to restore {}", original.display()));
747    restore?;
748    result
749}
750
751fn run_status(program: &str, args: &[&str], inherit: bool) -> Result<std::process::ExitStatus> {
752    run_status_in(Path::new("."), program, args, inherit)
753}
754
755fn run_status_in(
756    cwd: &Path,
757    program: &str,
758    args: &[&str],
759    inherit: bool,
760) -> Result<std::process::ExitStatus> {
761    let mut command = Command::new(program);
762    command.args(args).current_dir(cwd).stdin(Stdio::null());
763    if inherit {
764        command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
765    } else {
766        command.stdout(Stdio::null()).stderr(Stdio::null());
767    }
768    command
769        .status()
770        .with_context(|| format!("failed to spawn {program}"))
771}
772
773fn run_output_in(cwd: &Path, program: &str, args: &[&str]) -> Result<String> {
774    let output = Command::new(program)
775        .args(args)
776        .current_dir(cwd)
777        .stdin(Stdio::null())
778        .output()
779        .with_context(|| format!("failed to spawn {program}"))?;
780    if !output.status.success() {
781        bail!("{program} exited with {}", output.status);
782    }
783    String::from_utf8(output.stdout).with_context(|| format!("{program} emitted non-UTF-8 stdout"))
784}
785
786fn command_on_path(name: &str) -> bool {
787    if name.contains('/') {
788        return Path::new(name).is_file();
789    }
790    let Some(paths) = std::env::var_os("PATH") else {
791        return false;
792    };
793    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
794}
795
796fn env_path(name: &str) -> Option<PathBuf> {
797    std::env::var_os(name)
798        .filter(|value| !value.is_empty())
799        .map(PathBuf::from)
800}
801
802fn path_str(path: &Path) -> Result<&str> {
803    path.to_str()
804        .with_context(|| format!("non-UTF-8 path: {}", path.display()))
805}
806
807fn display_relative<'a>(root: &'a Path, path: &'a Path) -> String {
808    path.strip_prefix(root)
809        .unwrap_or(path)
810        .display()
811        .to_string()
812}
813
814#[cfg(test)]
815mod tests {
816    use super::{
817        PreReleaseOptions, contains_json_key, json_has_no_version, parse_bump_wrapper_level,
818        skill_has_description, skill_has_name,
819    };
820    use crate::release_versions::BumpLevel;
821    use serde_json::json;
822
823    #[test]
824    fn bump_wrapper_parser_matches_contract() {
825        assert_eq!(parse_bump_wrapper_level("patch").unwrap(), BumpLevel::Patch);
826        assert_eq!(parse_bump_wrapper_level("minor").unwrap(), BumpLevel::Minor);
827        assert_eq!(parse_bump_wrapper_level("major").unwrap(), BumpLevel::Major);
828        assert!(
829            parse_bump_wrapper_level("")
830                .unwrap_err()
831                .to_string()
832                .contains("Usage:")
833        );
834        assert!(
835            parse_bump_wrapper_level("soma")
836                .unwrap_err()
837                .to_string()
838                .contains("component-aware bumps")
839        );
840    }
841
842    #[test]
843    fn pre_release_options_preserve_shell_defaults() {
844        let options = PreReleaseOptions::parse(&[]).unwrap();
845        assert!(options.run_verify);
846        assert!(options.run_build_plugin);
847        assert!(!options.run_mcporter);
848
849        let options = PreReleaseOptions::parse(&[
850            "--skip-verify".to_owned(),
851            "--skip-build-plugin".to_owned(),
852            "--mcporter".to_owned(),
853        ])
854        .unwrap();
855        assert!(!options.run_verify);
856        assert!(!options.run_build_plugin);
857        assert!(options.run_mcporter);
858    }
859
860    #[test]
861    fn version_key_check_is_recursive() {
862        assert!(contains_json_key(
863            &json!({"nested": [{"version": "1"}]}),
864            "version"
865        ));
866        assert!(!contains_json_key(&json!({"name": "soma"}), "version"));
867    }
868
869    #[test]
870    fn json_no_version_rejects_nested_version_key() {
871        let temp = tempfile::NamedTempFile::new().unwrap();
872        std::fs::write(temp.path(), r#"{"name":"x","nested":{"version":"1"}}"#).unwrap();
873        assert!(json_has_no_version(temp.path()).is_err());
874    }
875
876    #[test]
877    fn skill_front_matter_checks_match_shell_awk_rules() {
878        let temp = tempfile::NamedTempFile::new().unwrap();
879        std::fs::write(temp.path(), "name: soma\ndescription: Useful skill\n").unwrap();
880        assert!(skill_has_name(temp.path(), "soma").is_ok());
881        assert!(skill_has_description(temp.path()).is_ok());
882
883        std::fs::write(temp.path(), "name: other\ndescription:\n").unwrap();
884        assert!(skill_has_name(temp.path(), "soma").is_err());
885        assert!(skill_has_description(temp.path()).is_err());
886    }
887}