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