//! El diario de mutaciones. Ver `docs/05-journal.md`. //! //! Formato: archivo único de **JSON-líneas append-only** (`mutations.jsonl`). Cada línea es //! un `MutationEvent` autónomo; `cat` / `grep` / `jq` los procesan sin parseo extra. //! //! Esta crate es **lógica pura**: nada de fanotify ni de mounts. El watcher real vive en //! `hammerd` y empuja eventos aquí vía `Journal::record`. El hook desde //! `takana_overlay::commit` también empuja por aquí. //! //! Diseño rationale (SDD 05): //! - El diario no bloquea acciones; sólo observa. Pero también acepta eventos "trazables" //! (de un `hydrate` o `commit`) que llegan con `actor.source = HammerHydrate { artifact }`. //! - Las "mutaciones opacas" (pisada manual sin rastro de artefacto) llegan con //! `actor.source = External { pid, uid }`. Eso permite que `takana export` distinga //! replays reproducibles de cambios que sólo se pueden compartir como warnings. use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::PathBuf; use serde::{Deserialize, Serialize}; /// Operación sobre el FHS. Ver SDD 05 §2 — la lista evolucionará con los eventos que el /// watcher fanotify aprenda a distinguir. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum MutationOp { /// Archivo nuevo donde antes no había nada. Create, /// Pisado: el archivo existía y fue sustituido (mismo path, distinto contenido o inode). Replace, /// Edición en sitio: el archivo cambió sin que cambiara el path (típico de configs en /etc). Edit, /// Borrado. Delete, } impl MutationOp { pub fn as_str(&self) -> &'static str { match self { MutationOp::Create => "create", MutationOp::Replace => "replace", MutationOp::Edit => "edit", MutationOp::Delete => "delete", } } } /// Quién originó la mutación. Distinguir trazable vs opaca es lo que más tarde permite /// que `takana export` produzca un `.swm` reproducible (artefactos) más una lista de /// warnings (pisadas manuales). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "kebab-case")] pub enum Source { /// Vino de un `takana hydrate ` — la mutación es replay-able a partir del hash. HammerHydrate { artifact: String }, /// Vino de un `takana commit ` — origen indirecto: el `actor` fue el overlay, /// y `artifact` es opcional (puede no haber, si la promoción fue de un edit manual). HammerCommit { overlay: String, #[serde(default, skip_serializing_if = "Option::is_none")] artifact: Option, }, /// Pisada manual fuera del flujo de takana. La grabamos sin juzgar — el SDD insiste en /// que es información, no error. External, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Actor { pub source: Source, #[serde(default, skip_serializing_if = "Option::is_none")] pub pid: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub uid: Option, } /// Un evento atómico — una línea del diario. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MutationEvent { /// RFC 3339 UTC. El watcher fija el timestamp en el momento de la observación. pub ts: String, pub op: MutationOp, pub path: PathBuf, pub by: Actor, /// Opcional: hash blake3 del contenido del archivo tras la mutación. Permite a `export` /// detectar mutaciones idempotentes (mismo contenido tras N replaces → un sólo evento). #[serde(default, skip_serializing_if = "Option::is_none")] pub content_hash: Option, /// Nota libre (humana). Útil para que la IA explique su intención. #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, } /// Handle del diario: una ruta a `mutations.jsonl`. Las operaciones son thread-safe en el /// sentido de que cada `record` abre + append + cierra; suficiente para el throughput /// esperado (cambios humanos, no log-de-tracing). #[derive(Debug, Clone)] pub struct Journal { pub dir: PathBuf, } #[derive(Debug, thiserror::Error)] pub enum Error { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("json: {0}")] Json(#[from] serde_json::Error), #[error("journal: {0}")] Other(String), } pub type Result = std::result::Result; impl Journal { /// Abre (creando el directorio si hace falta) un diario bajo `dir`. El archivo /// `mutations.jsonl` se crea en el primer `record`. pub fn open(dir: impl Into) -> Result { let dir = dir.into(); std::fs::create_dir_all(&dir)?; Ok(Self { dir }) } pub fn file_path(&self) -> PathBuf { self.dir.join("mutations.jsonl") } /// Último evento registrado para `path` (o `None` si nunca apareció). Naïve: relee el /// diario; suficiente para la cadencia humana de Fase 3 (cuando crezca, un índice por /// path lo acelera). pub fn last_for_path(&self, path: &std::path::Path) -> Result> { let all = self.read_all()?; Ok(all.into_iter().rev().find(|e| e.path == path)) } /// Como [`record`], pero **de-dup idempotente**: si la mutación deja el archivo en el /// mismo estado que el último evento del diario para ese path, no la registra. Devuelve /// `true` si escribió un evento, `false` si lo consideró redundante. /// /// Dos casos de redundancia (ver SDD 05 — el diario es información, no ruido): /// 1. **Contenido idéntico:** el `content_hash` nuevo coincide con el último de ese path /// (p. ej. un editor que reescribe el archivo sin cambiarlo, o un replay del mismo /// artefacto). /// 2. **Borrado repetido:** un `Delete` sobre un path cuyo último evento ya era `Delete`. pub fn record_dedup(&self, ev: &MutationEvent) -> Result { if let Some(last) = self.last_for_path(&ev.path)? { let idempotent_content = matches!( (&ev.content_hash, &last.content_hash), (Some(a), Some(b)) if a == b ); let repeated_delete = ev.op == MutationOp::Delete && last.op == MutationOp::Delete; if idempotent_content || repeated_delete { tracing::debug!(path = %ev.path.display(), "journal: evento idempotente, omitido"); return Ok(false); } } self.record(ev)?; Ok(true) } /// Append-only. Una línea JSON + `\n`. Maneja la creación del archivo si no existe. pub fn record(&self, ev: &MutationEvent) -> Result<()> { let line = serde_json::to_string(ev)?; let mut f = std::fs::OpenOptions::new() .create(true) .append(true) .open(self.file_path())?; f.write_all(line.as_bytes())?; f.write_all(b"\n")?; f.sync_data()?; Ok(()) } /// Lee todos los eventos en orden de aparición. Líneas vacías o malformadas se loguean /// y se saltan — el diario es append-only pero queremos resilencia a truncados de /// emergencia (kernel-panic en medio de una escritura). pub fn read_all(&self) -> Result> { let path = self.file_path(); if !path.is_file() { return Ok(Vec::new()); } let f = std::fs::File::open(&path)?; let rdr = BufReader::new(f); let mut out = Vec::new(); for (i, line) in rdr.lines().enumerate() { let line = line?; if line.trim().is_empty() { continue; } match serde_json::from_str::(&line) { Ok(ev) => out.push(ev), Err(e) => { tracing::warn!(line = i + 1, error = %e, "journal: línea malformada, saltando"); } } } Ok(out) } /// Lee los últimos `n` eventos sin cargar todo el archivo. Implementación naïve por /// ahora (delega a `read_all` y trunca); cuando el archivo crezca añadiremos un índice /// por bloques. pub fn tail(&self, n: usize) -> Result> { let mut all = self.read_all()?; if all.len() > n { all.drain(..(all.len() - n)); } Ok(all) } /// Devuelve el offset en bytes del fin de archivo actual. Útil para implementar un /// "follow" en CLI: tras un read inicial, hacemos polling desde este offset. pub fn end_offset(&self) -> Result { let path = self.file_path(); if !path.is_file() { return Ok(0); } let m = std::fs::metadata(&path)?; Ok(m.len()) } /// Lee eventos desde un offset en bytes hasta el final actual. Devuelve los eventos y el /// nuevo offset (para usar en la siguiente iteración del follow). pub fn read_from_offset(&self, offset: u64) -> Result<(Vec, u64)> { let path = self.file_path(); if !path.is_file() { return Ok((Vec::new(), 0)); } let mut f = std::fs::File::open(&path)?; f.seek(SeekFrom::Start(offset))?; let mut buf = String::new(); f.read_to_string(&mut buf)?; let new_offset = offset + buf.len() as u64; let mut out = Vec::new(); for line in buf.lines() { if line.trim().is_empty() { continue; } match serde_json::from_str::(line) { Ok(ev) => out.push(ev), Err(e) => tracing::warn!(error = %e, "journal: línea malformada en tail"), } } Ok((out, new_offset)) } } /// Hash BLAKE3 del contenido de un archivo, con prefijo `b3:`. Es un blake3 **plano** del /// contenido (lo que daría `b3sum`), distinto del `ArtifactHash::of_inputs` de takana-core /// (que prefija longitudes para identidad de artefactos). Aquí queremos "¿el archivo cambió?". pub fn content_hash_of(bytes: &[u8]) -> String { format!("b3:{}", blake3::hash(bytes).to_hex()) } /// Hashea el contenido de `path` tras una mutación. Devuelve `None` si el archivo ya no existe /// (p. ej. fue borrado, o es lo que el watcher ve como `... (deleted)`); propaga otros errores /// de IO. pub fn hash_file(path: &std::path::Path) -> Result> { match std::fs::read(path) { Ok(bytes) => Ok(Some(content_hash_of(&bytes))), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Construye un timestamp RFC 3339 UTC del instante actual usando std-only (sin chrono). pub fn now_rfc3339() -> String { use std::time::SystemTime; let d = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_default(); let secs = d.as_secs() as i64; let nanos = d.subsec_nanos(); let (year, month, day, hour, minute, second) = unix_to_ymdhms(secs); format!( "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{:09}Z", nanos ) } /// Conversión epoch → (year, month, day, hour, minute, second) UTC. Algoritmo de Howard /// Hinnant (`civil_from_days`), válido para todo rango representable por i64. fn unix_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) { let day_secs = 86_400i64; let mut days = secs.div_euclid(day_secs); let time_of_day = secs.rem_euclid(day_secs) as u32; let hour = time_of_day / 3600; let minute = (time_of_day / 60) % 60; let second = time_of_day % 60; days += 719_468; // shift al epoch del algoritmo (0000-03-01) let era = if days >= 0 { days } else { days - 146_096 } / 146_097; let doe = (days - era * 146_097) as u64; // [0, 146_096] let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] let mp = (5 * doy + 2) / 153; // [0, 11] let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31] let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12] let year = (y + if month <= 2 { 1 } else { 0 }) as i32; (year, month, day, hour, minute, second) } #[cfg(test)] mod tests { use super::*; fn ev(op: MutationOp, path: &str) -> MutationEvent { MutationEvent { ts: now_rfc3339(), op, path: PathBuf::from(path), by: Actor { source: Source::External, pid: Some(1234), uid: Some(1000), }, content_hash: None, note: None, } } #[test] fn record_and_read_roundtrip() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); j.record(&ev(MutationOp::Replace, "/bin/grep")).unwrap(); j.record(&ev(MutationOp::Edit, "/etc/hosts")).unwrap(); let read = j.read_all().unwrap(); assert_eq!(read.len(), 2); assert_eq!(read[0].op, MutationOp::Replace); assert_eq!(read[0].path, PathBuf::from("/bin/grep")); assert_eq!(read[1].op, MutationOp::Edit); } #[test] fn empty_journal_reads_empty() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); assert!(j.read_all().unwrap().is_empty()); assert_eq!(j.end_offset().unwrap(), 0); } #[test] fn tail_returns_last_n() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); for i in 0..5 { j.record(&ev(MutationOp::Create, &format!("/etc/{i}"))).unwrap(); } let last = j.tail(2).unwrap(); assert_eq!(last.len(), 2); assert_eq!(last[0].path, PathBuf::from("/etc/3")); assert_eq!(last[1].path, PathBuf::from("/etc/4")); } #[test] fn follow_via_offset_yields_new_events_only() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); j.record(&ev(MutationOp::Create, "/etc/a")).unwrap(); let cursor = j.end_offset().unwrap(); j.record(&ev(MutationOp::Create, "/etc/b")).unwrap(); j.record(&ev(MutationOp::Create, "/etc/c")).unwrap(); let (news, new_cursor) = j.read_from_offset(cursor).unwrap(); assert_eq!(news.len(), 2); assert_eq!(news[0].path, PathBuf::from("/etc/b")); assert_eq!(news[1].path, PathBuf::from("/etc/c")); assert_eq!(new_cursor, j.end_offset().unwrap()); // Una segunda llamada desde el nuevo cursor devuelve vacío. let (more, _) = j.read_from_offset(new_cursor).unwrap(); assert!(more.is_empty()); } #[test] fn malformed_line_is_skipped_not_fatal() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); j.record(&ev(MutationOp::Create, "/etc/a")).unwrap(); // Plantamos basura en medio (kernel panic mid-write). let mut f = std::fs::OpenOptions::new().append(true).open(j.file_path()).unwrap(); f.write_all(b"{esto no es JSON valido\n").unwrap(); f.write_all(b"\n").unwrap(); // línea vacía drop(f); j.record(&ev(MutationOp::Create, "/etc/b")).unwrap(); let read = j.read_all().unwrap(); assert_eq!(read.len(), 2, "líneas válidas se preservan"); } fn ev_hash(op: MutationOp, path: &str, hash: Option<&str>) -> MutationEvent { let mut e = ev(op, path); e.content_hash = hash.map(|s| s.to_string()); e } #[test] fn content_hash_is_plain_blake3_with_prefix() { let h = content_hash_of(b"hola hammer\n"); assert!(h.starts_with("b3:")); // Determinista y sensible al contenido. assert_eq!(h, content_hash_of(b"hola hammer\n")); assert_ne!(h, content_hash_of(b"otra cosa")); } #[test] fn hash_file_some_and_none() { let d = tempfile::tempdir().unwrap(); let f = d.path().join("x"); std::fs::write(&f, b"abc").unwrap(); assert_eq!(hash_file(&f).unwrap(), Some(content_hash_of(b"abc"))); assert_eq!(hash_file(&d.path().join("noexiste")).unwrap(), None); } #[test] fn dedup_skips_identical_content() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); let h = content_hash_of(b"v1"); // Primer evento: se registra. assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap()); // Mismo contenido otra vez: idempotente, se omite. assert!(!j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap()); // Contenido distinto: se registra. let h2 = content_hash_of(b"v2"); assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h2))).unwrap()); // Vuelta a v1 (no es el ÚLTIMO estado): se registra (no colapsamos histórico). assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/x", Some(&h))).unwrap()); let all = j.read_all().unwrap(); assert_eq!(all.len(), 3, "v1, v2, v1 — sólo se omitió el v1→v1 consecutivo"); } #[test] fn dedup_is_per_path() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); let h = content_hash_of(b"same"); assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/a", Some(&h))).unwrap()); // Mismo hash pero OTRO path: no es redundante. assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/bin/b", Some(&h))).unwrap()); assert_eq!(j.read_all().unwrap().len(), 2); } #[test] fn dedup_collapses_repeated_delete() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); assert!(j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap()); assert!(!j.record_dedup(&ev_hash(MutationOp::Delete, "/etc/x", None)).unwrap()); assert_eq!(j.read_all().unwrap().len(), 1); } #[test] fn dedup_does_not_collapse_hashless_replaces() { // Dos Replace sin content_hash NO se consideran idempotentes (no sabemos el contenido). let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap()); assert!(j.record_dedup(&ev_hash(MutationOp::Replace, "/etc/x", None)).unwrap()); assert_eq!(j.read_all().unwrap().len(), 2); } #[test] fn last_for_path_finds_latest() { let d = tempfile::tempdir().unwrap(); let j = Journal::open(d.path()).unwrap(); j.record(&ev(MutationOp::Create, "/etc/a")).unwrap(); j.record(&ev(MutationOp::Replace, "/etc/a")).unwrap(); j.record(&ev(MutationOp::Edit, "/etc/b")).unwrap(); assert_eq!(j.last_for_path(&PathBuf::from("/etc/a")).unwrap().unwrap().op, MutationOp::Replace); assert_eq!(j.last_for_path(&PathBuf::from("/etc/b")).unwrap().unwrap().op, MutationOp::Edit); assert!(j.last_for_path(&PathBuf::from("/etc/z")).unwrap().is_none()); } #[test] fn source_serializes_with_tag() { let e = ev(MutationOp::Replace, "/bin/grep"); let j = serde_json::to_string(&e).unwrap(); assert!(j.contains(r#""kind":"external""#), "got {j}"); let mut e2 = e.clone(); e2.by.source = Source::HammerHydrate { artifact: "b3:abc".into(), }; let j2 = serde_json::to_string(&e2).unwrap(); assert!(j2.contains(r#""kind":"hammer-hydrate""#), "got {j2}"); assert!(j2.contains(r#""artifact":"b3:abc""#), "got {j2}"); } #[test] fn rfc3339_format_basics() { let ts = now_rfc3339(); // Verifica forma `YYYY-MM-DDThh:mm:ss.nnnnnnnnnZ` minimal. assert!(ts.len() >= 29); assert_eq!(&ts[4..5], "-"); assert_eq!(&ts[7..8], "-"); assert_eq!(&ts[10..11], "T"); assert_eq!(&ts[13..14], ":"); assert_eq!(&ts[16..17], ":"); assert!(ts.ends_with('Z')); // Año razonable (2026 actualmente; este test sólo verifica que no salimos del rango // 1970..3000). let year: i32 = ts[..4].parse().unwrap(); assert!((1970..3000).contains(&year), "year = {year}"); } }