1use camino::{Utf8Path, Utf8PathBuf};
16use include_dir::{include_dir, Dir, File};
17
18static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets");
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Agent {
31 Claude,
34 Cursor,
36 Gemini,
38 Codex,
40 Cascade,
42}
43
44impl Agent {
45 pub const ALL: [Agent; 5] = [
47 Agent::Claude,
48 Agent::Cursor,
49 Agent::Gemini,
50 Agent::Codex,
51 Agent::Cascade,
52 ];
53
54 pub fn parse(name: &str) -> Option<Agent> {
56 match name.to_ascii_lowercase().as_str() {
57 "claude" | "claude-code" => Some(Agent::Claude),
58 "cursor" => Some(Agent::Cursor),
59 "gemini" | "gemini-cli" => Some(Agent::Gemini),
60 "codex" | "agents" => Some(Agent::Codex),
61 "cascade" | "devin" | "windsurf" => Some(Agent::Cascade),
62 _ => None,
63 }
64 }
65
66 pub fn name(self) -> &'static str {
68 match self {
69 Agent::Claude => "claude",
70 Agent::Cursor => "cursor",
71 Agent::Gemini => "gemini",
72 Agent::Codex => "codex",
73 Agent::Cascade => "cascade",
74 }
75 }
76
77 fn entries(self) -> &'static [&'static str] {
80 match self {
81 Agent::Claude => &[".claude", ".mcp.json", "scripts/mollify-report.sh"],
84 Agent::Cursor => &[".cursor"],
85 Agent::Gemini => &[".gemini", "GEMINI.md"],
86 Agent::Codex => &[".codex", ".agents", "AGENTS.md"],
87 Agent::Cascade => &[".devin", ".windsurf", "scripts/mollify-report.sh"],
88 }
89 }
90
91 fn artifacts(self) -> Vec<(Utf8PathBuf, &'static [u8])> {
95 let mut out: Vec<(Utf8PathBuf, &'static [u8])> = Vec::new();
96 for name in self.entries() {
97 if let Some(file) = ASSETS.get_file(name) {
98 out.push((path_of(file), file.contents()));
99 } else if let Some(dir) = ASSETS.get_dir(name) {
100 let mut files: Vec<&File> = Vec::new();
101 collect_files(dir, &mut files);
102 for f in files {
103 out.push((path_of(f), f.contents()));
104 }
105 } else {
106 panic!("embedded asset `{name}` is missing — run scripts/sync-agent-assets.sh");
107 }
108 }
109 out.sort_by(|a, b| a.0.cmp(&b.0));
110 out
111 }
112}
113
114fn path_of(f: &File) -> Utf8PathBuf {
116 Utf8Path::from_path(f.path())
117 .expect("embedded asset paths are valid UTF-8")
118 .to_path_buf()
119}
120
121fn collect_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<&'a File<'a>>) {
123 for entry in dir.entries() {
124 match entry {
125 include_dir::DirEntry::File(f) => out.push(f),
126 include_dir::DirEntry::Dir(d) => collect_files(d, out),
127 }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum FileOutcome {
134 Created,
135 Overwritten,
136 Skipped,
137}
138
139#[derive(Debug, Clone)]
141pub struct InstalledFile {
142 pub path: Utf8PathBuf,
143 pub outcome: FileOutcome,
144}
145
146pub fn install(root: &Utf8Path, agent: Agent, force: bool) -> std::io::Result<Vec<InstalledFile>> {
149 let mut results = Vec::new();
150 for (rel, contents) in agent.artifacts() {
151 let dest = root.join(&rel);
152 let exists = dest.exists();
153 if exists && !force {
154 results.push(InstalledFile {
155 path: rel,
156 outcome: FileOutcome::Skipped,
157 });
158 continue;
159 }
160 if let Some(parent) = dest.parent() {
161 std::fs::create_dir_all(parent)?;
162 }
163 std::fs::write(&dest, contents)?;
164 results.push(InstalledFile {
165 path: rel,
166 outcome: if exists {
167 FileOutcome::Overwritten
168 } else {
169 FileOutcome::Created
170 },
171 });
172 }
173 Ok(results)
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn temp(tag: &str) -> Utf8PathBuf {
181 let base =
182 std::env::temp_dir().join(format!("mollify-agents-{}-{tag}", std::process::id()));
183 let _ = std::fs::remove_dir_all(&base);
184 std::fs::create_dir_all(&base).unwrap();
185 Utf8PathBuf::from_path_buf(base).unwrap()
186 }
187
188 #[test]
189 fn parses_agent_names_and_aliases() {
190 assert_eq!(Agent::parse("Claude"), Some(Agent::Claude));
191 assert_eq!(Agent::parse("devin"), Some(Agent::Cascade));
192 assert_eq!(Agent::parse("windsurf"), Some(Agent::Cascade));
193 assert_eq!(Agent::parse("nope"), None);
194 }
195
196 #[test]
197 fn every_agent_has_artifacts() {
198 for a in Agent::ALL {
199 assert!(!a.artifacts().is_empty(), "{} has no artifacts", a.name());
200 }
201 }
202
203 #[test]
204 fn installs_cursor_files_and_skips_existing() {
205 let d = temp("cursor");
206 let r = install(&d, Agent::Cursor, false).unwrap();
207 assert!(r.iter().all(|f| f.outcome == FileOutcome::Created));
208 assert!(
210 d.join(".cursor/rules/mollify.mdc").exists(),
211 "cursor rule not written"
212 );
213 let r2 = install(&d, Agent::Cursor, false).unwrap();
215 assert!(r2.iter().all(|f| f.outcome == FileOutcome::Skipped));
216 let r3 = install(&d, Agent::Cursor, true).unwrap();
218 assert!(r3.iter().all(|f| f.outcome == FileOutcome::Overwritten));
219 std::fs::remove_dir_all(&d).ok();
220 }
221
222 #[test]
223 fn claude_installs_root_markers() {
224 let d = temp("claude");
225 install(&d, Agent::Claude, false).unwrap();
226 assert!(d.join(".mcp.json").exists());
227 assert!(d.join(".claude/skills/mollify/SKILL.md").exists());
228 std::fs::remove_dir_all(&d).ok();
229 }
230
231 #[test]
236 fn assets_match_repo_root_sources() {
237 let root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
238 .join("..")
239 .join("..");
240 if !root.join(".claude").exists() {
243 return;
244 }
245 let mut files: Vec<&File> = Vec::new();
246 collect_files(&ASSETS, &mut files);
247 assert!(
248 !files.is_empty(),
249 "no embedded assets — sync script not run?"
250 );
251 for f in files {
252 let rel = path_of(f);
253 let src = root.join(&rel);
254 let on_disk = std::fs::read(src.as_std_path()).unwrap_or_else(|_| {
255 panic!("canonical source missing for {rel}; run scripts/sync-agent-assets.sh")
256 });
257 assert!(
258 on_disk == f.contents(),
259 "{rel} is out of sync; run scripts/sync-agent-assets.sh"
260 );
261 }
262 }
263}