Skip to main content

saorsa_agent/context/
system.rs

1//! SYSTEM.md loading and integration.
2
3use crate::error::Result;
4use std::path::{Path, PathBuf};
5
6use super::types::SystemMode;
7
8/// Loaded and merged SYSTEM.md context.
9#[derive(Debug, Clone, Default)]
10pub struct SystemContext {
11    /// Merged content ready for system prompt.
12    pub content: String,
13}
14
15impl SystemContext {
16    /// Load and merge SYSTEM.md files from discovered paths.
17    ///
18    /// Files are processed in precedence order (highest first).
19    /// Mode is determined by front matter or defaults to Append.
20    pub fn load_and_merge(paths: &[PathBuf]) -> Result<Self> {
21        if paths.is_empty() {
22            return Ok(Self {
23                content: String::new(),
24            });
25        }
26
27        // Determine mode from first file's front matter
28        let mode = parse_system_mode(&paths[0]).unwrap_or_default();
29
30        let content = match mode {
31            SystemMode::Replace => {
32                // Use only the highest precedence file
33                load_file(&paths[0])?
34            }
35            SystemMode::Append => {
36                // Merge all files with separators
37                let mut merged = String::new();
38                for (i, path) in paths.iter().enumerate() {
39                    let file_content = load_file(path)?;
40                    merged.push_str(&file_content);
41                    // Add separator between files (but not after the last one)
42                    if i < paths.len() - 1 {
43                        merged.push_str("\n\n---\n\n");
44                    }
45                }
46                merged
47            }
48        };
49
50        Ok(Self { content })
51    }
52
53    /// Combine with default system prompt according to mode.
54    ///
55    /// If mode is Replace, returns only custom content.
56    /// If mode is Append, returns default + custom.
57    pub fn apply_to_default(&self, default: &str, mode: SystemMode) -> String {
58        match mode {
59            SystemMode::Replace => {
60                if self.content.is_empty() {
61                    default.to_string()
62                } else {
63                    self.content.clone()
64                }
65            }
66            SystemMode::Append => {
67                if self.content.is_empty() {
68                    default.to_string()
69                } else {
70                    format!("{}\n\n{}", default, self.content)
71                }
72            }
73        }
74    }
75}
76
77/// Load a single file, stripping front matter.
78fn load_file(path: &Path) -> Result<String> {
79    let content = std::fs::read_to_string(path)?;
80    Ok(strip_front_matter(&content))
81}
82
83/// Parse system mode from front matter.
84///
85/// Front matter format:
86/// ```text
87/// ---
88/// mode: replace|append
89/// ---
90/// ```
91fn parse_system_mode(path: &Path) -> Option<SystemMode> {
92    let content = std::fs::read_to_string(path).ok()?;
93    let front_matter = extract_front_matter(&content)?;
94
95    for line in front_matter.lines() {
96        let line = line.trim();
97        if let Some(value) = line.strip_prefix("mode:") {
98            let value = value.trim();
99            return match value {
100                "replace" => Some(SystemMode::Replace),
101                "append" => Some(SystemMode::Append),
102                _ => None,
103            };
104        }
105    }
106    None
107}
108
109/// Extract front matter from content (text between --- delimiters).
110fn extract_front_matter(content: &str) -> Option<String> {
111    let trimmed = content.trim_start();
112    if !trimmed.starts_with("---") {
113        return None;
114    }
115
116    let after_first = &trimmed[3..];
117    after_first
118        .find("---")
119        .map(|end_pos| after_first[..end_pos].to_string())
120}
121
122/// Strip front matter from content, returning only the body.
123fn strip_front_matter(content: &str) -> String {
124    let trimmed = content.trim_start();
125    if !trimmed.starts_with("---") {
126        return content.to_string();
127    }
128
129    let after_first = &trimmed[3..];
130    if let Some(end_pos) = after_first.find("---") {
131        let body_start = end_pos + 3; // skip the closing "---"
132        after_first[body_start..].trim_start().to_string()
133    } else {
134        content.to_string()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::fs;
142    use tempfile::TempDir;
143
144    fn make_temp_dir() -> TempDir {
145        match TempDir::new() {
146            Ok(t) => t,
147            Err(e) => unreachable!("Failed to create temp dir: {e}"),
148        }
149    }
150
151    fn create_file(dir: &Path, name: &str, content: &str) -> PathBuf {
152        let path = dir.join(name);
153        assert!(fs::write(&path, content).is_ok());
154        path
155    }
156
157    #[test]
158    fn test_load_single_file() {
159        let temp = make_temp_dir();
160        let path = create_file(temp.path(), "SYSTEM.md", "Custom system prompt");
161
162        let result = SystemContext::load_and_merge(&[path]);
163        assert!(result.is_ok());
164
165        let ctx = match result {
166            Ok(c) => c,
167            Err(_) => unreachable!("load_and_merge should succeed"),
168        };
169        assert_eq!(ctx.content, "Custom system prompt");
170    }
171
172    #[test]
173    fn test_append_mode_merges_files() {
174        let temp = make_temp_dir();
175        let path1 = create_file(temp.path(), "SYSTEM1.md", "First instruction");
176        let path2 = create_file(temp.path(), "SYSTEM2.md", "Second instruction");
177
178        let result = SystemContext::load_and_merge(&[path1, path2]);
179        assert!(result.is_ok());
180
181        let ctx = match result {
182            Ok(c) => c,
183            Err(_) => unreachable!("load_and_merge should succeed"),
184        };
185        assert_eq!(
186            ctx.content,
187            "First instruction\n\n---\n\nSecond instruction"
188        );
189    }
190
191    #[test]
192    fn test_replace_mode_uses_first_only() {
193        let temp = make_temp_dir();
194        let content1 = "---\nmode: replace\n---\nFirst instruction";
195        let content2 = "Second instruction";
196
197        let path1 = create_file(temp.path(), "SYSTEM1.md", content1);
198        let path2 = create_file(temp.path(), "SYSTEM2.md", content2);
199
200        let result = SystemContext::load_and_merge(&[path1, path2]);
201        assert!(result.is_ok());
202
203        let ctx = match result {
204            Ok(c) => c,
205            Err(_) => unreachable!("load_and_merge should succeed"),
206        };
207        assert_eq!(ctx.content, "First instruction");
208    }
209
210    #[test]
211    fn test_apply_to_default_append() {
212        let ctx = SystemContext {
213            content: "Custom addition".to_string(),
214        };
215        let result = ctx.apply_to_default("Default prompt", SystemMode::Append);
216        assert_eq!(result, "Default prompt\n\nCustom addition");
217    }
218
219    #[test]
220    fn test_apply_to_default_replace() {
221        let ctx = SystemContext {
222            content: "Completely custom".to_string(),
223        };
224        let result = ctx.apply_to_default("Default prompt", SystemMode::Replace);
225        assert_eq!(result, "Completely custom");
226    }
227
228    #[test]
229    fn test_apply_to_default_empty_content_append() {
230        let ctx = SystemContext {
231            content: String::new(),
232        };
233        let result = ctx.apply_to_default("Default prompt", SystemMode::Append);
234        assert_eq!(result, "Default prompt");
235    }
236
237    #[test]
238    fn test_apply_to_default_empty_content_replace() {
239        let ctx = SystemContext {
240            content: String::new(),
241        };
242        let result = ctx.apply_to_default("Default prompt", SystemMode::Replace);
243        assert_eq!(result, "Default prompt");
244    }
245
246    #[test]
247    fn test_front_matter_parsing() {
248        let temp = make_temp_dir();
249        let content = "---\nmode: replace\n---\nBody content";
250        let path = create_file(temp.path(), "SYSTEM.md", content);
251
252        let mode = parse_system_mode(&path);
253        assert_eq!(mode, Some(SystemMode::Replace));
254    }
255
256    #[test]
257    fn test_front_matter_stripping() {
258        let content = "---\nmode: append\n---\nBody content";
259        let stripped = strip_front_matter(content);
260        assert_eq!(stripped, "Body content");
261    }
262
263    #[test]
264    fn test_no_front_matter() {
265        let content = "Body content only";
266        let stripped = strip_front_matter(content);
267        assert_eq!(stripped, "Body content only");
268    }
269
270    #[test]
271    fn test_empty_file_list() {
272        let result = SystemContext::load_and_merge(&[]);
273        assert!(result.is_ok());
274
275        let ctx = match result {
276            Ok(c) => c,
277            Err(_) => unreachable!("load_and_merge should succeed"),
278        };
279        assert!(ctx.content.is_empty());
280    }
281
282    #[test]
283    fn test_file_read_error_propagated() {
284        let nonexistent = PathBuf::from("/nonexistent/SYSTEM.md");
285        let result = SystemContext::load_and_merge(&[nonexistent]);
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_extract_front_matter_valid() {
291        let content = "---\nmode: append\nother: value\n---\nBody";
292        let fm = extract_front_matter(content);
293        assert!(fm.is_some());
294        match fm {
295            Some(f) => assert!(f.contains("mode: append")),
296            None => unreachable!("Should extract front matter"),
297        }
298    }
299
300    #[test]
301    fn test_extract_front_matter_none_when_missing() {
302        let content = "No front matter here";
303        let fm = extract_front_matter(content);
304        assert!(fm.is_none());
305    }
306
307    #[test]
308    fn test_system_mode_default() {
309        assert_eq!(SystemMode::default(), SystemMode::Append);
310    }
311}