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