use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use argonauts_core::layout::repo_state;
use argonauts_core::names::ModelRef;
use argonauts_core::steps::task::MemoryProposal;
use argonauts_core::{RunId, TaskId};
use argonauts_learning::memory::{self, Source};
use argonauts_learning::scan::Finding;
pub const NOTES_DIR: &str = ".argonauts/notes";
pub const FLAGGED_DIR: &str = "flagged-notes";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Flagged {
pub note: MemoryProposal,
pub source: Source,
pub findings: Vec<Finding>,
}
pub fn keep_note(
state_dir: &Path,
note: &MemoryProposal,
source: &Source,
) -> std::io::Result<Option<String>> {
let relative = format!("{}/{}.md", repo_state::NOTES, slug(¬e.topic));
let changed = append_note(&state_dir.join(&relative), note, source)?;
Ok(changed.then_some(relative))
}
pub fn write_note(
worktree: &Path,
note: &MemoryProposal,
source: &Source,
) -> std::io::Result<Option<String>> {
let relative = format!("{NOTES_DIR}/{}.md", slug(¬e.topic));
let changed = append_note(&worktree.join(&relative), note, source)?;
Ok(changed.then_some(relative))
}
fn append_note(path: &Path, note: &MemoryProposal, source: &Source) -> std::io::Result<bool> {
let mut text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
format!("# {}\n\n", note.topic.trim())
}
Err(error) => return Err(error),
};
let start = format!("- {} (", one_line(¬e.text));
let run = source.run.as_str();
if text
.lines()
.any(|line| line.starts_with(&start) && line.contains(run))
{
return Ok(false);
}
let date = source
.date
.format(&Rfc3339)
.map_err(std::io::Error::other)?;
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!(
"{start}{}, {}, {run}, {date})\n",
source.task, source.worker
));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, text)?;
Ok(true)
}
pub fn parse(text: &str) -> Vec<(MemoryProposal, Source)> {
let mut topic = "general".to_owned();
let mut notes = Vec::new();
for line in text.lines() {
if let Some(heading) = line.strip_prefix("# ") {
heading.trim().clone_into(&mut topic);
continue;
}
let Some(bullet) = line.strip_prefix("- ") else {
continue;
};
let Some((body, source)) = bullet
.strip_suffix(')')
.and_then(|bullet| bullet.rsplit_once(" ("))
else {
continue;
};
let fields: Vec<&str> = source.split(", ").collect();
let [task, worker, run, date] = fields[..] else {
continue;
};
let (Ok(task), Ok(worker), Ok(run), Ok(date)) = (
TaskId::new(task),
worker.parse::<ModelRef>(),
RunId::new(run),
OffsetDateTime::parse(date, &Rfc3339),
) else {
continue;
};
notes.push((
MemoryProposal {
topic: topic.clone(),
text: body.to_owned(),
},
Source {
run,
task,
worker,
date,
evidence: Vec::new(),
},
));
}
notes
}
pub struct NoteFile {
pub relative: String,
pub text: String,
pub notes: Vec<(MemoryProposal, Source)>,
}
pub fn read_all(tree: &Path, state_dir: &Path) -> Vec<NoteFile> {
let mut files = read_in(&state_dir.join(repo_state::NOTES), repo_state::NOTES);
files.extend(read_files(tree));
files
}
pub fn read_files(tree: &Path) -> Vec<NoteFile> {
read_in(&tree.join(NOTES_DIR), NOTES_DIR)
}
fn read_in(dir: &Path, shown: &str) -> Vec<NoteFile> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "md") && path.is_file())
.collect();
paths.sort();
paths
.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(&path).ok()?;
let name = path.file_name()?.to_string_lossy().into_owned();
Some(NoteFile {
relative: format!("{shown}/{name}"),
notes: parse(&text),
text,
})
})
.collect()
}
pub fn quoted(tree: &Path, state_dir: &Path, judged: Option<&ModelRef>) -> String {
let notes: Vec<(MemoryProposal, Source)> = read_all(tree, state_dir)
.into_iter()
.flat_map(|file| file.notes)
.filter(|(_, source)| judged.is_none_or(|judged| source.worker != *judged))
.collect();
memory::quoted(¬es)
}
pub fn flag(state_dir: &Path, flagged: &Flagged) -> std::io::Result<PathBuf> {
let dir = state_dir.join(FLAGGED_DIR);
std::fs::create_dir_all(&dir)?;
let digest = argonauts_core::sha256_hex(
format!("{}\n{}", flagged.note.topic, flagged.note.text).as_bytes(),
);
let path = dir.join(format!(
"{}-{}-{}.json",
flagged.source.run,
flagged.source.task,
&digest[..12]
));
let text = serde_json::to_string_pretty(flagged).map_err(std::io::Error::other)?;
std::fs::write(&path, text)?;
Ok(path)
}
pub fn flagged(state_dir: &Path) -> Vec<(PathBuf, Flagged)> {
let Ok(entries) = std::fs::read_dir(state_dir.join(FLAGGED_DIR)) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, Flagged)> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.extension().is_some_and(|ext| ext == "json"))
.filter_map(|path| {
let text = std::fs::read_to_string(&path).ok()?;
let flagged = serde_json::from_str(&text).ok()?;
Some((path, flagged))
})
.collect();
found.sort_by(|a, b| a.0.cmp(&b.0));
found
}
pub fn write(
worktree: &Path,
run: &RunId,
notes: &[MemoryProposal],
) -> std::io::Result<Vec<String>> {
let mut changed: Vec<String> = Vec::new();
for note in notes {
let relative = format!("{NOTES_DIR}/{}.md", slug(¬e.topic));
let path = worktree.join(&relative);
let mut text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
format!("# {}\n\n", note.topic.trim())
}
Err(error) => return Err(error),
};
let bullet = format!("- {} ({run})", one_line(¬e.text));
if text.lines().any(|line| line == bullet) {
continue;
}
if !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&bullet);
text.push('\n');
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, text)?;
if !changed.contains(&relative) {
changed.push(relative);
}
}
Ok(changed)
}
pub fn slug(topic: &str) -> String {
let mut slug = String::new();
for c in topic.trim().chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
slug.push(c);
} else if !slug.ends_with('-') && !slug.is_empty() {
slug.push('-');
}
}
let slug: String = slug.trim_end_matches('-').chars().take(40).collect();
let slug = slug.trim_end_matches('-').to_owned();
if slug.is_empty() {
"general".to_owned()
} else {
slug
}
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn notes_go_one_file_per_topic_once() {
let dir = tempfile::tempdir().unwrap();
let run = RunId::new("run-01").unwrap();
let note = |topic: &str, text: &str| MemoryProposal {
topic: topic.to_owned(),
text: text.to_owned(),
};
let notes = [
note("Redis", "Counters expire\nwith the window."),
note("redis", "Use one pool per process."),
note("Rate limits", "Keys are per API token."),
];
let changed = write(dir.path(), &run, ¬es).unwrap();
assert_eq!(
changed,
[
".argonauts/notes/redis.md",
".argonauts/notes/rate-limits.md"
]
);
assert!(write(dir.path(), &run, ¬es).unwrap().is_empty());
let redis = std::fs::read_to_string(dir.path().join(".argonauts/notes/redis.md")).unwrap();
assert_eq!(
redis,
"# Redis\n\n- Counters expire with the window. (run-01)\n- Use one pool per process. (run-01)\n"
);
assert_eq!(slug(" Ünïcode / *** "), "n-code");
assert_eq!(slug("***"), "general");
}
}