batuta/agent/
auto_memory.rs1use std::path::{Path, PathBuf};
33
34pub fn project_slug(cwd: &Path) -> String {
40 let abs = if cwd.is_absolute() {
41 cwd.to_path_buf()
42 } else {
43 std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
46 };
47 let s = abs.to_string_lossy();
48 let mut out = String::with_capacity(s.len() + 1);
54 let starts_with_sep = s.starts_with('/') || s.starts_with('\\');
55 if !starts_with_sep {
56 out.push('-');
57 }
58 for ch in s.chars() {
59 if ch == '/' || ch == '\\' {
60 out.push('-');
61 } else {
62 out.push(ch);
63 }
64 }
65 out
66}
67
68pub fn auto_memory_root() -> Option<PathBuf> {
72 if let Ok(custom) = std::env::var("APR_CONFIG") {
73 if !custom.is_empty() {
74 return Some(PathBuf::from(custom).join("projects"));
75 }
76 }
77 dirs::config_dir().map(|d| d.join("apr").join("projects"))
78}
79
80pub fn project_memory_dir(cwd: &Path) -> Option<PathBuf> {
82 auto_memory_root().map(|r| r.join(project_slug(cwd)).join("memory"))
83}
84
85pub fn load_auto_memory(cwd: &Path, warnings: &mut Vec<String>) -> Option<String> {
94 let dir = project_memory_dir(cwd)?;
95 if !dir.is_dir() {
96 return None;
97 }
98 let mut entries: Vec<PathBuf> = match std::fs::read_dir(&dir) {
99 Ok(rd) => rd
100 .flatten()
101 .map(|e| e.path())
102 .filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "md"))
103 .collect(),
104 Err(e) => {
105 warnings.push(format!("auto-memory: read_dir({}) failed: {e}", dir.display()));
106 return None;
107 }
108 };
109 entries.sort();
110 if entries.is_empty() {
111 return None;
112 }
113 let mut out = String::new();
114 for path in &entries {
115 match std::fs::read_to_string(path) {
116 Ok(body) => {
117 let name =
118 path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
119 if !out.is_empty() && !out.ends_with("\n\n") {
120 out.push('\n');
121 }
122 out.push_str(&format!("### {name}\n\n"));
123 out.push_str(&body);
124 if !out.ends_with('\n') {
125 out.push('\n');
126 }
127 }
128 Err(e) => {
129 warnings.push(format!("auto-memory: read({}) failed: {e}", path.display()));
130 }
131 }
132 }
133 if out.is_empty() {
134 None
135 } else {
136 Some(out)
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::fs;
144 use std::path::Path;
145
146 fn write(path: &Path, body: &str) {
147 if let Some(p) = path.parent() {
148 fs::create_dir_all(p).expect("mkdir");
149 }
150 fs::write(path, body).expect("write");
151 }
152
153 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
157 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
158 LOCK.lock().unwrap_or_else(|e| e.into_inner())
159 }
160
161 #[test]
164 fn slug_for_absolute_path() {
165 let s = project_slug(Path::new("/home/noah/src/aprender"));
166 assert_eq!(s, "-home-noah-src-aprender");
167 }
168
169 #[test]
170 fn slug_for_root() {
171 let s = project_slug(Path::new("/"));
172 assert_eq!(s, "-");
174 }
175
176 #[test]
177 fn slug_with_dots_preserved() {
178 let s = project_slug(Path::new("/tmp/a.b.c"));
180 assert_eq!(s, "-tmp-a.b.c");
181 }
182
183 #[test]
184 fn slug_strips_trailing_slash() {
185 let s = project_slug(Path::new("/tmp/x/"));
190 assert!(s == "-tmp-x-" || s == "-tmp-x", "got {s:?}");
191 }
192
193 #[test]
196 fn root_honors_apr_config_env() {
197 let _guard = env_lock();
198 let dir = tempfile::tempdir().expect("tempdir");
199 std::env::set_var("APR_CONFIG", dir.path());
200 let r = auto_memory_root().expect("root resolved");
201 std::env::remove_var("APR_CONFIG");
202 assert_eq!(r, dir.path().join("projects"));
203 }
204
205 #[test]
206 fn root_uses_config_dir_when_env_unset() {
207 let _guard = env_lock();
208 std::env::remove_var("APR_CONFIG");
209 let r = auto_memory_root().expect("root resolved on supported platform");
210 assert!(r.ends_with("apr/projects"), "unexpected root: {r:?}");
212 }
213
214 #[test]
217 fn project_memory_dir_layout() {
218 let _guard = env_lock();
219 let cfg = tempfile::tempdir().expect("cfg");
220 std::env::set_var("APR_CONFIG", cfg.path());
221 let dir = project_memory_dir(Path::new("/tmp/myproj")).expect("dir");
222 std::env::remove_var("APR_CONFIG");
223 assert_eq!(dir, cfg.path().join("projects").join("-tmp-myproj").join("memory"));
224 }
225
226 #[test]
229 fn load_returns_none_when_no_dir() {
230 let _guard = env_lock();
231 let cfg = tempfile::tempdir().expect("cfg");
232 std::env::set_var("APR_CONFIG", cfg.path());
233 let mut warns = Vec::new();
234 let out = load_auto_memory(Path::new("/tmp/never"), &mut warns);
236 std::env::remove_var("APR_CONFIG");
237 assert!(out.is_none());
238 assert!(warns.is_empty());
239 }
240
241 #[test]
242 fn load_returns_none_when_dir_empty() {
243 let _guard = env_lock();
244 let cfg = tempfile::tempdir().expect("cfg");
245 std::env::set_var("APR_CONFIG", cfg.path());
246 let mem_dir = cfg.path().join("projects").join("-tmp-x").join("memory");
247 fs::create_dir_all(&mem_dir).expect("mkdir");
248 let mut warns = Vec::new();
249 let out = load_auto_memory(Path::new("/tmp/x"), &mut warns);
250 std::env::remove_var("APR_CONFIG");
251 assert!(out.is_none(), "empty memory dir → None, got: {out:?}");
252 assert!(warns.is_empty());
253 }
254
255 #[test]
256 fn load_concatenates_md_files_in_lex_order() {
257 let _guard = env_lock();
258 let cfg = tempfile::tempdir().expect("cfg");
259 let mem_dir = cfg.path().join("projects").join("-tmp-y").join("memory");
260 write(&mem_dir.join("MEMORY.md"), "# Top-of-memory index\n");
261 write(&mem_dir.join("zzz_user.md"), "User notes\n");
262 write(&mem_dir.join("feedback_x.md"), "Feedback X\n");
263 std::env::set_var("APR_CONFIG", cfg.path());
264 let mut warns = Vec::new();
265 let out = load_auto_memory(Path::new("/tmp/y"), &mut warns).expect("loaded");
266 std::env::remove_var("APR_CONFIG");
267 assert!(warns.is_empty());
268 let memory_idx = out.find("Top-of-memory index").expect("MEMORY present");
270 let feedback_idx = out.find("Feedback X").expect("feedback present");
271 let user_idx = out.find("User notes").expect("user present");
272 assert!(memory_idx < feedback_idx, "MEMORY.md must come first");
273 assert!(feedback_idx < user_idx, "feedback < user lexicographically");
274 assert!(out.contains("### MEMORY.md"));
276 assert!(out.contains("### feedback_x.md"));
277 assert!(out.contains("### zzz_user.md"));
278 }
279
280 #[test]
281 fn load_skips_non_md_files() {
282 let _guard = env_lock();
283 let cfg = tempfile::tempdir().expect("cfg");
284 let mem_dir = cfg.path().join("projects").join("-tmp-skip").join("memory");
285 write(&mem_dir.join("note.md"), "kept\n");
286 write(&mem_dir.join("note.txt"), "skipped\n");
287 write(&mem_dir.join("note.json"), "skipped\n");
288 std::env::set_var("APR_CONFIG", cfg.path());
289 let mut warns = Vec::new();
290 let out = load_auto_memory(Path::new("/tmp/skip"), &mut warns).expect("loaded");
291 std::env::remove_var("APR_CONFIG");
292 assert!(out.contains("kept"));
293 assert!(!out.contains("skipped"), "non-md files must NOT be loaded");
294 }
295
296 #[test]
297 fn load_skips_subdirectories() {
298 let _guard = env_lock();
299 let cfg = tempfile::tempdir().expect("cfg");
300 let mem_dir = cfg.path().join("projects").join("-tmp-sub").join("memory");
301 write(&mem_dir.join("ok.md"), "ok-content\n");
302 fs::create_dir_all(mem_dir.join("nested")).expect("mkdir nested");
304 write(&mem_dir.join("nested").join("hidden.md"), "hidden-content\n");
305 std::env::set_var("APR_CONFIG", cfg.path());
306 let mut warns = Vec::new();
307 let out = load_auto_memory(Path::new("/tmp/sub"), &mut warns).expect("loaded");
308 std::env::remove_var("APR_CONFIG");
309 assert!(out.contains("ok-content"));
310 assert!(!out.contains("hidden-content"), "must not recurse into subdirs");
311 }
312}