1use 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 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}