Skip to main content

caliban_memory/
init_import.rs

1//! `/init` companion: probe a workspace for legacy / sibling-tool guidance
2//! files (`AGENTS.md`, `.cursorrules`, `.windsurfrules`) and concatenate their
3//! contents into a single body the operator can paste into their CLAUDE.md.
4//!
5//! Part of ADR 0036 — see the spec's "AGENTS.md" + `/init` notes.
6
7use std::path::{Path, PathBuf};
8
9/// One legacy-rules file discovered in the workspace.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct LegacyRulesFile {
12    /// Filename (e.g. `AGENTS.md`).
13    pub name: String,
14    /// Absolute path.
15    pub path: PathBuf,
16    /// File body (UTF-8 lossy).
17    pub body: String,
18}
19
20/// Filenames probed by `/init` in order of precedence.
21pub const INIT_FILENAMES: &[&str] = &["AGENTS.md", ".cursorrules", ".windsurfrules"];
22
23/// Scan `workspace_root` for any of [`INIT_FILENAMES`] and return the ones
24/// that exist as a `Vec<LegacyRulesFile>` in declaration order.
25#[must_use]
26pub fn scan_init_files(workspace_root: &Path) -> Vec<LegacyRulesFile> {
27    let mut out = Vec::new();
28    for name in INIT_FILENAMES {
29        let path = workspace_root.join(name);
30        if !path.is_file() {
31            continue;
32        }
33        match std::fs::read(&path) {
34            Ok(bytes) => out.push(LegacyRulesFile {
35                name: (*name).to_string(),
36                path,
37                body: String::from_utf8_lossy(&bytes).into_owned(),
38            }),
39            Err(e) => tracing::warn!(
40                target: caliban_common::tracing_targets::TARGET_MEMORY_INIT,
41                path = %path.display(),
42                error = %e,
43                "failed to read init file",
44            ),
45        }
46    }
47    out
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::fs;
54    use tempfile::TempDir;
55
56    #[test]
57    fn init_reads_agents_md_cursorrules_and_windsurfrules() {
58        let tmp = TempDir::new().unwrap();
59        let root = tmp.path();
60        fs::write(root.join("AGENTS.md"), "AGENTS-BODY").unwrap();
61        fs::write(root.join(".cursorrules"), "CURSOR-BODY").unwrap();
62        fs::write(root.join(".windsurfrules"), "WINDSURF-BODY").unwrap();
63
64        let found = scan_init_files(root);
65        let names: Vec<&str> = found.iter().map(|f| f.name.as_str()).collect();
66        assert_eq!(names, vec!["AGENTS.md", ".cursorrules", ".windsurfrules"]);
67        assert!(found[0].body.contains("AGENTS-BODY"));
68        assert!(found[1].body.contains("CURSOR-BODY"));
69        assert!(found[2].body.contains("WINDSURF-BODY"));
70    }
71
72    #[test]
73    fn init_omits_missing_files() {
74        let tmp = TempDir::new().unwrap();
75        let root = tmp.path();
76        fs::write(root.join(".cursorrules"), "ONLY-CURSOR").unwrap();
77        let found = scan_init_files(root);
78        assert_eq!(found.len(), 1);
79        assert_eq!(found[0].name, ".cursorrules");
80    }
81}