107 lines
3.5 KiB
Rust
107 lines
3.5 KiB
Rust
//! JSONL file–backed `EditLogRepository`.
|
||||
|
|
//!
|
|||
|
|
//! Path: `<session_dir>/edits.jsonl`
|
|||
|
|
//!
|
|||
|
|
//! Append-only log: new entries are appended to the file, never rewritten.
|
|||
|
|
//! In-memory cache is capped at 10K entries to prevent unbounded growth.
|
|||
|
|
|
|||
|
|
#![allow(
|
|||
|
|
clippy::cast_possible_truncation,
|
|||
|
|
clippy::cast_sign_loss,
|
|||
|
|
clippy::cast_precision_loss,
|
|||
|
|
clippy::cast_possible_wrap
|
|||
|
|
)]
|
|||
|
|
|
|||
|
|
use std::io::{BufRead, BufReader, Write};
|
|||
|
|
use std::path::Path;
|
|||
|
|
|
|||
|
|
use anyhow::{Context, Result};
|
|||
|
|
|
|||
|
|
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
|
|||
|
|
use crate::domain::repository::EditLogRepository;
|
|||
|
|
|
|||
|
|
/// Persists `EditLog` as an append-only JSONL file at `<session_dir>/edits.jsonl`.
|
|||
|
|
#[derive(Debug, Clone, Default)]
|
|||
|
|
pub struct JsonlEditLogRepository;
|
|||
|
|
|
|||
|
|
impl JsonlEditLogRepository {
|
|||
|
|
/// Create a new repository instance.
|
|||
|
|
pub fn new() -> Self {
|
|||
|
|
Self
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
|
|||
|
|
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
|||
|
|
let Ok(file) = std::fs::File::open(path) else {
|
|||
|
|
return Vec::new();
|
|||
|
|
};
|
|||
|
|
let reader = BufReader::new(file);
|
|||
|
|
let mut entries: Vec<EditLogEntry> = Vec::new();
|
|||
|
|
for line in reader.lines() {
|
|||
|
|
let Ok(line) = line else {
|
|||
|
|
continue;
|
|||
|
|
};
|
|||
|
|
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
|||
|
|
if entries.len() >= MAX_MEMORY_ENTRIES {
|
|||
|
|
entries.remove(0);
|
|||
|
|
}
|
|||
|
|
entries.push(entry);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
entries
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl EditLogRepository for JsonlEditLogRepository {
|
|||
|
|
fn open(&self, session_dir: &Path) -> Result<EditLog> {
|
|||
|
|
let path = session_dir.join("edits.jsonl");
|
|||
|
|
// Ensure parent dir exists
|
|||
|
|
if let Some(parent) = path.parent() {
|
|||
|
|
std::fs::create_dir_all(parent)
|
|||
|
|
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
|
|||
|
|
}
|
|||
|
|
let entries = Self::load_from_disk(&path);
|
|||
|
|
// Touch the file if it doesn't exist yet
|
|||
|
|
if !path.exists() {
|
|||
|
|
std::fs::OpenOptions::new()
|
|||
|
|
.create(true)
|
|||
|
|
.append(true)
|
|||
|
|
.open(&path)
|
|||
|
|
.with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?;
|
|||
|
|
}
|
|||
|
|
Ok(EditLog { entries })
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
|
|||
|
|
let path = session_dir.join("edits.jsonl");
|
|||
|
|
let line = serde_json::to_string(&entry)
|
|||
|
|
.context("failed to serialize edit log entry")?
|
|||
|
|
+ "\n";
|
|||
|
|
if let Some(parent) = path.parent() {
|
|||
|
|
std::fs::create_dir_all(parent)
|
|||
|
|
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
|
|||
|
|
}
|
|||
|
|
{
|
|||
|
|
let mut file = std::fs::OpenOptions::new()
|
|||
|
|
.create(true)
|
|||
|
|
.append(true)
|
|||
|
|
.open(&path)
|
|||
|
|
.with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?;
|
|||
|
|
file.write_all(line.as_bytes())
|
|||
|
|
.context("failed to write edit log entry")?;
|
|||
|
|
file.sync_all()
|
|||
|
|
.context("failed to fsync edit log")?;
|
|||
|
|
}
|
|||
|
|
log.entries.push(entry);
|
|||
|
|
// Enforce in-memory cap
|
|||
|
|
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
|||
|
|
log.entries.remove(0);
|
|||
|
|
}
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
|||
|
|
log.entries.clone()
|
|||
|
|
}
|
|||
|
|
}
|