use std::path::Path;
use oxi_frontmatter::{
FrontmatterError, NoteFormat, Parsed, Synthesize, Table, Value, WriteOutcome, atomic_write,
emit, parse, write_document,
};
use time::OffsetDateTime;
use crate::types::{
CHAT_FILENAME, DIR_ARCHIVE, DIR_HABITS, DIR_INSIGHTS, DIR_JOURNAL, DIR_MEDIA, DONE_FILENAME,
LATER_FILENAME, MD_EXT, NoteMeta, READ_FILENAME, SHOP_FILENAME, WATCH_FILENAME,
};
fn assert_safe_rel(rel_path: &str) -> Result<(), FrontmatterError> {
if rel_path.is_empty()
|| rel_path.starts_with('/')
|| rel_path.starts_with('\\')
|| rel_path.starts_with("..")
|| rel_path.contains("/../")
|| rel_path.contains("\\..\\")
|| rel_path.contains('\0')
{
return Err(FrontmatterError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unsafe path: {rel_path:?}"),
)));
}
Ok(())
}
const SYSTEM_DIRS: &[&str] = &[
DIR_ARCHIVE,
DIR_JOURNAL,
DIR_HABITS,
DIR_INSIGHTS,
DIR_MEDIA,
"img",
];
const SYSTEM_FILES_ROOT: &[&str] = &[
CHAT_FILENAME,
LATER_FILENAME,
DONE_FILENAME,
SHOP_FILENAME,
WATCH_FILENAME,
READ_FILENAME,
];
pub fn is_system_path(rel_path: &str) -> bool {
if !rel_path.ends_with(MD_EXT) {
return true;
}
let first = rel_path.split('/').next().unwrap_or(rel_path);
if SYSTEM_FILES_ROOT.contains(&rel_path) {
return true;
}
if SYSTEM_DIRS.contains(&first) {
return true;
}
false
}
pub fn read_note_meta(content: &str) -> Result<Option<NoteMeta>, FrontmatterError> {
let parsed = parse(content, NoteFormat::Markdown)?;
Ok(match parsed {
Parsed::Memo { table, .. } => table_to_note_meta(&table),
Parsed::BodyOnly { .. } => None,
})
}
pub fn read_note_body(content: &str) -> Result<String, FrontmatterError> {
Ok(match parse(content, NoteFormat::Markdown)? {
Parsed::Memo { body, .. } => body,
Parsed::BodyOnly { body } => body,
})
}
pub fn with_oxios_table(content: &str, meta: &NoteMeta) -> Result<String, FrontmatterError> {
let (incoming_table, body) = match parse(content, NoteFormat::Markdown)? {
Parsed::Memo { table, body } => (table, body),
Parsed::BodyOnly { body } => (Table::new(), body),
};
let mut merged = incoming_table;
merge_note_meta(&mut merged, meta);
Ok(emit(&merged, &body, NoteFormat::Markdown))
}
pub fn write_note(
root: &Path,
rel: &str,
content: &str,
now: OffsetDateTime,
) -> Result<WriteOutcome, FrontmatterError> {
assert_safe_rel(rel)?;
let path = root.join(rel);
if is_system_path(rel) {
let existing = std::fs::read(&path).ok();
if existing.as_deref() == Some(content.as_bytes()) {
return Ok(WriteOutcome::NoOp);
}
atomic_write(&path, content.as_bytes())?;
return Ok(WriteOutcome::Written);
}
match parse(content, NoteFormat::Markdown)? {
Parsed::Memo {
table: incoming_table,
body,
} => write_memo_with_incoming_table(&path, incoming_table, &body, now),
Parsed::BodyOnly { body } => {
write_document(
&path,
&body,
NoteFormat::Markdown,
oxi_frontmatter::Mutation::default(),
Synthesize::Yes,
now,
)
}
}
}
fn write_memo_with_incoming_table(
path: &Path,
incoming_table: Table,
body: &str,
now: OffsetDateTime,
) -> Result<WriteOutcome, FrontmatterError> {
let existing_parsed: Option<Parsed> = match std::fs::read(path) {
Ok(b) => match std::str::from_utf8(&b) {
Ok(s) => Some(parse(s, NoteFormat::Markdown)?),
Err(_) => {
return Err(FrontmatterError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("file at {} is not valid UTF-8", path.display()),
)));
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(FrontmatterError::Io(e)),
};
let mut next_table: Table = match &existing_parsed {
Some(Parsed::Memo { table, .. }) => table.clone(),
_ => Table::new(),
};
for (k, v) in incoming_table {
next_table.insert(k, v);
}
if !next_table.contains_key("id") {
next_table.insert(
"id".to_string(),
Value::Str(uuid::Uuid::now_v7().to_string()),
);
}
if !next_table.contains_key("created") {
next_table.insert("created".to_string(), Value::Str(format_offset(now)));
}
let same = match &existing_parsed {
Some(Parsed::Memo { table: t, body: b }) => {
let probe = emit(&next_table, body, NoteFormat::Markdown);
match parse(&probe, NoteFormat::Markdown) {
Ok(Parsed::Memo {
table: t2,
body: b2,
}) => t == &t2 && b == &b2,
_ => false,
}
}
_ => false,
};
if same {
return Ok(WriteOutcome::NoOp);
}
next_table.insert("updated".to_string(), Value::Str(format_offset(now)));
let new_bytes = emit(&next_table, body, NoteFormat::Markdown).into_bytes();
atomic_write(path, &new_bytes)?;
Ok(WriteOutcome::Written)
}
fn format_offset(t: OffsetDateTime) -> String {
t.format(&time::format_description::well_known::Rfc3339)
.expect("RFC3339 formatting of OffsetDateTime cannot fail")
}
fn table_to_note_meta(table: &Table) -> Option<NoteMeta> {
let oxios = table.get("oxios")?;
let Value::Map(map) = oxios else {
return None;
};
let author = get_str(map, "author").unwrap_or_default();
let quality = get_str(map, "quality")
.and_then(|s| parse_quality(&s))
.unwrap_or(crate::types::NoteQuality::Raw);
let source = get_str(map, "source")
.and_then(|s| parse_source(&s))
.unwrap_or(crate::types::NoteSource::Hook);
let needs_review = get_bool(map, "needs_review").unwrap_or(false);
let session_id = get_str(map, "session_id");
let message_index = get_usize(map, "message_index");
let saved_at = get_str(map, "saved_at");
Some(NoteMeta {
author,
source,
quality,
needs_review,
session_id,
message_index,
saved_at,
})
}
fn merge_note_meta(table: &mut Table, meta: &NoteMeta) {
let mut inner = Table::new();
inner.insert("author".to_string(), Value::Str(meta.author.clone()));
inner.insert(
"source".to_string(),
Value::Str(source_str(&meta.source).to_string()),
);
inner.insert(
"quality".to_string(),
Value::Str(quality_str(&meta.quality).to_string()),
);
inner.insert("needs_review".to_string(), Value::Bool(meta.needs_review));
if let Some(sid) = &meta.session_id {
inner.insert("session_id".to_string(), Value::Str(sid.clone()));
}
if let Some(idx) = meta.message_index {
inner.insert("message_index".to_string(), Value::Str(idx.to_string()));
}
if let Some(ts) = &meta.saved_at {
inner.insert("saved_at".to_string(), Value::Str(ts.clone()));
}
table.insert("oxios".to_string(), Value::Map(inner));
}
fn get_str(map: &Table, key: &str) -> Option<String> {
match map.get(key)? {
Value::Str(s) => Some(s.clone()),
_ => None,
}
}
fn get_bool(map: &Table, key: &str) -> Option<bool> {
match map.get(key)? {
Value::Bool(b) => Some(*b),
_ => None,
}
}
fn get_usize(map: &Table, key: &str) -> Option<usize> {
match map.get(key)? {
Value::Str(s) => s.parse().ok(),
_ => None,
}
}
fn parse_quality(s: &str) -> Option<crate::types::NoteQuality> {
match s {
"raw" => Some(crate::types::NoteQuality::Raw),
"curated" => Some(crate::types::NoteQuality::Curated),
"refined" => Some(crate::types::NoteQuality::Refined),
_ => None,
}
}
fn parse_source(s: &str) -> Option<crate::types::NoteSource> {
match s {
"hook" => Some(crate::types::NoteSource::Hook),
"tool" => Some(crate::types::NoteSource::Tool),
"ui" => Some(crate::types::NoteSource::Ui),
"dream" => Some(crate::types::NoteSource::Dream),
_ => None,
}
}
fn source_str(s: &crate::types::NoteSource) -> &'static str {
match s {
crate::types::NoteSource::Hook => "hook",
crate::types::NoteSource::Tool => "tool",
crate::types::NoteSource::Ui => "ui",
crate::types::NoteSource::Dream => "dream",
}
}
fn quality_str(q: &crate::types::NoteQuality) -> &'static str {
match q {
crate::types::NoteQuality::Raw => "raw",
crate::types::NoteQuality::Curated => "curated",
crate::types::NoteQuality::Refined => "refined",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::NoteQuality;
use oxi_frontmatter::parse;
use time::macros::datetime;
#[test]
fn system_paths_are_excluded() {
for p in [
"Chat.md",
"Later.md",
"Done.md",
"Shop.md",
"journal/2026.08 August.md",
"habits/Mood.md",
"insights/2026 Habits.md",
"archive/Done.md",
"config.json",
"img/x.png",
] {
assert!(is_system_path(p), "{p} should be a system path");
}
assert!(
!is_system_path("brain/Rust.md"),
"first-class memo must NOT be a system path"
);
assert!(
!is_system_path("personal/Chat.md"),
"filename equality is root-anchored; personal/Chat.md is a memo"
);
}
#[test]
fn unsafe_rel_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let now = datetime!(2026-08-21 00:00 UTC);
for bad in [
"../Chat.md",
"/etc/passwd",
"\\Windows\\System32",
"ok/\x00/bad",
] {
let err = write_note(tmp.path(), bad, "body", now).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("unsafe path"),
"{bad} should be rejected; got {msg}"
);
}
}
#[test]
fn legacy_rfc022_is_native_and_meta_roundtrips() {
let legacy = "---\noxios:\n author: agent\n quality: raw\n---\nbody";
let meta = read_note_meta(legacy)
.expect("legacy RFC-022 must parse")
.expect("oxios: present");
assert_eq!(meta.author, "agent");
assert_eq!(meta.quality, NoteQuality::Raw);
let out = with_oxios_table(legacy, &meta).expect("emit must succeed");
assert!(
out.starts_with("---\n") && out.contains("oxios:"),
"canonical form must carry ---\\noxios:; got: {out:?}"
);
let reparsed = read_note_meta(&out)
.expect("canonical form must parse")
.expect("oxios: present");
assert_eq!(reparsed.author, "agent");
assert_eq!(reparsed.quality, NoteQuality::Raw);
}
#[test]
fn user_authored_means_no_oxios_table() {
assert!(
read_note_meta(
"---\nid: a\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\nbody"
)
.unwrap()
.is_none(),
"frontmatter without `oxios:` key is user-authored"
);
assert!(
read_note_meta("plain body, no frontmatter")
.unwrap()
.is_none(),
"no-fence content returns None"
);
}
#[test]
fn read_note_body_strips_frontmatter_and_hard_fails_on_malformed() {
let body = read_note_body(
"---\nid: a\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\noxios:\n author: agent\n needs_review: true\n---\n# Curate me\n",
)
.expect("memo must parse");
assert_eq!(body, "# Curate me\n");
assert!(!body.contains("---"), "frontmatter must be stripped");
assert_eq!(
read_note_body("plain body, no frontmatter").unwrap(),
"plain body, no frontmatter"
);
assert!(
read_note_body("---\nfoo: [unclosed\n---\nbody").is_err(),
"malformed frontmatter must be a hard parse error"
);
}
#[test]
fn write_note_synthesizes_and_preserves() {
let tmp = tempfile::tempdir().unwrap();
let now = datetime!(2026-08-21 00:00 UTC);
let rel = "brain/Rust.md";
let outcome =
write_note(tmp.path(), rel, "# Rust\n\nOwnership rules.", now).expect("write_note");
assert_eq!(outcome, WriteOutcome::Written);
let bytes = std::fs::read(tmp.path().join(rel)).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
assert!(
text.starts_with("---\n"),
"must have frontmatter; got: {text}"
);
assert!(text.contains("id:"), "must synthesize id; got: {text}");
assert!(
text.contains("created:"),
"must synthesize created; got: {text}"
);
assert!(
text.contains("updated:"),
"must synthesize updated; got: {text}"
);
assert!(
text.contains("Ownership rules."),
"body preserved; got: {text}"
);
let outcome2 = write_note(tmp.path(), rel, "# Rust\n\nOwnership rules.", now).unwrap();
assert_eq!(outcome2, WriteOutcome::NoOp);
let _ = std::fs::write(
tmp.path().join(rel),
"---\nid: pre-existing-id\nlegacy_key: kept\n---\n# Rust\n\nOwnership rules.\n",
);
let editor_input =
"---\ntags: [rust, design]\ncustom_key: hello\n---\n# Rust\n\nOwnership rules.\n";
let outcome3 = write_note(tmp.path(), rel, editor_input, now).unwrap();
assert_eq!(outcome3, WriteOutcome::Written);
let text2 = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
let parsed = parse(&text2, NoteFormat::Markdown).expect("written file must parse");
let Parsed::Memo { table, body } = parsed else {
panic!("written file must have frontmatter; got: {text2}")
};
assert!(
table.contains_key("id"),
"pre-existing id must remain; got table keys: {:?}",
table.keys().collect::<Vec<_>>()
);
assert!(
table.contains_key("legacy_key"),
"pre-existing foreign key must carry forward; got table keys: {:?}",
table.keys().collect::<Vec<_>>()
);
assert!(
table.contains_key("tags"),
"editor-supplied tags key must survive; got table keys: {:?}",
table.keys().collect::<Vec<_>>()
);
assert!(
table.contains_key("custom_key"),
"editor-supplied custom_key must survive; got table keys: {:?}",
table.keys().collect::<Vec<_>>()
);
assert!(
!table.contains_key("oxios"),
"write_note must NOT add an oxios: row; got table keys: {:?}",
table.keys().collect::<Vec<_>>()
);
assert!(
!body.starts_with("---"),
"body must not start with a fence; got body: {body:?}"
);
assert!(
body.contains("Ownership rules."),
"body must contain the user content; got: {body}"
);
}
#[test]
fn write_note_system_path_is_raw_atomic() {
let tmp = tempfile::tempdir().unwrap();
let now = datetime!(2026-08-21 00:00 UTC);
let rel = "Chat.md";
let content = "free-form chat log, no frontmatter expected\n";
let outcome = write_note(tmp.path(), rel, content, now).unwrap();
assert_eq!(outcome, WriteOutcome::Written);
let bytes = std::fs::read(tmp.path().join(rel)).unwrap();
let text = std::str::from_utf8(&bytes).unwrap();
assert_eq!(text, content, "system path gets raw bytes");
assert!(!text.starts_with("---\n"), "no frontmatter synthesized");
let outcome2 = write_note(tmp.path(), rel, content, now).unwrap();
assert_eq!(outcome2, WriteOutcome::NoOp);
let cfg = "{\"k\":1}";
let outcome3 = write_note(tmp.path(), "config.json", cfg, now).unwrap();
assert_eq!(outcome3, WriteOutcome::Written);
let cfg_bytes = std::fs::read(tmp.path().join("config.json")).unwrap();
assert_eq!(cfg_bytes, cfg.as_bytes());
}
#[test]
fn write_note_body_change_is_not_a_noop() {
let tmp = tempfile::tempdir().unwrap();
let now = datetime!(2026-08-21 00:00 UTC);
let rel = "brain/Rust.md";
std::fs::create_dir_all(tmp.path().join("brain")).unwrap();
let seed = "---\nid: pre-existing-id\ntags: [keep]\n---\nold body\n";
std::fs::write(tmp.path().join(rel), seed).unwrap();
let incoming = "---\nid: pre-existing-id\ntags: [keep]\n---\nnew body\n";
let outcome1 = write_note(tmp.path(), rel, incoming, now).expect("first write");
assert_eq!(
outcome1,
WriteOutcome::Written,
"body change must produce Written, never a silent NoOp"
);
let after_first = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
let parsed1 = parse(&after_first, NoteFormat::Markdown).expect("file parses");
let Parsed::Memo {
table: t1,
body: b1,
} = parsed1
else {
panic!("expected Memo after first write; got BodyOnly; file: {after_first}")
};
assert_eq!(b1, "new body\n", "body must reflect the incoming content");
assert!(
t1.contains_key("updated"),
"updated must be present on a real write; got keys: {:?}",
t1.keys().collect::<Vec<_>>()
);
let outcome2 = write_note(tmp.path(), rel, incoming, now).expect("second write");
assert_eq!(
outcome2,
WriteOutcome::NoOp,
"second identical write must be NoOp"
);
let after_second = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
assert_eq!(after_second, after_first, "NoOp must not modify the file");
}
#[test]
fn write_note_noop_survives_advancing_clock() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("brain")).unwrap();
let rel = "brain/Rust.md";
let now1 = datetime!(2026-08-21 00:00 UTC);
let now2 = datetime!(2026-08-21 09:30 UTC);
let now3 = datetime!(2026-08-22 14:10 UTC);
let incoming =
"---\nid: fixed-id\ncreated: 2026-08-20T00:00:00Z\ntags: [keep]\n---\nstable body\n";
assert_eq!(
write_note(tmp.path(), rel, incoming, now1).unwrap(),
WriteOutcome::Written
);
let after_first = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
assert_eq!(
write_note(tmp.path(), rel, &after_first, now2).unwrap(),
WriteOutcome::NoOp,
"unchanged re-save with an advanced clock must be NoOp"
);
let after_resave = String::from_utf8(std::fs::read(tmp.path().join(rel)).unwrap()).unwrap();
assert_eq!(
after_resave, after_first,
"NoOp must leave the file byte-identical (no updated bump, no re-canonicalization)"
);
let edited = after_first.replace("stable body", "edited body");
assert_eq!(
write_note(tmp.path(), rel, &edited, now3).unwrap(),
WriteOutcome::Written
);
let after_edit = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
let parsed = parse(&after_edit, NoteFormat::Markdown).expect("file must parse");
let Parsed::Memo { table, body } = parsed else {
panic!("edited file must have frontmatter; got: {after_edit}")
};
assert_eq!(body, "edited body\n", "body must reflect the edit");
assert_eq!(
table.get("updated"),
Some(&Value::Str(format_offset(now3))),
"real write must bump updated to the injected now"
);
assert_eq!(
table.get("id"),
Some(&Value::Str("fixed-id".to_string())),
"id must carry forward"
);
assert_eq!(
table.get("created"),
Some(&Value::Str("2026-08-20T00:00:00Z".to_string())),
"created must carry forward"
);
}
}