soma_schema/
agent_rules.rs1use std::path::Path;
8
9use crate::error::Result;
10
11pub const AGENTS_RULES_TEXT: &str = include_str!("../AGENTS.md");
17
18pub const SKILL_TEXT: &str = include_str!("../assets/soma-schema-skill.md");
20
21const IDEMPOTENCY_SENTINEL: &str = "soma-schema";
25const SECTION_HEADER: &str = "\n\n---\n\n";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum RulesTarget {
32 Agents,
34 Claude,
36 Cursor,
38 Windsurf,
40 All,
42 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
59pub 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
75pub 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
90fn 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
108fn 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#[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 assert!(after.starts_with("# existing"));
186 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}