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/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}