use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::tracked::{TrackedMap, TrackedSet};
use super::{LeanError, LeanResult};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BaselineEntry {
pub etag: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub generation: u64,
pub size: u64,
pub mtime_unix: i64,
#[serde(default)]
pub mtime_nanos: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<u32>,
pub crc64_b64: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub racy: bool,
}
impl BaselineEntry {
pub fn cites_mode(&self, cited: u32) -> bool {
self.mode.is_none_or(|m| (m ^ cited) & super::scan::PERM_BITS == 0)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Baseline {
pub seq: u64,
pub manifest_etag: Option<String>,
pub entries: TrackedMap<BaselineEntry>,
#[serde(default)]
pub derived_etag: Option<String>,
#[serde(default)]
pub skipped: TrackedSet,
pub prev_scan: TrackedSet,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) journal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScopeIntent {
pub target: Option<Vec<String>>,
#[serde(default)]
pub drop: Vec<String>,
#[serde(default)]
pub held: BTreeMap<String, BaselineEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Incarnation {
pub holder_id: String,
pub epoch: u64,
pub last_token: Option<String>,
pub quiet_polls: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoreConformance {
pub identity: String,
pub probe_version: u32,
pub answered: bool,
pub conditional_put: bool,
pub conditional_delete: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
pub at_unix: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentJournal {
pub flush_uuid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub carrier: Option<Carrier>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Carrier {
pub pending_id: String,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub paths: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub deletes: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub uncited: BTreeSet<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub published: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub dropped: BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictRecord {
pub path: String,
pub foreign_etag: String,
pub preserved_key: Option<String>,
pub kind: String, pub at_unix: u64,
}
pub struct SyncerState {
dir: PathBuf,
_lock: std::fs::File,
baseline: std::sync::Mutex<BaselineStore>,
}
const MARKER: &str = "checkout-complete";
pub const DRAINED: &str = "drained.json";
const LOCK: &str = "lock";
const BASELINE: &str = "baseline.json";
const SCOPE: &str = "scope.json";
const SCOPE_INTENT: &str = "scope-intent.json";
const INCARNATION: &str = "incarnation.json";
const INTENT: &str = "intent.json";
const UNTRACKED_SWEEP: &str = "untracked-sweep";
const ORPHAN_SWEEP: &str = "orphan-sweep";
const COLLECT_AT: &str = "collect-at";
const RETIRE_CACHE: &str = "retire-cache.json";
const CONFORMANCE: &str = "conformance.json";
const CONFLICTS: &str = "conflicts.jsonl";
const CONFLICTS_PREV: &str = "conflicts.1.jsonl";
const CONFLICTS_DROPPED: &str = "conflicts.dropped";
const CONFLICTS_MAX_BYTES: u64 = 1 << 20;
fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
super::safefs::check_parent(path)?;
let tmp = path.with_extension("tmp");
super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
}
const BASELINE_LOG: &str = "baseline.log";
const COMPACT_SHARE: u64 = 4;
const COMPACT_MIN_BYTES: u64 = 256 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct JournalHead {
snapshot: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct JournalRecord {
seq: u64,
#[serde(default)]
manifest_etag: Option<String>,
#[serde(default)]
derived_etag: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
put: Vec<(String, BaselineEntry)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
del: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skipped_add: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
skipped_del: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
scan_add: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
scan_del: Vec<String>,
}
impl JournalRecord {
fn drain(b: &mut Baseline) -> JournalRecord {
let mut r = JournalRecord {
seq: b.seq,
manifest_etag: b.manifest_etag.clone(),
derived_etag: b.derived_etag.clone(),
..Default::default()
};
for k in b.entries.take_touched() {
match b.entries.get(&k) {
Some(e) => r.put.push((k, e.clone())),
None => r.del.push(k),
}
}
for k in b.skipped.take_touched() {
match b.skipped.contains(&k) {
true => r.skipped_add.push(k),
false => r.skipped_del.push(k),
}
}
for k in b.prev_scan.take_touched() {
match b.prev_scan.contains(&k) {
true => r.scan_add.push(k),
false => r.scan_del.push(k),
}
}
r
}
fn changes_members(&self) -> bool {
!(self.put.is_empty()
&& self.del.is_empty()
&& self.skipped_add.is_empty()
&& self.skipped_del.is_empty()
&& self.scan_add.is_empty()
&& self.scan_del.is_empty())
}
fn apply(self, b: &mut Baseline) {
b.seq = self.seq;
b.manifest_etag = self.manifest_etag;
b.derived_etag = self.derived_etag;
for (k, e) in self.put {
b.entries.apply_put(k, e);
}
for k in &self.del {
b.entries.apply_del(k);
}
for k in self.skipped_add {
b.skipped.apply_add(k);
}
for k in &self.skipped_del {
b.skipped.apply_del(k);
}
for k in self.scan_add {
b.prev_scan.apply_add(k);
}
for k in &self.scan_del {
b.prev_scan.apply_del(k);
}
}
}
fn journal_line(json: &[u8]) -> Vec<u8> {
let mut l = format!("{:016x} ", flint_store::crc64_nvme(json)).into_bytes();
l.extend_from_slice(json);
l.push(b'\n');
l
}
fn journal_payload(line: &[u8]) -> Option<&[u8]> {
if line.len() < 18 || line[16] != b' ' { return None }
let crc = u64::from_str_radix(std::str::from_utf8(&line[..16]).ok()?, 16).ok()?;
let json = &line[17..];
(flint_store::crc64_nvme(json) == crc).then_some(json)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Scalars {
seq: u64,
manifest_etag: Option<String>,
derived_etag: Option<String>,
}
impl Scalars {
fn of(b: &Baseline) -> Scalars {
Scalars { seq: b.seq, manifest_etag: b.manifest_etag.clone(), derived_etag: b.derived_etag.clone() }
}
}
#[derive(Default)]
struct BaselineStore {
cache: Option<Baseline>,
lineage: u64,
next_lineage: u64,
snapshot: Option<u64>,
log_len: u64,
scalars: Scalars,
parses: u64,
#[cfg(test)]
compact_min_bytes: Option<u64>,
}
impl BaselineStore {
fn next(&mut self) -> u64 {
self.next_lineage += 1;
self.next_lineage
}
}
fn stamp(b: &mut Baseline, lineage: u64) {
b.entries.stamp(lineage);
b.skipped.stamp(lineage);
b.prev_scan.stamp(lineage);
}
impl SyncerState {
fn baseline_store(&self) -> std::sync::MutexGuard<'_, BaselineStore> {
self.baseline.lock().unwrap_or_else(|p| p.into_inner())
}
fn fill(&self, st: &mut BaselineStore) -> LeanResult<()> {
if st.cache.is_some() {
return Ok(());
}
let disk = read_baseline_files(&self.dir, true)?;
st.parses += disk.snapshot_len.is_some() as u64;
let mut b = disk.baseline;
let lineage = st.next();
stamp(&mut b, lineage);
st.lineage = lineage;
st.snapshot = disk.snapshot_len;
st.log_len = disk.log_len;
st.scalars = Scalars::of(&b);
st.cache = Some(b);
Ok(())
}
fn persist(&self, st: &mut BaselineStore, b: &mut Baseline) -> LeanResult<()> {
let ours = st.lineage != 0
&& b.entries.lineage() == st.lineage
&& b.skipped.lineage() == st.lineage
&& b.prev_scan.lineage() == st.lineage;
let room = match st.snapshot {
Some(len) if st.log_len > 0 => {
#[cfg(test)]
let min = st.compact_min_bytes.unwrap_or(COMPACT_MIN_BYTES);
#[cfg(not(test))]
let min = COMPACT_MIN_BYTES;
st.log_len < (len / COMPACT_SHARE).max(min)
}
_ => false,
};
let changed = b.entries.touched_len() + b.skipped.touched_len() + b.prev_scan.touched_len();
let small = changed <= (b.entries.len() / 8).max(4096);
if ours && room && small {
let rec = JournalRecord::drain(b);
if !rec.changes_members() && Scalars::of(b) == st.scalars {
return Ok(());
}
let json = serde_json::to_vec(&rec).map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
let line = journal_line(&json);
let path = self.dir.join(BASELINE_LOG);
super::safefs::check_parent(&path)?;
use std::io::{Seek, SeekFrom, Write};
let mut f = fs::OpenOptions::new().write(true).open(&path)?;
f.seek(SeekFrom::Start(st.log_len))?;
f.write_all(&line)?;
let len = st.log_len + line.len() as u64;
f.set_len(len)?;
f.sync_data()?;
st.log_len = len;
} else {
b.entries.take_touched();
b.skipped.take_touched();
b.prev_scan.take_touched();
let id = uuid::Uuid::new_v4().to_string();
b.journal = Some(id.clone());
let bytes = serde_json::to_vec(&*b).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
write_atomic(&self.dir.join(BASELINE), &bytes)?;
let json = serde_json::to_vec(&JournalHead { snapshot: id })
.map_err(|e| LeanError::State(format!("baseline journal: {e}")))?;
let line = journal_line(&json);
write_atomic(&self.dir.join(BASELINE_LOG), &line)?;
*b = Baseline::default();
*b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
st.snapshot = Some(bytes.len() as u64);
st.log_len = line.len() as u64;
}
let lineage = st.next();
stamp(b, lineage);
st.lineage = lineage;
st.scalars = Scalars::of(b);
Ok(())
}
pub fn load_baseline(&self) -> LeanResult<Baseline> {
let mut st = self.baseline_store();
self.fill(&mut st)?;
Ok(st.cache.clone().expect("filled above"))
}
pub fn with_baseline<R>(&self, f: impl FnOnce(&Baseline) -> R) -> LeanResult<R> {
let mut st = self.baseline_store();
self.fill(&mut st)?;
Ok(f(st.cache.as_ref().expect("filled above")))
}
pub fn take_baseline(&self) -> LeanResult<Baseline> {
let mut st = self.baseline_store();
self.fill(&mut st)?;
Ok(st.cache.take().expect("filled above"))
}
pub fn commit_baseline(&self, mut b: Baseline) -> LeanResult<()> {
let mut st = self.baseline_store();
if let Err(e) = self.persist(&mut st, &mut b) {
st.cache = None;
st.lineage = 0;
st.snapshot = None;
st.log_len = 0;
return Err(e);
}
st.cache = Some(b);
Ok(())
}
pub fn save_baseline(&self, b: &Baseline) -> LeanResult<()> {
self.commit_baseline(b.clone())
}
pub fn baseline_parses(&self) -> u64 {
self.baseline_store().parses
}
#[cfg(test)]
pub(crate) fn forget_baseline(&self) {
let mut st = self.baseline_store();
st.cache = None;
st.lineage = 0;
}
#[cfg(test)]
pub(crate) fn set_baseline_compact_min(&self, bytes: u64) {
self.baseline_store().compact_min_bytes = Some(bytes);
}
}
struct DiskBaseline {
baseline: Baseline,
snapshot_len: Option<u64>,
log_len: u64,
}
fn read_baseline_files(dir: &Path, owner: bool) -> LeanResult<DiskBaseline> {
match fs::read(dir.join(BASELINE)) {
Ok(bytes) => {
let b = serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("baseline: {e}")))?;
replay_onto(dir, b, bytes.len() as u64, owner)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(DiskBaseline { baseline: Baseline::default(), snapshot_len: None, log_len: 0 })
}
Err(e) => Err(LeanError::State(format!("read {BASELINE}: {e}"))),
}
}
fn replay_onto(dir: &Path, mut b: Baseline, snapshot_len: u64, owner: bool) -> LeanResult<DiskBaseline> {
let none = |b| Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: 0 });
let Some(id) = b.journal.clone() else { return none(b) };
let path = dir.join(BASELINE_LOG);
let bytes = match fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return none(b),
Err(e) => return Err(LeanError::State(format!("read {BASELINE_LOG}: {e}"))),
};
let mut lines = bytes.split_inclusive(|c| *c == b'\n');
let Some(first) = lines.next().filter(|l| l.ends_with(b"\n")) else { return none(b) };
let belongs = journal_payload(&first[..first.len() - 1])
.and_then(|j| serde_json::from_slice::<JournalHead>(j).ok())
.is_some_and(|h| h.snapshot == id);
if !belongs {
return none(b);
}
let mut good = first.len() as u64;
for line in lines {
let rec = line
.strip_suffix(b"\n")
.and_then(journal_payload)
.and_then(|j| serde_json::from_slice::<JournalRecord>(j).ok());
match rec {
Some(r) => {
r.apply(&mut b);
good += line.len() as u64;
}
None => break,
}
}
if owner && good < bytes.len() as u64 {
let f = fs::OpenOptions::new().write(true).open(&path)?;
f.set_len(good)?;
f.sync_all()?;
}
Ok(DiskBaseline { baseline: b, snapshot_len: Some(snapshot_len), log_len: good })
}
pub fn read_baseline_at(dir: &Path) -> LeanResult<Baseline> {
Ok(read_baseline_files(dir, false)?.baseline)
}
impl SyncerState {
pub fn open(dir: PathBuf) -> LeanResult<SyncerState> {
fs::create_dir_all(&dir)?;
let lock = fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join(LOCK))?;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
let rc = unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc != 0 {
return Err(LeanError::State(format!(
"another flint-sync already holds this workspace ({}): \
refusing to run two syncers over one tree",
dir.display()
)));
}
}
Ok(SyncerState { dir, _lock: lock, baseline: Default::default() })
}
pub fn marker_present(&self) -> bool {
self.dir.join(MARKER).exists()
}
pub fn sync_tree(&self) -> LeanResult<()> {
super::safefs::sync_tree(&self.dir)
}
pub fn write_drained(&self, seq: Option<u64>, acks: usize) -> LeanResult<()> {
let doc = serde_json::json!({
"unix": super::now_unix(),
"seq": seq,
"acks": acks,
});
write_atomic(&self.dir.join(DRAINED), doc.to_string().as_bytes())
}
pub fn clear_drained(&self) -> LeanResult<()> {
match fs::remove_file(self.dir.join(DRAINED)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(LeanError::State(format!("clear {DRAINED}: {e}"))),
}
}
pub fn drained_path(&self) -> PathBuf {
self.dir.join(DRAINED)
}
pub fn write_marker(&self) -> LeanResult<()> {
write_atomic(&self.dir.join(MARKER), b"ok\n")
}
pub fn load_scope(&self) -> LeanResult<Option<Vec<String>>> {
let p = self.dir.join(SCOPE);
let bytes = match fs::read(&p) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(LeanError::State(format!(
"cannot read the checkout scope at {}: {e} — refusing to \
guess what this workspace is holding",
p.display()
)))
}
};
serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("scope: {e}")))
}
pub fn save_scope(&self, scope: Option<&[String]>) -> LeanResult<()> {
let p = self.dir.join(SCOPE);
match scope {
Some(entries) => {
let bytes = serde_json::to_vec(entries)
.map_err(|e| LeanError::State(format!("scope: {e}")))?;
write_atomic(&p, &bytes)
}
None => match fs::remove_file(&p) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(LeanError::State(format!("clear {SCOPE}: {e}"))),
},
}
}
pub fn load_scope_intent(&self) -> LeanResult<Option<ScopeIntent>> {
let p = self.dir.join(SCOPE_INTENT);
let bytes = match fs::read(&p) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(LeanError::State(format!(
"cannot read the rescope intent at {}: {e} — refusing to run a barrier \
over a half-applied scope",
p.display()
)))
}
};
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| LeanError::State(format!("scope intent: {e}")))
}
pub fn save_scope_intent(&self, intent: &ScopeIntent) -> LeanResult<()> {
let bytes = serde_json::to_vec(intent)
.map_err(|e| LeanError::State(format!("scope intent: {e}")))?;
write_atomic(&self.dir.join(SCOPE_INTENT), &bytes)
}
pub fn clear_scope_intent(&self) -> LeanResult<()> {
match fs::remove_file(self.dir.join(SCOPE_INTENT)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(LeanError::State(format!("clear {SCOPE_INTENT}: {e}"))),
}
}
pub fn load_incarnation(&self) -> LeanResult<Option<Incarnation>> {
let p = self.dir.join(INCARNATION);
if !p.exists() {
return Ok(None);
}
let bytes = fs::read(&p)?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| LeanError::State(format!("incarnation: {e}")))
}
pub fn save_incarnation(&self, i: &Incarnation) -> LeanResult<()> {
let bytes = serde_json::to_vec_pretty(i)
.map_err(|e| LeanError::State(format!("incarnation: {e}")))?;
write_atomic(&self.dir.join(INCARNATION), &bytes)
}
pub fn load_untracked_sweep_at(&self) -> LeanResult<u64> {
match fs::read_to_string(self.dir.join(UNTRACKED_SWEEP)) {
Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(e.into()),
}
}
pub fn save_untracked_sweep_at(&self, at: u64) -> LeanResult<()> {
write_atomic(&self.dir.join(UNTRACKED_SWEEP), at.to_string().as_bytes())
}
pub fn load_orphan_sweep_at(&self) -> LeanResult<u64> {
match fs::read_to_string(self.dir.join(ORPHAN_SWEEP)) {
Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(e.into()),
}
}
pub fn save_orphan_sweep_at(&self, at: u64) -> LeanResult<()> {
write_atomic(&self.dir.join(ORPHAN_SWEEP), at.to_string().as_bytes())
}
pub fn load_collect_at(&self) -> u64 {
fs::read_to_string(self.dir.join(COLLECT_AT)).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0)
}
pub fn save_collect_at(&self, at: u64) -> LeanResult<()> {
write_atomic(&self.dir.join(COLLECT_AT), at.to_string().as_bytes())
}
pub fn load_retire_cache(&self) -> BTreeMap<String, super::manifest::RetireLog> {
fs::read(self.dir.join(RETIRE_CACHE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
pub fn save_retire_cache(&self, c: &BTreeMap<String, super::manifest::RetireLog>) -> LeanResult<()> {
let bytes = serde_json::to_vec(c).map_err(|e| LeanError::State(format!("retire cache: {e}")))?;
write_atomic(&self.dir.join(RETIRE_CACHE), &bytes)
}
pub fn load_conformance(&self) -> LeanResult<Option<StoreConformance>> {
match fs::read(self.dir.join(CONFORMANCE)) {
Ok(b) => Ok(serde_json::from_slice(&b).ok()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn save_conformance(&self, c: &StoreConformance) -> LeanResult<()> {
let bytes = serde_json::to_vec_pretty(c)
.map_err(|e| LeanError::State(format!("conformance: {e}")))?;
write_atomic(&self.dir.join(CONFORMANCE), &bytes)
}
pub fn load_intent(&self) -> LeanResult<IntentJournal> {
let p = self.dir.join(INTENT);
if !p.exists() {
return Ok(IntentJournal::default());
}
let bytes = fs::read(&p)?;
serde_json::from_slice(&bytes).map_err(|e| LeanError::State(format!("intent: {e}")))
}
pub fn save_intent(&self, j: &IntentJournal) -> LeanResult<()> {
let bytes =
serde_json::to_vec_pretty(j).map_err(|e| LeanError::State(format!("intent: {e}")))?;
write_atomic(&self.dir.join(INTENT), &bytes)
}
pub fn clear_intent_keys(&self) -> LeanResult<()> {
let mut j = self.load_intent()?;
j.flush_uuid = String::new();
self.save_intent(&j)
}
pub fn append_conflict(&self, c: &ConflictRecord) -> LeanResult<()> {
use std::io::Write;
let line =
serde_json::to_string(c).map_err(|e| LeanError::State(format!("conflict: {e}")))?;
let live = self.dir.join(CONFLICTS);
let mut f = fs::OpenOptions::new().create(true).append(true).open(&live)?;
writeln!(f, "{line}")?;
let len = f.metadata()?.len();
drop(f);
if len > CONFLICTS_MAX_BYTES {
self.rotate_conflicts()?;
}
Ok(())
}
fn rotate_conflicts(&self) -> LeanResult<()> {
let live = self.dir.join(CONFLICTS);
let prev = self.dir.join(CONFLICTS_PREV);
if let Ok(text) = fs::read_to_string(&prev) {
let dropped = text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
let total = self.conflicts_dropped()? + dropped;
write_atomic(&self.dir.join(CONFLICTS_DROPPED), total.to_string().as_bytes())?;
}
fs::rename(&live, &prev)?;
Ok(())
}
pub fn conflicts_dropped(&self) -> LeanResult<u64> {
match fs::read_to_string(self.dir.join(CONFLICTS_DROPPED)) {
Ok(s) => Ok(s.trim().parse().unwrap_or(0)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(e) => Err(LeanError::State(format!("{CONFLICTS_DROPPED}: {e}"))),
}
}
pub fn conflicts_appended(&self) -> LeanResult<u64> {
Ok(self.conflicts_dropped()? + self.load_conflicts()?.len() as u64)
}
pub fn load_conflicts(&self) -> LeanResult<Vec<ConflictRecord>> {
let mut out = vec![];
for name in [CONFLICTS_PREV, CONFLICTS] {
let p = self.dir.join(name);
let text = match fs::read_to_string(&p) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(LeanError::State(format!(
"cannot read {name}: {e} — refusing to report an unreadable \
conflict log as an empty one"
)))
}
};
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
out.push(
serde_json::from_str(line)
.map_err(|e| LeanError::State(format!("conflict line: {e}")))?,
);
}
}
Ok(out)
}
}