use serde::{Deserialize, Serialize};
use std::path::Path;
use thiserror::Error;
use crate::normalize_slashes;
pub const SENTIMENT_NEGATIVE_THRESHOLD: f32 = -0.3;
pub const SENTIMENT_POSITIVE_THRESHOLD: f32 = 0.3;
const MAX_NOTES: usize = 10_000;
#[derive(Error, Debug)]
pub enum NoteError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
Toml(#[from] toml::de::Error),
#[error("TOML serialization error: {0}")]
TomlSer(#[from] toml::ser::Error),
#[error("Note not found: {0}")]
NotFound(String),
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NoteEntry {
#[serde(default)]
pub sentiment: f32,
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mentions: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct NoteFile {
#[serde(default)]
pub note: Vec<NoteEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Note {
pub id: String,
pub text: String,
pub sentiment: f32,
pub mentions: Vec<String>,
}
impl Note {
pub fn embedding_text(&self) -> String {
let prefix = if self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD {
"Warning: "
} else if self.sentiment > SENTIMENT_POSITIVE_THRESHOLD {
"Pattern: "
} else {
""
};
format!("{}{}", prefix, self.text)
}
pub fn sentiment(&self) -> f32 {
self.sentiment
}
pub fn is_warning(&self) -> bool {
self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD
}
pub fn is_pattern(&self) -> bool {
self.sentiment > SENTIMENT_POSITIVE_THRESHOLD
}
pub fn sentiment_label(&self) -> &'static str {
if self.sentiment < SENTIMENT_NEGATIVE_THRESHOLD {
"WARNING"
} else if self.sentiment > SENTIMENT_POSITIVE_THRESHOLD {
"PATTERN"
} else {
"NOTE"
}
}
}
pub const NOTES_HEADER: &str = "\
# Notes - unified memory for AI collaborators
# Surprises (prediction errors) worth remembering
# sentiment: DISCRETE values only: -1, -0.5, 0, 0.5, 1
# -1 = serious pain, -0.5 = notable pain, 0 = neutral, 0.5 = notable gain, 1 = major win
";
pub fn parse_notes(path: &Path) -> Result<Vec<Note>, NoteError> {
let _span = tracing::debug_span!("parse_notes", path = %path.display()).entered();
let lock_path = path.with_extension("toml.lock");
let lock_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", lock_path.display(), e),
))
})?;
lock_file.lock_shared().map_err(|e| {
NoteError::Io(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
format!("Could not lock {} for reading: {}", lock_path.display(), e),
))
})?;
use std::io::Read;
let mut data_file = std::fs::File::open(path).map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", path.display(), e),
))
})?;
const MAX_NOTES_FILE_SIZE: u64 = 10 * 1024 * 1024;
if let Ok(meta) = data_file.metadata() {
if meta.len() > MAX_NOTES_FILE_SIZE {
return Err(NoteError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{}: file too large ({}MB, limit {}MB)",
path.display(),
meta.len() / (1024 * 1024),
MAX_NOTES_FILE_SIZE / (1024 * 1024)
),
)));
}
}
let mut content = String::new();
data_file.read_to_string(&mut content).map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", path.display(), e),
))
})?;
parse_notes_str(&content)
}
pub fn rewrite_notes_file(
notes_path: &Path,
mutate: impl FnOnce(&mut Vec<NoteEntry>) -> Result<(), NoteError>,
) -> Result<Vec<NoteEntry>, NoteError> {
let _span = tracing::debug_span!("rewrite_notes_file", path = %notes_path.display()).entered();
let lock_path = notes_path.with_extension("toml.lock");
let _lock_file = {
let f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", lock_path.display(), e),
))
})?;
f.lock().map_err(|e| {
NoteError::Io(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
format!("Could not lock {} for writing: {}", lock_path.display(), e),
))
})?;
f };
use std::io::Read;
let mut data_file = std::fs::OpenOptions::new()
.read(true)
.open(notes_path)
.map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", notes_path.display(), e),
))
})?;
const MAX_NOTES_FILE_SIZE: u64 = 10 * 1024 * 1024;
if let Ok(meta) = data_file.metadata() {
if meta.len() > MAX_NOTES_FILE_SIZE {
return Err(NoteError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"{}: file too large ({}MB, limit {}MB)",
notes_path.display(),
meta.len() / (1024 * 1024),
MAX_NOTES_FILE_SIZE / (1024 * 1024)
),
)));
}
}
let mut content = String::new();
data_file.read_to_string(&mut content).map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", notes_path.display(), e),
))
})?;
let mut file: NoteFile = toml::from_str(&content)?;
mutate(&mut file.note)?;
let suffix = crate::temp_suffix();
let tmp_path = notes_path.with_extension(format!("toml.{:016x}.tmp", suffix));
let serialized = match toml::to_string_pretty(&file) {
Ok(s) => s,
Err(e) => {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
};
let output = format!("{}\n{}", NOTES_HEADER, serialized);
std::fs::write(&tmp_path, &output).map_err(|e| {
NoteError::Io(std::io::Error::new(
e.kind(),
format!("{}: {}", tmp_path.display(), e),
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600))
{
tracing::debug!(path = %tmp_path.display(), error = %e, "Failed to set file permissions");
}
}
if let Err(rename_err) = std::fs::rename(&tmp_path, notes_path) {
let dest_dir = notes_path.parent().unwrap_or(Path::new("."));
let dest_tmp = dest_dir.join(format!(".notes.{:016x}.tmp", suffix));
if let Err(copy_err) = std::fs::copy(&tmp_path, &dest_tmp) {
let _ = std::fs::remove_file(&tmp_path);
let _ = std::fs::remove_file(&dest_tmp);
return Err(NoteError::Io(std::io::Error::new(
copy_err.kind(),
format!(
"rename {} -> {} failed ({}), copy fallback also failed: {}",
tmp_path.display(),
notes_path.display(),
rename_err,
copy_err
),
)));
}
let _ = std::fs::remove_file(&tmp_path);
if let Err(e) = std::fs::rename(&dest_tmp, notes_path) {
let _ = std::fs::remove_file(&dest_tmp);
return Err(NoteError::Io(e));
}
}
Ok(file.note)
}
pub fn parse_notes_str(content: &str) -> Result<Vec<Note>, NoteError> {
let file: NoteFile = toml::from_str(content)?;
let notes = file
.note
.into_iter()
.take(MAX_NOTES)
.map(|entry| {
let hash = blake3::hash(entry.text.as_bytes());
let id = format!("note:{}", &hash.to_hex()[..16]);
Note {
id,
text: entry.text.trim().to_string(),
sentiment: entry.sentiment.clamp(-1.0, 1.0),
mentions: entry.mentions,
}
})
.collect();
Ok(notes)
}
pub fn path_matches_mention(path: &str, mention: &str) -> bool {
let path = normalize_slashes(path);
let mention = normalize_slashes(mention);
if let Some(stripped) = path.strip_suffix(mention.as_str()) {
stripped.is_empty() || stripped.ends_with('/')
} else if let Some(stripped) = path.strip_prefix(mention.as_str()) {
stripped.is_empty() || stripped.starts_with('/')
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_notes() {
let content = r#"
[[note]]
sentiment = -0.8
text = "tree-sitter version mismatch causes mysterious failures"
mentions = ["tree-sitter", "Cargo.toml"]
[[note]]
sentiment = 0.9
text = "OnceCell lazy init pattern works cleanly"
mentions = ["embedder.rs"]
[[note]]
text = "neutral observation without explicit sentiment"
"#;
let notes = parse_notes_str(content).unwrap();
assert_eq!(notes.len(), 3);
assert_eq!(notes[0].sentiment, -0.8);
assert!(notes[0].is_warning());
assert!(notes[0].embedding_text().starts_with("Warning: "));
assert_eq!(notes[1].sentiment, 0.9);
assert!(notes[1].is_pattern());
assert!(notes[1].embedding_text().starts_with("Pattern: "));
assert_eq!(notes[2].sentiment, 0.0); assert!(!notes[2].is_warning());
assert!(!notes[2].is_pattern());
}
#[test]
fn test_sentiment_clamping() {
let content = r#"
[[note]]
sentiment = -5.0
text = "way too negative"
[[note]]
sentiment = 99.0
text = "way too positive"
"#;
let notes = parse_notes_str(content).unwrap();
assert_eq!(notes[0].sentiment, -1.0);
assert_eq!(notes[1].sentiment, 1.0);
}
#[test]
fn test_empty_file() {
let content = "# Just a comment\n";
let notes = parse_notes_str(content).unwrap();
assert!(notes.is_empty());
}
#[test]
fn test_stable_ids_across_reordering() {
let content1 = r#"
[[note]]
text = "first note"
[[note]]
text = "second note"
"#;
let content2 = r#"
[[note]]
text = "second note"
[[note]]
text = "first note"
"#;
let notes1 = parse_notes_str(content1).unwrap();
let notes2 = parse_notes_str(content2).unwrap();
assert_eq!(notes1[0].id, notes2[1].id); assert_eq!(notes1[1].id, notes2[0].id);
assert!(notes1[0].id.starts_with("note:"));
assert_eq!(notes1[0].id.len(), 5 + 16); }
#[test]
fn test_rewrite_update_note() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.toml");
std::fs::write(
&path,
"# header\n\n[[note]]\nsentiment = -0.5\ntext = \"old text\"\nmentions = [\"file.rs\"]\n",
)
.unwrap();
rewrite_notes_file(&path, |entries| {
let entry = entries.iter_mut().find(|e| e.text == "old text").unwrap();
entry.text = "new text".to_string();
entry.sentiment = 0.5;
Ok(())
})
.unwrap();
let notes = parse_notes(&path).unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "new text");
assert_eq!(notes[0].sentiment, 0.5);
assert_eq!(notes[0].mentions, vec!["file.rs"]);
}
#[test]
fn test_rewrite_remove_note() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.toml");
std::fs::write(
&path,
"[[note]]\ntext = \"keep\"\n\n[[note]]\ntext = \"remove\"\n",
)
.unwrap();
rewrite_notes_file(&path, |entries| {
entries.retain(|e| e.text != "remove");
Ok(())
})
.unwrap();
let notes = parse_notes(&path).unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].text, "keep");
}
#[test]
fn test_rewrite_preserves_header() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.toml");
std::fs::write(&path, "[[note]]\ntext = \"hello\"\n").unwrap();
rewrite_notes_file(&path, |_entries| Ok(())).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.starts_with("# Notes"),
"Should have standard header"
);
}
#[test]
fn test_rewrite_not_found_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.toml");
std::fs::write(&path, "[[note]]\ntext = \"exists\"\n").unwrap();
let result = rewrite_notes_file(&path, |entries| {
entries
.iter()
.find(|e| e.text == "nonexistent")
.ok_or_else(|| NoteError::NotFound("not found".into()))?;
Ok(())
});
assert!(result.is_err());
}
mod fuzz {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn fuzz_parse_notes_str_no_panic(input in "\\PC{0,500}") {
let _ = parse_notes_str(&input);
}
#[test]
fn fuzz_parse_notes_toml_like(
sentiment in -10.0f64..10.0,
text in "[a-zA-Z0-9 ]{0,100}",
mention in "[a-z.]{1,20}"
) {
let input = format!(
"[[note]]\nsentiment = {}\ntext = \"{}\"\nmentions = [\"{}\"]",
sentiment, text, mention
);
let _ = parse_notes_str(&input);
}
#[test]
fn fuzz_parse_notes_repeated(count in 0usize..50) {
let input: String = (0..count)
.map(|i| format!("[[note]]\ntext = \"note {}\"\n", i))
.collect();
let result = parse_notes_str(&input);
if let Ok(notes) = result {
prop_assert!(notes.len() <= count);
}
}
}
}
}