use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use parking_lot::Mutex;
use crate::memory::config::MemoryConfig;
use crate::memory::error::{MemoryEngineResult, MemoryError};
use super::types::GoalsDoc;
pub const GOALS_FILE: &str = "MEMORY_GOALS.md";
pub const GOALS_FILE_MAX_CHARS: usize = 2000;
pub const GOALS_MAX_ITEMS: usize = 8;
pub(super) fn goals_mutation_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
pub fn goals_path(workspace: &Path) -> PathBuf {
workspace.join(GOALS_FILE)
}
fn validate_within_workspace(workspace: &Path) -> MemoryEngineResult<PathBuf> {
let path = goals_path(workspace);
let workspace_canon = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let parent = path.parent().unwrap_or(workspace);
let parent_canon = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
if !parent_canon.starts_with(&workspace_canon) {
return Err(MemoryError::PathEscape(format!(
"goals path resolves outside workspace: {path:?}"
)));
}
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if meta.file_type().is_symlink() {
let resolved = path.canonicalize().map_err(|e| {
MemoryError::PathEscape(format!("failed to resolve goals symlink {path:?}: {e}"))
})?;
if !resolved.starts_with(&workspace_canon) {
return Err(MemoryError::PathEscape(format!(
"goals symlink resolves outside workspace: {resolved:?}"
)));
}
}
}
Ok(path)
}
pub fn load(workspace: &Path) -> MemoryEngineResult<GoalsDoc> {
let path = validate_within_workspace(workspace)?;
match std::fs::read_to_string(&path) {
Ok(body) => Ok(GoalsDoc::parse(&body)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GoalsDoc::default()),
Err(e) => Err(MemoryError::Io(e)),
}
}
fn enforce_caps(doc: &mut GoalsDoc) -> Vec<String> {
let mut dropped = Vec::new();
while doc.items.len() > GOALS_MAX_ITEMS {
let removed = doc.items.remove(0);
dropped.push(removed.id);
}
while doc.render().len() > GOALS_FILE_MAX_CHARS && doc.items.len() > 1 {
let removed = doc.items.remove(0);
dropped.push(removed.id);
}
dropped
}
pub fn save(workspace: &Path, doc: &mut GoalsDoc) -> MemoryEngineResult<()> {
let path = validate_within_workspace(workspace)?;
let _dropped = enforce_caps(doc);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, doc.render())?;
Ok(())
}
pub fn add(workspace: &Path, text: &str) -> MemoryEngineResult<(String, GoalsDoc)> {
let _guard = goals_mutation_lock().lock();
let mut doc = load(workspace)?;
let id = doc.add(text)?;
save(workspace, &mut doc)?;
Ok((id, doc))
}
pub fn edit(workspace: &Path, id: &str, text: &str) -> MemoryEngineResult<GoalsDoc> {
let _guard = goals_mutation_lock().lock();
let mut doc = load(workspace)?;
doc.edit(id, text)?;
save(workspace, &mut doc)?;
Ok(doc)
}
pub fn delete(workspace: &Path, id: &str) -> MemoryEngineResult<GoalsDoc> {
let _guard = goals_mutation_lock().lock();
let mut doc = load(workspace)?;
doc.delete(id)?;
save(workspace, &mut doc)?;
Ok(doc)
}
pub fn list_for(config: &MemoryConfig) -> MemoryEngineResult<GoalsDoc> {
load(&config.workspace)
}
pub fn add_for(config: &MemoryConfig, text: &str) -> MemoryEngineResult<(String, GoalsDoc)> {
add(&config.workspace, text)
}
pub fn edit_for(config: &MemoryConfig, id: &str, text: &str) -> MemoryEngineResult<GoalsDoc> {
edit(&config.workspace, id, text)
}
pub fn delete_for(config: &MemoryConfig, id: &str) -> MemoryEngineResult<GoalsDoc> {
delete(&config.workspace, id)
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;