Skip to main content

atomcode_core/config/
instructions.rs

1// crates/atomcode-core/src/config/instructions.rs
2//
3// Three-tier layered instruction system: global → project → user.
4//
5// 1. Global:  ~/.atomcode/ATOMCODE.md — personal preferences across all projects
6// 2. Project: <project>/.atomcode.md, ATOMCODE.md, CLAUDE.md, or claude.md
7//             (first match wins; CLAUDE.md/claude.md are accepted for
8//             compatibility with projects migrating from Claude Code)
9// 3. User:    <project>/.atomcode.user.md — personal per-project, in .gitignore
10
11use std::path::{Path, PathBuf};
12
13/// Maximum size for a single instruction file (1 MB).
14const 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    /// Load all three instruction tiers from disk.
54    ///
55    /// - Global:  `~/.atomcode/ATOMCODE.md`
56    /// - Project: `<project_root>/.atomcode.md`, `ATOMCODE.md`, `CLAUDE.md`,
57    ///   or `claude.md` (first match wins, in that order)
58    /// - User:    `<project_root>/.atomcode.user.md`
59    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        // Lookup order: native names first, then Claude Code names for compatibility.
64        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    /// Merge all levels into prompt text. Low priority first, high last
97    /// (recency effect: user > project > global).
98    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    /// Return status for all three levels (loaded path or None).
128    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        // Simulate the load logic: .atomcode.md is checked first.
238        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        // Content should be capped around MAX_INSTRUCTION_SIZE + the suffix.
348        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}