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