pub mod anchors;
pub mod classify;
pub mod filter;
pub mod hash;
pub mod semantic_store;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct MemoryStore {
memories_dir: PathBuf,
}
impl MemoryStore {
pub fn open(project_root: &Path) -> Result<Self> {
let memories_dir = project_root.join(".codescout").join("memories");
std::fs::create_dir_all(&memories_dir)?;
Ok(Self { memories_dir })
}
pub fn from_dir(memories_dir: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&memories_dir)?;
Ok(Self { memories_dir })
}
pub fn dir(&self) -> &Path {
&self.memories_dir
}
pub fn open_private(project_root: &Path) -> Result<Self> {
let memories_dir = project_root.join(".codescout").join("private-memories");
std::fs::create_dir_all(&memories_dir)?;
Self::ensure_gitignored(project_root, ".codescout/private-memories/")?;
Ok(Self { memories_dir })
}
fn ensure_gitignored(project_root: &Path, entry: &str) -> Result<()> {
let gitignore_path = project_root.join(".gitignore");
let existing = if gitignore_path.exists() {
std::fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
if existing.lines().any(|l| l.trim() == entry) {
return Ok(());
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(entry);
content.push('\n');
crate::util::fs::atomic_write(&gitignore_path, &content)?;
Ok(())
}
pub fn write(&self, topic: &str, content: &str) -> Result<()> {
let path = self.topic_path(topic);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
crate::util::fs::atomic_write(&path, content)?;
Ok(())
}
pub fn read(&self, topic: &str) -> Result<Option<String>> {
let path = self.topic_path(topic);
if path.exists() {
Ok(Some(std::fs::read_to_string(path)?))
} else {
Ok(None)
}
}
pub fn list(&self) -> Result<Vec<String>> {
let mut topics = vec![];
for entry in walkdir::WalkDir::new(&self.memories_dir)
.into_iter()
.flatten()
{
if entry.file_type().is_file() {
if let Some(ext) = entry.path().extension() {
if ext == "md" {
if let Ok(rel) = entry.path().strip_prefix(&self.memories_dir) {
let topic = rel.with_extension("").to_string_lossy().replace('\\', "/");
topics.push(topic);
}
}
}
}
}
topics.sort();
Ok(topics)
}
pub fn delete(&self, topic: &str) -> Result<()> {
let path = self.topic_path(topic);
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
pub(crate) fn topic_path(&self, topic: &str) -> PathBuf {
let safe = sanitize_topic(topic);
self.memories_dir.join(safe).with_extension("md")
}
}
pub(crate) fn sanitize_topic(topic: &str) -> String {
use std::path::{Component, Path};
let sanitized: PathBuf = Path::new(topic)
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s),
_ => None,
})
.collect();
let result = sanitized.to_string_lossy().into_owned();
if result.is_empty() {
"_empty".to_string()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_store() -> (tempfile::TempDir, MemoryStore) {
let dir = tempdir().unwrap();
let store = MemoryStore::open(dir.path()).unwrap();
(dir, store)
}
#[test]
fn open_private_creates_private_memories_dir() {
let dir = tempdir().unwrap();
let _store = MemoryStore::open_private(dir.path()).unwrap();
assert!(dir.path().join(".codescout/private-memories").is_dir());
}
#[test]
fn open_private_adds_to_gitignore() {
let dir = tempdir().unwrap();
MemoryStore::open_private(dir.path()).unwrap();
let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains(".codescout/private-memories/"));
}
#[test]
fn open_private_does_not_duplicate_gitignore_entry() {
let dir = tempdir().unwrap();
MemoryStore::open_private(dir.path()).unwrap();
MemoryStore::open_private(dir.path()).unwrap();
let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
let count = content
.lines()
.filter(|l| l.trim() == ".codescout/private-memories/")
.count();
assert_eq!(count, 1);
}
#[test]
fn open_private_appends_to_existing_gitignore() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
MemoryStore::open_private(dir.path()).unwrap();
let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(content.contains("target/\n"));
assert!(content.contains(".codescout/private-memories/"));
}
#[test]
fn write_and_read_roundtrip() {
let (_dir, store) = make_store();
store.write("my-topic", "hello memory").unwrap();
assert_eq!(
store.read("my-topic").unwrap(),
Some("hello memory".to_string())
);
}
#[test]
fn read_missing_returns_none() {
let (_dir, store) = make_store();
assert_eq!(store.read("does-not-exist").unwrap(), None);
}
#[test]
fn list_empty_store() {
let (_dir, store) = make_store();
assert_eq!(store.list().unwrap(), Vec::<String>::new());
}
#[test]
fn list_after_writes_is_sorted() {
let (_dir, store) = make_store();
store.write("c-topic", "c").unwrap();
store.write("a-topic", "a").unwrap();
store.write("b-topic", "b").unwrap();
let list = store.list().unwrap();
assert_eq!(list, vec!["a-topic", "b-topic", "c-topic"]);
}
#[test]
fn delete_removes_entry() {
let (_dir, store) = make_store();
store.write("to-delete", "content").unwrap();
store.delete("to-delete").unwrap();
assert_eq!(store.read("to-delete").unwrap(), None);
assert!(!store.list().unwrap().contains(&"to-delete".to_string()));
}
#[test]
fn delete_nonexistent_is_ok() {
let (_dir, store) = make_store();
assert!(store.delete("ghost").is_ok());
}
#[test]
fn nested_topic_roundtrip() {
let (_dir, store) = make_store();
store
.write("debugging/async-patterns", "avoid blocking")
.unwrap();
assert_eq!(
store.read("debugging/async-patterns").unwrap(),
Some("avoid blocking".to_string())
);
assert!(store
.list()
.unwrap()
.contains(&"debugging/async-patterns".to_string()));
}
#[test]
fn overwrite_replaces_content() {
let (_dir, store) = make_store();
store.write("key", "v1").unwrap();
store.write("key", "v2").unwrap();
assert_eq!(store.read("key").unwrap(), Some("v2".to_string()));
}
#[test]
fn dotdot_in_topic_is_sanitized() {
let (_dir, store) = make_store();
store.write("../escape", "evil").unwrap();
let result = store.read("../escape").unwrap();
assert_eq!(result, Some("evil".to_string()));
}
#[test]
fn absolute_path_topic_stays_inside_memories_dir() {
let (_dir, store) = make_store();
let evil_topic = "/etc/shadow";
let resolved = store.topic_path(evil_topic);
assert!(
resolved.starts_with(&store.memories_dir),
"absolute path topic escaped memories dir: {:?}",
resolved
);
}
#[test]
fn topic_with_null_byte_is_handled() {
let (_dir, store) = make_store();
let result = store.write("safe\0evil", "content");
if result.is_ok() {
let path = store.topic_path("safe\0evil");
assert!(path.starts_with(&store.memories_dir));
}
}
#[test]
fn topic_with_backslash_traversal_stays_inside() {
let (_dir, store) = make_store();
let resolved = store.topic_path("..\\..\\etc\\passwd");
assert!(
resolved.starts_with(&store.memories_dir),
"backslash traversal escaped memories dir: {:?}",
resolved
);
}
#[test]
fn empty_topic_does_not_panic() {
let (_dir, store) = make_store();
let resolved = store.topic_path("");
assert!(resolved.starts_with(&store.memories_dir));
}
#[test]
fn deeply_nested_topic_works() {
let (_dir, store) = make_store();
store.write("a/b/c/d/e/deep-topic", "deep content").unwrap();
assert_eq!(
store.read("a/b/c/d/e/deep-topic").unwrap(),
Some("deep content".to_string())
);
}
#[test]
fn topic_with_special_chars() {
let (_dir, store) = make_store();
for topic in &["hello world", "a&b", "test=value", "name@domain"] {
let result = store.write(topic, "content");
if result.is_ok() {
assert_eq!(store.read(topic).unwrap(), Some("content".to_string()));
}
}
}
#[test]
fn topic_path_blocks_dot_slash_traversal() {
let (_dir, store) = make_store();
let path = store.topic_path("a/./b/../../../etc/passwd");
assert!(
path.starts_with(&store.memories_dir),
"path {:?} must be inside {:?}",
path,
store.memories_dir,
);
}
#[test]
fn topic_path_blocks_single_dot() {
let (_dir, store) = make_store();
let path = store.topic_path(".");
assert!(
path.starts_with(&store.memories_dir),
"path {:?} must be inside {:?}",
path,
store.memories_dir,
);
assert_ne!(path, store.memories_dir);
}
#[test]
fn dashboard_topic_is_sanitized() {
let (_dir, store) = make_store();
let path = store.topic_path("../../etc/passwd");
assert!(path.starts_with(&store.memories_dir));
}
}