Skip to main content

xtask/
workspace_commands.rs

1use anyhow::{bail, Context, Result};
2use std::process::{Command, Stdio};
3use walkdir::WalkDir;
4
5use crate::{
6    architecture, command_exists, generated_surfaces, mcp_registry, patterns, provider_manifest,
7    run_cargo, run_cmd, scripts_lane_b, scripts_lane_d, test_siblings, web_source,
8};
9
10pub(crate) fn contract_audit() -> Result<()> {
11    println!("==> contract-audit: local static/spec checks only");
12    println!("==> [1/13] cargo xtask check-architecture");
13    architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
14
15    println!("==> [2/13] cargo xtask patterns");
16    patterns::run(patterns::PatternOptions::default()).context("patterns contract check failed")?;
17
18    println!("==> [3/13] cargo xtask check-test-siblings");
19    test_siblings::check().context("test sibling check failed")?;
20
21    println!("==> [4/13] cargo xtask check-docs");
22    check_docs().context("generated docs check failed")?;
23
24    println!("==> [5/13] cargo xtask check-stale-claims");
25    check_stale_claims().context("stale claim check failed")?;
26
27    println!("==> [6/13] cargo xtask check-schema-docs --check");
28    scripts_lane_d::check_schema_docs(&["--check".to_owned()])
29        .context("schema docs check failed")?;
30
31    println!("==> [7/13] cargo xtask check-openapi --check");
32    scripts_lane_d::check_openapi(&["--check".to_owned()]).context("OpenAPI docs check failed")?;
33
34    println!("==> [8/13] cargo xtask check-mcp-registry");
35    mcp_registry::check_default(std::path::Path::new("."))
36        .context("MCP registry manifest check failed")?;
37
38    println!("==> [9/13] cargo xtask check-provider-manifest-contract");
39    provider_manifest::check().context("provider manifest contract check failed")?;
40
41    println!("==> [10/13] cargo xtask check-palette-manifest --check");
42    generated_surfaces::check_palette_manifest(&["--check".to_owned()])
43        .context("Palette manifest check failed")?;
44
45    println!("==> [11/13] cargo xtask generate-provider-surfaces --check");
46    generated_surfaces::provider_surfaces(&["--check".to_owned()])
47        .context("provider surfaces check failed")?;
48
49    println!("==> [12/13] cargo xtask check-scaffold-intent-contract");
50    scripts_lane_d::check_scaffold_intent_contract()
51        .context("scaffold intent contract check failed")?;
52
53    println!("==> [13/13] cargo xtask test-soma-features");
54    scripts_lane_b::test_soma_features(std::path::Path::new("."))
55        .context("Soma feature smoke failed")?;
56
57    println!("==> contract-audit: passed; no live upstream services were contacted");
58    Ok(())
59}
60
61pub(crate) fn generate_docs() -> Result<()> {
62    run_cmd("python3", &["scripts/generate-docs.py", "--write"])
63        .context("generated docs update failed")
64}
65
66pub(crate) fn check_docs() -> Result<()> {
67    run_cmd("python3", &["scripts/generate-docs.py", "--check"])
68        .context("generated docs are stale; run `cargo xtask generate-docs`")
69}
70
71pub(crate) fn check_stale_claims() -> Result<()> {
72    run_cmd("python3", &["scripts/check-stale-claims.py"]).context("stale claim check failed")
73}
74
75pub(crate) fn dist() -> Result<()> {
76    const BINARY_NAME: &str = "soma";
77
78    println!("==> Building release binary: {BINARY_NAME}");
79    run_cargo(&["build", "--release", "--locked", "--bin", BINARY_NAME])?;
80
81    let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
82    let artifact = std::path::Path::new(&target_dir)
83        .join("release")
84        .join(BINARY_NAME);
85    if !artifact.exists() {
86        bail!("Release binary not found at {artifact:?} — build must have failed");
87    }
88
89    println!("==> Built {artifact:?}");
90    println!("==> Run `just install-local` to install it to ~/.local/bin for plugin use");
91    Ok(())
92}
93
94/// `cargo xtask doc` — generate rustdoc API reference for workspace crates.
95///
96/// Defaults match the repo's documented doc-build posture: public items only,
97/// no dependency docs, all features so the full gated surface renders. The
98/// `--strict` flag enforces `RUSTDOCFLAGS=-D warnings` (what CI and
99/// `cargo xtask ci` use); without it warnings are surfaced but non-fatal so a
100/// stray doc-link doesn't block a local `cargo xtask doc --open`.
101///
102/// `--docsrs-cfg` appends `--cfg docsrs` to RUSTDOCFLAGS so the
103/// `#![cfg_attr(docsrs, feature(doc_auto_cfg))]` attributes in feature-gated
104/// crates activate and rustdoc renders per-item feature-requirement badges.
105/// `doc_auto_cfg` is a nightly rustdoc feature, so this flag needs a nightly
106/// toolchain (e.g. `RUSTUP_TOOLCHAIN=nightly cargo xtask doc --docsrs-cfg`);
107/// the stable CI doc gate never passes it and is unaffected.
108///
109/// After a successful build the doc root also gets a landing `index.html`
110/// (workspace crate listing) plus `openapi.html`/`openapi.json` (Redoc) — see
111/// `doc_site::emit`.
112pub(crate) fn doc(args: &[String]) -> Result<()> {
113    let mut cargo_args = vec![
114        "doc".to_owned(),
115        "--workspace".to_owned(),
116        "--no-deps".to_owned(),
117        "--locked".to_owned(),
118    ];
119    let mut all_features = true;
120    let mut open = false;
121    let mut strict = false;
122    let mut docsrs_cfg = false;
123    let mut packages: Vec<String> = Vec::new();
124    let mut document_private_items = false;
125
126    let mut iter = args.iter();
127    while let Some(arg) = iter.next() {
128        match arg.as_str() {
129            "--open" => open = true,
130            "--strict" => strict = true,
131            "--docsrs-cfg" => docsrs_cfg = true,
132            "--no-all-features" => all_features = false,
133            "--document-private-items" => document_private_items = true,
134            "--all-features" => all_features = true,
135            "-p" | "--package" => {
136                let pkg = iter
137                    .next()
138                    .ok_or_else(|| anyhow::anyhow!("`{arg}` requires a package name"))?;
139                packages.push(pkg.clone());
140            }
141            "--help" | "-h" => {
142                println!("{DOC_HELP}");
143                return Ok(());
144            }
145            other => bail!("Unknown `cargo xtask doc` option: {other:?}"),
146        }
147    }
148
149    if all_features {
150        cargo_args.push("--all-features".to_owned());
151    }
152    if document_private_items {
153        cargo_args.push("--document-private-items".to_owned());
154    }
155    for pkg in &packages {
156        cargo_args.push("-p".to_owned());
157        cargo_args.push(pkg.clone());
158    }
159    if open {
160        cargo_args.push("--open".to_owned());
161    }
162
163    print_doc_plan(
164        all_features,
165        &packages,
166        document_private_items,
167        open,
168        strict,
169        docsrs_cfg,
170    );
171
172    // Drive `cargo doc` directly rather than through `run_cargo` so we can set
173    // RUSTDOCFLAGS on the child env. Mirrors `run_cargo`'s streaming behavior.
174    // Flags are appended to any RUSTDOCFLAGS already in the environment so an
175    // operator's own flags (or docs.yml's global -D warnings) survive.
176    let mut cmd = Command::new("cargo");
177    cmd.args(&cargo_args);
178    let mut rustdocflags: Vec<String> = std::env::var("RUSTDOCFLAGS")
179        .ok()
180        .filter(|value| !value.trim().is_empty())
181        .into_iter()
182        .collect();
183    if strict {
184        rustdocflags.push("-D warnings".to_owned());
185    }
186    if docsrs_cfg {
187        rustdocflags.push("--cfg docsrs".to_owned());
188    }
189    if !rustdocflags.is_empty() {
190        cmd.env("RUSTDOCFLAGS", rustdocflags.join(" "));
191    }
192    cmd.stdin(Stdio::null());
193
194    let status = cmd
195        .status()
196        .with_context(|| "Failed to spawn `cargo doc`")?;
197    if !status.success() {
198        bail!("`cargo doc` exited with status {status}");
199    }
200
201    let target_dir = std::env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".into());
202    let doc_root = std::path::Path::new(&target_dir).join("doc");
203
204    // Give the doc root an entry point: landing page + OpenAPI/Redoc assets.
205    // This is what .github/workflows/docs.yml deploys to GitHub Pages.
206    crate::doc_site::emit(&doc_root)?;
207
208    println!();
209    println!("==> Rustdoc generated under {doc_root:?}");
210    if !open {
211        println!(
212            "    Open {}/index.html (or pass --open)",
213            doc_root.display()
214        );
215    }
216    if !strict {
217        println!("    Note: warnings were not fatal. CI enforces them via `--strict`.");
218    }
219    Ok(())
220}
221
222/// Build rustdoc with `RUSTDOCFLAGS=-D warnings` — the CI-grade doc gate.
223/// Used by `cargo xtask ci`. Standalone `cargo xtask doc --strict` uses the
224/// same flag via the `doc()` arg path; this helper exists so `ci()` reads as
225/// one line per check, matching the surrounding steps.
226fn run_doc_strict() -> Result<()> {
227    let status = Command::new("cargo")
228        .args([
229            "doc",
230            "--workspace",
231            "--no-deps",
232            "--all-features",
233            "--locked",
234        ])
235        .env("RUSTDOCFLAGS", "-D warnings")
236        .stdin(Stdio::null())
237        .status()
238        .context("Failed to spawn `cargo doc`")?;
239    if !status.success() {
240        bail!("`cargo doc` exited with status {status}");
241    }
242    Ok(())
243}
244
245fn print_doc_plan(
246    all_features: bool,
247    packages: &[String],
248    document_private_items: bool,
249    open: bool,
250    strict: bool,
251    docsrs_cfg: bool,
252) {
253    println!("==> cargo xtask doc");
254    println!(
255        "    features:        {}",
256        if all_features { "all" } else { "default" }
257    );
258    println!(
259        "    packages:        {}",
260        if packages.is_empty() {
261            "workspace".to_owned()
262        } else {
263            packages.join(", ")
264        }
265    );
266    println!(
267        "    private items:   {}",
268        if document_private_items {
269            "yes"
270        } else {
271            "no (public only)"
272        }
273    );
274    println!("    open in browser: {}", if open { "yes" } else { "no" });
275    println!(
276        "    strict (-D warn): {}",
277        if strict { "yes" } else { "no" }
278    );
279    println!(
280        "    docsrs cfg:      {}",
281        if docsrs_cfg {
282            "yes (--cfg docsrs; needs nightly rustdoc)"
283        } else {
284            "no"
285        }
286    );
287}
288
289const DOC_HELP: &str = "cargo xtask doc — generate Rust API documentation (rustdoc)
290
291USAGE:
292  cargo xtask doc [OPTIONS]
293
294OPTIONS:
295      --open                    Open the generated docs in a browser
296      --strict                  Treat rustdoc warnings as errors
297                                (RUSTDOCFLAGS=\"-D warnings\"; mirrors CI)
298      --docsrs-cfg              Append `--cfg docsrs` to RUSTDOCFLAGS so
299                                feature-requirement badges render
300                                (doc_auto_cfg; requires a nightly toolchain)
301      --no-all-features         Document default features only (faster)
302      --all-features            Document all features (default)
303      --document-private-items  Include private items (internal/team docs)
304  -p, --package <NAME>          Document a single workspace package
305                                (repeatable; --no-deps is always set)
306  -h, --help                    Show this help
307
308DEFAULTS:
309  Public items only, no dependency docs, all features. These match the
310  repo's documented cargo-doc posture and what `.github/workflows/docs.yml`
311  deploys to GitHub Pages. Every run also writes target/doc/index.html (a
312  landing page listing all workspace crates) plus openapi.html/openapi.json
313  (the REST contract rendered with Redoc).
314
315EXAMPLES:
316  cargo xtask doc                     # full workspace API docs
317  cargo xtask doc --open              # ...and open in a browser
318  cargo xtask doc -p soma-application # one crate only
319  cargo xtask doc --strict            # CI-grade (warnings are errors)
320  RUSTUP_TOOLCHAIN=nightly cargo xtask doc --docsrs-cfg  # feature badges";
321
322pub(crate) fn ci() -> Result<()> {
323    println!("==> [1/15] cargo fmt --check");
324    run_cargo(&["fmt", "--all", "--", "--check"]).context("fmt failed — run `cargo fmt` to fix")?;
325
326    println!("==> [2/15] cargo xtask check-architecture");
327    architecture::check(std::path::Path::new(".")).context("architecture check failed")?;
328
329    println!("==> [3/15] cargo clippy");
330    run_cargo(&["clippy", "--all-targets", "--", "-D", "warnings"]).context("clippy failed")?;
331
332    println!("==> [4/15] cargo doc --workspace --no-deps --all-features (-D warnings)");
333    run_doc_strict().context("rustdoc failed — run `cargo xtask doc --strict` to see details")?;
334
335    println!("==> [5/15] cargo nextest run --profile ci");
336    if command_exists("cargo-nextest") {
337        run_cargo(&["nextest", "run", "--profile", "ci"]).context("nextest failed")?;
338    } else {
339        eprintln!("  (nextest not installed — falling back to cargo test)");
340        run_cargo(&["test"]).context("cargo test failed")?;
341    }
342
343    println!("==> [6/15] taplo check");
344    if command_exists("taplo") {
345        run_cmd("taplo", &["check"]).context("taplo check failed — run `taplo format` to fix")?;
346    } else {
347        eprintln!("  (taplo not installed — skipping TOML format check)");
348    }
349
350    println!("==> [7/15] cargo xtask patterns");
351    patterns::run(patterns::PatternOptions::default())
352        .context("PATTERNS.md contract check failed")?;
353
354    println!("==> [8/15] cargo xtask check-test-siblings");
355    test_siblings::check().context("test sibling check failed")?;
356
357    println!("==> [9/15] cargo xtask check-docs");
358    check_docs().context("generated docs check failed")?;
359
360    println!("==> [10/15] cargo xtask check-stale-claims");
361    check_stale_claims().context("stale claim check failed")?;
362
363    println!("==> [11/15] cargo xtask check-mcp-registry");
364    mcp_registry::check_default(std::path::Path::new("."))
365        .context("MCP registry manifest check failed")?;
366
367    println!("==> [12/15] cargo xtask check-provider-manifest-contract");
368    provider_manifest::check().context("provider manifest contract check failed")?;
369
370    println!("==> [13/15] cargo xtask check-palette-manifest --check");
371    generated_surfaces::check_palette_manifest(&["--check".to_owned()])
372        .context("Palette manifest check failed")?;
373
374    println!("==> [14/15] cargo xtask check-web-source-sync");
375    web_source::check().context("web source bundle drifted from apps/web")?;
376
377    println!("==> [15/15] cargo audit");
378    if command_exists("cargo-audit") {
379        run_cargo(&["audit"]).context("cargo audit found vulnerabilities")?;
380    } else {
381        eprintln!(
382            "  (cargo-audit not installed — skipping; install with `cargo install cargo-audit`)"
383        );
384    }
385
386    println!("==> All CI checks passed!");
387    Ok(())
388}
389
390pub(crate) fn symlink_docs() -> Result<()> {
391    let mut created = 0usize;
392    let mut skipped = 0usize;
393
394    for entry in WalkDir::new(".")
395        .into_iter()
396        .filter_entry(|entry| {
397            let name = entry.file_name().to_string_lossy();
398            !matches!(name.as_ref(), ".git" | "target")
399        })
400        .filter_map(|entry| entry.ok())
401    {
402        if entry.file_name() != "CLAUDE.md" {
403            continue;
404        }
405
406        let dir = entry.path().parent().expect("CLAUDE.md has a parent");
407        for link_name in ["AGENTS.md", "GEMINI.md"] {
408            let link_path = dir.join(link_name);
409            if link_path.exists() || link_path.symlink_metadata().is_ok() {
410                println!("  skip  {}", link_path.display());
411                skipped += 1;
412                continue;
413            }
414
415            #[cfg(unix)]
416            std::os::unix::fs::symlink("CLAUDE.md", &link_path)
417                .with_context(|| format!("Failed to create symlink at {}", link_path.display()))?;
418            #[cfg(windows)]
419            std::os::windows::fs::symlink_file("CLAUDE.md", &link_path).with_context(|| {
420                format!(
421                    "Failed to create symlink at {} (may need developer mode on Windows)",
422                    link_path.display()
423                )
424            })?;
425
426            println!("  link  {} → CLAUDE.md", link_path.display());
427            created += 1;
428        }
429    }
430
431    println!("==> symlink-docs: {created} created, {skipped} already present");
432    Ok(())
433}
434
435pub(crate) fn check_env() -> Result<()> {
436    let required_vars: &[(&str, &str)] = &[];
437    let optional_vars: &[(&str, &str)] = &[
438        (
439            "SOMA_MCP_TOKEN",
440            "Static bearer token for /mcp (required in production; omit only in loopback dev mode)",
441        ),
442        (
443            "SOMA_MCP_HOST",
444            "Bind host (default 127.0.0.1 — set to 0.0.0.0 only with auth or trusted gateway)",
445        ),
446        ("SOMA_MCP_PORT", "Bind port (default 40060)"),
447        (
448            "RUST_LOG",
449            "Log filter (e.g. info,rmcp=warn — default: info in server mode, warn in stdio/cli)",
450        ),
451    ];
452
453    print_env_report(required_vars, optional_vars)
454}
455
456fn print_env_report(required_vars: &[(&str, &str)], optional_vars: &[(&str, &str)]) -> Result<()> {
457    let mut missing = Vec::new();
458
459    println!("==> Checking required environment variables:");
460    for &(var, desc) in required_vars {
461        match std::env::var(var) {
462            Ok(v) if !v.is_empty() => println!("  OK  {var}"),
463            _ => {
464                println!("  MISSING  {var}");
465                println!("           {desc}");
466                missing.push(var);
467            }
468        }
469    }
470
471    println!("\n==> Optional variables (missing = feature degraded, not error):");
472    for &(var, desc) in optional_vars {
473        match std::env::var(var) {
474            Ok(v) if !v.is_empty() => println!("  set      {var} = {v}"),
475            _ => println!("  unset    {var}  ({desc})"),
476        }
477    }
478
479    if !missing.is_empty() {
480        bail!(
481            "\nMissing required environment variables: {}\n\
482             Copy .env.example to .env and fill in the values.",
483            missing.join(", ")
484        );
485    }
486
487    println!("\n==> All required environment variables are set.");
488    Ok(())
489}
490
491pub(crate) fn print_help() {
492    eprintln!("{HELP_TEXT}");
493}
494
495const HELP_TEXT: &str = "cargo xtask — repo automation for soma
496
497USAGE:
498  cargo xtask <command>
499
500COMMANDS:
501  dist                  Build release binary into Cargo target dir
502  ci                    Run all CI checks: fmt, clippy, nextest, taplo, audit
503  symlink-docs          Create AGENTS.md + GEMINI.md symlinks next to every CLAUDE.md
504  check-env             Validate required environment variables are set
505  check-architecture    Validate workspace dependency-layer boundaries
506  check-test-siblings   Verify every src/*.rs has a sibling *_tests.rs
507  patterns              Check static contracts from docs/PATTERNS.md (--strict, --json)
508  contract-audit        Run local static/spec checks without live upstream calls
509  scaffold             Plan/apply/verify a generated project from Soma
510  codex-schema         Rebuild/bisect the vendored codex-app-server-client schema
511                        (see `cargo xtask codex-schema --help`)
512  cargo-generate        Smoke-test real cargo-generate output (--no-cargo-check)
513  cargo-generate-post   Internal generated-project rewrite command
514  generate-docs         Generate volatile docs and metadata from canonical specs
515  doc                   Generate Rust API docs (rustdoc) for workspace crates (--open, --strict)
516  check-docs            Validate generated docs and metadata are current
517  check-mcp-registry    Validate server.json against docs/contracts/mcp-server.schema.json
518  check-stale-claims    Fail on stale hardcoded Soma claims
519  check-cargo-generate  Validate cargo-generate output
520  sync-web-source       Copy apps/web into crates/soma-web/assets/source
521  check-web-source-sync Validate bundled web source matches apps/web
522  update-aurora-web     Refresh Aurora registry components, validate, then sync
523  build-web             Build optional apps/web static export
524  web-watch             Watch apps/web and rebuild on changes
525  generate-cli          Generate dist/soma-cli through mcporter
526  repair                Rebuild and restart local soma-mcp runtime
527  test-mcp-auth         Smoke-test HTTP MCP bearer auth
528  test-trace-headers    Bounded live smoke for SOMA_MCP_TRACE_HEADERS
529  asciicheck            Check/fix explicit files for non-ASCII characters
530  check-blob-size       Check changed git blobs against size budget
531  block-env-commits     Prevent staged .env secrets from being committed
532  check-coupled-files   Check common companion-file drift in a diff
533  check-dependency-updates
534                        Report Cargo dependency updates
535  check-file-size       Check staged source files against size budgets
536  check-openapi         Generate/check docs/generated/openapi.json
537  check-plugin-hook-contract
538                        Audit cross-repo plugin hook contracts
539  run-ascii-check       Check or fix tracked source/config/docs ASCII hygiene
540  check-plugin-stdio-smoke
541                        Smoke-test installed plugin stdio binary
542  check-runtime-current Check systemd/Docker runtime artifact freshness
543  check-schema-docs     Generate/check docs/MCP_SCHEMA.md
544  check-scaffold-intent-contract
545                        Validate scaffold intent schema/examples
546  apply-no-mcp-marketplace
547                        Remove bundled MCP registrations for the no-MCP branch
548  check-no-mcp-drift    Validate marketplace-no-MCP invariants and branch drift
549  check-ts-client       Regenerate/verify the checked-in codex-app-server-client TS REST client
550  sync-cargo            Copy Cargo.lock into plugin data directories
551  pre-release-check     Run release-readiness gate
552  refresh-docs          Refresh ignored reference docs
553  test-soma-features
554                        Run Soma invariant smoke tests
555  validate-plugin-layout
556                        Validate Claude/Codex/Gemini plugin package layout
557  check-version-sync    Validate release manifest version-file parity
558  check-release-versions [--base REF] [--head REF] [--mode pr|main] [--json]
559                        Validate changed release components have fresh versions/tags
560  release-plan          Print changed release components and candidate tags
561  sync-release-please-version
562                        Sync version files to .release-please-manifest.json
563  bump-version          Bump a component: cargo xtask bump-version soma patch
564  bump-soma-version Bump Soma component: cargo xtask bump-soma-version patch
565  changed-paths         Classify changed files into CI routing categories
566  help                  Show this help
567
568CUSTOMIZE:
569  Add commands by extending the match block in xtask/src/main.rs.
570  Keep dependencies minimal — xtask should compile in seconds.";
571
572#[cfg(test)]
573#[path = "workspace_commands_tests.rs"]
574mod tests;