1use 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 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 ":!: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}