use crate::clock;
use serde_json::{Value, json};
use std::io::Write;
use std::path::PathBuf;
const MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const IMAGE: &str = "image";
pub const VIDEO: &str = "video";
pub const DONE: &str = "done";
pub const STARTED: &str = "started";
pub fn path() -> Option<PathBuf> {
if disabled() {
return None;
}
let config = crate::config::preferred_path()?;
Some(config.with_file_name("renders.jsonl"))
}
pub fn disabled() -> bool {
crate::config::var("LUCIDA_NO_LEDGER").is_some()
}
pub fn record(entry: Value) {
let Some(path) = path() else { return };
if let Err(e) = append(&path, &entry) {
eprintln!("note: could not write the render ledger ({}): {e:#}", path.display());
}
}
pub fn image(
provider: &str,
model: &str,
prompt: &str,
path: &str,
seed: Option<u64>,
estimated_usd: f64,
) {
record(json!({
"at": clock::now(),
"kind": IMAGE,
"status": DONE,
"provider": provider,
"model": model,
"prompt": prompt,
"path": path,
"seed": seed,
"estimated_usd": estimated_usd,
}));
}
pub fn video_started(model: &str, prompt: &str, operation: &str, estimated_usd: f64) {
record(json!({
"at": clock::now(),
"kind": VIDEO,
"status": STARTED,
"provider": "google",
"model": model,
"prompt": prompt,
"operation": operation,
"estimated_usd": estimated_usd,
}));
}
pub fn video_done(operation: &str, path: &str) {
record(json!({
"at": clock::now(),
"kind": VIDEO,
"status": DONE,
"provider": "google",
"operation": operation,
"path": path,
}));
}
pub fn entries() -> Vec<Value> {
let Some(path) = path() else { return Vec::new() };
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
text.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
pub fn outstanding() -> Vec<Value> {
let all = entries();
let collected: std::collections::HashSet<String> = all
.iter()
.filter(|e| e["status"] == DONE)
.filter_map(|e| e["operation"].as_str().map(str::to_string))
.collect();
let mut seen = std::collections::HashSet::new();
all.into_iter()
.filter(|e| e["status"] == STARTED)
.filter(|e| {
e["operation"]
.as_str()
.is_some_and(|op| !collected.contains(op) && seen.insert(op.to_string()))
})
.collect()
}
fn append(path: &std::path::Path, entry: &Value) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES) {
prune(path);
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{entry}")?;
Ok(())
}
fn prune(path: &std::path::Path) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let lines: Vec<&str> = text.lines().collect();
let keep = lines.split_at(lines.len() / 2).1.join("\n");
let _ = crate::write_atomically(path, format!("{keep}\n").as_bytes(), false);
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
let dir = std::env::temp_dir().join(format!(
"lucida-ledger-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir.join("renders.jsonl")
}
fn read(path: &std::path::Path) -> Vec<Value> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
#[test]
fn entries_append_one_line_each() {
let path = temp();
for n in 0..3 {
append(&path, &json!({ "n": n })).unwrap();
}
let written = read(&path);
assert_eq!(written.len(), 3);
assert_eq!(written[0]["n"], 0, "oldest must be first");
assert_eq!(written[2]["n"], 2);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_damaged_line_does_not_cost_the_history_around_it() {
let path = temp();
std::fs::write(
&path,
"{\"n\":1}\nthis is not json\n{\"n\":2}\n{\"n\":3}\n",
)
.unwrap();
let survived = read(&path);
assert_eq!(survived.len(), 3);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_collected_render_is_no_longer_outstanding() {
let all = [
json!({ "kind": VIDEO, "status": STARTED, "operation": "operations/a" }),
json!({ "kind": VIDEO, "status": STARTED, "operation": "operations/b" }),
json!({ "kind": VIDEO, "status": DONE, "operation": "operations/a" }),
];
let collected: std::collections::HashSet<String> = all
.iter()
.filter(|e| e["status"] == DONE)
.filter_map(|e| e["operation"].as_str().map(str::to_string))
.collect();
let open: Vec<&Value> = all
.iter()
.filter(|e| e["status"] == STARTED)
.filter(|e| !collected.contains(e["operation"].as_str().unwrap()))
.collect();
assert_eq!(open.len(), 1);
assert_eq!(open[0]["operation"], "operations/b");
}
#[test]
fn pruning_keeps_the_newest_half() {
let path = temp();
for n in 0..10 {
append(&path, &json!({ "n": n })).unwrap();
}
prune(&path);
let left = read(&path);
assert_eq!(left.len(), 5);
assert_eq!(left[0]["n"], 5, "the newest half must survive");
assert_eq!(left[4]["n"], 9);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_ledger_write_that_cannot_succeed_is_still_not_an_error_for_the_caller() {
let path = temp();
std::fs::create_dir_all(&path).unwrap();
assert!(append(&path, &json!({ "n": 1 })).is_err());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
}