use std::fs;
use std::io::{self, Write};
use std::path::Path;
use chrono::Utc;
const MAX_MEMORY_SIZE: usize = 100 * 1024;
#[must_use]
pub fn load(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
if content.trim().is_empty() {
return None;
}
Some(content)
}
#[must_use]
pub fn as_system_block(content: &str, source: &Path) -> Option<String> {
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
let display = source.display();
let payload = if content.len() > MAX_MEMORY_SIZE {
let mut head = content[..MAX_MEMORY_SIZE].to_string();
head.push_str("\n…(truncated, raise [memory].max_size or trim memory.md)");
head
} else {
trimmed.to_string()
};
Some(format!(
"<user_memory source=\"{display}\">\n{payload}\n</user_memory>"
))
}
#[must_use]
pub fn compose_block(enabled: bool, path: &Path) -> Option<String> {
if !enabled {
return None;
}
let content = load(path)?;
as_system_block(&content, path)
}
pub fn append_entry(path: &Path, entry: &str) -> io::Result<()> {
let trimmed = entry.trim_start_matches('#').trim();
if trimmed.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"memory entry is empty after stripping `#` prefix",
));
}
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
let timestamp = Utc::now().format("%Y-%m-%d %H:%M UTC");
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "- ({timestamp}) {trimmed}")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn load_returns_none_for_missing_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("never-existed.md");
assert!(load(&path).is_none());
}
#[test]
fn load_returns_none_for_whitespace_only_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
fs::write(&path, " \n \n").unwrap();
assert!(load(&path).is_none());
}
#[test]
fn load_returns_content_for_real_file() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
fs::write(&path, "remember the milk").unwrap();
assert_eq!(load(&path).as_deref(), Some("remember the milk"));
}
#[test]
fn as_system_block_produces_xml_wrapper() {
let block = as_system_block("note 1", Path::new("/tmp/m.md")).unwrap();
assert!(block.contains("<user_memory source=\"/tmp/m.md\">"));
assert!(block.contains("note 1"));
assert!(block.ends_with("</user_memory>"));
}
#[test]
fn as_system_block_returns_none_for_empty_content() {
assert!(as_system_block(" ", Path::new("/tmp/m.md")).is_none());
}
#[test]
fn as_system_block_truncates_oversize_input() {
let big = "x".repeat(MAX_MEMORY_SIZE + 100);
let block = as_system_block(&big, Path::new("/tmp/m.md")).unwrap();
assert!(block.contains("(truncated"));
}
#[test]
fn append_entry_creates_file_and_writes_one_bullet() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
append_entry(&path, "# remember the milk").unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("remember the milk"), "{body}");
assert!(
body.starts_with("- ("),
"should start with bullet + date: {body}"
);
assert!(body.trim_end().ends_with("remember the milk"));
}
#[test]
fn append_entry_appends_subsequent_lines() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
append_entry(&path, "# first").unwrap();
append_entry(&path, "second").unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.contains("first"));
assert!(body.contains("second"));
assert_eq!(body.matches("- (").count(), 2);
}
#[test]
fn append_entry_rejects_empty_after_strip() {
let tmp = tempdir().unwrap();
let path = tmp.path().join("memory.md");
let err = append_entry(&path, "###").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
}