1use anyhow::{bail, Context, Result};
8use serde_json::Value;
9use std::collections::BTreeSet;
10use std::ffi::OsStr;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::process::{Command, Stdio};
14
15const DEFAULT_MAX_BYTES: u64 = 500 * 1024;
16const DEFAULT_BLOB_ALLOWLIST: &str = "scripts/blob-size-allowlist.txt";
17const DEFAULT_RUNTIME_UNIT: &str = "soma-mcp.service";
18const DEFAULT_RUNTIME_SERVICE: &str = "soma-mcp";
19const REQUIRED_PLUGIN_FIELDS: [&str; 5] = [
20 "exit_policy",
21 "ran_repair",
22 "no_repair",
23 "blocking_failures",
24 "advisory_failures",
25];
26const EXIT_POLICIES: [&str; 3] = ["success", "advisory_failure", "blocking_failure"];
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ChangedBlob {
30 pub path: String,
31 pub size_bytes: u64,
32 pub is_allowlisted: bool,
33 pub is_binary: bool,
34}
35
36pub fn check_blob_size(args: &[String]) -> Result<()> {
37 let options = BlobSizeOptions::parse(args)?;
38 let allowlist = load_allowlist(&options.allowlist)?;
39 let blobs = collect_changed_blobs(&options.base, &options.head, &allowlist)?;
40 let violations: Vec<&ChangedBlob> = blobs
41 .iter()
42 .filter(|blob| blob.size_bytes > options.max_bytes && !blob.is_allowlisted)
43 .collect();
44
45 write_blob_step_summary(options.max_bytes, &blobs, &violations)?;
46
47 if blobs.is_empty() {
48 println!("No changed files were detected.");
49 return Ok(());
50 }
51
52 println!(
53 "Checked {} changed file(s) against the {}-byte limit.",
54 blobs.len(),
55 options.max_bytes
56 );
57 for blob in &blobs {
58 let mut status = if blob.is_allowlisted {
59 "allowlisted"
60 } else {
61 "ok"
62 };
63 if violations
64 .iter()
65 .any(|violation| violation.path == blob.path)
66 {
67 status = "blocked";
68 }
69 let kind = if blob.is_binary {
70 "binary"
71 } else {
72 "non-binary"
73 };
74 println!(
75 "- {}: {} bytes ({}) [{kind}, {status}]",
76 blob.path,
77 blob.size_bytes,
78 format_kib(blob.size_bytes)
79 );
80 }
81
82 if !violations.is_empty() {
83 println!("\nFile(s) exceed the configured limit:");
84 for blob in violations {
85 println!(
86 "- {}: {} bytes > {} bytes",
87 blob.path, blob.size_bytes, options.max_bytes
88 );
89 }
90 println!(
91 "\nIf this is a real checked-in asset, add its repo-relative path or glob \
92to scripts/blob-size-allowlist.txt. Otherwise, shrink it or keep it out of git."
93 );
94 bail!("changed blob(s) exceed the configured size limit");
95 }
96
97 Ok(())
98}
99
100pub fn check_dependency_updates(args: &[String]) -> Result<()> {
101 let options = DependencyUpdateOptions::parse(args)?;
102
103 require_command("cargo")?;
104 println!("\n== Lockfile-compatible updates ==");
105 let dry_run = command_output(
106 Command::new("cargo")
107 .arg("update")
108 .arg("--dry-run")
109 .env("CARGO_TERM_COLOR", "never"),
110 )
111 .context("cargo update --dry-run failed")?;
112 print!("{dry_run}");
113
114 let mut updates_found = dry_run_reports_updates(&dry_run);
115
116 if !options.skip_search {
117 println!("\n== Direct dependency latest versions ==");
118 println!(
119 "{:<32} {:<18} {:<18} status",
120 "crate", "requirement", "latest"
121 );
122 for dependency in extract_direct_deps(Path::new("Cargo.toml"))? {
123 let version_req = dependency_version_req(&dependency.line);
124 let Some(version_req) = version_req else {
125 println!("{:<32} {:<18} {:<18} skipped", dependency.name, "-", "-");
126 continue;
127 };
128 let latest = latest_crate_version(&dependency.name)?;
129 let Some(latest) = latest else {
130 println!(
131 "{:<32} {:<18} {:<18} check failed",
132 dependency.name, version_req, "unknown"
133 );
134 continue;
135 };
136 let status = direct_dependency_status(&version_req, &latest);
137 if status == "review" {
138 updates_found = true;
139 }
140 println!(
141 "{:<32} {:<18} {:<18} {status}",
142 dependency.name, version_req, latest
143 );
144 }
145 }
146
147 println!("\n== Result ==");
148 if updates_found {
149 println!("Dependency updates may be available.");
150 if options.fail_on_updates {
151 bail!("dependency updates may be available");
152 }
153 } else {
154 println!("No dependency updates detected.");
155 }
156
157 Ok(())
158}
159
160pub fn check_runtime_current(args: &[String]) -> Result<()> {
161 let mut options = RuntimeOptions::from_env();
162 options.parse_args(args)?;
163
164 let mode = if options.mode == RuntimeMode::Auto {
165 detect_runtime_mode(&options)?
166 } else {
167 options.mode
168 };
169
170 match mode {
171 RuntimeMode::Systemd => check_runtime_systemd(&options),
172 RuntimeMode::Docker => check_runtime_docker(&options),
173 RuntimeMode::Auto => unreachable!("auto mode should have been resolved"),
174 RuntimeMode::None => {
175 bail!(
176 "FAIL: no running {} systemd unit or {} container detected",
177 options.unit,
178 options.service
179 )
180 }
181 }
182}
183
184pub fn check_plugin_hook_contract(args: &[String]) -> Result<()> {
185 let execute = match args {
186 [] => false,
187 [arg] if arg == "--execute" => true,
188 [arg] if arg == "--help" || arg == "-h" => {
189 println!(
190 "Usage: cargo xtask check-plugin-hook-contract [--execute]\n\
191 \n\
192 Audit binary-owned plugin hook setup contracts across Rust MCP servers."
193 );
194 return Ok(());
195 }
196 _ => bail!("Usage: cargo xtask check-plugin-hook-contract [--execute]"),
197 };
198
199 for server in default_plugin_servers()? {
200 check_plugin_layout(&server)?;
201 check_required_recipes(&server)?;
202 check_hook_delegation(&server)?;
203 if execute {
204 check_plugin_binary_contract(&server)?;
205 }
206 println!("ok {}", server.name);
207 }
208
209 Ok(())
210}
211
212pub fn refresh_docs(args: &[String]) -> Result<()> {
213 let options = RefreshDocsOptions::parse(args)?;
214 if options.skip_crawl && options.skip_repomix {
215 bail!("ERROR: --skip-crawl and --skip-repomix cannot both be set");
216 }
217
218 let root = std::env::current_dir().context("failed to read current directory")?;
219 let ref_dir = root.join("docs/references");
220 let changes_file = ref_dir.join("CHANGES.md");
221 let axon_output_dir = env_path("AXON_OUTPUT_DIR")
222 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".axon/output")))
223 .unwrap_or_else(|| PathBuf::from(".axon/output"));
224
225 let mut before_snapshot = Vec::new();
226 if !options.dry_run {
227 before_snapshot = snapshot_references(&ref_dir)?;
228 }
229
230 fs::create_dir_all(ref_dir.join("mcp/docs"))?;
231 fs::create_dir_all(ref_dir.join("mcp/repos"))?;
232 fs::create_dir_all(ref_dir.join("claude-code"))?;
233 fs::create_dir_all(ref_dir.join("mcporter/docs"))?;
234 fs::create_dir_all(ref_dir.join("mcporter/repos"))?;
235
236 if !options.skip_crawl {
237 let mut failed = false;
238 for crawl in default_crawls() {
239 if let Err(error) = crawl_docs(&options, &ref_dir, &axon_output_dir, crawl) {
240 eprintln!(
241 "[refresh-docs] ERROR: {} docs crawl failed: {error}",
242 crawl.label
243 );
244 failed = true;
245 }
246 }
247 if failed {
248 bail!("ERROR: one or more required crawls failed - reference docs may be stale");
249 }
250 }
251
252 if !options.skip_repomix {
253 for pack in default_repomix_packs() {
254 pack_repo(&options, &ref_dir, pack)?;
255 }
256 sparse_clone_path(
257 &options,
258 &ref_dir,
259 SparseClone {
260 remote: "https://github.com/openclaw/mcporter",
261 sparse_path: "docs",
262 target_rel: "mcporter/docs",
263 mode: SparseCloneMode::Recursive,
264 },
265 )?;
266 }
267
268 if !options.dry_run {
269 write_reference_index(&ref_dir)?;
270 let after_snapshot = snapshot_references(&ref_dir)?;
271 summarize_reference_changes(
272 &changes_file,
273 refresh_scope(&options),
274 &before_snapshot,
275 &after_snapshot,
276 )?;
277 }
278
279 println!("[refresh-docs] done");
280 Ok(())
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284struct BlobSizeOptions {
285 base: String,
286 head: String,
287 max_bytes: u64,
288 allowlist: PathBuf,
289}
290
291impl BlobSizeOptions {
292 fn parse(args: &[String]) -> Result<Self> {
293 let mut base = None;
294 let mut head = "HEAD".to_owned();
295 let mut max_bytes = DEFAULT_MAX_BYTES;
296 let mut allowlist = PathBuf::from(DEFAULT_BLOB_ALLOWLIST);
297 let mut index = 0usize;
298 while index < args.len() {
299 match args[index].as_str() {
300 "--base" => {
301 index += 1;
302 base = Some(
303 args.get(index)
304 .context("--base requires a value")?
305 .to_owned(),
306 );
307 }
308 "--head" => {
309 index += 1;
310 head = args
311 .get(index)
312 .context("--head requires a value")?
313 .to_owned();
314 }
315 "--max-bytes" => {
316 index += 1;
317 max_bytes = args
318 .get(index)
319 .context("--max-bytes requires a value")?
320 .parse()
321 .context("--max-bytes must be an integer")?;
322 }
323 "--allowlist" => {
324 index += 1;
325 allowlist = args
326 .get(index)
327 .context("--allowlist requires a value")?
328 .into();
329 }
330 "--help" | "-h" => {
331 bail!("Usage: cargo xtask check-blob-size [--base REF] [--head REF] [--max-bytes N] [--allowlist PATH]");
332 }
333 unknown => bail!("unknown option: {unknown}"),
334 }
335 index += 1;
336 }
337
338 Ok(Self {
339 base: base.unwrap_or_else(default_blob_base),
340 head,
341 max_bytes,
342 allowlist,
343 })
344 }
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
348struct DependencyUpdateOptions {
349 skip_search: bool,
350 fail_on_updates: bool,
351}
352
353impl DependencyUpdateOptions {
354 fn parse(args: &[String]) -> Result<Self> {
355 let mut skip_search = false;
356 let mut fail_on_updates = false;
357 for arg in args {
358 match arg.as_str() {
359 "--skip-search" => skip_search = true,
360 "--fail-on-updates" => fail_on_updates = true,
361 "--help" | "-h" => bail!("Usage: cargo xtask check-dependency-updates [--skip-search] [--fail-on-updates]"),
362 unknown => bail!("ERROR: unknown option: {unknown}"),
363 }
364 }
365 Ok(Self {
366 skip_search,
367 fail_on_updates,
368 })
369 }
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373enum RuntimeMode {
374 Auto,
375 Systemd,
376 Docker,
377 None,
378}
379
380#[derive(Debug, Clone, PartialEq, Eq)]
381struct RuntimeOptions {
382 mode: RuntimeMode,
383 pull: bool,
384 unit: String,
385 service: String,
386 compose_dir: PathBuf,
387 expected_binary: Option<PathBuf>,
388}
389
390impl RuntimeOptions {
391 fn from_env() -> Self {
392 Self {
393 mode: RuntimeMode::Auto,
394 pull: false,
395 unit: std::env::var("SOMA_MCP_SYSTEMD_UNIT")
396 .unwrap_or_else(|_| DEFAULT_RUNTIME_UNIT.to_owned()),
397 service: std::env::var("SOMA_MCP_DOCKER_SERVICE")
398 .unwrap_or_else(|_| DEFAULT_RUNTIME_SERVICE.to_owned()),
399 compose_dir: env_path("SOMA_MCP_COMPOSE_DIR").unwrap_or_else(current_dir),
400 expected_binary: env_path("SOMA_MCP_EXPECTED_BINARY"),
401 }
402 }
403
404 fn parse_args(&mut self, args: &[String]) -> Result<()> {
405 let mut index = 0usize;
406 while index < args.len() {
407 match args[index].as_str() {
408 "--mode" => {
409 index += 1;
410 self.mode = parse_runtime_mode(args.get(index).context("--mode requires a value")?)?;
411 }
412 "--pull" => self.pull = true,
413 "--unit" => {
414 index += 1;
415 self.unit = args.get(index).context("--unit requires a value")?.to_owned();
416 }
417 "--service" => {
418 index += 1;
419 self.service = args
420 .get(index)
421 .context("--service requires a value")?
422 .to_owned();
423 }
424 "--compose-dir" => {
425 index += 1;
426 self.compose_dir = args
427 .get(index)
428 .context("--compose-dir requires a value")?
429 .into();
430 }
431 "--expected-binary" => {
432 index += 1;
433 self.expected_binary = Some(
434 args.get(index)
435 .context("--expected-binary requires a value")?
436 .into(),
437 );
438 }
439 "--help" | "-h" => bail!("Usage: cargo xtask check-runtime-current [--mode auto|systemd|docker] [--pull] [--unit NAME] [--service NAME] [--compose-dir DIR] [--expected-binary PATH]"),
440 unknown => bail!("unknown argument: {unknown}"),
441 }
442 index += 1;
443 }
444 Ok(())
445 }
446}
447
448#[derive(Debug, Clone, PartialEq, Eq)]
449struct PluginServer {
450 name: String,
451 repo: PathBuf,
452 binary: String,
453 hook: Option<PathBuf>,
454 plugin_root: Option<PathBuf>,
455 check_plugin_layout: bool,
456 package_args: Vec<String>,
457 setup_args: Vec<String>,
458 env: Vec<(String, String)>,
459 appdata_env: String,
460 make_appdata: bool,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq)]
464struct DirectDependency {
465 name: String,
466 line: String,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470struct RefreshDocsOptions {
471 dry_run: bool,
472 skip_crawl: bool,
473 skip_repomix: bool,
474}
475
476impl RefreshDocsOptions {
477 fn parse(args: &[String]) -> Result<Self> {
478 let mut dry_run = false;
479 let mut skip_crawl = false;
480 let mut skip_repomix = false;
481 for arg in args {
482 match arg.as_str() {
483 "--dry-run" => dry_run = true,
484 "--skip-crawl" => skip_crawl = true,
485 "--skip-repomix" => skip_repomix = true,
486 "--help" | "-h" => bail!(
487 "Usage: cargo xtask refresh-docs [--dry-run] [--skip-crawl] [--skip-repomix]"
488 ),
489 unknown => bail!("ERROR: unknown option: {unknown}"),
490 }
491 }
492 Ok(Self {
493 dry_run,
494 skip_crawl,
495 skip_repomix,
496 })
497 }
498}
499
500#[derive(Debug, Clone, Copy)]
501struct CrawlTarget {
502 label: &'static str,
503 url: &'static str,
504 domain: &'static str,
505 target_rel: &'static str,
506}
507
508#[derive(Debug, Clone, Copy)]
509struct RepoPack {
510 remote: &'static str,
511 target_rel: &'static str,
512 include: &'static str,
513 ignore: &'static str,
514}
515
516#[derive(Debug, Clone, Copy)]
517#[allow(dead_code)]
518enum SparseCloneMode {
519 Recursive,
520 FlatMdx,
521}
522
523#[derive(Debug, Clone, Copy)]
524struct SparseClone {
525 remote: &'static str,
526 sparse_path: &'static str,
527 target_rel: &'static str,
528 mode: SparseCloneMode,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq)]
532struct ReferenceSnapshotEntry {
533 path: String,
534 sha256: String,
535}
536
537fn default_blob_base() -> String {
538 for candidate in ["origin/main", "main"] {
539 if git_status(["rev-parse", "--verify", candidate]) {
540 return candidate.to_owned();
541 }
542 }
543 "HEAD~1".to_owned()
544}
545
546fn load_allowlist(path: &Path) -> Result<Vec<String>> {
547 if !path.exists() {
548 return Ok(Vec::new());
549 }
550 let text = fs::read_to_string(path)
551 .with_context(|| format!("failed to read allowlist {}", path.display()))?;
552 Ok(parse_allowlist(&text))
553}
554
555fn parse_allowlist(text: &str) -> Vec<String> {
556 text.lines()
557 .filter_map(|line| {
558 line.split_once('#')
559 .map_or(Some(line), |(prefix, _)| Some(prefix))
560 })
561 .map(str::trim)
562 .filter(|line| !line.is_empty())
563 .map(str::to_owned)
564 .collect()
565}
566
567fn collect_changed_blobs(base: &str, head: &str, allowlist: &[String]) -> Result<Vec<ChangedBlob>> {
568 let paths = get_changed_paths(base, head)?;
569 let mut blobs = Vec::new();
570 for path in paths {
571 blobs.push(ChangedBlob {
572 size_bytes: blob_size(head, &path)?,
573 is_allowlisted: is_allowlisted(&path, allowlist),
574 is_binary: is_binary_change(base, head, &path)?,
575 path,
576 });
577 }
578 Ok(blobs)
579}
580
581fn get_changed_paths(base: &str, head: &str) -> Result<Vec<String>> {
582 let output = git_output([
583 "diff",
584 "--name-only",
585 "--diff-filter=AM",
586 "--no-renames",
587 "-z",
588 base,
589 head,
590 ])?;
591 Ok(output
592 .split('\0')
593 .filter(|path| !path.is_empty())
594 .map(str::to_owned)
595 .collect())
596}
597
598fn is_binary_change(base: &str, head: &str, path: &str) -> Result<bool> {
599 let output = git_output([
600 "diff",
601 "--numstat",
602 "--diff-filter=AM",
603 "--no-renames",
604 base,
605 head,
606 "--",
607 path,
608 ])?;
609 let Some(line) = output.lines().next() else {
610 return Ok(false);
611 };
612 let fields: Vec<&str> = line.split('\t').collect();
613 Ok(fields.len() >= 2 && fields[0] == "-" && fields[1] == "-")
614}
615
616fn blob_size(commit: &str, path: &str) -> Result<u64> {
617 git_output(["cat-file", "-s", &format!("{commit}:{path}")])?
618 .trim()
619 .parse()
620 .with_context(|| format!("failed to parse blob size for {commit}:{path}"))
621}
622
623fn is_allowlisted(path: &str, patterns: &[String]) -> bool {
624 patterns.iter().any(|pattern| glob_match(pattern, path))
625}
626
627fn glob_match(pattern: &str, path: &str) -> bool {
628 if pattern == path {
629 return true;
630 }
631 let parts: Vec<&str> = pattern.split('*').collect();
632 if parts.len() == 1 {
633 return false;
634 }
635 if !path.starts_with(parts[0]) {
636 return false;
637 }
638 if !pattern.ends_with('*') && !path.ends_with(parts.last().copied().unwrap_or_default()) {
639 return false;
640 }
641 let mut remainder = path;
642 for part in parts.into_iter().filter(|part| !part.is_empty()) {
643 let Some(index) = remainder.find(part) else {
644 return false;
645 };
646 remainder = &remainder[index + part.len()..];
647 }
648 true
649}
650
651fn format_kib(size_bytes: u64) -> String {
652 format!("{:.1} KiB", size_bytes as f64 / 1024.0)
653}
654
655fn write_blob_step_summary(
656 max_bytes: u64,
657 blobs: &[ChangedBlob],
658 violations: &[&ChangedBlob],
659) -> Result<()> {
660 let Some(summary_path) = env_path("GITHUB_STEP_SUMMARY") else {
661 return Ok(());
662 };
663 let mut lines = vec![
664 "## Blob Size Policy".to_owned(),
665 String::new(),
666 format!(
667 "Default max: `{max_bytes}` bytes ({})",
668 format_kib(max_bytes)
669 ),
670 format!("Changed files checked: `{}`", blobs.len()),
671 format!("Violations: `{}`", violations.len()),
672 String::new(),
673 ];
674 if blobs.is_empty() {
675 lines.push("No changed files were detected.".to_owned());
676 } else {
677 lines.push("| Path | Kind | Size | Status |".to_owned());
678 lines.push("| --- | --- | ---: | --- |".to_owned());
679 for blob in blobs {
680 let mut status = if blob.is_allowlisted {
681 "allowlisted"
682 } else {
683 "ok"
684 };
685 if violations
686 .iter()
687 .any(|violation| violation.path == blob.path)
688 {
689 status = "blocked";
690 }
691 let kind = if blob.is_binary {
692 "binary"
693 } else {
694 "non-binary"
695 };
696 lines.push(format!(
697 "| `{}` | {kind} | `{}` bytes ({}) | {status} |",
698 blob.path,
699 blob.size_bytes,
700 format_kib(blob.size_bytes)
701 ));
702 }
703 }
704 lines.push(String::new());
705 fs::write(&summary_path, lines.join("\n"))
706 .with_context(|| format!("failed to write {}", summary_path.display()))?;
707 Ok(())
708}
709
710fn dry_run_reports_updates(output: &str) -> bool {
711 output.lines().any(|line| {
712 let trimmed = line.trim_start();
713 trimmed.starts_with("Adding ")
714 || trimmed.starts_with("Removing ")
715 || trimmed.starts_with("Downgrading ")
716 || (trimmed.starts_with("Updating ") && trimmed.contains(" v"))
717 || (trimmed.starts_with("Locking ")
718 && trimmed
719 .split_whitespace()
720 .nth(1)
721 .and_then(|value| value.parse::<usize>().ok())
722 .is_some_and(|count| count > 0))
723 })
724}
725
726fn extract_direct_deps(manifest: &Path) -> Result<Vec<DirectDependency>> {
727 let text = fs::read_to_string(manifest)
728 .with_context(|| format!("failed to read {}", manifest.display()))?;
729 Ok(extract_direct_deps_from_toml(&text))
730}
731
732fn extract_direct_deps_from_toml(text: &str) -> Vec<DirectDependency> {
733 let mut in_dependency_section = false;
734 let mut dependencies = Vec::new();
735 for raw_line in text.lines() {
736 let line = raw_line.trim();
737 if line.starts_with('[') {
738 in_dependency_section = matches!(
739 line,
740 "[dependencies]"
741 | "[dev-dependencies]"
742 | "[build-dependencies]"
743 | "[workspace.dependencies]"
744 | "[workspace.dev-dependencies]"
745 | "[workspace.build-dependencies]"
746 );
747 continue;
748 }
749 if !in_dependency_section || line.starts_with('#') || !line.contains('=') {
750 continue;
751 }
752 let name = line.split('=').next().map(str::trim).unwrap_or_default();
753 if name
754 .chars()
755 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
756 {
757 dependencies.push(DirectDependency {
758 name: name.to_owned(),
759 line: line.to_owned(),
760 });
761 }
762 }
763 dependencies.sort_by(|left, right| left.line.cmp(&right.line));
764 dependencies.dedup_by(|left, right| left.line == right.line);
765 dependencies
766}
767
768fn dependency_version_req(line: &str) -> Option<String> {
769 let rhs = line.split_once('=')?.1.trim();
770 if let Some(value) = quoted_prefix(rhs) {
771 return Some(value.to_owned());
772 }
773 let (_, after_version) = rhs.split_once("version")?;
774 let (_, after_equals) = after_version.split_once('=')?;
775 quoted_prefix(after_equals.trim()).map(str::to_owned)
776}
777
778fn quoted_prefix(value: &str) -> Option<&str> {
779 let rest = value.strip_prefix('"')?;
780 rest.split_once('"').map(|(version, _)| version)
781}
782
783fn latest_crate_version(crate_name: &str) -> Result<Option<String>> {
784 let output = Command::new("cargo")
785 .arg("search")
786 .arg(crate_name)
787 .arg("--limit")
788 .arg("1")
789 .stderr(Stdio::null())
790 .output()
791 .with_context(|| format!("failed to run cargo search {crate_name}"))?;
792 if !output.status.success() {
793 return Ok(None);
794 }
795 let stdout =
796 String::from_utf8(output.stdout).context("cargo search emitted non-UTF-8 stdout")?;
797 Ok(parse_cargo_search_version(crate_name, &stdout))
798}
799
800fn parse_cargo_search_version(crate_name: &str, output: &str) -> Option<String> {
801 for line in output.lines() {
802 let mut parts = line.split_whitespace();
803 if parts.next() == Some(crate_name) && parts.next() == Some("=") {
804 return parts
805 .next()
806 .map(|version| version.trim_matches('"').to_owned());
807 }
808 }
809 None
810}
811
812fn direct_dependency_status(requirement: &str, latest: &str) -> &'static str {
813 if requirement == latest {
814 "ok"
815 } else if requirement_is_numeric_prefix(requirement)
816 && latest.starts_with(&format!("{requirement}."))
817 {
818 "compatible-range"
819 } else {
820 "review"
821 }
822}
823
824fn requirement_is_numeric_prefix(requirement: &str) -> bool {
825 requirement.chars().all(|ch| ch.is_ascii_digit())
826 || requirement.split_once('.').is_some_and(|(major, minor)| {
827 !major.is_empty()
828 && !minor.is_empty()
829 && major.chars().all(|ch| ch.is_ascii_digit())
830 && minor.chars().all(|ch| ch.is_ascii_digit())
831 })
832}
833
834fn parse_runtime_mode(value: &str) -> Result<RuntimeMode> {
835 match value {
836 "auto" => Ok(RuntimeMode::Auto),
837 "systemd" => Ok(RuntimeMode::Systemd),
838 "docker" => Ok(RuntimeMode::Docker),
839 other => bail!("invalid mode: {other}"),
840 }
841}
842
843fn detect_runtime_mode(options: &RuntimeOptions) -> Result<RuntimeMode> {
844 if command_status(
845 Command::new("systemctl")
846 .args(["--user", "is-active", "--quiet", &options.unit])
847 .stderr(Stdio::null()),
848 ) {
849 return Ok(RuntimeMode::Systemd);
850 }
851
852 if command_exists("docker") {
853 if options.compose_dir.is_dir() {
854 let output = Command::new("docker")
855 .args(["compose", "ps", "-q", &options.service])
856 .current_dir(&options.compose_dir)
857 .stderr(Stdio::null())
858 .output();
859 if output
860 .ok()
861 .filter(|output| output.status.success())
862 .and_then(|output| String::from_utf8(output.stdout).ok())
863 .is_some_and(|stdout| !stdout.trim().is_empty())
864 {
865 return Ok(RuntimeMode::Docker);
866 }
867 }
868 let output = Command::new("docker")
869 .args([
870 "ps",
871 "--filter",
872 &format!("name=^/{}$", options.service),
873 "--format",
874 "{{.ID}}",
875 ])
876 .stderr(Stdio::null())
877 .output();
878 if output
879 .ok()
880 .filter(|output| output.status.success())
881 .and_then(|output| String::from_utf8(output.stdout).ok())
882 .is_some_and(|stdout| !stdout.trim().is_empty())
883 {
884 return Ok(RuntimeMode::Docker);
885 }
886 }
887
888 Ok(RuntimeMode::None)
889}
890
891fn check_runtime_systemd(options: &RuntimeOptions) -> Result<()> {
892 status_line("mode", "systemd");
893 status_line("unit", &options.unit);
894 let active = command_output(
895 Command::new("systemctl")
896 .args(["--user", "is-active", &options.unit])
897 .stderr(Stdio::null()),
898 )
899 .unwrap_or_default();
900 let active = active.trim();
901 status_line("state", active);
902 if active != "active" {
903 bail!("FAIL: systemd unit is not active");
904 }
905
906 let pid = command_output(Command::new("systemctl").args([
907 "--user",
908 "show",
909 &options.unit,
910 "-p",
911 "MainPID",
912 "--value",
913 ]))?;
914 let pid = pid.trim();
915 let proc_exe = PathBuf::from(format!("/proc/{pid}/exe"));
916 if pid.is_empty() || pid == "0" || !proc_exe.exists() {
917 bail!("FAIL: cannot resolve running process for {}", options.unit);
918 }
919
920 let exe = fs::canonicalize(&proc_exe)
921 .with_context(|| format!("failed to resolve {}", proc_exe.display()))?;
922 let exec_start = command_output(Command::new("systemctl").args([
923 "--user",
924 "show",
925 &options.unit,
926 "-p",
927 "ExecStart",
928 "--value",
929 ]))?;
930 let unit_exec = parse_systemd_exec_path(&exec_start)
931 .ok_or_else(|| anyhow::anyhow!("FAIL: cannot parse ExecStart for {}", options.unit))?;
932 let unit_exec = fs::canonicalize(unit_exec)
933 .with_context(|| format!("failed to resolve unit ExecStart for {}", options.unit))?;
934
935 let running_sha = sha256_file(&proc_exe)?;
936 let unit_sha = sha256_file(&unit_exec)?;
937 status_line("pid", pid);
938 status_line("running_exe", &exe.display().to_string());
939 status_line("unit_exec", &unit_exec.display().to_string());
940 status_line("running_version", &version_of(&exe));
941 status_line("unit_version", &version_of(&unit_exec));
942 status_line("running_sha", &running_sha);
943 status_line("unit_sha", &unit_sha);
944
945 if running_sha != unit_sha {
946 println!("STALE: running process does not match unit ExecStart binary");
947 println!("fix: systemctl --user restart {}", options.unit);
948 bail!("running systemd process is stale");
949 }
950
951 if let Some(expected_binary) = &options.expected_binary {
952 let expected_binary = fs::canonicalize(expected_binary)
953 .with_context(|| format!("failed to resolve {}", expected_binary.display()))?;
954 let expected_sha = sha256_file(&expected_binary)?;
955 status_line("expected_binary", &expected_binary.display().to_string());
956 status_line("expected_version", &version_of(&expected_binary));
957 status_line("expected_sha", &expected_sha);
958 if running_sha != expected_sha {
959 println!("STALE: running process does not match expected binary");
960 println!(
961 "fix: install {} to {} and restart {}",
962 expected_binary.display(),
963 unit_exec.display(),
964 options.unit
965 );
966 bail!("running systemd process does not match expected binary");
967 }
968 }
969
970 println!("CURRENT: running systemd service matches installed binary");
971 Ok(())
972}
973
974fn check_runtime_docker(options: &RuntimeOptions) -> Result<()> {
975 status_line("mode", "docker");
976 status_line("compose_dir", &options.compose_dir.display().to_string());
977 status_line("service", &options.service);
978
979 let mut cid = String::new();
980 if options.compose_dir.is_dir() {
981 cid = command_output(
982 Command::new("docker")
983 .args(["compose", "ps", "-q", &options.service])
984 .current_dir(&options.compose_dir)
985 .stderr(Stdio::null()),
986 )
987 .unwrap_or_default()
988 .trim()
989 .to_owned();
990 }
991 if cid.is_empty() {
992 cid = command_output(
993 Command::new("docker")
994 .args([
995 "ps",
996 "--filter",
997 &format!("name=^/{}$", options.service),
998 "--format",
999 "{{.ID}}",
1000 ])
1001 .stderr(Stdio::null()),
1002 )
1003 .unwrap_or_default()
1004 .lines()
1005 .next()
1006 .unwrap_or_default()
1007 .to_owned();
1008 }
1009 if cid.is_empty() {
1010 bail!("FAIL: {} container is not running", options.service);
1011 }
1012
1013 let mut image = compose_image(&options.compose_dir).unwrap_or_default();
1014 if image.is_empty() {
1015 image = command_output(Command::new("docker").args([
1016 "inspect",
1017 &cid,
1018 "--format",
1019 "{{.Config.Image}}",
1020 ]))?
1021 .trim()
1022 .to_owned();
1023 }
1024
1025 if options.pull && options.compose_dir.is_dir() {
1026 let status = Command::new("docker")
1027 .args(["compose", "pull", "--quiet", &options.service])
1028 .current_dir(&options.compose_dir)
1029 .status()
1030 .context("failed to run docker compose pull")?;
1031 if !status.success() {
1032 bail!("docker compose pull failed with status {status}");
1033 }
1034 }
1035
1036 let running_image =
1037 command_output(Command::new("docker").args(["inspect", &cid, "--format", "{{.Image}}"]))?
1038 .trim()
1039 .to_owned();
1040 let local_image = command_output(
1041 Command::new("docker")
1042 .args(["image", "inspect", &image, "--format", "{{.Id}}"])
1043 .stderr(Stdio::null()),
1044 )
1045 .unwrap_or_default()
1046 .trim()
1047 .to_owned();
1048 let repo_digests = command_output(
1049 Command::new("docker")
1050 .args([
1051 "image",
1052 "inspect",
1053 &image,
1054 "--format",
1055 "{{join .RepoDigests \", \"}}",
1056 ])
1057 .stderr(Stdio::null()),
1058 )
1059 .unwrap_or_default()
1060 .trim()
1061 .to_owned();
1062
1063 status_line("container", &cid);
1064 status_line("image", &image);
1065 status_line("running_image_id", &running_image);
1066 status_line(
1067 "local_image_id",
1068 if local_image.is_empty() {
1069 "missing"
1070 } else {
1071 &local_image
1072 },
1073 );
1074 if !repo_digests.is_empty() {
1075 status_line("repo_digests", &repo_digests);
1076 }
1077 if local_image.is_empty() {
1078 println!("FAIL: compose image is not present locally");
1079 println!(
1080 "fix: cd {} && docker compose pull {}",
1081 options.compose_dir.display(),
1082 options.service
1083 );
1084 bail!("compose image is not present locally");
1085 }
1086 if running_image != local_image {
1087 println!("STALE: running container image differs from local compose image");
1088 println!(
1089 "fix: cd {} && docker compose up -d --force-recreate --no-build {}",
1090 options.compose_dir.display(),
1091 options.service
1092 );
1093 bail!("running container image is stale");
1094 }
1095
1096 println!("CURRENT: running container matches local compose image");
1097 Ok(())
1098}
1099
1100fn compose_image(compose_dir: &Path) -> Result<String> {
1101 if !compose_dir.is_dir() {
1102 return Ok(String::new());
1103 }
1104 let output = command_output(
1105 Command::new("docker")
1106 .args(["compose", "config", "--images"])
1107 .current_dir(compose_dir)
1108 .stderr(Stdio::null()),
1109 )?;
1110 Ok(output.lines().next().unwrap_or_default().to_owned())
1111}
1112
1113fn parse_systemd_exec_path(value: &str) -> Option<PathBuf> {
1114 let (_, after_path) = value.split_once("path=")?;
1115 let path = after_path.split([' ', ';']).next().unwrap_or_default();
1116 if path.is_empty() {
1117 None
1118 } else {
1119 Some(PathBuf::from(path))
1120 }
1121}
1122
1123fn default_plugin_servers() -> Result<Vec<PluginServer>> {
1124 let root = std::env::current_dir().context("failed to read current directory")?;
1125 let workspace = root
1126 .parent()
1127 .map(Path::to_path_buf)
1128 .unwrap_or_else(|| root.clone());
1129 Ok(vec![
1130 PluginServer {
1131 name: "cortex".to_owned(),
1132 repo: workspace.join("cortex"),
1133 binary: "cortex".to_owned(),
1134 hook: Some("scripts/plugin-setup.sh".into()),
1135 plugin_root: Some(".".into()),
1136 check_plugin_layout: true,
1137 package_args: Vec::new(),
1138 setup_args: strings(["setup", "plugin-hook", "--no-repair", "--json"]),
1139 env: pairs([("SYSLOG_MCP_TOKEN", "test-token")]),
1140 appdata_env: "CLAUDE_PLUGIN_DATA".to_owned(),
1141 make_appdata: true,
1142 },
1143 PluginServer {
1144 name: "gotify".to_owned(),
1145 repo: workspace.join("gotify-rmcp"),
1146 binary: "rgotify".to_owned(),
1147 hook: Some("plugins/gotify/scripts/plugin-setup.sh".into()),
1148 plugin_root: None,
1149 check_plugin_layout: true,
1150 package_args: Vec::new(),
1151 setup_args: strings(["--json", "setup", "plugin-hook", "--no-repair"]),
1152 env: Vec::new(),
1153 appdata_env: "GOTIFY_MCP_HOME".to_owned(),
1154 make_appdata: true,
1155 },
1156 PluginServer {
1157 name: "unifi".to_owned(),
1158 repo: workspace.join("unifi-rmcp"),
1159 binary: "runifi".to_owned(),
1160 hook: Some("plugins/unifi/scripts/plugin-setup.sh".into()),
1161 plugin_root: None,
1162 check_plugin_layout: true,
1163 package_args: Vec::new(),
1164 setup_args: strings(["--json", "setup", "plugin-hook", "--no-repair"]),
1165 env: Vec::new(),
1166 appdata_env: "UNIFI_MCP_HOME".to_owned(),
1167 make_appdata: true,
1168 },
1169 PluginServer {
1170 name: "tailscale".to_owned(),
1171 repo: workspace.join("tailscale-rmcp"),
1172 binary: "rtailscale".to_owned(),
1173 hook: Some("plugins/tailscale/scripts/plugin-setup.sh".into()),
1174 plugin_root: None,
1175 check_plugin_layout: true,
1176 package_args: Vec::new(),
1177 setup_args: strings(["--json", "setup", "plugin-hook", "--no-repair"]),
1178 env: Vec::new(),
1179 appdata_env: "TAILSCALE_MCP_HOME".to_owned(),
1180 make_appdata: true,
1181 },
1182 PluginServer {
1183 name: "apprise".to_owned(),
1184 repo: workspace.join("apprise-rmcp"),
1185 binary: "rapprise".to_owned(),
1186 hook: Some("plugins/apprise/scripts/plugin-setup.sh".into()),
1187 plugin_root: None,
1188 check_plugin_layout: true,
1189 package_args: Vec::new(),
1190 setup_args: strings(["setup", "plugin-hook", "--no-repair"]),
1191 env: pairs([
1192 ("APPRISE_URL", "http://apprise.example:8000"),
1193 ("APPRISE_MCP_TOKEN", "test-token"),
1194 ]),
1195 appdata_env: "CLAUDE_PLUGIN_DATA".to_owned(),
1196 make_appdata: true,
1197 },
1198 PluginServer {
1199 name: "unraid".to_owned(),
1200 repo: workspace.join("unraid-rmcp"),
1201 binary: "runraid".to_owned(),
1202 hook: Some("plugins/unraid/scripts/plugin-setup.sh".into()),
1203 plugin_root: None,
1204 check_plugin_layout: true,
1205 package_args: Vec::new(),
1206 setup_args: strings(["setup", "plugin-hook", "--no-repair"]),
1207 env: pairs([
1208 ("UNRAID_API_URL", "https://tower.example/graphql"),
1209 ("UNRAID_API_KEY", "test-key"),
1210 ("UNRAID_MCP_TOKEN", "test-token"),
1211 ]),
1212 appdata_env: "UNRAID_HOME".to_owned(),
1213 make_appdata: true,
1214 },
1215 PluginServer {
1216 name: "soma".to_owned(),
1217 repo: root.clone(),
1218 binary: "soma".to_owned(),
1219 hook: None,
1220 plugin_root: None,
1221 check_plugin_layout: true,
1222 package_args: Vec::new(),
1223 setup_args: strings(["setup", "plugin-hook", "--no-repair"]),
1224 env: pairs([
1225 ("SOMA_API_URL", "https://api.example.test"),
1226 ("SOMA_API_KEY", "test-key"),
1227 ("SOMA_MCP_TOKEN", "test-token"),
1228 ]),
1229 appdata_env: "SOMA_HOME".to_owned(),
1230 make_appdata: true,
1231 },
1232 PluginServer {
1233 name: "labby".to_owned(),
1234 repo: workspace.join("lab"),
1235 binary: "labby".to_owned(),
1236 hook: None,
1237 plugin_root: None,
1238 check_plugin_layout: false,
1239 package_args: strings(["-p", "labby", "--all-features"]),
1240 setup_args: strings(["setup", "plugin-hook", "--no-repair", "--json"]),
1241 env: Vec::new(),
1242 appdata_env: "LAB_HOME".to_owned(),
1243 make_appdata: true,
1244 },
1245 ])
1246}
1247
1248fn check_hook_delegation(server: &PluginServer) -> Result<()> {
1249 let Some(hook_rel) = &server.hook else {
1250 return Ok(());
1251 };
1252 let hook = server.repo.join(hook_rel);
1253 if !hook.is_file() {
1254 bail!("{}: missing hook {}", server.name, hook.display());
1255 }
1256 let text = fs::read_to_string(&hook)
1257 .with_context(|| format!("failed to read hook {}", hook.display()))?;
1258 validate_hook_text(server, &text)?;
1259 let status = Command::new("bash")
1260 .arg("-n")
1261 .arg(&hook)
1262 .status()
1263 .with_context(|| format!("failed to run bash -n {}", hook.display()))?;
1264 if !status.success() {
1265 bail!("{}: bash -n failed for {}", server.name, hook.display());
1266 }
1267 Ok(())
1268}
1269
1270fn validate_hook_text(server: &PluginServer, text: &str) -> Result<()> {
1271 let expected = format!("{} setup plugin-hook \"$@\"", server.binary);
1272 let delegates_via_resolved_binary =
1273 text.contains("}\" setup plugin-hook \"$@\"") && text.contains("command -v");
1274 if !text.contains(&expected) && !delegates_via_resolved_binary {
1275 bail!(
1276 "{}: hook must delegate with `{expected}` or a command-v-resolved binary",
1277 server.name
1278 );
1279 }
1280 let mut found = Vec::new();
1281 for token in [
1282 "cargo build",
1283 "cargo install",
1284 "cargo run",
1285 "docker compose",
1286 "systemctl",
1287 ] {
1288 if text.contains(token) {
1289 found.push(token);
1290 }
1291 }
1292 if text.contains("curl") && text.contains("| sh") {
1293 found.push("curl | sh");
1294 }
1295 if !found.is_empty() {
1296 bail!(
1297 "{}: hook contains forbidden bootstrap tokens: {}",
1298 server.name,
1299 found.join(", ")
1300 );
1301 }
1302 Ok(())
1303}
1304
1305fn check_plugin_layout(server: &PluginServer) -> Result<()> {
1306 if !server.check_plugin_layout {
1307 return Ok(());
1308 }
1309 let plugin_root = server.plugin_root.as_ref().map_or_else(
1310 || server.repo.join(format!("plugins/{}", server.name)),
1311 |root| server.repo.join(root),
1312 );
1313 if !plugin_root.is_dir() {
1314 bail!(
1315 "{}: missing plugin root {}",
1316 server.name,
1317 plugin_root.display()
1318 );
1319 }
1320
1321 let mut found_manifest = false;
1322 for relative in [".claude-plugin/plugin.json", ".codex-plugin/plugin.json"] {
1323 let manifest = plugin_root.join(relative);
1324 if !manifest.is_file() {
1325 continue;
1326 }
1327 found_manifest = true;
1328 let payload = read_json_file(&manifest)?;
1329 if payload.get("version").is_some() {
1330 bail!(
1331 "{}: plugin manifest must not contain version: {}",
1332 server.name,
1333 manifest.display()
1334 );
1335 }
1336 }
1337 if !found_manifest {
1338 bail!(
1339 "{}: missing plugin manifests under {}",
1340 server.name,
1341 plugin_root.display()
1342 );
1343 }
1344
1345 let mut required = Vec::new();
1346 if plugin_root.join(".mcp.json").exists() {
1347 required.push(plugin_root.join(".mcp.json"));
1348 }
1349 if server.hook.is_some() && plugin_root.join("hooks/hooks.json").exists() {
1350 required.push(plugin_root.join("hooks/hooks.json"));
1351 }
1352 for path in required {
1353 let _ = read_json_file(&path)?;
1354 }
1355 Ok(())
1356}
1357
1358fn read_json_file(path: &Path) -> Result<Value> {
1359 let text =
1360 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
1361 serde_json::from_str(&text).with_context(|| format!("invalid JSON in {}", path.display()))
1362}
1363
1364fn check_required_recipes(server: &PluginServer) -> Result<()> {
1365 let justfile = server.repo.join("Justfile");
1366 if !justfile.is_file() {
1367 bail!("{}: missing Justfile", server.name);
1368 }
1369 let output = Command::new("just")
1370 .arg("--list")
1371 .current_dir(&server.repo)
1372 .output()
1373 .with_context(|| format!("failed to run just --list in {}", server.repo.display()))?;
1374 if !output.status.success() {
1375 bail!(
1376 "{}: just --list failed: {}",
1377 server.name,
1378 String::from_utf8_lossy(&output.stderr).trim()
1379 );
1380 }
1381 let recipes = String::from_utf8_lossy(&output.stdout);
1382 let missing: Vec<&str> = ["validate-plugin", "runtime-current"]
1383 .into_iter()
1384 .filter(|recipe| {
1385 !recipes.contains(&format!(" {recipe}")) && !recipes.contains(&format!("{recipe}\n"))
1386 })
1387 .collect();
1388 if !missing.is_empty() {
1389 bail!(
1390 "{}: missing Justfile recipes: {}",
1391 server.name,
1392 missing.join(", ")
1393 );
1394 }
1395 Ok(())
1396}
1397
1398fn check_plugin_binary_contract(server: &PluginServer) -> Result<()> {
1399 let temp = tempfile_dir(format!("{}-plugin-contract-", server.name))?;
1400 let appdata = temp.join("appdata");
1401 let log_dir = temp.join("logs");
1402 if server.make_appdata {
1403 fs::create_dir(&appdata)?;
1404 }
1405 fs::create_dir(&log_dir)?;
1406
1407 let mut command = Command::new("cargo");
1408 command
1409 .arg("run")
1410 .arg("--locked")
1411 .arg("--quiet")
1412 .args(&server.package_args)
1413 .arg("--")
1414 .args(&server.setup_args)
1415 .current_dir(&server.repo)
1416 .env(
1417 "PATH",
1418 format!(
1419 "{}:{}",
1420 server.repo.join("target/debug").display(),
1421 std::env::var("PATH").unwrap_or_default()
1422 ),
1423 )
1424 .env("RUST_LOG", "warn")
1425 .env("LAB_LOG_DIR", &log_dir)
1426 .env(&server.appdata_env, &appdata)
1427 .env("CLAUDE_PLUGIN_DATA", &appdata);
1428 for (key, value) in &server.env {
1429 command.env(key, value);
1430 }
1431
1432 let output = command
1433 .output()
1434 .with_context(|| format!("failed to run setup command for {}", server.name))?;
1435 fs::remove_dir_all(&temp).ok();
1436 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
1437 validate_plugin_setup_stdout(server, &stdout, output.status.success(), &output.stderr)
1438}
1439
1440fn validate_plugin_setup_stdout(
1441 server: &PluginServer,
1442 stdout: &str,
1443 success: bool,
1444 stderr: &[u8],
1445) -> Result<()> {
1446 if !stdout.starts_with('{') {
1447 bail!(
1448 "{}: setup command did not emit clean JSON on stdout: {:?}; stderr: {:?}",
1449 server.name,
1450 stdout.chars().take(120).collect::<String>(),
1451 String::from_utf8_lossy(stderr)
1452 .chars()
1453 .take(240)
1454 .collect::<String>()
1455 );
1456 }
1457 let payload: Value = serde_json::from_str(stdout)
1458 .with_context(|| format!("{}: setup stdout is not JSON", server.name))?;
1459 let missing: BTreeSet<&str> = REQUIRED_PLUGIN_FIELDS
1460 .into_iter()
1461 .filter(|field| payload.get(*field).is_none())
1462 .collect();
1463 if !missing.is_empty() {
1464 bail!(
1465 "{}: JSON missing fields: {}",
1466 server.name,
1467 missing.into_iter().collect::<Vec<_>>().join(", ")
1468 );
1469 }
1470 if !payload
1471 .get("exit_policy")
1472 .and_then(Value::as_str)
1473 .is_some_and(|policy| EXIT_POLICIES.contains(&policy))
1474 {
1475 bail!(
1476 "{}: invalid exit_policy {:?}",
1477 server.name,
1478 payload.get("exit_policy")
1479 );
1480 }
1481 if !payload
1482 .get("blocking_failures")
1483 .is_some_and(Value::is_array)
1484 {
1485 bail!("{}: blocking_failures must be an array", server.name);
1486 }
1487 if !payload
1488 .get("advisory_failures")
1489 .is_some_and(Value::is_array)
1490 {
1491 bail!("{}: advisory_failures must be an array", server.name);
1492 }
1493 if !success && payload.get("exit_policy").and_then(Value::as_str) != Some("blocking_failure") {
1494 bail!("{}: nonzero exit with non-blocking policy", server.name);
1495 }
1496 Ok(())
1497}
1498
1499fn default_crawls() -> Vec<CrawlTarget> {
1500 vec![
1501 CrawlTarget {
1502 label: "mcp",
1503 url: "https://modelcontextprotocol.io",
1504 domain: "modelcontextprotocol.io",
1505 target_rel: "mcp/docs",
1506 },
1507 CrawlTarget {
1508 label: "claude-code",
1509 url: "https://code.claude.com/",
1510 domain: "code.claude.com",
1511 target_rel: "claude-code",
1512 },
1513 ]
1514}
1515
1516fn default_repomix_packs() -> Vec<RepoPack> {
1517 vec![
1518 RepoPack {
1519 remote: "modelcontextprotocol/rust-sdk",
1520 target_rel: "mcp/repos/modelcontextprotocol-rust-sdk.xml",
1521 include: "",
1522 ignore: "",
1523 },
1524 RepoPack {
1525 remote: "modelcontextprotocol/modelcontextprotocol",
1526 target_rel: "mcp/repos/modelcontextprotocol-modelcontextprotocol.xml",
1527 include: "docs/**,spec/**",
1528 ignore: "**/*.svg,**/*.excalidraw.svg",
1529 },
1530 RepoPack {
1531 remote: "modelcontextprotocol/registry",
1532 target_rel: "mcp/repos/modelcontextprotocol-registry.xml",
1533 include: "",
1534 ignore: "**/*.svg,**/*.excalidraw.svg",
1535 },
1536 RepoPack {
1537 remote: "openclaw/mcporter",
1538 target_rel: "mcporter/repos/openclaw-mcporter.xml",
1539 include: "",
1540 ignore: "",
1541 },
1542 ]
1543}
1544
1545fn crawl_docs(
1546 options: &RefreshDocsOptions,
1547 ref_dir: &Path,
1548 axon_output_dir: &Path,
1549 target: CrawlTarget,
1550) -> Result<()> {
1551 println!(
1552 "[refresh-docs] crawl {} -> docs/references/{}",
1553 target.url, target.target_rel
1554 );
1555 if options.dry_run {
1556 return Ok(());
1557 }
1558 require_command("axon")?;
1559 let output = command_output(
1560 Command::new("axon").args(["crawl", target.url, "--wait", "true", "--yes"]),
1561 )?;
1562 print!("{output}");
1563 let source_dir = parse_axon_job_id(&output)
1564 .map(|job_id| {
1565 axon_output_dir
1566 .join("domains")
1567 .join(target.domain)
1568 .join(job_id)
1569 })
1570 .filter(|path| path.is_dir())
1571 .or_else(|| newest_domain_run(axon_output_dir, target.domain))
1572 .ok_or_else(|| anyhow::anyhow!("could not locate Axon output for {}", target.domain))?;
1573 copy_job_output_to_layout(&source_dir, &ref_dir.join(target.target_rel))
1574}
1575
1576fn parse_axon_job_id(output: &str) -> Option<String> {
1577 output
1578 .lines()
1579 .filter_map(|line| line.strip_prefix("Job ID:").map(str::trim))
1580 .next_back()
1581 .map(str::to_owned)
1582}
1583
1584fn newest_domain_run(axon_output_dir: &Path, domain: &str) -> Option<PathBuf> {
1585 let domain_dir = axon_output_dir.join("domains").join(domain);
1586 let entries = fs::read_dir(domain_dir).ok()?;
1587 entries
1588 .filter_map(|entry| {
1589 let entry = entry.ok()?;
1590 let metadata = entry.metadata().ok()?;
1591 if !metadata.is_dir() {
1592 return None;
1593 }
1594 let modified = metadata.modified().ok()?;
1595 Some((modified, entry.path()))
1596 })
1597 .max_by_key(|(modified, _)| *modified)
1598 .map(|(_, path)| path)
1599}
1600
1601fn copy_job_output_to_layout(source_dir: &Path, target_dir: &Path) -> Result<()> {
1602 if !source_dir.join("manifest.jsonl").is_file() {
1603 bail!(
1604 "ERROR: missing Axon manifest: {}",
1605 source_dir.join("manifest.jsonl").display()
1606 );
1607 }
1608 if !source_dir.join("markdown").is_dir() {
1609 bail!(
1610 "ERROR: missing Axon markdown dir: {}",
1611 source_dir.join("markdown").display()
1612 );
1613 }
1614 let tmp_target = tempfile_dir("example-refresh-docs.")?;
1615 copy_dir_all(source_dir, &tmp_target)?;
1616 atomic_replace_dir(&tmp_target, target_dir)?;
1617 Ok(())
1618}
1619
1620fn pack_repo(options: &RefreshDocsOptions, ref_dir: &Path, pack: RepoPack) -> Result<()> {
1621 println!(
1622 "[refresh-docs] pack {} -> docs/references/{}",
1623 pack.remote, pack.target_rel
1624 );
1625 if !pack.include.is_empty() {
1626 println!("[refresh-docs] include: {}", pack.include);
1627 }
1628 if !pack.ignore.is_empty() {
1629 println!("[refresh-docs] ignore: {}", pack.ignore);
1630 }
1631 if options.dry_run {
1632 return Ok(());
1633 }
1634 let tmp_dir = tempfile_dir("example-refresh-docs.")?;
1635 let tmp_file = tmp_dir.join("repomix-output.xml");
1636 let mut args = vec![
1637 "--remote".to_owned(),
1638 pack.remote.to_owned(),
1639 "--style".to_owned(),
1640 "xml".to_owned(),
1641 "--output".to_owned(),
1642 tmp_file.display().to_string(),
1643 "--top-files-len".to_owned(),
1644 "10".to_owned(),
1645 ];
1646 if !pack.include.is_empty() {
1647 args.push("--include".to_owned());
1648 args.push(pack.include.to_owned());
1649 }
1650 if !pack.ignore.is_empty() {
1651 args.push("--ignore".to_owned());
1652 args.push(pack.ignore.to_owned());
1653 }
1654 run_repomix(&args)?;
1655 if !tmp_file.is_file() || fs::metadata(&tmp_file)?.len() == 0 {
1656 bail!("ERROR: Repomix produced no output for {}", pack.remote);
1657 }
1658 let target_file = ref_dir.join(pack.target_rel);
1659 fs::create_dir_all(target_file.parent().unwrap_or(ref_dir))?;
1660 fs::rename(&tmp_file, &target_file).with_context(|| {
1661 format!(
1662 "failed to move {} to {}",
1663 tmp_file.display(),
1664 target_file.display()
1665 )
1666 })?;
1667 fs::remove_dir_all(tmp_dir).ok();
1668 Ok(())
1669}
1670
1671fn run_repomix(args: &[String]) -> Result<()> {
1672 if let Some(bin) = env_path("REPOMIX_BIN") {
1673 return run_status(Command::new(bin).args(args));
1674 }
1675 if command_exists("repomix") {
1676 return run_status(Command::new("repomix").args(args));
1677 }
1678 require_command("npx")?;
1679 run_status(Command::new("npx").arg("--yes").arg("repomix").args(args))
1680}
1681
1682fn sparse_clone_path(
1683 options: &RefreshDocsOptions,
1684 ref_dir: &Path,
1685 sparse: SparseClone,
1686) -> Result<()> {
1687 println!(
1688 "[refresh-docs] sparse clone {}/{} -> docs/references/{}",
1689 sparse.remote, sparse.sparse_path, sparse.target_rel
1690 );
1691 if options.dry_run {
1692 return Ok(());
1693 }
1694 require_command("git")?;
1695 let tmp_dir = tempfile_dir("example-refresh-docs.")?;
1696 let clone_dir = tmp_dir.join("repo");
1697 let tmp_target = tmp_dir.join("output");
1698 run_status(Command::new("git").args([
1699 "clone",
1700 "--filter=blob:none",
1701 "--sparse",
1702 "--depth=1",
1703 sparse.remote,
1704 clone_dir.to_str().context("non-UTF-8 clone path")?,
1705 ]))?;
1706 run_status(Command::new("git").arg("-C").arg(&clone_dir).args([
1707 "sparse-checkout",
1708 "set",
1709 sparse.sparse_path,
1710 ]))?;
1711 fs::create_dir_all(&tmp_target)?;
1712 match sparse.mode {
1713 SparseCloneMode::Recursive => {
1714 copy_dir_all(&clone_dir.join(sparse.sparse_path), &tmp_target)?;
1715 }
1716 SparseCloneMode::FlatMdx => {
1717 for entry in fs::read_dir(clone_dir.join(sparse.sparse_path))? {
1718 let entry = entry?;
1719 if entry.path().extension() == Some(OsStr::new("mdx")) {
1720 fs::copy(entry.path(), tmp_target.join(entry.file_name()))?;
1721 }
1722 }
1723 }
1724 }
1725 atomic_replace_dir(&tmp_target, &ref_dir.join(sparse.target_rel))?;
1726 fs::remove_dir_all(tmp_dir).ok();
1727 Ok(())
1728}
1729
1730fn write_reference_index(ref_dir: &Path) -> Result<()> {
1731 let mcp_docs = count_files(&ref_dir.join("mcp/docs"));
1732 let claude_docs = count_files(&ref_dir.join("claude-code"));
1733 let mcporter_docs = count_files(&ref_dir.join("mcporter/docs"));
1734 let updated = command_output(Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]))
1735 .unwrap_or_else(|_| "unknown".to_owned())
1736 .trim()
1737 .to_owned();
1738 fs::write(
1739 ref_dir.join("INDEX.md"),
1740 format!(
1741 "# Reference Index - soma\n\n\
1742CUSTOMIZE: When you adapt Soma, update this index to reflect your service's\n\
1743reference material.\n\n\
1744| Path | Contents | Source |\n\
1745| --- | --- | --- |\n\
1746| `mcp/docs/` | MCP protocol docs (crawled) | modelcontextprotocol.io |\n\
1747| `mcp/repos/` | MCP Rust SDK + spec (repomix) | modelcontextprotocol/* |\n\
1748| `claude-code/` | Claude Code docs (crawled) | code.claude.com |\n\
1749| `mcporter/docs/` | mcporter docs (sparse clone) | openclaw/mcporter/docs |\n\
1750| `mcporter/repos/` | mcporter source (repomix) | openclaw/mcporter |\n\n\
1751## Crawled Doc File Counts\n\n\
1752| Path | Files |\n\
1753| --- | ---: |\n\
1754| `mcp/docs/` | {mcp_docs} |\n\
1755| `claude-code/` | {claude_docs} |\n\
1756| `mcporter/docs/` | {mcporter_docs} |\n\n\
1757## Key References for MCP Server Development\n\n\
1758- **rmcp crate**: `mcp/repos/modelcontextprotocol-rust-sdk.xml`\n\
1759 The primary reference for implementing ServerHandler, tool dispatch, elicitation, resources, prompts.\n\n\
1760- **MCP spec**: `mcp/repos/modelcontextprotocol-modelcontextprotocol.xml`\n\
1761 Protocol specification - useful when the SDK doesn't expose something you need.\n\n\
1762- **server.json schema**: `mcp/repos/modelcontextprotocol-registry.xml`\n\
1763 JSON schema for MCP registry publishing (`server.json`).\n\n\
1764- **mcporter**: `mcporter/repos/openclaw-mcporter.xml`\n\
1765 Integration testing tool used by `apps/soma/tests/mcporter/test-mcp.sh`.\n\n\
1766_Updated: {updated}_\n"
1767 ),
1768 )
1769 .with_context(|| format!("failed to write {}", ref_dir.join("INDEX.md").display()))?;
1770 Ok(())
1771}
1772
1773fn refresh_scope(options: &RefreshDocsOptions) -> &'static str {
1774 if options.skip_crawl {
1775 "repomix-only"
1776 } else if options.skip_repomix {
1777 "crawl-only"
1778 } else {
1779 "full"
1780 }
1781}
1782
1783fn snapshot_references(ref_dir: &Path) -> Result<Vec<ReferenceSnapshotEntry>> {
1784 if !ref_dir.is_dir() {
1785 return Ok(Vec::new());
1786 }
1787 let mut entries = Vec::new();
1788 collect_snapshot_entries(ref_dir, ref_dir, &mut entries)?;
1789 entries.sort_by(|left, right| left.path.cmp(&right.path));
1790 Ok(entries)
1791}
1792
1793fn collect_snapshot_entries(
1794 root: &Path,
1795 dir: &Path,
1796 entries: &mut Vec<ReferenceSnapshotEntry>,
1797) -> Result<()> {
1798 for entry in fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))? {
1799 let entry = entry?;
1800 let path = entry.path();
1801 if path.is_dir() {
1802 collect_snapshot_entries(root, &path, entries)?;
1803 continue;
1804 }
1805 if path.file_name() == Some(OsStr::new("CHANGES.md")) {
1806 continue;
1807 }
1808 let relative = path
1809 .strip_prefix(root)
1810 .unwrap_or(&path)
1811 .to_string_lossy()
1812 .replace('\\', "/");
1813 entries.push(ReferenceSnapshotEntry {
1814 path: relative,
1815 sha256: sha256_file(&path)?,
1816 });
1817 }
1818 Ok(())
1819}
1820
1821fn summarize_reference_changes(
1822 changes_file: &Path,
1823 scope: &str,
1824 before: &[ReferenceSnapshotEntry],
1825 after: &[ReferenceSnapshotEntry],
1826) -> Result<()> {
1827 let (added, modified, removed) = reference_change_counts(before, after);
1828 println!(
1829 "[refresh-docs] change summary: {added} added, {modified} modified, {removed} removed"
1830 );
1831 ensure_changes_file(changes_file)?;
1832 let timestamp = command_output(Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]))
1833 .unwrap_or_else(|_| "unknown".to_owned())
1834 .trim()
1835 .to_owned();
1836 use std::io::Write;
1837 let mut file = fs::OpenOptions::new()
1838 .append(true)
1839 .open(changes_file)
1840 .with_context(|| format!("failed to open {}", changes_file.display()))?;
1841 writeln!(
1842 file,
1843 "\n## {timestamp}\n\n- scope: `{scope}`\n- summary: `{added} added, {modified} modified, {removed} removed`"
1844 )?;
1845 Ok(())
1846}
1847
1848fn ensure_changes_file(changes_file: &Path) -> Result<()> {
1849 if changes_file.is_file() {
1850 return Ok(());
1851 }
1852 fs::create_dir_all(changes_file.parent().unwrap_or_else(|| Path::new(".")))?;
1853 let timestamp = command_output(Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]))
1854 .unwrap_or_else(|_| "unknown".to_owned())
1855 .trim()
1856 .to_owned();
1857 fs::write(
1858 changes_file,
1859 format!(
1860 "---\n\
1861title: Reference Refresh Change Log - soma\n\
1862generated_by: cargo xtask refresh-docs\n\
1863created_at: {timestamp}\n\
1864---\n\n\
1865# Reference Refresh Change Log\n\n\
1866Each entry records file-level changes after a real refresh run.\n"
1867 ),
1868 )?;
1869 Ok(())
1870}
1871
1872fn reference_change_counts(
1873 before: &[ReferenceSnapshotEntry],
1874 after: &[ReferenceSnapshotEntry],
1875) -> (usize, usize, usize) {
1876 let before_paths: BTreeSet<&str> = before.iter().map(|entry| entry.path.as_str()).collect();
1877 let after_paths: BTreeSet<&str> = after.iter().map(|entry| entry.path.as_str()).collect();
1878 let added = after_paths.difference(&before_paths).count();
1879 let removed = before_paths.difference(&after_paths).count();
1880 let modified = before_paths
1881 .intersection(&after_paths)
1882 .filter(|path| {
1883 let before_sha = before
1884 .iter()
1885 .find(|entry| entry.path == **path)
1886 .map(|entry| entry.sha256.as_str());
1887 let after_sha = after
1888 .iter()
1889 .find(|entry| entry.path == **path)
1890 .map(|entry| entry.sha256.as_str());
1891 before_sha != after_sha
1892 })
1893 .count();
1894 (added, modified, removed)
1895}
1896
1897fn atomic_replace_dir(src: &Path, dst: &Path) -> Result<()> {
1898 let parent = dst.parent().unwrap_or_else(|| Path::new("."));
1899 fs::create_dir_all(parent)?;
1900 let backup = tempfile_dir_in(parent, format!(".{}.backup.", basename(dst)))?;
1901 fs::remove_dir(&backup)?;
1902 if dst.exists() {
1903 fs::rename(dst, &backup)
1904 .with_context(|| format!("failed to move {} to {}", dst.display(), backup.display()))?;
1905 }
1906 if let Err(error) = fs::rename(src, dst) {
1907 if backup.exists() {
1908 let _ = fs::rename(&backup, dst);
1909 }
1910 return Err(error)
1911 .with_context(|| format!("failed to move {} to {}", src.display(), dst.display()));
1912 }
1913 if backup.exists() {
1914 fs::remove_dir_all(&backup).ok();
1915 }
1916 Ok(())
1917}
1918
1919fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
1920 fs::create_dir_all(dst)?;
1921 for entry in fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))? {
1922 let entry = entry?;
1923 let source = entry.path();
1924 let target = dst.join(entry.file_name());
1925 if source.is_dir() {
1926 copy_dir_all(&source, &target)?;
1927 } else {
1928 fs::copy(&source, &target).with_context(|| {
1929 format!(
1930 "failed to copy {} to {}",
1931 source.display(),
1932 target.display()
1933 )
1934 })?;
1935 }
1936 }
1937 Ok(())
1938}
1939
1940fn count_files(path: &Path) -> usize {
1941 if !path.is_dir() {
1942 return 0;
1943 }
1944 let mut count = 0usize;
1945 if let Ok(entries) = fs::read_dir(path) {
1946 for entry in entries.flatten() {
1947 let path = entry.path();
1948 if path.is_dir() {
1949 count += count_files(&path);
1950 } else {
1951 count += 1;
1952 }
1953 }
1954 }
1955 count
1956}
1957
1958fn status_line(key: &str, value: &str) {
1959 println!("{key:<18} {value}");
1960}
1961
1962fn version_of(path: &Path) -> String {
1963 if !path.is_file() {
1964 return String::new();
1965 }
1966 command_output(Command::new(path).arg("--version").stderr(Stdio::null()))
1967 .unwrap_or_default()
1968 .trim()
1969 .to_owned()
1970}
1971
1972fn sha256_file(path: &Path) -> Result<String> {
1973 Ok(command_output(Command::new("sha256sum").arg(path))?
1974 .split_whitespace()
1975 .next()
1976 .unwrap_or_default()
1977 .to_owned())
1978}
1979
1980fn require_command(command: &str) -> Result<()> {
1981 if command_exists(command) {
1982 Ok(())
1983 } else {
1984 bail!("ERROR: required command not found: {command}")
1985 }
1986}
1987
1988fn command_exists(command: &str) -> bool {
1989 Command::new("sh")
1990 .arg("-c")
1991 .arg(format!(
1992 "command -v {} >/dev/null 2>&1",
1993 shell_quote(command)
1994 ))
1995 .stdin(Stdio::null())
1996 .stdout(Stdio::null())
1997 .stderr(Stdio::null())
1998 .status()
1999 .map(|status| status.success())
2000 .unwrap_or(false)
2001}
2002
2003fn command_status(command: &mut Command) -> bool {
2004 command
2005 .stdin(Stdio::null())
2006 .stdout(Stdio::null())
2007 .status()
2008 .map(|status| status.success())
2009 .unwrap_or(false)
2010}
2011
2012fn run_status(command: &mut Command) -> Result<()> {
2013 let status = command.status().context("failed to spawn command")?;
2014 if !status.success() {
2015 bail!("command failed with status {status}");
2016 }
2017 Ok(())
2018}
2019
2020fn command_output(command: &mut Command) -> Result<String> {
2021 let output = command.output().context("failed to spawn command")?;
2022 if !output.status.success() {
2023 bail!("command failed with status {}", output.status);
2024 }
2025 String::from_utf8(output.stdout).context("command emitted non-UTF-8 stdout")
2026}
2027
2028fn git_output<I, S>(args: I) -> Result<String>
2029where
2030 I: IntoIterator<Item = S>,
2031 S: AsRef<OsStr>,
2032{
2033 command_output(Command::new("git").args(args))
2034}
2035
2036fn git_status<I, S>(args: I) -> bool
2037where
2038 I: IntoIterator<Item = S>,
2039 S: AsRef<OsStr>,
2040{
2041 command_status(Command::new("git").args(args).stderr(Stdio::null()))
2042}
2043
2044fn env_path(name: &str) -> Option<PathBuf> {
2045 std::env::var_os(name)
2046 .filter(|value| !value.is_empty())
2047 .map(PathBuf::from)
2048}
2049
2050fn current_dir() -> PathBuf {
2051 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
2052}
2053
2054fn tempfile_dir(prefix: impl AsRef<str>) -> Result<PathBuf> {
2055 tempfile_dir_in(std::env::temp_dir(), prefix)
2056}
2057
2058fn tempfile_dir_in(parent: impl AsRef<Path>, prefix: impl AsRef<str>) -> Result<PathBuf> {
2059 let parent = parent.as_ref();
2060 fs::create_dir_all(parent)?;
2061 for attempt in 0..1000u32 {
2062 let candidate = parent.join(format!(
2063 "{}{}-{}",
2064 prefix.as_ref(),
2065 std::process::id(),
2066 attempt
2067 ));
2068 match fs::create_dir(&candidate) {
2069 Ok(()) => return Ok(candidate),
2070 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
2071 Err(error) => {
2072 return Err(error)
2073 .with_context(|| format!("failed to create {}", candidate.display()))
2074 }
2075 }
2076 }
2077 bail!(
2078 "failed to create temporary directory in {}",
2079 parent.display()
2080 )
2081}
2082
2083fn basename(path: &Path) -> String {
2084 path.file_name()
2085 .and_then(OsStr::to_str)
2086 .unwrap_or("target")
2087 .to_owned()
2088}
2089
2090fn strings<const N: usize>(values: [&str; N]) -> Vec<String> {
2091 values.into_iter().map(str::to_owned).collect()
2092}
2093
2094fn pairs<const N: usize>(values: [(&str, &str); N]) -> Vec<(String, String)> {
2095 values
2096 .into_iter()
2097 .map(|(key, value)| (key.to_owned(), value.to_owned()))
2098 .collect()
2099}
2100
2101fn shell_quote(value: &str) -> String {
2102 format!("'{}'", value.replace('\'', "'\\''"))
2103}
2104
2105#[cfg(test)]
2106mod tests {
2107 use super::*;
2108
2109 #[test]
2110 fn allowlist_ignores_comments_and_blank_lines() {
2111 let patterns = parse_allowlist(
2112 r#"
2113# comment
2114docs/reference/**
2115assets/*.png # real assets
2116
2117"#,
2118 );
2119 assert_eq!(patterns, vec!["docs/reference/**", "assets/*.png"]);
2120 assert!(is_allowlisted("assets/logo.png", &patterns));
2121 assert!(is_allowlisted("docs/reference/mcp/index.md", &patterns));
2122 assert!(!is_allowlisted("src/main.rs", &patterns));
2123 }
2124
2125 #[test]
2126 fn blob_options_match_python_defaults_and_overrides() {
2127 let args = strings([
2128 "--base",
2129 "origin/dev",
2130 "--head",
2131 "feature",
2132 "--max-bytes",
2133 "42",
2134 "--allowlist",
2135 "allow.txt",
2136 ]);
2137 let parsed = BlobSizeOptions::parse(&args).unwrap();
2138 assert_eq!(parsed.base, "origin/dev");
2139 assert_eq!(parsed.head, "feature");
2140 assert_eq!(parsed.max_bytes, 42);
2141 assert_eq!(parsed.allowlist, PathBuf::from("allow.txt"));
2142 }
2143
2144 #[test]
2145 fn blob_summary_matches_script_shape() {
2146 let blob = ChangedBlob {
2147 path: "big.bin".to_owned(),
2148 size_bytes: 1024,
2149 is_allowlisted: false,
2150 is_binary: true,
2151 };
2152 let violations = vec![&blob];
2153 let temp = tempfile_dir("lane-c-summary.").unwrap();
2154 let summary = temp.join("summary.md");
2155 std::env::set_var("GITHUB_STEP_SUMMARY", &summary);
2156 write_blob_step_summary(512, std::slice::from_ref(&blob), &violations).unwrap();
2157 std::env::remove_var("GITHUB_STEP_SUMMARY");
2158 let text = fs::read_to_string(summary).unwrap();
2159 assert!(text.contains("## Blob Size Policy"));
2160 assert!(text.contains("| `big.bin` | binary | `1024` bytes (1.0 KiB) | blocked |"));
2161 fs::remove_dir_all(temp).ok();
2162 }
2163
2164 #[test]
2165 fn dependency_parser_matches_awks_section_scope() {
2166 let deps = extract_direct_deps_from_toml(
2167 r#"
2168[package]
2169name = "x"
2170
2171[workspace.dependencies]
2172anyhow = "1"
2173serde = { version = "1", features = ["derive"] }
2174local = { path = "crates/local" }
2175
2176[profile.release]
2177debug = false
2178"#,
2179 );
2180 assert_eq!(
2181 deps,
2182 vec![
2183 DirectDependency {
2184 name: "anyhow".to_owned(),
2185 line: "anyhow = \"1\"".to_owned()
2186 },
2187 DirectDependency {
2188 name: "local".to_owned(),
2189 line: "local = { path = \"crates/local\" }".to_owned()
2190 },
2191 DirectDependency {
2192 name: "serde".to_owned(),
2193 line: "serde = { version = \"1\", features = [\"derive\"] }".to_owned()
2194 },
2195 ]
2196 );
2197 assert_eq!(dependency_version_req(&deps[0].line).as_deref(), Some("1"));
2198 assert_eq!(dependency_version_req(&deps[1].line), None);
2199 assert_eq!(dependency_version_req(&deps[2].line).as_deref(), Some("1"));
2200 }
2201
2202 #[test]
2203 fn dependency_status_preserves_shell_cases() {
2204 assert_eq!(direct_dependency_status("1.2.3", "1.2.3"), "ok");
2205 assert_eq!(direct_dependency_status("1", "1.9.0"), "compatible-range");
2206 assert_eq!(direct_dependency_status("1.2", "1.2.9"), "compatible-range");
2207 assert_eq!(direct_dependency_status("^1", "2.0.0"), "review");
2208 }
2209
2210 #[test]
2211 fn dry_run_update_detector_matches_cargo_output_patterns() {
2212 assert!(dry_run_reports_updates(
2213 " Updating anyhow v1.0.0 -> v1.0.1\n"
2214 ));
2215 assert!(dry_run_reports_updates(
2216 " Locking 2 packages to latest compatible versions\n"
2217 ));
2218 assert!(!dry_run_reports_updates(
2219 " Locking 0 packages to latest compatible versions\n"
2220 ));
2221 }
2222
2223 #[test]
2224 fn runtime_args_and_execstart_parser_match_shell_script() {
2225 let mut options = RuntimeOptions::from_env();
2226 options
2227 .parse_args(&strings([
2228 "--mode",
2229 "docker",
2230 "--pull",
2231 "--unit",
2232 "x.service",
2233 "--service",
2234 "svc",
2235 "--compose-dir",
2236 "/tmp/compose",
2237 "--expected-binary",
2238 "/bin/echo",
2239 ]))
2240 .unwrap();
2241 assert_eq!(options.mode, RuntimeMode::Docker);
2242 assert!(options.pull);
2243 assert_eq!(options.unit, "x.service");
2244 assert_eq!(options.service, "svc");
2245 assert_eq!(options.compose_dir, PathBuf::from("/tmp/compose"));
2246 assert_eq!(options.expected_binary, Some(PathBuf::from("/bin/echo")));
2247
2248 assert_eq!(
2249 parse_systemd_exec_path("{ path=/home/me/bin/soma ; argv[]=/home/me/bin/soma serve; }"),
2250 Some(PathBuf::from("/home/me/bin/soma"))
2251 );
2252 }
2253
2254 #[test]
2255 fn plugin_hook_validation_blocks_bootstrap_work() {
2256 let server = PluginServer {
2257 name: "demo".to_owned(),
2258 repo: PathBuf::new(),
2259 binary: "demo".to_owned(),
2260 hook: None,
2261 plugin_root: None,
2262 check_plugin_layout: true,
2263 package_args: Vec::new(),
2264 setup_args: Vec::new(),
2265 env: Vec::new(),
2266 appdata_env: "DEMO_HOME".to_owned(),
2267 make_appdata: true,
2268 };
2269 validate_hook_text(&server, "exec demo setup plugin-hook \"$@\"").unwrap();
2270 let error =
2271 validate_hook_text(&server, "cargo run -- demo setup plugin-hook \"$@\"").unwrap_err();
2272 assert!(error.to_string().contains("forbidden bootstrap tokens"));
2273 }
2274
2275 #[test]
2276 fn plugin_setup_json_contract_checks_required_fields() {
2277 let server = PluginServer {
2278 name: "demo".to_owned(),
2279 repo: PathBuf::new(),
2280 binary: "demo".to_owned(),
2281 hook: None,
2282 plugin_root: None,
2283 check_plugin_layout: true,
2284 package_args: Vec::new(),
2285 setup_args: Vec::new(),
2286 env: Vec::new(),
2287 appdata_env: "DEMO_HOME".to_owned(),
2288 make_appdata: true,
2289 };
2290 validate_plugin_setup_stdout(
2291 &server,
2292 r#"{"exit_policy":"success","ran_repair":false,"no_repair":true,"blocking_failures":[],"advisory_failures":[]}"#,
2293 true,
2294 b"",
2295 )
2296 .unwrap();
2297 let error = validate_plugin_setup_stdout(
2298 &server,
2299 r#"{"exit_policy":"success","blocking_failures":[],"advisory_failures":[]}"#,
2300 true,
2301 b"",
2302 )
2303 .unwrap_err();
2304 assert!(error.to_string().contains("JSON missing fields"));
2305 }
2306
2307 #[test]
2308 fn refresh_docs_options_and_scope_match_shell() {
2309 let options = RefreshDocsOptions::parse(&strings(["--dry-run", "--skip-crawl"])).unwrap();
2310 assert!(options.dry_run);
2311 assert!(options.skip_crawl);
2312 assert_eq!(refresh_scope(&options), "repomix-only");
2313 let options = RefreshDocsOptions::parse(&strings(["--skip-repomix"])).unwrap();
2314 assert_eq!(refresh_scope(&options), "crawl-only");
2315 let options = RefreshDocsOptions::parse(&[]).unwrap();
2316 assert_eq!(refresh_scope(&options), "full");
2317 }
2318
2319 #[test]
2320 fn axon_job_id_parser_uses_last_job_line() {
2321 assert_eq!(
2322 parse_axon_job_id("noise\nJob ID: first\nJob ID: second\n").as_deref(),
2323 Some("second")
2324 );
2325 }
2326
2327 #[test]
2328 fn reference_change_summary_counts_added_modified_removed() {
2329 let before = vec![
2330 ReferenceSnapshotEntry {
2331 path: "a.md".to_owned(),
2332 sha256: "1".to_owned(),
2333 },
2334 ReferenceSnapshotEntry {
2335 path: "b.md".to_owned(),
2336 sha256: "2".to_owned(),
2337 },
2338 ];
2339 let after = vec![
2340 ReferenceSnapshotEntry {
2341 path: "b.md".to_owned(),
2342 sha256: "3".to_owned(),
2343 },
2344 ReferenceSnapshotEntry {
2345 path: "c.md".to_owned(),
2346 sha256: "4".to_owned(),
2347 },
2348 ];
2349 assert_eq!(reference_change_counts(&before, &after), (1, 1, 1));
2350 }
2351
2352 #[test]
2353 fn cargo_search_version_parser_matches_script_awk() {
2354 assert_eq!(
2355 parse_cargo_search_version("anyhow", "anyhow = \"1.0.99\" # error handling\n"),
2356 Some("1.0.99".to_owned())
2357 );
2358 assert_eq!(
2359 parse_cargo_search_version("anyhow", "not-anyhow = \"9\"\n"),
2360 None
2361 );
2362 }
2363
2364 #[test]
2365 fn flat_mdx_variant_is_kept_for_future_service_docs() {
2366 assert!(matches!(SparseCloneMode::FlatMdx, SparseCloneMode::FlatMdx));
2367 }
2368}