use std::path::Path;
use crate::error::Result;
pub const AGENTS_RULES_TEXT: &str = include_str!("../AGENTS.md");
pub const SKILL_TEXT: &str = include_str!("../assets/soma-schema-skill.md");
const IDEMPOTENCY_SENTINEL: &str = "soma-schema";
const SECTION_HEADER: &str = "\n\n---\n\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RulesTarget {
Agents,
Claude,
Cursor,
Windsurf,
All,
None,
}
impl std::fmt::Display for RulesTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RulesTarget::Agents => write!(f, "agents"),
RulesTarget::Claude => write!(f, "claude"),
RulesTarget::Cursor => write!(f, "cursor"),
RulesTarget::Windsurf => write!(f, "windsurf"),
RulesTarget::All => write!(f, "all"),
RulesTarget::None => write!(f, "none"),
}
}
}
pub fn write_rules(cwd: &Path, target: &RulesTarget) -> Result<Vec<String>> {
let files = target_files(target);
let mut written = Vec::new();
for rel_path in files {
let path = cwd.join(&rel_path);
let msg = write_rules_file(&path, AGENTS_RULES_TEXT)?;
written.push(format!("{rel_path}: {msg}"));
}
Ok(written)
}
pub fn install_skill() -> Result<String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
let skill_dir = Path::new(&home)
.join(".claude")
.join("skills")
.join("soma-schema");
std::fs::create_dir_all(&skill_dir)?;
let skill_path = skill_dir.join("SKILL.md");
let msg = write_rules_file(&skill_path, SKILL_TEXT)?;
Ok(format!("{}: {msg}", skill_path.display()))
}
fn target_files(target: &RulesTarget) -> Vec<String> {
match target {
RulesTarget::Agents => vec!["AGENTS.md".into()],
RulesTarget::Claude => vec!["CLAUDE.md".into()],
RulesTarget::Cursor => vec![".cursor/rules/soma-schema.mdc".into()],
RulesTarget::Windsurf => vec![".windsurf/rules/soma-schema.md".into()],
RulesTarget::All => vec![
"AGENTS.md".into(),
"CLAUDE.md".into(),
".cursor/rules/soma-schema.mdc".into(),
".windsurf/rules/soma-schema.md".into(),
],
RulesTarget::None => vec![],
}
}
fn write_rules_file(path: &Path, content: &str) -> Result<String> {
if path.exists() {
let existing = std::fs::read_to_string(path)?;
if existing.contains(IDEMPOTENCY_SENTINEL) {
return Ok("already contains soma-schema section — skipped".into());
}
let appended = format!("{existing}{SECTION_HEADER}{content}");
std::fs::write(path, &appended)?;
return Ok("appended soma-schema section".into());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content)?;
Ok("created".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agents_target_creates_agents_md() {
let dir = tempfile::tempdir().unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::Agents).unwrap();
assert_eq!(msgs.len(), 1);
assert!(msgs[0].contains("AGENTS.md"), "path: {}", msgs[0]);
assert!(msgs[0].contains("created"), "action: {}", msgs[0]);
let content = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
assert!(content.contains(IDEMPOTENCY_SENTINEL));
}
#[test]
fn claude_target_creates_claude_md() {
let dir = tempfile::tempdir().unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::Claude).unwrap();
assert_eq!(msgs.len(), 1);
assert!(msgs[0].contains("CLAUDE.md"), "path: {}", msgs[0]);
let content = std::fs::read_to_string(dir.path().join("CLAUDE.md")).unwrap();
assert!(content.contains(IDEMPOTENCY_SENTINEL));
}
#[test]
fn none_target_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::None).unwrap();
assert!(msgs.is_empty());
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
assert!(entries.is_empty(), "expected no files written");
}
#[test]
fn idempotent_when_section_already_present() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("AGENTS.md");
std::fs::write(
&path,
"# existing\n\nThis mentions soma-schema migrations.\n",
)
.unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::Agents).unwrap();
assert!(
msgs[0].contains("skipped"),
"expected skipped, got: {}",
msgs[0]
);
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.starts_with("# existing"));
assert!(
!after.contains("-- DOWN =="),
"should not have appended rules"
);
}
#[test]
fn appends_when_file_has_no_section() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("CLAUDE.md");
std::fs::write(&path, "# My project CLAUDE.md\n\nSome instructions.\n").unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::Claude).unwrap();
assert!(
msgs[0].contains("appended"),
"expected appended, got: {}",
msgs[0]
);
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("My project CLAUDE.md"),
"original preserved"
);
assert!(content.contains(IDEMPOTENCY_SENTINEL), "rules appended");
}
#[test]
fn all_target_writes_four_files() {
let dir = tempfile::tempdir().unwrap();
let msgs = write_rules(dir.path(), &RulesTarget::All).unwrap();
assert_eq!(msgs.len(), 4);
assert!(dir.path().join("AGENTS.md").exists());
assert!(dir.path().join("CLAUDE.md").exists());
assert!(dir.path().join(".cursor/rules/soma-schema.mdc").exists());
assert!(dir.path().join(".windsurf/rules/soma-schema.md").exists());
}
#[test]
fn cursor_and_windsurf_create_nested_dirs() {
let dir = tempfile::tempdir().unwrap();
write_rules(dir.path(), &RulesTarget::Cursor).unwrap();
assert!(dir.path().join(".cursor/rules/soma-schema.mdc").exists());
write_rules(dir.path(), &RulesTarget::Windsurf).unwrap();
assert!(dir.path().join(".windsurf/rules/soma-schema.md").exists());
}
}