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 crate::agent::env_test_support::{env_lock, ScopedEnv};
150 use std::fs;
151 use std::path::Path;
152
153 fn write(path: &Path, body: &str) {
154 if let Some(p) = path.parent() {
155 fs::create_dir_all(p).expect("mkdir");
156 }
157 fs::write(path, body).expect("write");
158 }
159
160 #[test]
163 fn slug_for_absolute_path() {
164 let s = project_slug(Path::new("/home/noah/src/aprender"));
165 assert_eq!(s, "-home-noah-src-aprender");
166 }
167
168 #[test]
169 fn slug_for_root() {
170 let s = project_slug(Path::new("/"));
171 assert_eq!(s, "-");
173 }
174
175 #[test]
176 fn slug_with_dots_preserved() {
177 let s = project_slug(Path::new("/tmp/a.b.c"));
179 assert_eq!(s, "-tmp-a.b.c");
180 }
181
182 #[test]
183 fn slug_strips_trailing_slash() {
184 let s = project_slug(Path::new("/tmp/x/"));
189 assert!(s == "-tmp-x-" || s == "-tmp-x", "got {s:?}");
190 }
191
192 #[test]
195 fn root_honors_apr_config_env() {
196 let _guard = env_lock();
197 let dir = tempfile::tempdir().expect("tempdir");
198 let _env = ScopedEnv::set("APR_CONFIG", dir.path());
199 let r = auto_memory_root().expect("root resolved");
200 assert_eq!(r, dir.path().join("projects"));
201 }
202
203 #[test]
204 fn root_uses_config_dir_when_env_unset() {
205 let _guard = env_lock();
206 let _env = ScopedEnv::remove("APR_CONFIG");
207 let r = auto_memory_root().expect("root resolved on supported platform");
208 assert!(r.ends_with("apr/projects"), "unexpected root: {r:?}");
210 }
211
212 #[test]
215 fn project_memory_dir_layout() {
216 let _guard = env_lock();
217 let cfg = tempfile::tempdir().expect("cfg");
218 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
219 let dir = project_memory_dir(Path::new("/tmp/myproj")).expect("dir");
220 assert_eq!(dir, cfg.path().join("projects").join("-tmp-myproj").join("memory"));
221 }
222
223 #[test]
226 fn load_returns_none_when_no_dir() {
227 let _guard = env_lock();
228 let cfg = tempfile::tempdir().expect("cfg");
229 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
230 let mut warns = Vec::new();
231 let out = load_auto_memory(Path::new("/tmp/never"), &mut warns);
233 assert!(out.is_none());
234 assert!(warns.is_empty());
235 }
236
237 #[test]
238 fn load_returns_none_when_dir_empty() {
239 let _guard = env_lock();
240 let cfg = tempfile::tempdir().expect("cfg");
241 let mem_dir = cfg.path().join("projects").join("-tmp-x").join("memory");
242 fs::create_dir_all(&mem_dir).expect("mkdir");
243 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
244 let mut warns = Vec::new();
245 let out = load_auto_memory(Path::new("/tmp/x"), &mut warns);
246 assert!(out.is_none(), "empty memory dir → None, got: {out:?}");
247 assert!(warns.is_empty());
248 }
249
250 #[test]
251 fn load_concatenates_md_files_in_lex_order() {
252 let _guard = env_lock();
253 let cfg = tempfile::tempdir().expect("cfg");
254 let mem_dir = cfg.path().join("projects").join("-tmp-y").join("memory");
255 write(&mem_dir.join("MEMORY.md"), "# Top-of-memory index\n");
256 write(&mem_dir.join("zzz_user.md"), "User notes\n");
257 write(&mem_dir.join("feedback_x.md"), "Feedback X\n");
258 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
259 let mut warns = Vec::new();
260 let out = load_auto_memory(Path::new("/tmp/y"), &mut warns).expect("loaded");
261 assert!(warns.is_empty());
262 let memory_idx = out.find("Top-of-memory index").expect("MEMORY present");
264 let feedback_idx = out.find("Feedback X").expect("feedback present");
265 let user_idx = out.find("User notes").expect("user present");
266 assert!(memory_idx < feedback_idx, "MEMORY.md must come first");
267 assert!(feedback_idx < user_idx, "feedback < user lexicographically");
268 assert!(out.contains("### MEMORY.md"));
270 assert!(out.contains("### feedback_x.md"));
271 assert!(out.contains("### zzz_user.md"));
272 }
273
274 #[test]
275 fn load_skips_non_md_files() {
276 let _guard = env_lock();
277 let cfg = tempfile::tempdir().expect("cfg");
278 let mem_dir = cfg.path().join("projects").join("-tmp-skip").join("memory");
279 write(&mem_dir.join("note.md"), "kept\n");
280 write(&mem_dir.join("note.txt"), "skipped\n");
281 write(&mem_dir.join("note.json"), "skipped\n");
282 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
283 let mut warns = Vec::new();
284 let out = load_auto_memory(Path::new("/tmp/skip"), &mut warns).expect("loaded");
285 assert!(out.contains("kept"));
286 assert!(!out.contains("skipped"), "non-md files must NOT be loaded");
287 }
288
289 #[test]
290 fn load_skips_subdirectories() {
291 let _guard = env_lock();
292 let cfg = tempfile::tempdir().expect("cfg");
293 let mem_dir = cfg.path().join("projects").join("-tmp-sub").join("memory");
294 write(&mem_dir.join("ok.md"), "ok-content\n");
295 fs::create_dir_all(mem_dir.join("nested")).expect("mkdir nested");
297 write(&mem_dir.join("nested").join("hidden.md"), "hidden-content\n");
298 let _env = ScopedEnv::set("APR_CONFIG", cfg.path());
299 let mut warns = Vec::new();
300 let out = load_auto_memory(Path::new("/tmp/sub"), &mut warns).expect("loaded");
301 assert!(out.contains("ok-content"));
302 assert!(!out.contains("hidden-content"), "must not recurse into subdirs");
303 }
304}