use std::path::{Path, PathBuf};
pub fn project_slug(cwd: &Path) -> String {
let abs = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())
};
let s = abs.to_string_lossy();
let mut out = String::with_capacity(s.len() + 1);
let starts_with_sep = s.starts_with('/') || s.starts_with('\\');
if !starts_with_sep {
out.push('-');
}
for ch in s.chars() {
if ch == '/' || ch == '\\' {
out.push('-');
} else {
out.push(ch);
}
}
out
}
pub fn auto_memory_root() -> Option<PathBuf> {
if let Ok(custom) = std::env::var("APR_CONFIG") {
if !custom.is_empty() {
return Some(PathBuf::from(custom).join("projects"));
}
}
dirs::config_dir().map(|d| d.join("apr").join("projects"))
}
pub fn project_memory_dir(cwd: &Path) -> Option<PathBuf> {
auto_memory_root().map(|r| r.join(project_slug(cwd)).join("memory"))
}
pub fn load_auto_memory(cwd: &Path, warnings: &mut Vec<String>) -> Option<String> {
let dir = project_memory_dir(cwd)?;
if !dir.is_dir() {
return None;
}
let mut entries: Vec<PathBuf> = match std::fs::read_dir(&dir) {
Ok(rd) => rd
.flatten()
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|e| e == "md"))
.collect(),
Err(e) => {
warnings.push(format!("auto-memory: read_dir({}) failed: {e}", dir.display()));
return None;
}
};
entries.sort();
if entries.is_empty() {
return None;
}
let mut out = String::new();
for path in &entries {
match std::fs::read_to_string(path) {
Ok(body) => {
let name =
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if !out.is_empty() && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(&format!("### {name}\n\n"));
out.push_str(&body);
if !out.ends_with('\n') {
out.push('\n');
}
}
Err(e) => {
warnings.push(format!("auto-memory: read({}) failed: {e}", path.display()));
}
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn write(path: &Path, body: &str) {
if let Some(p) = path.parent() {
fs::create_dir_all(p).expect("mkdir");
}
fs::write(path, body).expect("write");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn slug_for_absolute_path() {
let s = project_slug(Path::new("/home/noah/src/aprender"));
assert_eq!(s, "-home-noah-src-aprender");
}
#[test]
fn slug_for_root() {
let s = project_slug(Path::new("/"));
assert_eq!(s, "-");
}
#[test]
fn slug_with_dots_preserved() {
let s = project_slug(Path::new("/tmp/a.b.c"));
assert_eq!(s, "-tmp-a.b.c");
}
#[test]
fn slug_strips_trailing_slash() {
let s = project_slug(Path::new("/tmp/x/"));
assert!(s == "-tmp-x-" || s == "-tmp-x", "got {s:?}");
}
#[test]
fn root_honors_apr_config_env() {
let _guard = env_lock();
let dir = tempfile::tempdir().expect("tempdir");
std::env::set_var("APR_CONFIG", dir.path());
let r = auto_memory_root().expect("root resolved");
std::env::remove_var("APR_CONFIG");
assert_eq!(r, dir.path().join("projects"));
}
#[test]
fn root_uses_config_dir_when_env_unset() {
let _guard = env_lock();
std::env::remove_var("APR_CONFIG");
let r = auto_memory_root().expect("root resolved on supported platform");
assert!(r.ends_with("apr/projects"), "unexpected root: {r:?}");
}
#[test]
fn project_memory_dir_layout() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
std::env::set_var("APR_CONFIG", cfg.path());
let dir = project_memory_dir(Path::new("/tmp/myproj")).expect("dir");
std::env::remove_var("APR_CONFIG");
assert_eq!(dir, cfg.path().join("projects").join("-tmp-myproj").join("memory"));
}
#[test]
fn load_returns_none_when_no_dir() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_auto_memory(Path::new("/tmp/never"), &mut warns);
std::env::remove_var("APR_CONFIG");
assert!(out.is_none());
assert!(warns.is_empty());
}
#[test]
fn load_returns_none_when_dir_empty() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
std::env::set_var("APR_CONFIG", cfg.path());
let mem_dir = cfg.path().join("projects").join("-tmp-x").join("memory");
fs::create_dir_all(&mem_dir).expect("mkdir");
let mut warns = Vec::new();
let out = load_auto_memory(Path::new("/tmp/x"), &mut warns);
std::env::remove_var("APR_CONFIG");
assert!(out.is_none(), "empty memory dir → None, got: {out:?}");
assert!(warns.is_empty());
}
#[test]
fn load_concatenates_md_files_in_lex_order() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let mem_dir = cfg.path().join("projects").join("-tmp-y").join("memory");
write(&mem_dir.join("MEMORY.md"), "# Top-of-memory index\n");
write(&mem_dir.join("zzz_user.md"), "User notes\n");
write(&mem_dir.join("feedback_x.md"), "Feedback X\n");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_auto_memory(Path::new("/tmp/y"), &mut warns).expect("loaded");
std::env::remove_var("APR_CONFIG");
assert!(warns.is_empty());
let memory_idx = out.find("Top-of-memory index").expect("MEMORY present");
let feedback_idx = out.find("Feedback X").expect("feedback present");
let user_idx = out.find("User notes").expect("user present");
assert!(memory_idx < feedback_idx, "MEMORY.md must come first");
assert!(feedback_idx < user_idx, "feedback < user lexicographically");
assert!(out.contains("### MEMORY.md"));
assert!(out.contains("### feedback_x.md"));
assert!(out.contains("### zzz_user.md"));
}
#[test]
fn load_skips_non_md_files() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let mem_dir = cfg.path().join("projects").join("-tmp-skip").join("memory");
write(&mem_dir.join("note.md"), "kept\n");
write(&mem_dir.join("note.txt"), "skipped\n");
write(&mem_dir.join("note.json"), "skipped\n");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_auto_memory(Path::new("/tmp/skip"), &mut warns).expect("loaded");
std::env::remove_var("APR_CONFIG");
assert!(out.contains("kept"));
assert!(!out.contains("skipped"), "non-md files must NOT be loaded");
}
#[test]
fn load_skips_subdirectories() {
let _guard = env_lock();
let cfg = tempfile::tempdir().expect("cfg");
let mem_dir = cfg.path().join("projects").join("-tmp-sub").join("memory");
write(&mem_dir.join("ok.md"), "ok-content\n");
fs::create_dir_all(mem_dir.join("nested")).expect("mkdir nested");
write(&mem_dir.join("nested").join("hidden.md"), "hidden-content\n");
std::env::set_var("APR_CONFIG", cfg.path());
let mut warns = Vec::new();
let out = load_auto_memory(Path::new("/tmp/sub"), &mut warns).expect("loaded");
std::env::remove_var("APR_CONFIG");
assert!(out.contains("ok-content"));
assert!(!out.contains("hidden-content"), "must not recurse into subdirs");
}
}