use std::fs;
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result};
use chrono::Utc;
use uuid::Uuid;
use crate::memory::config::MemoryConfig;
use crate::memory::entities::canonical::slugify_id;
use crate::memory::entities::frontmatter::{compose, extract_notes, parse};
use crate::memory::entities::types::{Entity, EntityKind};
const ENTITIES_DIR: &str = "entities";
fn content_root(config: &MemoryConfig) -> PathBuf {
config.workspace.join("memory_tree").join("content")
}
fn kind_dir(config: &MemoryConfig, kind: EntityKind) -> PathBuf {
content_root(config).join(ENTITIES_DIR).join(kind.as_str())
}
fn entity_path(config: &MemoryConfig, kind: EntityKind, canonical_id: &str) -> PathBuf {
kind_dir(config, kind).join(format!("{}.md", slugify_id(canonical_id)))
}
pub fn put_entity(config: &MemoryConfig, mut entity: Entity) -> Result<Entity> {
let dir = kind_dir(config, entity.kind);
fs::create_dir_all(&dir).with_context(|| format!("failed to mkdir -p {}", dir.display()))?;
let path = entity_path(config, entity.kind, &entity.id);
let existing_notes = match fs::read_to_string(&path) {
Ok(text) => extract_notes(&text),
Err(_) => String::new(),
};
entity.updated_at = Utc::now();
let bytes = compose(&entity, &existing_notes).into_bytes();
write_entity_atomic(&path, &bytes)?;
Ok(entity)
}
fn write_entity_atomic(path: &PathBuf, bytes: &[u8]) -> Result<()> {
let parent = path
.parent()
.with_context(|| format!("entity path has no parent: {}", path.display()))?;
let tmp_path = parent.join(format!(".entity-{}.tmp", Uuid::new_v4()));
let write_result = (|| -> Result<()> {
{
let mut file = fs::File::create(&tmp_path)
.with_context(|| format!("failed to create {}", tmp_path.display()))?;
file.write_all(bytes)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("failed to sync {}", tmp_path.display()))?;
}
fs::rename(&tmp_path, path).with_context(|| {
format!(
"failed to atomically replace {} with {}",
path.display(),
tmp_path.display()
)
})?;
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&tmp_path);
}
write_result
}
pub fn get_entity(
config: &MemoryConfig,
kind: EntityKind,
canonical_id: &str,
) -> Result<Option<Entity>> {
let path = entity_path(config, kind, canonical_id);
if !path.exists() {
return Ok(None);
}
let text =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
Ok(parse(&text))
}
pub fn list_entities(config: &MemoryConfig, kind: EntityKind) -> Result<Vec<Entity>> {
let dir = kind_dir(config, kind);
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in
fs::read_dir(&dir).with_context(|| format!("failed to read_dir {}", dir.display()))?
{
let entry = entry?;
let name = entry.file_name();
let s = name.to_string_lossy();
if !s.ends_with(".md") {
continue;
}
let text = fs::read_to_string(entry.path())
.with_context(|| format!("failed to read {}", entry.path().display()))?;
if let Some(e) = parse(&text) {
out.push(e);
}
}
Ok(out)
}
pub fn lookup_alias(
config: &MemoryConfig,
kind: EntityKind,
needle: &str,
) -> Result<Option<Entity>> {
let lower = needle.to_lowercase();
for e in list_entities(config, kind)? {
if e.aliases.iter().any(|a| a.to_lowercase() == lower) {
return Ok(Some(e));
}
if e.emails.iter().any(|m| m.to_lowercase() == lower) {
return Ok(Some(e));
}
if e.handles.iter().any(|h| h.value.to_lowercase() == lower) {
return Ok(Some(e));
}
if e.display_name
.as_deref()
.map(|n| n.to_lowercase() == lower)
.unwrap_or(false)
{
return Ok(Some(e));
}
}
Ok(None)
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;