Skip to main content

xtask/
scripts.rs

1//! Rust implementations for small scripts that have thin wrappers
2//! in `scripts/`.
3
4use anyhow::{bail, Context, Result};
5use std::path::{Path, PathBuf};
6use std::process::{Command, Stdio};
7
8use crate::{run_cargo, run_cmd_output};
9
10pub fn block_env_commits() -> Result<()> {
11    let staged = git_output(&["diff", "--cached", "--name-only"])?;
12    let blocked: Vec<&str> = staged
13        .lines()
14        .filter(|path| is_blocked_env_path(path))
15        .collect();
16
17    if blocked.is_empty() {
18        return Ok(());
19    }
20
21    eprintln!("block-env-commits: BLOCKED - .env file(s) staged for commit:");
22    for path in &blocked {
23        eprintln!("  {path}");
24    }
25    eprintln!();
26    eprintln!("Only .env.example is allowed to be committed.");
27    eprintln!("Remove the staged file(s) with: git restore --staged <file>");
28    eprintln!("Then add them to .gitignore if they aren't already.");
29    bail!(".env file(s) staged for commit")
30}
31
32pub fn check_coupled_files(args: &[String]) -> Result<()> {
33    if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
34        println!("Usage: cargo xtask check-coupled-files [BASE] [HEAD]");
35        return Ok(());
36    }
37    if args.len() > 2 {
38        bail!("Usage: cargo xtask check-coupled-files [BASE] [HEAD]");
39    }
40
41    let mut base = args.first().map(String::as_str).unwrap_or("origin/main");
42    let head = args.get(1).map(String::as_str).unwrap_or("HEAD");
43    if !git_ref_exists(base) {
44        base = "HEAD~1";
45    }
46
47    let changed = git_output(&["diff", "--name-only", base, head])?;
48    let changed: Vec<&str> = changed.lines().collect();
49    let mut issues = Vec::new();
50
51    if changed_path(&changed, "Justfile") && !changed_path(&changed, "lefthook.yml") {
52        issues.push("Justfile changed but lefthook.yml did not; confirm hook/recipe parity.");
53    }
54    if changed_path(&changed, "lefthook.yml") && !changed_path(&changed, "Justfile") {
55        issues.push(
56            "lefthook.yml changed but Justfile did not; confirm matching manual recipe exists.",
57        );
58    }
59    if changed_path(&changed, "scripts/*") && !changed_path(&changed, "scripts/README.md") {
60        issues.push("scripts changed but scripts/README.md did not; document new or changed script behavior.");
61    }
62    if changed_path(&changed, "crates/soma/mcp/src/schemas.rs")
63        && !changed_path(&changed, "docs/MCP_SCHEMA.md")
64        && crate::scripts_lane_d::check_schema_docs(&["--check".to_owned()]).is_err()
65    {
66        issues.push("crates/soma/mcp/src/schemas.rs changed but docs/MCP_SCHEMA.md did not; run scripts/check-schema-docs.py --write.");
67    }
68    if changed_path(&changed, "plugins/soma/*") && !changed_path(&changed, "docs/PLUGINS.md") {
69        issues.push("plugin package changed but docs/PLUGINS.md did not; confirm plugin docs are still current.");
70    }
71
72    if !issues.is_empty() {
73        eprintln!("Coupled-file check failed:");
74        for issue in &issues {
75            eprintln!("  - {issue}");
76        }
77        bail!("coupled-file check failed");
78    }
79
80    println!("Coupled-file check passed ({base}..{head}).");
81    Ok(())
82}
83
84pub fn check_file_size() -> Result<()> {
85    let max_rs = env_usize("MAX_RS", 350)?;
86    let max_ts = env_usize("MAX_TS", 300)?;
87    let staged = git_output(&["diff", "--cached", "--name-only", "--diff-filter=ACM"])?;
88    let mut violations = Vec::new();
89
90    for file in staged.lines() {
91        let path = Path::new(file);
92        if !path.is_file() || is_test_file(file) {
93            continue;
94        }
95
96        let Some(limit) = source_limit(file, max_rs, max_ts) else {
97            continue;
98        };
99        let text =
100            std::fs::read_to_string(path).with_context(|| format!("failed to read {file}"))?;
101        let lines = if file.ends_with(".rs") {
102            rust_production_lines(&text)
103        } else {
104            count_effective_loc(&text, None)
105        };
106
107        if lines > limit {
108            violations.push(format!(
109                "  {file}: {lines} effective lines (limit: {limit})"
110            ));
111        }
112    }
113
114    if violations.is_empty() {
115        return Ok(());
116    }
117
118    eprintln!();
119    eprintln!("Monolithic staged file(s) detected; split them into focused modules:");
120    for violation in &violations {
121        eprintln!("{violation}");
122    }
123    eprintln!();
124    eprintln!("Limits: .rs={max_rs} production lines, .ts/.tsx={max_ts} lines; test files exempt.");
125    bail!("staged source file size budget exceeded")
126}
127
128pub fn sync_cargo() -> Result<()> {
129    let repo_root = env_path("CLAUDE_PLUGIN_ROOT").unwrap_or_else(current_dir);
130    let data_root = env_path("CLAUDE_PLUGIN_DATA").unwrap_or_else(|| repo_root.clone());
131    let src_lock = repo_root.join("Cargo.lock");
132    let dst_lock = data_root.join("Cargo.lock");
133
134    if !src_lock.is_file() {
135        bail!("sync-cargo.sh: missing lockfile at {}", src_lock.display());
136    }
137
138    if same_file_bytes(&src_lock, &dst_lock)? {
139        return Ok(());
140    }
141
142    std::fs::create_dir_all(&data_root)
143        .with_context(|| format!("failed to create {}", data_root.display()))?;
144
145    if let Err(copy_error) = std::fs::copy(&src_lock, &dst_lock) {
146        eprintln!(
147            "sync-cargo: failed to copy {} to {}: {copy_error}; falling back to cargo fetch",
148            src_lock.display(),
149            dst_lock.display()
150        );
151        if let Err(fetch_error) = run_cargo_fetch(&repo_root) {
152            let _ = std::fs::remove_file(&dst_lock);
153            return Err(fetch_error);
154        }
155    }
156
157    Ok(())
158}
159
160pub fn run_ascii_check(args: &[String]) -> Result<()> {
161    let fix = match args {
162        [] => false,
163        [arg] if arg == "--fix" => true,
164        [arg] if arg == "--help" || arg == "-h" => {
165            println!("Usage: cargo xtask run-ascii-check [--fix]");
166            return Ok(());
167        }
168        _ => bail!("Usage: cargo xtask run-ascii-check [--fix]"),
169    };
170
171    let output = git_output(&[
172        "ls-files",
173        "*.md",
174        "*.rs",
175        "*.toml",
176        "*.json",
177        "*.yml",
178        "*.yaml",
179        "*.sh",
180        "*.py",
181        ":!:docs/references/**",
182        ":!:docs/sessions/**",
183    ])?;
184    let files: Vec<String> = output
185        .lines()
186        .filter(|path| Path::new(path).is_file())
187        .map(str::to_owned)
188        .collect();
189
190    if files.is_empty() {
191        println!("No files to check");
192        return Ok(());
193    }
194
195    let mut ascii_args = Vec::new();
196    if fix {
197        ascii_args.push("--fix".to_owned());
198    }
199    ascii_args.extend(files);
200    crate::scripts_lane_d::asciicheck(&ascii_args)
201}
202
203pub fn check_plugin_stdio_smoke() -> Result<()> {
204    let bin = std::env::var("BIN").unwrap_or_else(|_| "soma".to_owned());
205    let timeout_secs = std::env::var("TIMEOUT_SECS").unwrap_or_else(|_| "5".to_owned());
206
207    if !command_on_path(&bin) {
208        bail!("plugin stdio smoke: {bin} is not on PATH\nrun: just install-local");
209    }
210
211    let input = [
212        r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"plugin-stdio-smoke","version":"0.0.0"}}}"#,
213        r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#,
214        r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"soma","arguments":{"action":"status"}}}"#,
215    ]
216    .join("\n");
217
218    let mut child = Command::new("timeout")
219        .arg(format!("{timeout_secs}s"))
220        .arg(&bin)
221        .arg("mcp")
222        .env("SOMA_API_URL", "")
223        .env("RUST_LOG", "warn")
224        .stdin(Stdio::piped())
225        .stdout(Stdio::piped())
226        .stderr(Stdio::inherit())
227        .spawn()
228        .with_context(|| format!("failed to spawn timeout/{bin}"))?;
229
230    {
231        use std::io::Write;
232        let stdin = child.stdin.as_mut().context("failed to open child stdin")?;
233        stdin
234            .write_all(input.as_bytes())
235            .context("failed to write JSON-RPC smoke input")?;
236        stdin
237            .write_all(b"\n")
238            .context("failed to write trailing newline")?;
239    }
240
241    let output = child
242        .wait_with_output()
243        .context("failed to read plugin stdio smoke output")?;
244    if !output.status.success() {
245        bail!("plugin stdio smoke command exited with {}", output.status);
246    }
247    let stdout =
248        String::from_utf8(output.stdout).context("plugin stdio smoke emitted non-UTF-8 stdout")?;
249
250    for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
251        let value: serde_json::Value = serde_json::from_str(line)
252            .with_context(|| format!("invalid JSON-RPC line from stdio smoke: {line}"))?;
253        if value.get("id").and_then(serde_json::Value::as_i64) != Some(2) {
254            continue;
255        }
256        if value
257            .pointer("/result/structuredContent/status")
258            .and_then(serde_json::Value::as_str)
259            == Some("ok")
260        {
261            println!("plugin stdio smoke passed");
262            return Ok(());
263        }
264        bail!("plugin stdio smoke response for id=2 did not report status=ok: {value}");
265    }
266
267    bail!("plugin stdio smoke did not receive a tools/call response with id=2")
268}
269
270fn current_dir() -> PathBuf {
271    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
272}
273
274fn env_path(name: &str) -> Option<PathBuf> {
275    std::env::var_os(name)
276        .filter(|value| !value.is_empty())
277        .map(PathBuf::from)
278}
279
280fn env_usize(name: &str, default: usize) -> Result<usize> {
281    match std::env::var(name) {
282        Ok(value) => value
283            .parse()
284            .with_context(|| format!("{name} must be a positive integer")),
285        Err(std::env::VarError::NotPresent) => Ok(default),
286        Err(error) => Err(error).with_context(|| format!("failed to read {name}")),
287    }
288}
289
290fn git_output(args: &[&str]) -> Result<String> {
291    run_cmd_output("git", args)
292}
293
294fn git_ref_exists(ref_name: &str) -> bool {
295    Command::new("git")
296        .args(["rev-parse", "--verify", ref_name])
297        .stdin(Stdio::null())
298        .stdout(Stdio::null())
299        .stderr(Stdio::null())
300        .status()
301        .map(|status| status.success())
302        .unwrap_or(false)
303}
304
305fn command_on_path(name: &str) -> bool {
306    if name.contains('/') {
307        return Path::new(name).is_file();
308    }
309    let Some(paths) = std::env::var_os("PATH") else {
310        return false;
311    };
312    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
313}
314
315fn is_blocked_env_path(path: &str) -> bool {
316    if path.ends_with(".env.example") {
317        return false;
318    }
319    path.rsplit('/')
320        .next()
321        .is_some_and(|name| name.contains(".env"))
322}
323
324fn source_limit(path: &str, max_rs: usize, max_ts: usize) -> Option<usize> {
325    if path.ends_with(".rs") {
326        Some(max_rs)
327    } else if path.ends_with(".ts") || path.ends_with(".tsx") {
328        Some(max_ts)
329    } else {
330        None
331    }
332}
333
334fn is_test_file(path: &str) -> bool {
335    path.contains("/test/")
336        || path.contains("/tests/")
337        || path.ends_with("_test.rs")
338        || path.ends_with("/tests.rs")
339        || path.ends_with(".test.ts")
340        || path.ends_with(".test.tsx")
341        || path.ends_with(".spec.ts")
342        || path.ends_with(".spec.tsx")
343        || path.contains("/__tests__/")
344}
345
346fn rust_production_lines(text: &str) -> usize {
347    count_effective_loc(text, trailing_rust_test_module_start(text))
348}
349
350fn trailing_rust_test_module_start(text: &str) -> Option<usize> {
351    let mut cfg_line: Option<usize> = None;
352    for (index, raw_line) in text.lines().enumerate() {
353        let line_number = index + 1;
354        let line = raw_line.trim_start();
355        if line.contains("#[cfg(test)]") {
356            cfg_line = Some(line_number);
357            continue;
358        }
359        if let Some(start) = cfg_line {
360            if is_rust_mod_line(line) {
361                return Some(start);
362            }
363        }
364        cfg_line = None;
365    }
366    None
367}
368
369fn is_rust_mod_line(line: &str) -> bool {
370    let line = line.strip_prefix("pub ").unwrap_or(line);
371    let Some(rest) = line.strip_prefix("mod ") else {
372        return false;
373    };
374    let Some((name, tail)) = rest.split_once(' ') else {
375        return false;
376    };
377    !name.is_empty()
378        && name.chars().all(|ch| ch.is_ascii_lowercase() || ch == '_')
379        && tail.trim_start().starts_with('{')
380}
381
382fn count_effective_loc(text: &str, stop_before_line: Option<usize>) -> usize {
383    let mut count = 0usize;
384    let mut in_block = false;
385
386    for (index, raw_line) in text.lines().enumerate() {
387        let line_number = index + 1;
388        if stop_before_line.is_some_and(|stop| line_number >= stop) {
389            break;
390        }
391
392        let mut line = raw_line.trim_start();
393        if line.is_empty() {
394            continue;
395        }
396
397        if in_block {
398            if let Some(end) = line.find("*/") {
399                line = line[end + 2..].trim_start();
400                in_block = false;
401                if line.is_empty() {
402                    continue;
403                }
404            } else {
405                continue;
406            }
407        }
408
409        if line.starts_with("//") {
410            continue;
411        }
412
413        if line.starts_with("/*") {
414            if let Some(end) = line.find("*/") {
415                line = line[end + 2..].trim_start();
416                if line.is_empty() {
417                    continue;
418                }
419            } else {
420                in_block = true;
421                continue;
422            }
423        }
424
425        count += 1;
426    }
427
428    count
429}
430
431fn changed_path(paths: &[&str], pattern: &str) -> bool {
432    paths.iter().any(|path| glob_match(pattern, path))
433}
434
435fn glob_match(pattern: &str, path: &str) -> bool {
436    if let Some((prefix, suffix)) = pattern.split_once('*') {
437        path.starts_with(prefix) && path.ends_with(suffix)
438    } else {
439        path == pattern
440    }
441}
442
443fn same_file_bytes(left: &Path, right: &Path) -> Result<bool> {
444    if !right.exists() {
445        return Ok(false);
446    }
447    let left = std::fs::read(left).with_context(|| format!("failed to read {}", left.display()))?;
448    let right =
449        std::fs::read(right).with_context(|| format!("failed to read {}", right.display()))?;
450    Ok(left == right)
451}
452
453fn run_cargo_fetch(repo_root: &Path) -> Result<()> {
454    let manifest = repo_root.join("Cargo.toml");
455    let manifest = manifest
456        .to_str()
457        .with_context(|| format!("non-UTF-8 manifest path: {}", manifest.display()))?;
458    run_cargo(&["fetch", "--manifest-path", manifest])
459}
460
461#[cfg(test)]
462mod tests {
463    use super::{
464        changed_path, command_on_path, count_effective_loc, glob_match, is_blocked_env_path,
465        is_test_file, rust_production_lines, trailing_rust_test_module_start,
466    };
467
468    #[test]
469    fn blocks_env_files_except_examples() {
470        assert!(is_blocked_env_path(".env"));
471        assert!(is_blocked_env_path("config/.env.local"));
472        assert!(is_blocked_env_path("services/foo.env.prod"));
473        assert!(!is_blocked_env_path(".env.example"));
474        assert!(!is_blocked_env_path("docs/env.example.md"));
475    }
476
477    #[test]
478    fn matches_bash_style_single_star_patterns_used_by_coupled_check() {
479        assert!(glob_match("scripts/*", "scripts/check-coupled-files.sh"));
480        assert!(glob_match("plugins/soma/*", "plugins/soma/hooks/setup.sh"));
481        assert!(glob_match("Justfile", "Justfile"));
482        assert!(!glob_match("Justfile", "docs/Justfile"));
483    }
484
485    #[test]
486    fn changed_path_checks_any_changed_path() {
487        let paths = ["README.md", "scripts/check-coupled-files.sh"];
488        assert!(changed_path(&paths, "scripts/*"));
489        assert!(!changed_path(&paths, "lefthook.yml"));
490    }
491
492    #[test]
493    fn command_on_path_handles_absolute_missing_path() {
494        assert!(!command_on_path("/definitely/not/a/real/soma"));
495    }
496
497    #[test]
498    fn test_file_detection_matches_precommit_scope() {
499        assert!(is_test_file("crates/foo/tests/integration.rs"));
500        assert!(is_test_file("src/widget_test.rs"));
501        assert!(is_test_file("src/tests.rs"));
502        assert!(is_test_file("apps/web/button.test.tsx"));
503        assert!(is_test_file("apps/web/__tests__/button.ts"));
504        assert!(!is_test_file("src/app.rs"));
505    }
506
507    #[test]
508    fn effective_loc_ignores_comments_blanks_and_blocks() {
509        let text = r#"
510// comment
511
512/* block
513   comment */
514fn main() {}
515/* inline */ let x = 1;
516"#;
517        assert_eq!(count_effective_loc(text, None), 2);
518    }
519
520    #[test]
521    fn rust_production_lines_cut_before_trailing_test_module() {
522        let text = r#"
523pub fn production() {}
524
525#[cfg(test)]
526mod tests {
527    #[test]
528    fn works() {}
529}
530"#;
531        assert_eq!(trailing_rust_test_module_start(text), Some(4));
532        assert_eq!(rust_production_lines(text), 1);
533    }
534
535    #[test]
536    fn rust_production_lines_do_not_cut_for_cfg_test_function() {
537        let text = r#"
538pub fn production() {}
539
540#[cfg(test)]
541fn helper() {}
542"#;
543        assert_eq!(trailing_rust_test_module_start(text), None);
544        assert_eq!(rust_production_lines(text), 3);
545    }
546}