Skip to main content

soma_schema/
agent_rules.rs

1/// Agent-rules writing logic for `soma-schema init`.
2///
3/// Writes the canonical soma-schema agent-rules block to the appropriate file(s)
4/// in the current directory. Appends idempotently when the target file already
5/// exists (skips if a "soma-schema" migrations section is already present; never
6/// clobbers).
7use std::path::Path;
8
9use crate::error::Result;
10
11// ── Embedded content ──────────────────────────────────────────────────────────
12
13/// Canonical agent-rules block sourced from the repo-root AGENTS.md.
14/// Agents are NON-DETERMINISTIC; soma-schema gives them rules and guardrails
15/// that catch mistakes — not correctness guarantees.
16pub const AGENTS_RULES_TEXT: &str = include_str!("../AGENTS.md");
17
18/// Claude skill content for `~/.claude/skills/soma-schema/SKILL.md`.
19pub const SKILL_TEXT: &str = include_str!("../assets/soma-schema-skill.md");
20
21// ── Sentinel for idempotency ──────────────────────────────────────────────────
22
23/// Substring checked to decide whether the section is already present.
24const IDEMPOTENCY_SENTINEL: &str = "soma-schema";
25const SECTION_HEADER: &str = "\n\n---\n\n";
26
27// ── Public types ──────────────────────────────────────────────────────────────
28
29/// Which agent-rules file(s) `init --rules` should write.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RulesTarget {
32    /// `AGENTS.md` (default — works with Claude Code, Codex, Cursor, Zed, …)
33    Agents,
34    /// `CLAUDE.md`
35    Claude,
36    /// `.cursor/rules/soma-schema.mdc`
37    Cursor,
38    /// `.windsurf/rules/soma-schema.md`
39    Windsurf,
40    /// All of the above
41    All,
42    /// Skip rules writing entirely
43    None,
44}
45
46impl std::fmt::Display for RulesTarget {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            RulesTarget::Agents => write!(f, "agents"),
50            RulesTarget::Claude => write!(f, "claude"),
51            RulesTarget::Cursor => write!(f, "cursor"),
52            RulesTarget::Windsurf => write!(f, "windsurf"),
53            RulesTarget::All => write!(f, "all"),
54            RulesTarget::None => write!(f, "none"),
55        }
56    }
57}
58
59// ── Public API ────────────────────────────────────────────────────────────────
60
61/// Write the requested agent-rules file(s) into `cwd`.
62///
63/// Returns a list of short messages — one per file — describing what happened.
64pub fn write_rules(cwd: &Path, target: &RulesTarget) -> Result<Vec<String>> {
65    let files = target_files(target);
66    let mut written = Vec::new();
67    for rel_path in files {
68        let path = cwd.join(&rel_path);
69        let msg = write_rules_file(&path, AGENTS_RULES_TEXT)?;
70        written.push(format!("{rel_path}: {msg}"));
71    }
72    Ok(written)
73}
74
75/// Install the Claude skill to `~/.claude/skills/soma-schema/SKILL.md`.
76///
77/// Returns a short message describing what happened.
78pub fn install_skill() -> Result<String> {
79    let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
80    let skill_dir = Path::new(&home)
81        .join(".claude")
82        .join("skills")
83        .join("soma-schema");
84    std::fs::create_dir_all(&skill_dir)?;
85    let skill_path = skill_dir.join("SKILL.md");
86    let msg = write_rules_file(&skill_path, SKILL_TEXT)?;
87    Ok(format!("{}: {msg}", skill_path.display()))
88}
89
90// ── Helpers ───────────────────────────────────────────────────────────────────
91
92fn target_files(target: &RulesTarget) -> Vec<String> {
93    match target {
94        RulesTarget::Agents => vec!["AGENTS.md".into()],
95        RulesTarget::Claude => vec!["CLAUDE.md".into()],
96        RulesTarget::Cursor => vec![".cursor/rules/soma-schema.mdc".into()],
97        RulesTarget::Windsurf => vec![".windsurf/rules/soma-schema.md".into()],
98        RulesTarget::All => vec![
99            "AGENTS.md".into(),
100            "CLAUDE.md".into(),
101            ".cursor/rules/soma-schema.mdc".into(),
102            ".windsurf/rules/soma-schema.md".into(),
103        ],
104        RulesTarget::None => vec![],
105    }
106}
107
108/// Write `content` to `path` idempotently.
109/// - File absent → create.
110/// - File present and already contains sentinel → skip (return "skipped").
111/// - File present but no sentinel → append (return "appended …").
112fn write_rules_file(path: &Path, content: &str) -> Result<String> {
113    if path.exists() {
114        let existing = std::fs::read_to_string(path)?;
115        if existing.contains(IDEMPOTENCY_SENTINEL) {
116            return Ok("already contains soma-schema section — skipped".into());
117        }
118        let appended = format!("{existing}{SECTION_HEADER}{content}");
119        std::fs::write(path, &appended)?;
120        return Ok("appended soma-schema section".into());
121    }
122    if let Some(parent) = path.parent() {
123        std::fs::create_dir_all(parent)?;
124    }
125    std::fs::write(path, content)?;
126    Ok("created".into())
127}
128
129// ── Tests ─────────────────────────────────────────────────────────────────────
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn agents_target_creates_agents_md() {
137        let dir = tempfile::tempdir().unwrap();
138        let msgs = write_rules(dir.path(), &RulesTarget::Agents).unwrap();
139        assert_eq!(msgs.len(), 1);
140        assert!(msgs[0].contains("AGENTS.md"), "path: {}", msgs[0]);
141        assert!(msgs[0].contains("created"), "action: {}", msgs[0]);
142        let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
143        assert!(content.contains(IDEMPOTENCY_SENTINEL));
144    }
145
146    #[test]
147    fn claude_target_creates_claude_md() {
148        let dir = tempfile::tempdir().unwrap();
149        let msgs = write_rules(dir.path(), &RulesTarget::Claude).unwrap();
150        assert_eq!(msgs.len(), 1);
151        assert!(msgs[0].contains("CLAUDE.md"), "path: {}", msgs[0]);
152        let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
153        assert!(content.contains(IDEMPOTENCY_SENTINEL));
154    }
155
156    #[test]
157    fn none_target_writes_nothing() {
158        let dir = tempfile::tempdir().unwrap();
159        let msgs = write_rules(dir.path(), &RulesTarget::None).unwrap();
160        assert!(msgs.is_empty());
161        let entries: Vec<_> = std::fs::read_dir(dir.path())
162            .unwrap()
163            .collect::<std::result::Result<_, _>>()
164            .unwrap();
165        assert!(entries.is_empty(), "expected no files written");
166    }
167
168    #[test]
169    fn idempotent_when_section_already_present() {
170        let dir = tempfile::tempdir().unwrap();
171        let path = dir.path().join("AGENTS.md");
172        std::fs::write(
173            &path,
174            "# existing\n\nThis mentions soma-schema migrations.\n",
175        )
176        .unwrap();
177        let msgs = write_rules(dir.path(), &RulesTarget::Agents).unwrap();
178        assert!(
179            msgs[0].contains("skipped"),
180            "expected skipped, got: {}",
181            msgs[0]
182        );
183        let after = std::fs::read_to_string(&path).unwrap();
184        // Original content preserved and no extra blob appended.
185        assert!(after.starts_with("# existing"));
186        // The rule text (e.g. DOWN ==) should NOT have been appended.
187        assert!(
188            !after.contains("-- DOWN =="),
189            "should not have appended rules"
190        );
191    }
192
193    #[test]
194    fn appends_when_file_has_no_section() {
195        let dir = tempfile::tempdir().unwrap();
196        let path = dir.path().join("CLAUDE.md");
197        std::fs::write(&path, "# My project CLAUDE.md\n\nSome instructions.\n").unwrap();
198        let msgs = write_rules(dir.path(), &RulesTarget::Claude).unwrap();
199        assert!(
200            msgs[0].contains("appended"),
201            "expected appended, got: {}",
202            msgs[0]
203        );
204        let content = std::fs::read_to_string(&path).unwrap();
205        assert!(
206            content.contains("My project CLAUDE.md"),
207            "original preserved"
208        );
209        assert!(content.contains(IDEMPOTENCY_SENTINEL), "rules appended");
210    }
211
212    #[test]
213    fn all_target_writes_four_files() {
214        let dir = tempfile::tempdir().unwrap();
215        let msgs = write_rules(dir.path(), &RulesTarget::All).unwrap();
216        assert_eq!(msgs.len(), 4);
217        assert!(dir.path().join("AGENTS.md").exists());
218        assert!(dir.path().join("CLAUDE.md").exists());
219        assert!(dir.path().join(".cursor/rules/soma-schema.mdc").exists());
220        assert!(dir.path().join(".windsurf/rules/soma-schema.md").exists());
221    }
222
223    #[test]
224    fn cursor_and_windsurf_create_nested_dirs() {
225        let dir = tempfile::tempdir().unwrap();
226        write_rules(dir.path(), &RulesTarget::Cursor).unwrap();
227        assert!(dir.path().join(".cursor/rules/soma-schema.mdc").exists());
228        write_rules(dir.path(), &RulesTarget::Windsurf).unwrap();
229        assert!(dir.path().join(".windsurf/rules/soma-schema.md").exists());
230    }
231}