Skip to main content

xtask/
ci_paths.rs

1use anyhow::{Context, Result, bail};
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7const OUTPUT_KEYS: &[&str] = &[
8    "all", "docs", "workflow", "rust", "web", "native", "mcp", "docker", "toml", "soma",
9    "security", "secrets", "release",
10];
11
12pub fn run(args: &[String]) -> Result<()> {
13    let options = Options::parse(args)?;
14    let paths = match &options.changed_files {
15        Some(path) => read_paths(path)?,
16        None => resolve_paths(&options.event)?,
17    };
18
19    if let Some(path) = &options.write_changed_files {
20        fs::write(
21            path,
22            paths.join("\n") + if paths.is_empty() { "" } else { "\n" },
23        )
24        .with_context(|| format!("write changed files to {}", path.display()))?;
25    }
26
27    let values = classify(&options.event, &paths);
28    write_outputs(&options.output, &values)?;
29
30    for key in OUTPUT_KEYS {
31        println!("{key}={}", values[*key]);
32    }
33
34    Ok(())
35}
36
37#[derive(Debug)]
38struct Options {
39    event: String,
40    changed_files: Option<PathBuf>,
41    output: PathBuf,
42    write_changed_files: Option<PathBuf>,
43}
44
45impl Options {
46    fn parse(args: &[String]) -> Result<Self> {
47        let mut event = None;
48        let mut changed_files = None;
49        let mut output = None;
50        let mut write_changed_files = None;
51        let mut index = 0usize;
52
53        while index < args.len() {
54            match args[index].as_str() {
55                "--event" => {
56                    index += 1;
57                    event = Some(
58                        args.get(index)
59                            .context("--event requires a value")?
60                            .to_owned(),
61                    );
62                }
63                "--changed-files" => {
64                    index += 1;
65                    changed_files = Some(PathBuf::from(
66                        args.get(index)
67                            .context("--changed-files requires a value")?,
68                    ));
69                }
70                "--output" => {
71                    index += 1;
72                    output = Some(PathBuf::from(
73                        args.get(index).context("--output requires a value")?,
74                    ));
75                }
76                "--write-changed-files" => {
77                    index += 1;
78                    write_changed_files = Some(PathBuf::from(
79                        args.get(index)
80                            .context("--write-changed-files requires a value")?,
81                    ));
82                }
83                "--help" | "-h" => bail!(
84                    "Usage: cargo xtask changed-paths --event <event> --output <path> [--changed-files <path>] [--write-changed-files <path>]"
85                ),
86                unknown => bail!("unknown changed-paths option: {unknown}"),
87            }
88            index += 1;
89        }
90
91        Ok(Self {
92            event: event.context("--event is required")?,
93            changed_files,
94            output: output.context("--output is required")?,
95            write_changed_files,
96        })
97    }
98}
99
100fn classify(event: &str, paths: &[String]) -> BTreeMap<String, bool> {
101    if event == "workflow_dispatch" {
102        return all_enabled();
103    }
104    if paths.is_empty() {
105        return all_enabled();
106    }
107
108    let workflow = any(paths, |p| {
109        starts(p, &[".github/"])
110            || matches!(
111                p,
112                "xtask/src/ci_paths.rs" | "xtask/src/main.rs" | "docs/CI.md"
113            )
114    });
115    let docs = any(paths, |p| {
116        (starts(p, &["docs/"]) && !starts(p, &["docs/sessions/"]))
117            || matches!(
118                p,
119                "README.md" | "CHANGELOG.md" | "CLAUDE.md" | "AGENTS.md" | "GEMINI.md"
120            )
121    });
122    let web = any(paths, |p| {
123        starts(p, &["apps/web/", "crates/soma/web/"])
124            || matches!(p, "package.json" | "pnpm-lock.yaml" | "pnpm-workspace.yaml")
125    });
126    let mcp = any(paths, |p| {
127        starts(
128            p,
129            &[
130                "crates/soma/mcp/",
131                "crates/soma/api/",
132                // crates/soma/contracts was split (plan PR 13) and deleted
133                // (PR 19); the pieces that shape MCP tool schemas and server
134                // startup env now live in soma-domain (ACTION_SPECS) and
135                // soma-config (McpConfig, env prefixes).
136                "crates/soma/domain/",
137                "crates/soma/config/",
138                "apps/soma/tests/mcporter/",
139                "docs/reference/mcp/",
140            ],
141        )
142    });
143    let rust = any(paths, |p| {
144        starts(p, &["apps/soma/", "crates/", "xtask/"])
145            || matches!(
146                p,
147                "Cargo.toml" | "Cargo.lock" | "rust-toolchain.toml" | ".cargo/config.toml"
148            )
149    });
150    let docker = rust
151        || web
152        || any(paths, |p| {
153            starts(p, &["config/", "scripts/"])
154                || matches!(
155                    p,
156                    ".dockerignore"
157                        | ".env.example"
158                        | "docker-compose.yml"
159                        | "docker-compose.prod.yml"
160                )
161        });
162    let toml = any(paths, |p| p.ends_with(".toml"));
163    let soma = rust
164        || mcp
165        || docs
166        || any(paths, |p| {
167            starts(p, &["plugins/", "scaffold/", "cargo-generate/", ".claude/"])
168                || matches!(p, "Justfile" | "lefthook.yml")
169        });
170    let security = rust
171        || any(paths, |p| {
172            matches!(p, "Cargo.lock" | "deny.toml") || starts(p, &["vendor/"])
173        });
174    let native = rust || web;
175    let release = rust || web || any(paths, |p| starts(p, &["release/", "packages/soma-rmcp/"]));
176
177    let mut result = BTreeMap::new();
178    result.insert("all".to_owned(), false);
179    result.insert("docs".to_owned(), docs);
180    result.insert("workflow".to_owned(), workflow);
181    result.insert("rust".to_owned(), rust);
182    result.insert("web".to_owned(), web);
183    result.insert("native".to_owned(), native);
184    result.insert("mcp".to_owned(), mcp);
185    result.insert("docker".to_owned(), docker);
186    result.insert("toml".to_owned(), toml);
187    result.insert("soma".to_owned(), soma);
188    result.insert("security".to_owned(), security);
189    result.insert(
190        "secrets".to_owned(),
191        !only_low_risk_docs_or_agent_files(paths),
192    );
193    result.insert("release".to_owned(), release);
194
195    if workflow {
196        for key in OUTPUT_KEYS {
197            result.insert((*key).to_owned(), true);
198        }
199    }
200
201    result
202}
203
204fn all_enabled() -> BTreeMap<String, bool> {
205    OUTPUT_KEYS
206        .iter()
207        .map(|key| ((*key).to_owned(), true))
208        .collect()
209}
210
211fn only_low_risk_docs_or_agent_files(paths: &[String]) -> bool {
212    paths.iter().all(|p| {
213        starts(p, &[".agents/skills/", "docs/sessions/"])
214            || (starts(p, &["docs/"]) && p.ends_with(".md"))
215    })
216}
217
218fn starts(path: &str, prefixes: &[&str]) -> bool {
219    prefixes
220        .iter()
221        .any(|prefix| path == prefix.trim_end_matches('/') || path.starts_with(*prefix))
222}
223
224fn any(paths: &[String], predicate: impl Fn(&str) -> bool) -> bool {
225    paths.iter().any(|path| predicate(path))
226}
227
228fn read_paths(path: &Path) -> Result<Vec<String>> {
229    if !path.exists() {
230        return Ok(Vec::new());
231    }
232    let content = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
233    Ok(content
234        .lines()
235        .map(str::trim)
236        .filter(|line| !line.is_empty())
237        .map(str::to_owned)
238        .collect())
239}
240
241fn resolve_paths(event: &str) -> Result<Vec<String>> {
242    if event == "workflow_dispatch" {
243        return Ok(Vec::new());
244    }
245
246    let head = std::env::var("PR_HEAD_SHA")
247        .or_else(|_| std::env::var("HEAD_SHA"))
248        .or_else(|_| std::env::var("GITHUB_SHA"))
249        .unwrap_or_else(|_| "HEAD".to_owned());
250    let mut base = match event {
251        "pull_request" => std::env::var("PR_BASE_SHA").unwrap_or_default(),
252        "push" => {
253            if std::env::var("GITHUB_REF")
254                .unwrap_or_default()
255                .starts_with("refs/tags/")
256            {
257                return Ok(Vec::new());
258            }
259            std::env::var("PUSH_BEFORE_SHA").unwrap_or_default()
260        }
261        _ => return Ok(Vec::new()),
262    };
263
264    if base.is_empty() || base.chars().all(|ch| ch == '0') || !git_ref_exists(&base) {
265        base = git_output(&["rev-parse", "HEAD^"]).unwrap_or_default();
266    }
267    if base.is_empty() {
268        return Ok(Vec::new());
269    }
270
271    let raw = git_output(&["diff", "--name-only", &base, &head]).unwrap_or_default();
272    Ok(raw
273        .lines()
274        .map(str::trim)
275        .filter(|line| !line.is_empty())
276        .map(str::to_owned)
277        .collect())
278}
279
280fn git_ref_exists(rev: &str) -> bool {
281    Command::new("git")
282        .args(["cat-file", "-e", rev])
283        .stdout(Stdio::null())
284        .stderr(Stdio::null())
285        .status()
286        .map(|status| status.success())
287        .unwrap_or(false)
288}
289
290fn git_output(args: &[&str]) -> Result<String> {
291    let output = Command::new("git")
292        .args(args)
293        .stderr(Stdio::null())
294        .output()
295        .with_context(|| format!("run git {}", args.join(" ")))?;
296    if !output.status.success() {
297        bail!("git {} failed", args.join(" "));
298    }
299    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
300}
301
302fn write_outputs(path: &Path, values: &BTreeMap<String, bool>) -> Result<()> {
303    let content = OUTPUT_KEYS
304        .iter()
305        .map(|key| format!("{key}={}", values[*key]))
306        .collect::<Vec<_>>()
307        .join("\n")
308        + "\n";
309    if let Some(parent) = path.parent() {
310        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
311    }
312    fs::write(path, content).with_context(|| format!("write {}", path.display()))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn classify_paths(paths: &[&str]) -> BTreeMap<String, bool> {
320        classify(
321            "pull_request",
322            &paths
323                .iter()
324                .map(|path| (*path).to_owned())
325                .collect::<Vec<_>>(),
326        )
327    }
328
329    #[test]
330    fn agent_skill_changes_skip_expensive_ci() {
331        let out = classify_paths(&[".agents/skills/firecrawl-cli/SKILL.md"]);
332        for key in OUTPUT_KEYS {
333            assert!(!out[*key], ".agents changes should not enable {key}");
334        }
335    }
336
337    #[test]
338    fn prose_docs_skip_runtime_but_enable_soma_docs() {
339        let out = classify_paths(&["docs/SCAFFOLD.md"]);
340        assert!(out["docs"]);
341        assert!(out["soma"]);
342        assert!(!out["rust"]);
343        assert!(!out["web"]);
344        assert!(!out["native"]);
345        assert!(!out["docker"]);
346    }
347
348    #[test]
349    fn rust_changes_enable_runtime_dependents() {
350        let out = classify_paths(&["crates/soma/mcp/src/tool.rs"]);
351        assert!(out["rust"]);
352        assert!(out["mcp"]);
353        assert!(out["native"]);
354        assert!(out["docker"]);
355        assert!(out["soma"]);
356        assert!(out["security"]);
357        assert!(out["release"]);
358    }
359
360    #[test]
361    fn soma_app_changes_enable_runtime_dependents() {
362        let out = classify_paths(&["apps/soma/src/lib.rs"]);
363        assert!(out["rust"]);
364        assert!(out["native"]);
365        assert!(out["docker"]);
366        assert!(out["soma"]);
367        assert!(out["security"]);
368        assert!(out["release"]);
369    }
370
371    #[test]
372    fn npm_launcher_changes_enable_release_checks() {
373        let out = classify_paths(&["packages/soma-rmcp/scripts/install.js"]);
374        assert!(out["release"]);
375        assert!(!out["rust"]);
376        assert!(!out["web"]);
377    }
378
379    #[test]
380    fn workflow_changes_fail_safe_to_full_ci() {
381        let out = classify_paths(&[".github/workflows/ci.yml"]);
382        for key in OUTPUT_KEYS {
383            assert!(out[*key], "workflow changes should enable {key}");
384        }
385    }
386
387    #[test]
388    fn manual_runs_enable_full_ci() {
389        let out = classify("workflow_dispatch", &[]);
390        for key in OUTPUT_KEYS {
391            assert!(out[*key], "workflow_dispatch should enable {key}");
392        }
393    }
394}