use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde_json::{json, Map, Value};
use crate::domain::config::Config;
use crate::domain::labels::Labels;
use crate::domain::report::Report;
use crate::errors::{io_err, Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub enabled: bool,
pub dir: Option<PathBuf>,
pub max_runs: usize,
}
pub fn resolve(config: &Config, force_off: bool) -> Settings {
let legacy_off =
env_flag("LLMLINT_NO_HISTORY") && std::env::var_os("LLMLINT_HISTORY_ENABLED").is_none();
let enabled = resolve_enabled(force_off, legacy_off, config.history_enabled());
let env_dir = std::env::var_os("LLMLINT_HISTORY_DIR")
.filter(|s| !s.is_empty())
.map(PathBuf::from);
let dir = resolve_dir(env_dir, config.history.dir.as_deref(), default_data_dir());
Settings {
enabled,
dir,
max_runs: config.history_max_runs(),
}
}
fn resolve_enabled(force_off: bool, env_off: bool, config_enabled: bool) -> bool {
!force_off && !env_off && config_enabled
}
fn resolve_dir(
env_dir: Option<PathBuf>,
config_dir: Option<&str>,
default: Option<PathBuf>,
) -> Option<PathBuf> {
env_dir
.or_else(|| config_dir.map(PathBuf::from))
.or(default)
}
fn default_data_dir() -> Option<PathBuf> {
if let Some(x) = non_empty_var("XDG_DATA_HOME") {
return Some(PathBuf::from(x).join("llmlint").join("history"));
}
#[cfg(windows)]
if let Some(a) = non_empty_var("LOCALAPPDATA") {
return Some(
PathBuf::from(a)
.join("llmlint")
.join("data")
.join("history"),
);
}
if let Some(h) = non_empty_var("HOME") {
return Some(
PathBuf::from(h)
.join(".local")
.join("share")
.join("llmlint")
.join("history"),
);
}
None
}
fn non_empty_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| v == "1" || v == "true")
.unwrap_or(false)
}
pub fn generate_id(now: SystemTime) -> String {
let (secs, nanos) = epoch_parts(now);
let (y, mo, d, h, mi, s) = civil(secs);
let suffix = nanos & 0xF_FFFF;
format!("{y:04}{mo:02}{d:02}T{h:02}{mi:02}{s:02}Z-{suffix:05x}")
}
pub fn format_timestamp(now: SystemTime) -> String {
let (secs, _) = epoch_parts(now);
let (y, mo, d, h, mi, s) = civil(secs);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")
}
fn epoch_parts(now: SystemTime) -> (i64, u32) {
match now.duration_since(SystemTime::UNIX_EPOCH) {
Ok(d) => (d.as_secs() as i64, d.subsec_nanos()),
Err(_) => (0, 0),
}
}
fn civil(secs: i64) -> (i64, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let year = if m <= 2 { y + 1 } else { y };
(year, m as u32, d as u32, h as u32, mi as u32, s as u32)
}
#[allow(clippy::too_many_arguments)]
pub fn build_record(
id: &str,
timestamp: &str,
command: &str,
cwd: &Path,
exit_code: i32,
config_files: &[String],
labels: &Labels,
report: &Report,
) -> Value {
let report_json = report.to_json();
let mut obj = Map::new();
obj.insert("id".into(), json!(id));
obj.insert("timestamp".into(), json!(timestamp));
obj.insert("llmlint_version".into(), json!(env!("CARGO_PKG_VERSION")));
obj.insert("command".into(), json!(command));
obj.insert("cwd".into(), json!(cwd.display().to_string()));
obj.insert("exit_code".into(), json!(exit_code));
obj.insert("config_files".into(), json!(config_files));
if !labels.is_empty() {
obj.insert("labels".into(), json!(labels));
}
if let Value::Object(report_map) = report_json {
for (k, v) in report_map {
obj.insert(k, v);
}
}
Value::Object(obj)
}
pub fn write_record(dir: &Path, id: &str, record: &Value, max_runs: usize) -> Result<PathBuf> {
std::fs::create_dir_all(dir)
.map_err(|e| io_err(format!("creating history dir {}", dir.display()), e))?;
let path = dir.join(format!("{id}.json"));
let mut body = serde_json::to_string_pretty(record).map_err(|e| Error::Io(e.to_string()))?;
body.push('\n');
std::fs::write(&path, body)
.map_err(|e| io_err(format!("writing history record {}", path.display()), e))?;
prune(dir, max_runs)?;
Ok(path)
}
pub fn prune(dir: &Path, max_runs: usize) -> Result<()> {
let mut files = record_files(dir)?;
if files.len() <= max_runs {
return Ok(());
}
files.sort_by(|a, b| a.0.cmp(&b.0)); let remove = files.len() - max_runs;
for (_, path) in files.into_iter().take(remove) {
let _ = std::fs::remove_file(path);
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct Record {
pub id: String,
pub path: PathBuf,
pub value: Value,
}
impl Record {
pub fn labels(&self) -> Labels {
self.value
.get("labels")
.and_then(Value::as_object)
.map(|m| {
Labels::from_stored(
m.iter()
.filter_map(|(k, v)| Some((k.as_str(), v.as_str()?))),
)
})
.unwrap_or_default()
}
}
pub fn all(dir: &Path) -> Result<Vec<Record>> {
let mut files = record_files(dir)?;
files.sort_by(|a, b| b.0.cmp(&a.0)); let mut out = Vec::with_capacity(files.len());
for (id, path) in files {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = serde_json::from_str::<Value>(&text) else {
continue;
};
out.push(Record { id, path, value });
}
Ok(out)
}
pub fn load(dir: &Path, id: &str) -> Result<Record> {
if id == "latest" {
return all(dir)?
.into_iter()
.next()
.ok_or_else(|| Error::History(format!("no runs recorded yet in {}", dir.display())));
}
let path = dir.join(format!("{id}.json"));
let text = std::fs::read_to_string(&path).map_err(|_| {
Error::History(format!(
"no run with id {id:?} in {} (run `llmlint history` to list recorded runs)",
dir.display()
))
})?;
let value = serde_json::from_str(&text)
.map_err(|e| Error::History(format!("run {id}: corrupt record: {e}")))?;
Ok(Record {
id: id.to_string(),
path,
value,
})
}
fn record_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(io_err(format!("reading history dir {}", dir.display()), e)),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
out.push((stem.to_string(), path));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::tempdir;
fn at(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
}
#[test]
fn timestamp_and_id_are_utc_and_sortable() {
assert_eq!(format_timestamp(at(0)), "1970-01-01T00:00:00Z");
assert!(generate_id(at(0)).starts_with("19700101T000000Z-"));
assert_eq!(format_timestamp(at(1_700_000_000)), "2023-11-14T22:13:20Z");
assert!(generate_id(at(1_700_000_000)).starts_with("20231114T221320Z-"));
assert!(generate_id(at(0)) < generate_id(at(1_700_000_000)));
}
#[test]
fn id_suffix_reflects_subsecond_nanos() {
let a = generate_id(SystemTime::UNIX_EPOCH + Duration::new(10, 100_000));
let b = generate_id(SystemTime::UNIX_EPOCH + Duration::new(10, 200_000));
assert_ne!(a, b);
assert!(a.starts_with("19700101T000010Z-"));
}
#[test]
fn resolve_precedence_is_pure_and_ordered() {
assert!(!resolve_enabled(true, false, true)); assert!(!resolve_enabled(false, true, true)); assert!(!resolve_enabled(false, false, false)); assert!(resolve_enabled(false, false, true));
assert_eq!(
resolve_dir(Some("/env".into()), Some("/cfg"), Some("/def".into())),
Some(PathBuf::from("/env"))
);
assert_eq!(
resolve_dir(None, Some("/cfg"), Some("/def".into())),
Some(PathBuf::from("/cfg"))
);
assert_eq!(
resolve_dir(None, None, Some("/def".into())),
Some(PathBuf::from("/def"))
);
assert_eq!(resolve_dir(None, None, None), None);
}
#[test]
fn write_read_and_prune_round_trip() {
let dir = tempdir().unwrap();
let rec = |id: &str| json!({"id": id, "exit_code": 0});
for id in ["20260101T000000Z-00001", "20260102T000000Z-00002"] {
write_record(dir.path(), id, &rec(id), 10).unwrap();
}
let listed = all(dir.path()).unwrap();
assert_eq!(listed[0].id, "20260102T000000Z-00002");
assert_eq!(listed[1].id, "20260101T000000Z-00001");
assert_eq!(load(dir.path(), "latest").unwrap().id, listed[0].id);
assert_eq!(
load(dir.path(), "20260101T000000Z-00001").unwrap().value["id"],
"20260101T000000Z-00001"
);
assert!(load(dir.path(), "nope").is_err());
let id3 = "20260103T000000Z-00003";
write_record(dir.path(), id3, &rec(id3), 2).unwrap();
let ids: Vec<String> = all(dir.path()).unwrap().into_iter().map(|r| r.id).collect();
assert_eq!(
ids,
vec![id3.to_string(), "20260102T000000Z-00002".to_string()]
);
assert!(load(dir.path(), "20260101T000000Z-00001").is_err());
}
const GOLDEN_UNLABELLED: &str =
include_str!("../../tests/fixtures/history_record/unlabelled.json");
const GOLDEN_LABELLED: &str = include_str!("../../tests/fixtures/history_record/labelled.json");
fn persisted(labels: &Labels) -> String {
use crate::domain::verdict::{Outcome, RuleOutcome};
let report = Report::new(
vec![RuleOutcome {
name: "r".into(),
rationale: None,
outcome: Outcome::Pass,
votes_total: 1,
votes_hold: 1,
judges: vec![],
violations: vec![],
}],
vec![],
);
let mut rec = build_record(
"20260704T153000Z-1a2b3",
"2026-07-04T15:30:00Z",
"lint",
Path::new("/proj"),
0,
&["llmlint.yml".to_string()],
labels,
&report,
);
rec["llmlint_version"] = json!("<version>");
let mut body = serde_json::to_string_pretty(&rec).unwrap();
body.push('\n');
body
}
#[test]
fn persisted_record_matches_the_goldens() {
assert_eq!(persisted(&Labels::default()), GOLDEN_UNLABELLED);
let labels =
crate::domain::labels::resolve(None, &["turn=3".into(), "session=abc".into()]).unwrap();
assert_eq!(persisted(&labels), GOLDEN_LABELLED);
}
#[test]
fn build_record_carries_metadata_and_report_fields() {
use crate::domain::verdict::{Outcome, RuleOutcome};
let report = Report::new(
vec![RuleOutcome {
name: "r".into(),
rationale: None,
outcome: Outcome::Pass,
votes_total: 1,
votes_hold: 1,
judges: vec![],
violations: vec![],
}],
vec![],
);
let rec = build_record(
"id1",
"2026-07-04T00:00:00Z",
"lint",
Path::new("/proj"),
0,
&["llmlint.yml".to_string()],
&Labels::default(),
&report,
);
assert_eq!(rec["id"], "id1");
assert_eq!(rec["command"], "lint");
assert_eq!(rec["cwd"], "/proj");
assert_eq!(rec["exit_code"], 0);
assert_eq!(rec["config_files"][0], "llmlint.yml");
assert_eq!(rec["summary"]["passed"], 1);
assert_eq!(rec["rules"][0]["name"], "r");
assert!(rec["errors"].as_array().unwrap().is_empty());
assert!(rec.get("labels").is_none());
let labels =
crate::domain::labels::resolve(None, &["turn=1".into(), "session=abc".into()]).unwrap();
let rec = build_record(
"id2",
"2026-07-04T00:00:00Z",
"lint",
Path::new("/proj"),
0,
&[],
&labels,
&report,
);
let text = serde_json::to_string(&rec["labels"]).unwrap();
assert_eq!(text, r#"{"session":"abc","turn":"1"}"#);
let record = Record {
id: "id2".into(),
path: PathBuf::from("id2.json"),
value: rec,
};
assert_eq!(record.labels(), labels);
}
#[test]
fn resolve_reads_env_and_config() {
let prev_dir = std::env::var_os("LLMLINT_HISTORY_DIR");
let prev_off = std::env::var_os("LLMLINT_NO_HISTORY");
std::env::set_var("LLMLINT_HISTORY_DIR", "/tmp/llmlint-history-test");
std::env::remove_var("LLMLINT_NO_HISTORY");
let mut config = Config {
history: crate::domain::config::HistoryCfg {
max_runs: Some(3),
..Default::default()
},
..Default::default()
};
let s = resolve(&config, false);
assert!(s.enabled); assert_eq!(s.dir, Some(PathBuf::from("/tmp/llmlint-history-test"))); assert_eq!(s.max_runs, 3);
config.history.enabled = Some(true);
assert!(!resolve(&config, true).enabled);
std::env::set_var("LLMLINT_NO_HISTORY", "1");
assert!(!resolve(&config, false).enabled);
match prev_dir {
Some(v) => std::env::set_var("LLMLINT_HISTORY_DIR", v),
None => std::env::remove_var("LLMLINT_HISTORY_DIR"),
}
match prev_off {
Some(v) => std::env::set_var("LLMLINT_NO_HISTORY", v),
None => std::env::remove_var("LLMLINT_NO_HISTORY"),
}
}
#[test]
fn latest_of_empty_and_corrupt_records_error() {
let dir = tempdir().unwrap();
assert!(load(dir.path(), "latest").is_err());
std::fs::write(dir.path().join("bad.json"), "{ not json").unwrap();
assert!(load(dir.path(), "bad").is_err());
}
#[test]
fn all_of_a_missing_dir_is_empty() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope");
assert!(all(&missing).unwrap().is_empty());
}
#[test]
fn non_json_files_are_skipped() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), "hi").unwrap();
std::fs::write(dir.path().join("bad.json"), "{ not json").unwrap();
write_record(
dir.path(),
"20260101T000000Z-00001",
&json!({"id": "x"}),
10,
)
.unwrap();
let listed = all(dir.path()).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, "20260101T000000Z-00001");
}
}