use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use tokio::fs;
use uuid::Uuid;
use crate::llm::SynthesisTarget;
#[inline]
pub fn get_raw_path(username: &str, agent_id: Uuid, filename: &str) -> String {
format!(
"{}/{}/{}",
username,
agent_id,
filename.replace(['/', '\\'], "_")
)
}
#[inline]
fn synthesis_folder(target: &SynthesisTarget) -> String {
match target {
SynthesisTarget::User(u) => format!("{}/_synthesized", u),
SynthesisTarget::Global => "_synthesized".to_string(),
}
}
pub async fn current_synthesis_path(
memory_dir: &Path,
target: &SynthesisTarget,
today: NaiveDate,
max_size: u64,
) -> String {
let folder_rel = synthesis_folder(target);
let folder_abs = memory_dir.join(&folder_rel);
let today_str = today.format("%Y-%m-%d").to_string();
let candidate = match find_latest_synthesis_file(&folder_abs).await {
Some((name, size)) if size < max_size => name,
Some((name, _)) => next_filename(&name, &today_str),
None => format!("{}-01.md", today_str),
};
format!("{}/{}", folder_rel, candidate)
}
pub async fn get_latest_synthesis_file(
memory_dir: &Path,
target: &SynthesisTarget,
) -> Option<String> {
let folder_rel = synthesis_folder(target);
let folder_abs = memory_dir.join(&folder_rel);
find_latest_synthesis_file(&folder_abs)
.await
.map(|(name, _)| format!("{}/{}", folder_rel, name))
}
async fn find_latest_synthesis_file(folder_abs: &Path) -> Option<(String, u64)> {
let mut entries = fs::read_dir(folder_abs).await.ok()?;
let mut best: Option<(String, PathBuf, (String, u32))> = None;
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(key) = parse_filename(name) else {
continue;
};
match &best {
Some((_, _, k)) if &key <= k => {}
_ => best = Some((name.to_string(), path, key)),
}
}
let (name, path, _) = best?;
let size = fs::metadata(&path).await.ok()?.len();
Some((name, size))
}
#[inline]
fn parse_filename(name: &str) -> Option<(String, u32)> {
let stem = name.strip_suffix(".md")?;
let mut parts = stem.splitn(4, '-');
let y = parts.next()?;
let m = parts.next()?;
let d = parts.next()?;
let n = parts.next()?;
if y.len() != 4 || !y.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if m.len() != 2 || !m.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if d.len() != 2 || !d.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if n.is_empty() || !n.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let date = format!("{}-{}-{}", y, m, d);
let suffix = n.parse().ok()?;
Some((date, suffix))
}
#[inline]
fn next_filename(current: &str, today: &str) -> String {
let Some((date, suffix)) = parse_filename(current) else {
return format!("{}-01.md", today);
};
if date != today {
return format!("{}-01.md", today);
}
format!("{}-{:02}.md", today, suffix.saturating_add(1))
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn simple_filename_path() {
let agent_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let path = get_raw_path("alice", agent_id, "2026-03-31.md");
assert_eq!(
path,
"alice/550e8400-e29b-41d4-a716-446655440000/2026-03-31.md"
);
}
#[test]
fn filename_with_slash_is_flattened() {
let agent_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let path = get_raw_path("alice", agent_id, "notes/2026-03-31.md");
assert_eq!(
path,
"alice/550e8400-e29b-41d4-a716-446655440000/notes_2026-03-31.md"
);
let path = get_raw_path("alice", agent_id, "notes\\2026-03-31.md");
assert_eq!(
path,
"alice/550e8400-e29b-41d4-a716-446655440000/notes_2026-03-31.md"
);
}
#[tokio::test]
async fn synthesis_path_uses_date_name() {
let dir = tempfile::tempdir().unwrap();
let path = current_synthesis_path(
dir.path(),
&SynthesisTarget::User("alice".into()),
date(2026, 5, 20),
1_048_576,
)
.await;
assert_eq!(path, "alice/_synthesized/2026-05-20-01.md");
let path = current_synthesis_path(
dir.path(),
&SynthesisTarget::Global,
date(2026, 5, 20),
1024,
)
.await;
assert_eq!(path, "_synthesized/2026-05-20-01.md");
}
#[tokio::test]
async fn synthesis_path_reuses_existing_dated_file_under_cap() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("alice").join("_synthesized");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("2026-05-20-01.md"), b"short").unwrap();
let path = current_synthesis_path(
dir.path(),
&SynthesisTarget::User("alice".into()),
date(2026, 5, 20),
1024,
)
.await;
assert_eq!(path, "alice/_synthesized/2026-05-20-01.md");
}
#[tokio::test]
async fn synthesis_path_rolls_over_increments_suffix() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("alice").join("_synthesized");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("2026-05-20-01.md"), vec![b'x'; 1024]).unwrap();
fs::write(folder.join("2026-05-20-02.md"), vec![b'x'; 1024]).unwrap();
let path = current_synthesis_path(
dir.path(),
&SynthesisTarget::User("alice".into()),
date(2026, 5, 20),
1024,
)
.await;
assert_eq!(path, "alice/_synthesized/2026-05-20-03.md");
}
#[tokio::test]
async fn synthesis_path_starts_fresh_on_new_date() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("alice").join("_synthesized");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("2026-05-19-01.md"), vec![b'x'; 1024]).unwrap();
let path = current_synthesis_path(
dir.path(),
&SynthesisTarget::User("alice".into()),
date(2026, 5, 20),
1024,
)
.await;
assert_eq!(path, "alice/_synthesized/2026-05-20-01.md");
}
#[tokio::test]
async fn latest_synthesis_path_picks_lexicographic_max() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("alice").join("_synthesized");
fs::create_dir_all(&folder).unwrap();
fs::write(folder.join("2026-05-19-01.md"), b"a").unwrap();
fs::write(folder.join("2026-05-20-01.md"), b"b").unwrap();
fs::write(folder.join("2026-05-20-02.md"), b"c").unwrap();
let path =
get_latest_synthesis_file(dir.path(), &SynthesisTarget::User("alice".into())).await;
assert_eq!(path.as_deref(), Some("alice/_synthesized/2026-05-20-02.md"));
}
#[tokio::test]
async fn latest_synthesis_path_returns_none_when_empty() {
let dir = tempfile::tempdir().unwrap();
let path =
get_latest_synthesis_file(dir.path(), &SynthesisTarget::User("alice".into())).await;
assert!(path.is_none());
}
}