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::{bail, Context, Result};
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 hooks config", || {
171        json_field_eq(&layout.claude, "/hooks", "./hooks/hooks.json")
172    });
173    checks.check_result("Claude plugin points to skills directory", || {
174        json_field_eq(&layout.claude, "/skills", "./skills")
175    });
176    checks.check_result(
177        "Claude plugin declares optional server_url userConfig",
178        || {
179            let value = read_json(&layout.claude)?;
180            require_json_bool(&value, "/userConfig/server_url/required", false)?;
181            require_json_str(&value, "/userConfig/server_url/default", "")?;
182            Ok(())
183        },
184    );
185    checks.check_result("Claude plugin declares api_token as sensitive", || {
186        let value = read_json(&layout.claude)?;
187        require_json_bool(&value, "/userConfig/api_token/sensitive", true)
188    });
189    checks.check_result("Claude plugin declares no_auth toggle", || {
190        json_field_eq(&layout.claude, "/userConfig/no_auth/type", "boolean")
191    });
192    checks.check_result("Claude plugin declares auth_mode default", || {
193        json_field_eq(&layout.claude, "/userConfig/auth_mode/default", "bearer")
194    });
195
196    checks.check_result("Codex plugin manifest exists", || {
197        file_exists(&layout.codex)
198    });
199    checks.check_result("Codex plugin manifest is valid JSON", || {
200        read_json(&layout.codex).map(|_| ())
201    });
202    checks.check_result("Codex plugin name is soma", || {
203        json_field_eq(&layout.codex, "/name", "soma")
204    });
205    checks.check_result("Codex plugin has no version field", || {
206        json_has_no_version(&layout.codex)
207    });
208    checks.check_result("Codex plugin points to skills directory", || {
209        json_field_eq(&layout.codex, "/skills", "./skills/")
210    });
211    checks.check_result("Codex plugin composer icon asset exists", || {
212        plugin_asset_exists(&plugin_root, &layout.codex, "/interface/composerIcon")
213    });
214    checks.check_result("Codex plugin logo asset exists", || {
215        plugin_asset_exists(&plugin_root, &layout.codex, "/interface/logo")
216    });
217
218    checks.check_result("Gemini extension manifest exists", || {
219        file_exists(&layout.gemini)
220    });
221    checks.check_result("Gemini extension manifest is valid JSON", || {
222        read_json(&layout.gemini).map(|_| ())
223    });
224    checks.check_result("Gemini extension name is soma", || {
225        json_field_eq(&layout.gemini, "/name", "soma")
226    });
227    checks.check_result("Gemini extension has no version field", || {
228        json_has_no_version(&layout.gemini)
229    });
230    checks.check_result("Gemini extension points to skills directory", || {
231        json_field_eq(&layout.gemini, "/skills", "./skills")
232    });
233
234    // Marketplace manifests intentionally do not bundle MCP server registration
235    // (see plugins/README.md): the server connects through the user's gateway or
236    // local MCP setup. No .mcp.json / mcpServers checks here by design.
237
238    checks.check_result("hooks config exists", || file_exists(&layout.hooks));
239    checks.check_result("hooks config is valid JSON", || {
240        read_json(&layout.hooks).map(|_| ())
241    });
242    checks.check_result("SessionStart runs plugin setup", || {
243        hook_command_exists(&layout.hooks, "SessionStart", None)
244    });
245    checks.check_result("ConfigChange runs plugin setup", || {
246        hook_command_exists(&layout.hooks, "ConfigChange", Some("user_settings"))
247    });
248
249    checks.check_result("skills directory exists", || dir_exists(&layout.skills));
250
251    let skill_files = skill_files(&layout.skills).unwrap_or_default();
252    for skill_file in &skill_files {
253        let skill_dir = skill_file
254            .parent()
255            .and_then(Path::file_name)
256            .and_then(OsStr::to_str)
257            .unwrap_or("<unknown>");
258        let expected_skill_name = if skill_dir == "soma" {
259            "soma"
260        } else {
261            skill_dir
262        };
263        checks.check_result(&format!("skill {skill_dir} has front matter name"), || {
264            skill_has_name(skill_file, expected_skill_name)
265        });
266        checks.check_result(&format!("skill {skill_dir} has description"), || {
267            skill_has_description(skill_file)
268        });
269    }
270
271    checks.check("at least one plugin skill exists", || {
272        !skill_files.is_empty()
273    });
274    checks.finish()
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278struct PreReleaseOptions {
279    run_verify: bool,
280    run_build_plugin: bool,
281    run_mcporter: bool,
282}
283
284impl Default for PreReleaseOptions {
285    fn default() -> Self {
286        Self {
287            run_verify: true,
288            run_build_plugin: true,
289            run_mcporter: false,
290        }
291    }
292}
293
294impl PreReleaseOptions {
295    fn parse(args: &[String]) -> Result<Self> {
296        let mut options = Self::default();
297        for arg in args {
298            match arg.as_str() {
299                "--skip-verify" => options.run_verify = false,
300                "--skip-build-plugin" => options.run_build_plugin = false,
301                "--mcporter" => options.run_mcporter = true,
302                "-h" | "--help" => {
303                    println!("{}", PRE_RELEASE_USAGE.trim_end());
304                    std::process::exit(0);
305                }
306                unknown => {
307                    eprintln!("unknown argument: {unknown}");
308                    eprintln!("{}", PRE_RELEASE_USAGE.trim_end());
309                    bail!("unknown argument: {unknown}");
310                }
311            }
312        }
313        Ok(options)
314    }
315}
316
317const PRE_RELEASE_USAGE: &str = r#"Usage: scripts/pre-release-check.sh [OPTIONS]
318
319Options:
320  --skip-verify        Skip `just verify`.
321  --skip-build-plugin  Skip `just build-plugin`.
322  --mcporter           Also run `just test-mcporter` (requires running server).
323  -h, --help           Show this help.
324"#;
325
326#[derive(Default)]
327struct CheckRunner {
328    pass: usize,
329    fail: usize,
330    failed_checks: Vec<String>,
331}
332
333impl CheckRunner {
334    fn run(&mut self, label: &str, program: &str, args: &[&str]) {
335        println!("\n==> {label}");
336        match run_status(program, args, true) {
337            Ok(status) if status.success() => {
338                println!("PASS {label}");
339                self.pass += 1;
340            }
341            _ => {
342                eprintln!("FAIL {label}");
343                self.failed_checks.push(label.to_owned());
344                self.fail += 1;
345            }
346        }
347    }
348
349    fn finish(self) -> Result<()> {
350        println!("\n== Results ==");
351        println!("Passed: {}", self.pass);
352        println!("Failed: {}", self.fail);
353        if self.fail > 0 {
354            eprintln!("Failed checks:");
355            for label in &self.failed_checks {
356                eprintln!("  - {label}");
357            }
358            bail!("pre-release check failed");
359        }
360        println!("Release gate passed.");
361        Ok(())
362    }
363}
364
365#[derive(Default)]
366struct SmokeRunner {
367    pass: usize,
368    fail: usize,
369}
370
371impl SmokeRunner {
372    fn pass(&mut self, label: &str) {
373        println!("PASS  {label}");
374        self.pass += 1;
375    }
376
377    fn fail(&mut self, label: &str) {
378        eprintln!("FAIL  {label}");
379        self.fail += 1;
380    }
381
382    fn expect_ok(&mut self, label: &str, result: Result<()>) {
383        match result {
384            Ok(()) => self.pass(label),
385            Err(error) => {
386                let output = error.to_string().replace('\n', "");
387                let output: String = output.chars().take(200).collect();
388                self.fail(&format!("{label} ({output})"));
389            }
390        }
391    }
392
393    fn expect_fail(&mut self, label: &str, result: Result<()>) {
394        match result {
395            Ok(()) => self.fail(&format!("{label} (unexpected success)")),
396            Err(_) => self.pass(label),
397        }
398    }
399
400    fn finish(self) -> Result<()> {
401        println!("\n{} passed, {} failed", self.pass, self.fail);
402        if self.fail == 0 {
403            Ok(())
404        } else {
405            bail!("Soma feature smoke failed")
406        }
407    }
408}
409
410#[derive(Default)]
411struct PluginChecks {
412    checks: usize,
413    passed: usize,
414    failed: usize,
415}
416
417impl PluginChecks {
418    fn check(&mut self, name: &str, test: impl FnOnce() -> bool) {
419        self.check_result(name, || {
420            if test() {
421                Ok(())
422            } else {
423                bail!("check returned false")
424            }
425        });
426    }
427
428    fn check_result(&mut self, name: &str, test: impl FnOnce() -> Result<()>) {
429        self.checks += 1;
430        print!("Checking: {name}... ");
431        if test().is_ok() {
432            println!("\x1b[0;32mPASS\x1b[0m");
433            self.passed += 1;
434        } else {
435            println!("\x1b[0;31mFAIL\x1b[0m");
436            self.failed += 1;
437        }
438    }
439
440    fn finish(self) -> Result<()> {
441        println!();
442        println!("=== Results ===");
443        println!("Total checks: {}", self.checks);
444        println!("\x1b[0;32mPassed: {}\x1b[0m", self.passed);
445        if self.failed > 0 {
446            println!("\x1b[0;31mFailed: {}\x1b[0m", self.failed);
447            bail!("plugin layout validation failed");
448        }
449        println!("\x1b[0;32mAll checks passed.\x1b[0m");
450        Ok(())
451    }
452}
453
454struct PluginLayout {
455    claude: PathBuf,
456    codex: PathBuf,
457    gemini: PathBuf,
458    hooks: PathBuf,
459    skills: PathBuf,
460}
461
462impl PluginLayout {
463    fn new(plugin_root: &Path) -> Self {
464        Self {
465            claude: plugin_root.join(".claude-plugin/plugin.json"),
466            codex: plugin_root.join(".codex-plugin/plugin.json"),
467            gemini: plugin_root.join("gemini-extension.json"),
468            hooks: plugin_root.join("hooks/hooks.json"),
469            skills: plugin_root.join("skills"),
470        }
471    }
472}
473
474struct TempDir {
475    path: PathBuf,
476}
477
478impl TempDir {
479    fn create(prefix: &str) -> Result<Self> {
480        let nonce = SystemTime::now()
481            .duration_since(UNIX_EPOCH)
482            .context("system clock is before UNIX_EPOCH")?
483            .as_nanos();
484        let path = std::env::temp_dir().join(format!("{prefix}-{}-{nonce}", std::process::id()));
485        std::fs::create_dir_all(&path)
486            .with_context(|| format!("failed to create {}", path.display()))?;
487        Ok(Self { path })
488    }
489
490    fn path(&self) -> &Path {
491        &self.path
492    }
493}
494
495impl Drop for TempDir {
496    fn drop(&mut self) {
497        let _ = std::fs::remove_dir_all(&self.path);
498    }
499}
500
501fn parse_bump_wrapper_level(arg: &str) -> Result<BumpLevel> {
502    match arg {
503        "patch" => Ok(BumpLevel::Patch),
504        "minor" => Ok(BumpLevel::Minor),
505        "major" => Ok(BumpLevel::Major),
506        "" => bail!("Usage: scripts/bump-version.sh <major|minor|patch>"),
507        _ => bail!(
508            "scripts/bump-version.sh now accepts only major, minor, or patch; use cargo xtask for component-aware bumps."
509        ),
510    }
511}
512
513fn file_exists(path: &Path) -> Result<()> {
514    if path.is_file() {
515        Ok(())
516    } else {
517        bail!("missing file {}", path.display())
518    }
519}
520
521fn dir_exists(path: &Path) -> Result<()> {
522    if path.is_dir() {
523        Ok(())
524    } else {
525        bail!("missing directory {}", path.display())
526    }
527}
528
529fn read_json(path: &Path) -> Result<Value> {
530    let content = std::fs::read_to_string(path)
531        .with_context(|| format!("failed to read {}", path.display()))?;
532    serde_json::from_str(&content).with_context(|| format!("invalid JSON in {}", path.display()))
533}
534
535fn json_field_eq(path: &Path, pointer: &str, expected: &str) -> Result<()> {
536    let value = read_json(path)?;
537    require_json_str(&value, pointer, expected)
538}
539
540fn json_has_no_version(path: &Path) -> Result<()> {
541    let value = read_json(path)?;
542    if contains_json_key(&value, "version") {
543        bail!("must not contain a version key")
544    } else {
545        Ok(())
546    }
547}
548
549fn require_json_str(value: &Value, pointer: &str, expected: &str) -> Result<()> {
550    let actual = value
551        .pointer(pointer)
552        .and_then(Value::as_str)
553        .with_context(|| format!("missing JSON string at {pointer}"))?;
554    if actual == expected {
555        Ok(())
556    } else {
557        bail!("expected {pointer} to be {expected:?}, found {actual:?}")
558    }
559}
560
561fn require_json_bool(value: &Value, pointer: &str, expected: bool) -> Result<()> {
562    let actual = value
563        .pointer(pointer)
564        .and_then(Value::as_bool)
565        .with_context(|| format!("missing JSON boolean at {pointer}"))?;
566    if actual == expected {
567        Ok(())
568    } else {
569        bail!("expected {pointer} to be {expected}, found {actual}")
570    }
571}
572
573fn plugin_asset_exists(plugin_root: &Path, manifest: &Path, pointer: &str) -> Result<()> {
574    let value = read_json(manifest)?;
575    let asset = value
576        .pointer(pointer)
577        .and_then(Value::as_str)
578        .with_context(|| format!("missing JSON asset path at {pointer}"))?;
579    let relative = asset.strip_prefix("./").unwrap_or(asset);
580    let path = plugin_root.join(relative);
581    file_exists(&path)
582}
583
584fn contains_json_key(value: &Value, key: &str) -> bool {
585    match value {
586        Value::Object(map) => {
587            map.contains_key(key) || map.values().any(|value| contains_json_key(value, key))
588        }
589        Value::Array(values) => values.iter().any(|value| contains_json_key(value, key)),
590        _ => false,
591    }
592}
593
594fn hook_command_exists(path: &Path, event: &str, matcher: Option<&str>) -> Result<()> {
595    let value = read_json(path)?;
596    let entries = value
597        .pointer(&format!("/hooks/{event}"))
598        .and_then(Value::as_array)
599        .with_context(|| format!("missing hooks.{event} array"))?;
600    let found = entries.iter().any(|entry| {
601        if matcher
602            .is_some_and(|expected| entry.get("matcher").and_then(Value::as_str) != Some(expected))
603        {
604            return false;
605        }
606        entry
607            .get("hooks")
608            .and_then(Value::as_array)
609            .is_some_and(|hooks| {
610                hooks.iter().any(|hook| {
611                    hook.get("command").and_then(Value::as_str) == Some("soma setup plugin-hook")
612                })
613            })
614    });
615    if found {
616        Ok(())
617    } else {
618        bail!("missing {event} hook command")
619    }
620}
621
622fn skill_files(skills_dir: &Path) -> Result<Vec<PathBuf>> {
623    if !skills_dir.is_dir() {
624        return Ok(Vec::new());
625    }
626    let mut files = Vec::new();
627    for entry in std::fs::read_dir(skills_dir)? {
628        let entry = entry?;
629        if !entry.file_type()?.is_dir() {
630            continue;
631        }
632        let skill = entry.path().join("SKILL.md");
633        if skill.is_file() {
634            files.push(skill);
635        }
636    }
637    files.sort();
638    Ok(files)
639}
640
641fn skill_has_name(skill_file: &Path, expected: &str) -> Result<()> {
642    let content = std::fs::read_to_string(skill_file)
643        .with_context(|| format!("failed to read {}", skill_file.display()))?;
644    let expected_line = format!("name: {expected}");
645    if content.lines().any(|line| line.trim_end() == expected_line) {
646        Ok(())
647    } else {
648        bail!("missing front matter name {expected}")
649    }
650}
651
652fn skill_has_description(skill_file: &Path) -> Result<()> {
653    let content = std::fs::read_to_string(skill_file)
654        .with_context(|| format!("failed to read {}", skill_file.display()))?;
655    if content.lines().any(|line| {
656        line.strip_prefix("description:")
657            .is_some_and(|value| !value.trim().is_empty())
658    }) {
659        Ok(())
660    } else {
661        bail!("missing description")
662    }
663}
664
665fn create_agent_memory_symlinks(root: &Path) -> Result<()> {
666    for claude in find_named_files(root, "CLAUDE.md")? {
667        let dir = claude
668            .parent()
669            .with_context(|| format!("{} has no parent", claude.display()))?;
670        for link_name in ["AGENTS.md", "GEMINI.md"] {
671            let link = dir.join(link_name);
672            if link.exists() || link.symlink_metadata().is_ok() {
673                continue;
674            }
675            #[cfg(unix)]
676            std::os::unix::fs::symlink("CLAUDE.md", &link)
677                .with_context(|| format!("failed to create {}", link.display()))?;
678            #[cfg(windows)]
679            std::os::windows::fs::symlink_file("CLAUDE.md", &link)
680                .with_context(|| format!("failed to create {}", link.display()))?;
681        }
682    }
683    Ok(())
684}
685
686fn find_named_files(root: &Path, name: &str) -> Result<Vec<PathBuf>> {
687    let mut files = Vec::new();
688    visit_dirs(root, &mut |path| {
689        if path.file_name().and_then(OsStr::to_str) == Some(name) {
690            files.push(path.to_owned());
691        }
692        Ok(())
693    })?;
694    Ok(files)
695}
696
697fn visit_dirs(dir: &Path, f: &mut impl FnMut(&Path) -> Result<()>) -> Result<()> {
698    if dir
699        .file_name()
700        .and_then(OsStr::to_str)
701        .is_some_and(|name| matches!(name, ".git" | "target"))
702    {
703        return Ok(());
704    }
705    for entry in
706        std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?
707    {
708        let entry = entry?;
709        let path = entry.path();
710        let file_type = entry.file_type()?;
711        if file_type.is_dir() {
712            visit_dirs(&path, f)?;
713        } else if file_type.is_file() {
714            f(&path)?;
715        }
716    }
717    Ok(())
718}
719
720fn run_ascii_checker(repo_root: &Path) -> Result<()> {
721    let output = run_output_in(
722        repo_root,
723        "git",
724        &[
725            "ls-files",
726            "*.md",
727            "*.rs",
728            "*.toml",
729            "*.json",
730            "*.yml",
731            "*.yaml",
732            "*.sh",
733            "*.py",
734            ":!:docs/references/**",
735            ":!:docs/sessions/**",
736        ],
737    )?;
738    let mut files: Vec<String> = output
739        .lines()
740        .filter(|path| repo_root.join(path).is_file())
741        .map(ToOwned::to_owned)
742        .collect();
743    let mut args = Vec::new();
744    args.append(&mut files);
745    let original = std::env::current_dir().context("failed to capture current directory")?;
746    std::env::set_current_dir(repo_root)
747        .with_context(|| format!("failed to enter {}", repo_root.display()))?;
748    let result = scripts_lane_d::asciicheck(&args);
749    let restore = std::env::set_current_dir(&original)
750        .with_context(|| format!("failed to restore {}", original.display()));
751    restore?;
752    result
753}
754
755fn run_silent_in(cwd: &Path, program: &str, args: &[&str]) -> Result<()> {
756    let status = run_status_in(cwd, program, args, false)?;
757    if status.success() {
758        Ok(())
759    } else {
760        bail!("{program} exited with {status}")
761    }
762}
763
764fn run_env_guard_in(cwd: &Path) -> Result<()> {
765    let original = std::env::current_dir().context("failed to capture current directory")?;
766    std::env::set_current_dir(cwd).with_context(|| format!("failed to enter {}", cwd.display()))?;
767    let result = scripts::block_env_commits();
768    let restore = std::env::set_current_dir(&original)
769        .with_context(|| format!("failed to restore {}", original.display()));
770    restore?;
771    result
772}
773
774fn run_status(program: &str, args: &[&str], inherit: bool) -> Result<std::process::ExitStatus> {
775    run_status_in(Path::new("."), program, args, inherit)
776}
777
778fn run_status_in(
779    cwd: &Path,
780    program: &str,
781    args: &[&str],
782    inherit: bool,
783) -> Result<std::process::ExitStatus> {
784    let mut command = Command::new(program);
785    command.args(args).current_dir(cwd).stdin(Stdio::null());
786    if inherit {
787        command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
788    } else {
789        command.stdout(Stdio::null()).stderr(Stdio::null());
790    }
791    command
792        .status()
793        .with_context(|| format!("failed to spawn {program}"))
794}
795
796fn run_output_in(cwd: &Path, program: &str, args: &[&str]) -> Result<String> {
797    let output = Command::new(program)
798        .args(args)
799        .current_dir(cwd)
800        .stdin(Stdio::null())
801        .output()
802        .with_context(|| format!("failed to spawn {program}"))?;
803    if !output.status.success() {
804        bail!("{program} exited with {}", output.status);
805    }
806    String::from_utf8(output.stdout).with_context(|| format!("{program} emitted non-UTF-8 stdout"))
807}
808
809fn command_on_path(name: &str) -> bool {
810    if name.contains('/') {
811        return Path::new(name).is_file();
812    }
813    let Some(paths) = std::env::var_os("PATH") else {
814        return false;
815    };
816    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
817}
818
819fn env_path(name: &str) -> Option<PathBuf> {
820    std::env::var_os(name)
821        .filter(|value| !value.is_empty())
822        .map(PathBuf::from)
823}
824
825fn path_str(path: &Path) -> Result<&str> {
826    path.to_str()
827        .with_context(|| format!("non-UTF-8 path: {}", path.display()))
828}
829
830fn display_relative<'a>(root: &'a Path, path: &'a Path) -> String {
831    path.strip_prefix(root)
832        .unwrap_or(path)
833        .display()
834        .to_string()
835}
836
837#[cfg(test)]
838mod tests {
839    use super::{
840        contains_json_key, json_has_no_version, parse_bump_wrapper_level, skill_has_description,
841        skill_has_name, PreReleaseOptions,
842    };
843    use crate::release_versions::BumpLevel;
844    use serde_json::json;
845
846    #[test]
847    fn bump_wrapper_parser_matches_contract() {
848        assert_eq!(parse_bump_wrapper_level("patch").unwrap(), BumpLevel::Patch);
849        assert_eq!(parse_bump_wrapper_level("minor").unwrap(), BumpLevel::Minor);
850        assert_eq!(parse_bump_wrapper_level("major").unwrap(), BumpLevel::Major);
851        assert!(parse_bump_wrapper_level("")
852            .unwrap_err()
853            .to_string()
854            .contains("Usage:"));
855        assert!(parse_bump_wrapper_level("soma")
856            .unwrap_err()
857            .to_string()
858            .contains("component-aware bumps"));
859    }
860
861    #[test]
862    fn pre_release_options_preserve_shell_defaults() {
863        let options = PreReleaseOptions::parse(&[]).unwrap();
864        assert!(options.run_verify);
865        assert!(options.run_build_plugin);
866        assert!(!options.run_mcporter);
867
868        let options = PreReleaseOptions::parse(&[
869            "--skip-verify".to_owned(),
870            "--skip-build-plugin".to_owned(),
871            "--mcporter".to_owned(),
872        ])
873        .unwrap();
874        assert!(!options.run_verify);
875        assert!(!options.run_build_plugin);
876        assert!(options.run_mcporter);
877    }
878
879    #[test]
880    fn version_key_check_is_recursive() {
881        assert!(contains_json_key(
882            &json!({"nested": [{"version": "1"}]}),
883            "version"
884        ));
885        assert!(!contains_json_key(&json!({"name": "soma"}), "version"));
886    }
887
888    #[test]
889    fn json_no_version_rejects_nested_version_key() {
890        let temp = tempfile::NamedTempFile::new().unwrap();
891        std::fs::write(temp.path(), r#"{"name":"x","nested":{"version":"1"}}"#).unwrap();
892        assert!(json_has_no_version(temp.path()).is_err());
893    }
894
895    #[test]
896    fn skill_front_matter_checks_match_shell_awk_rules() {
897        let temp = tempfile::NamedTempFile::new().unwrap();
898        std::fs::write(temp.path(), "name: soma\ndescription: Useful skill\n").unwrap();
899        assert!(skill_has_name(temp.path(), "soma").is_ok());
900        assert!(skill_has_description(temp.path()).is_ok());
901
902        std::fs::write(temp.path(), "name: other\ndescription:\n").unwrap();
903        assert!(skill_has_name(temp.path(), "soma").is_err());
904        assert!(skill_has_description(temp.path()).is_err());
905    }
906}