use std::fs::OpenOptions;
use std::io::{BufRead, BufReader, Write};
use serde::{Deserialize, Serialize};
use crate::session::types::ItemRef;
use crate::workspace::Workspace;
use crate::{CliError, CliResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RejectionEntry {
pub ts: String,
pub kind: String,
pub item_ref: ItemRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub fingerprint: serde_json::Value,
}
#[aristo::intent(
"Rejection-log writes use `OpenOptions::append(true).create(true)` \
plus a single `write_all` of the JSON line + `\\n`. No locking is \
needed because (a) the file is per-workspace and gitignored, so \
no cross-team writers; (b) writes are line-sized and POSIX \
guarantees write atomicity for buffers ≤ PIPE_BUF (4 KiB on \
Linux/macOS, well above any single JSON rejection record). A \
refactor that read-modify-wrote the whole file would lose this \
property and need explicit locking.",
verify = "neural",
id = "rejection_log_append_relies_on_posix_write_atomicity"
)]
pub fn append(ws: &Workspace, entry: &RejectionEntry) -> CliResult<()> {
let dir = ws.sessions_dir();
std::fs::create_dir_all(&dir)?;
let path = ws.sessions_rejections_log();
let line = serde_json::to_string(entry).map_err(|e| CliError::Other {
message: format!("rejection serialize: {e}"),
exit_code: 1,
})?;
let mut f = OpenOptions::new().append(true).create(true).open(&path)?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
Ok(())
}
#[allow(
dead_code,
reason = "consumed by per-kind matches_prior_rejection computation in step 5"
)]
pub fn read_all(ws: &Workspace) -> CliResult<Vec<RejectionEntry>> {
let path = ws.sessions_rejections_log();
let f = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for line in BufReader::new(f).lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<RejectionEntry>(trimmed) {
out.push(entry);
}
}
Ok(out)
}
#[allow(
dead_code,
reason = "consumed by per-kind matches_prior_rejection computation in step 5"
)]
pub fn read_for_kind(ws: &Workspace, kind: &str) -> CliResult<Vec<RejectionEntry>> {
Ok(read_all(ws)?
.into_iter()
.filter(|r| r.kind == kind)
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fresh_workspace(dir: &TempDir) -> Workspace {
std::fs::write(dir.path().join("aristo.toml"), "").unwrap();
Workspace {
root: dir.path().to_path_buf(),
}
}
fn fixture(kind: &str, ref_str: &str) -> RejectionEntry {
RejectionEntry {
ts: "2026-05-18T13:05:00Z".into(),
kind: kind.into(),
item_ref: ItemRef::from_opaque(ref_str),
note: Some("intentionally narrative".into()),
fingerprint: serde_json::json!({
"category": "clarity",
"rationale_sketch": "defensive_commentary",
}),
}
}
#[test]
fn append_then_read_round_trips_one_entry() {
let tmp = TempDir::new().unwrap();
let ws = fresh_workspace(&tmp);
let e = fixture("critique-review", "foo#0");
append(&ws, &e).unwrap();
let back = read_all(&ws).unwrap();
assert_eq!(back, vec![e]);
}
#[test]
fn append_is_append_only_preserving_prior_entries() {
let tmp = TempDir::new().unwrap();
let ws = fresh_workspace(&tmp);
let a = fixture("critique-review", "foo#0");
let b = fixture("critique-review", "bar#1");
let c = fixture("proof-review", "proof_x#verdict");
append(&ws, &a).unwrap();
append(&ws, &b).unwrap();
append(&ws, &c).unwrap();
let all = read_all(&ws).unwrap();
assert_eq!(all, vec![a, b, c]);
}
#[test]
fn read_for_kind_filters_to_matching_entries() {
let tmp = TempDir::new().unwrap();
let ws = fresh_workspace(&tmp);
let a = fixture("critique-review", "foo#0");
let b = fixture("proof-review", "proof#verdict");
let c = fixture("critique-review", "baz#2");
append(&ws, &a).unwrap();
append(&ws, &b).unwrap();
append(&ws, &c).unwrap();
let critique = read_for_kind(&ws, "critique-review").unwrap();
assert_eq!(critique, vec![a, c]);
}
#[test]
fn read_all_returns_empty_when_log_missing() {
let tmp = TempDir::new().unwrap();
let ws = fresh_workspace(&tmp);
assert!(read_all(&ws).unwrap().is_empty());
}
#[test]
fn read_all_skips_garbage_lines_without_erroring() {
let tmp = TempDir::new().unwrap();
let ws = fresh_workspace(&tmp);
let good = fixture("critique-review", "foo#0");
append(&ws, &good).unwrap();
let mut f = OpenOptions::new()
.append(true)
.open(ws.sessions_rejections_log())
.unwrap();
f.write_all(b"this is not json\n").unwrap();
f.write_all(b"\n").unwrap(); let back = read_all(&ws).unwrap();
assert_eq!(back, vec![good]);
}
}