1use std::path::{Path, PathBuf};
12
13const MAX_INSTRUCTION_SIZE: usize = 1_048_576;
15
16#[derive(Debug)]
17pub struct InstructionFile {
18 pub path: PathBuf,
19 pub content: String,
20 pub level: InstructionLevel,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum InstructionLevel {
25 Global,
26 Project,
27 User,
28}
29
30impl InstructionLevel {
31 pub fn label(&self) -> &'static str {
32 match self {
33 Self::Global => "GLOBAL",
34 Self::Project => "PROJECT",
35 Self::User => "USER",
36 }
37 }
38}
39
40impl std::fmt::Display for InstructionLevel {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.write_str(self.label())
43 }
44}
45
46pub struct LayeredInstructions {
47 pub global: Option<InstructionFile>,
48 pub project: Option<InstructionFile>,
49 pub user: Option<InstructionFile>,
50}
51
52impl LayeredInstructions {
53 pub fn load(project_root: &Path) -> Self {
60 let config_dir = crate::config::Config::config_dir();
61 let global = Self::try_load(&config_dir.join("ATOMCODE.md"), InstructionLevel::Global);
62
63 let project = [".atomcode.md", "ATOMCODE.md", "CLAUDE.md", "claude.md"]
65 .iter()
66 .find_map(|name| Self::try_load(&project_root.join(name), InstructionLevel::Project));
67
68 let user =
69 Self::try_load(&project_root.join(".atomcode.user.md"), InstructionLevel::User);
70
71 Self {
72 global,
73 project,
74 user,
75 }
76 }
77
78 fn try_load(path: &Path, level: InstructionLevel) -> Option<InstructionFile> {
79 let content = std::fs::read_to_string(path).ok()?;
80 if content.trim().is_empty() {
81 return None;
82 }
83 let content = if content.len() > MAX_INSTRUCTION_SIZE {
84 let truncated: String = content.chars().take(MAX_INSTRUCTION_SIZE).collect();
85 format!("{}\n\n[Truncated — file exceeds 1MB]", truncated)
86 } else {
87 content
88 };
89 Some(InstructionFile {
90 path: path.to_path_buf(),
91 content,
92 level,
93 })
94 }
95
96 pub fn merged(&self) -> String {
99 let mut parts = Vec::new();
100 if let Some(ref g) = self.global {
101 parts.push(format!(
102 "=== {} INSTRUCTIONS ({}) ===\n{}",
103 g.level.label(),
104 g.path.display(),
105 g.content.trim()
106 ));
107 }
108 if let Some(ref p) = self.project {
109 parts.push(format!(
110 "=== {} INSTRUCTIONS ({}) ===\n{}",
111 p.level.label(),
112 p.path.display(),
113 p.content.trim()
114 ));
115 }
116 if let Some(ref u) = self.user {
117 parts.push(format!(
118 "=== {} INSTRUCTIONS ({}) ===\n{}",
119 u.level.label(),
120 u.path.display(),
121 u.content.trim()
122 ));
123 }
124 parts.join("\n\n")
125 }
126
127 pub fn status_lines(&self) -> Vec<(InstructionLevel, Option<&Path>)> {
129 vec![
130 (
131 InstructionLevel::Global,
132 self.global.as_ref().map(|f| f.path.as_path()),
133 ),
134 (
135 InstructionLevel::Project,
136 self.project.as_ref().map(|f| f.path.as_path()),
137 ),
138 (
139 InstructionLevel::User,
140 self.user.as_ref().map(|f| f.path.as_path()),
141 ),
142 ]
143 }
144
145 pub fn has_any(&self) -> bool {
146 self.global.is_some() || self.project.is_some() || self.user.is_some()
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::fs;
154
155 #[test]
156 fn empty_dir_produces_no_instructions() {
157 let tmp = tempfile::tempdir().unwrap();
158 let instructions = LayeredInstructions {
159 global: None,
160 project: LayeredInstructions::try_load(
161 &tmp.path().join(".atomcode.md"),
162 InstructionLevel::Project,
163 ),
164 user: LayeredInstructions::try_load(
165 &tmp.path().join(".atomcode.user.md"),
166 InstructionLevel::User,
167 ),
168 };
169 assert!(!instructions.has_any());
170 assert!(instructions.merged().is_empty());
171 }
172
173 #[test]
174 fn project_atomcode_md_is_found() {
175 let tmp = tempfile::tempdir().unwrap();
176 fs::write(tmp.path().join(".atomcode.md"), "Use tabs.").unwrap();
177 let instructions = LayeredInstructions::try_load(
178 &tmp.path().join(".atomcode.md"),
179 InstructionLevel::Project,
180 );
181 assert!(instructions.is_some());
182 let f = instructions.unwrap();
183 assert_eq!(f.level, InstructionLevel::Project);
184 assert!(f.content.contains("Use tabs."));
185 }
186
187 #[test]
188 fn claude_md_used_as_project_fallback() {
189 let tmp = tempfile::tempdir().unwrap();
190 fs::write(tmp.path().join("CLAUDE.md"), "from Claude Code").unwrap();
191 let instructions = LayeredInstructions::load(tmp.path());
192 let project = instructions
193 .project
194 .expect("CLAUDE.md should be loaded as project tier");
195 assert_eq!(project.level, InstructionLevel::Project);
196 assert!(project.content.contains("from Claude Code"));
197 assert!(project.path.ends_with("CLAUDE.md"));
198 }
199
200 #[test]
201 fn lowercase_claude_md_used_as_project_fallback() {
202 let tmp = tempfile::tempdir().unwrap();
203 fs::write(tmp.path().join("claude.md"), "lowercase claude").unwrap();
204 let instructions = LayeredInstructions::load(tmp.path());
205 let project = instructions
206 .project
207 .expect("claude.md should be loaded as project tier");
208 assert!(project.content.contains("lowercase claude"));
209 }
210
211 #[test]
212 fn atomcode_md_preferred_over_claude_md() {
213 let tmp = tempfile::tempdir().unwrap();
214 fs::write(tmp.path().join(".atomcode.md"), "atomcode wins").unwrap();
215 fs::write(tmp.path().join("CLAUDE.md"), "claude loses").unwrap();
216 let instructions = LayeredInstructions::load(tmp.path());
217 let project = instructions.project.expect("project tier should load");
218 assert!(project.content.contains("atomcode wins"));
219 }
220
221 #[test]
222 fn atomcode_uppercase_preferred_over_claude_md() {
223 let tmp = tempfile::tempdir().unwrap();
224 fs::write(tmp.path().join("ATOMCODE.md"), "ATOMCODE wins").unwrap();
225 fs::write(tmp.path().join("CLAUDE.md"), "claude loses").unwrap();
226 let instructions = LayeredInstructions::load(tmp.path());
227 let project = instructions.project.expect("project tier should load");
228 assert!(project.content.contains("ATOMCODE wins"));
229 }
230
231 #[test]
232 fn lowercase_preferred_over_uppercase() {
233 let tmp = tempfile::tempdir().unwrap();
234 fs::write(tmp.path().join(".atomcode.md"), "lowercase wins").unwrap();
235 fs::write(tmp.path().join("ATOMCODE.md"), "uppercase loses").unwrap();
236
237 let project = [".atomcode.md", "ATOMCODE.md"]
239 .iter()
240 .find_map(|name| {
241 LayeredInstructions::try_load(
242 &tmp.path().join(name),
243 InstructionLevel::Project,
244 )
245 });
246 assert!(project.is_some());
247 assert!(project.unwrap().content.contains("lowercase wins"));
248 }
249
250 #[test]
251 fn user_instructions_loaded() {
252 let tmp = tempfile::tempdir().unwrap();
253 fs::write(tmp.path().join(".atomcode.user.md"), "my prefs").unwrap();
254 let user = LayeredInstructions::try_load(
255 &tmp.path().join(".atomcode.user.md"),
256 InstructionLevel::User,
257 );
258 assert!(user.is_some());
259 let f = user.unwrap();
260 assert_eq!(f.level, InstructionLevel::User);
261 assert!(f.content.contains("my prefs"));
262 }
263
264 #[test]
265 fn empty_file_is_skipped() {
266 let tmp = tempfile::tempdir().unwrap();
267 fs::write(tmp.path().join(".atomcode.md"), " \n \n").unwrap();
268 let project = LayeredInstructions::try_load(
269 &tmp.path().join(".atomcode.md"),
270 InstructionLevel::Project,
271 );
272 assert!(project.is_none());
273 }
274
275 #[test]
276 fn merged_output_order_is_global_project_user() {
277 let tmp = tempfile::tempdir().unwrap();
278 let instructions = LayeredInstructions {
279 global: Some(InstructionFile {
280 path: tmp.path().join("global.md"),
281 content: "GLOBAL_CONTENT".to_string(),
282 level: InstructionLevel::Global,
283 }),
284 project: Some(InstructionFile {
285 path: tmp.path().join("project.md"),
286 content: "PROJECT_CONTENT".to_string(),
287 level: InstructionLevel::Project,
288 }),
289 user: Some(InstructionFile {
290 path: tmp.path().join("user.md"),
291 content: "USER_CONTENT".to_string(),
292 level: InstructionLevel::User,
293 }),
294 };
295 let merged = instructions.merged();
296 let global_pos = merged.find("GLOBAL_CONTENT").unwrap();
297 let project_pos = merged.find("PROJECT_CONTENT").unwrap();
298 let user_pos = merged.find("USER_CONTENT").unwrap();
299 assert!(
300 global_pos < project_pos,
301 "global must come before project"
302 );
303 assert!(
304 project_pos < user_pos,
305 "project must come before user"
306 );
307 }
308
309 #[test]
310 fn status_lines_show_all_three_levels() {
311 let tmp = tempfile::tempdir().unwrap();
312 let instructions = LayeredInstructions {
313 global: Some(InstructionFile {
314 path: tmp.path().join("g.md"),
315 content: "g".to_string(),
316 level: InstructionLevel::Global,
317 }),
318 project: None,
319 user: Some(InstructionFile {
320 path: tmp.path().join("u.md"),
321 content: "u".to_string(),
322 level: InstructionLevel::User,
323 }),
324 };
325 let lines = instructions.status_lines();
326 assert_eq!(lines.len(), 3);
327 assert_eq!(lines[0].0, InstructionLevel::Global);
328 assert!(lines[0].1.is_some());
329 assert_eq!(lines[1].0, InstructionLevel::Project);
330 assert!(lines[1].1.is_none());
331 assert_eq!(lines[2].0, InstructionLevel::User);
332 assert!(lines[2].1.is_some());
333 }
334
335 #[test]
336 fn large_file_is_truncated() {
337 let tmp = tempfile::tempdir().unwrap();
338 let big = "x".repeat(MAX_INSTRUCTION_SIZE + 100);
339 fs::write(tmp.path().join("big.md"), &big).unwrap();
340 let loaded = LayeredInstructions::try_load(
341 &tmp.path().join("big.md"),
342 InstructionLevel::Global,
343 );
344 assert!(loaded.is_some());
345 let f = loaded.unwrap();
346 assert!(f.content.ends_with("[Truncated — file exceeds 1MB]"));
347 assert!(f.content.len() < big.len());
349 }
350
351 #[test]
352 fn has_any_returns_true_when_any_level_loaded() {
353 let instructions = LayeredInstructions {
354 global: None,
355 project: Some(InstructionFile {
356 path: PathBuf::from("/tmp/p.md"),
357 content: "p".to_string(),
358 level: InstructionLevel::Project,
359 }),
360 user: None,
361 };
362 assert!(instructions.has_any());
363 }
364
365 #[test]
366 fn has_any_returns_false_when_all_none() {
367 let instructions = LayeredInstructions {
368 global: None,
369 project: None,
370 user: None,
371 };
372 assert!(!instructions.has_any());
373 }
374
375 #[test]
376 fn level_labels_are_correct() {
377 assert_eq!(InstructionLevel::Global.label(), "GLOBAL");
378 assert_eq!(InstructionLevel::Project.label(), "PROJECT");
379 assert_eq!(InstructionLevel::User.label(), "USER");
380 }
381
382 #[test]
383 fn merged_with_only_project_produces_single_section() {
384 let instructions = LayeredInstructions {
385 global: None,
386 project: Some(InstructionFile {
387 path: PathBuf::from("/project/.atomcode.md"),
388 content: "Only project rules".to_string(),
389 level: InstructionLevel::Project,
390 }),
391 user: None,
392 };
393 let merged = instructions.merged();
394 assert!(merged.contains("=== PROJECT INSTRUCTIONS"));
395 assert!(merged.contains("Only project rules"));
396 assert!(!merged.contains("GLOBAL"));
397 assert!(!merged.contains("USER"));
398 }
399}