Skip to main content

xtask/
ci_paths.rs

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