Skip to main content

xtask/
scripts_lane_c.rs

1//! Lane C ports for broad ops scripts.
2//!
3//! These functions are intentionally not wired into `main.rs` in this lane.
4//! The parent integration lane can expose them as xtask commands after checking
5//! command names and wrapper behavior.
6
7use 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        // FIXME: Audit that the environment access only happens in single-threaded code.
2166        unsafe { std::env::set_var("GITHUB_STEP_SUMMARY", &summary) };
2167        write_blob_step_summary(512, std::slice::from_ref(&blob), &violations).unwrap();
2168        // FIXME: Audit that the environment access only happens in single-threaded code.
2169        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}