use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sha2::{Digest, Sha256};
use crate::edit::{SpecEdit, ValidatedSpec, apply_edits, write_atomic};
use crate::error::{Error, Result};
use crate::manifest::MANIFEST_FILENAME;
use crate::record::RecordedStep;
pub const HISTORY_MAX_ENTRIES: usize = 50;
pub const HISTORY_MERGE_WINDOW: Duration = Duration::from_secs(10);
pub const LOG_FILENAME: &str = "arcform-log.txt";
const HISTORY_DIR_ENV: &str = "ARCFORM_HISTORY_DIR";
const SPEC_PATH_FILE: &str = "spec-path";
const WAY_MAX_LEN: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistoryKind {
Save,
Checkpoint,
Unsaved,
}
impl HistoryKind {
fn tag(self) -> &'static str {
match self {
HistoryKind::Save => "save",
HistoryKind::Checkpoint => "checkpoint",
HistoryKind::Unsaved => "unsaved",
}
}
fn from_tag(tag: &str) -> Option<Self> {
match tag {
"save" => Some(HistoryKind::Save),
"checkpoint" => Some(HistoryKind::Checkpoint),
"unsaved" => Some(HistoryKind::Unsaved),
_ => None,
}
}
}
#[derive(Debug, Clone)]
enum StepNote<'a> {
Unnamed,
Added(&'a str),
Compare,
Steps(Vec<StepChange>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StepChange {
step: String,
change: Change,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Change {
Added,
Removed,
Changed,
}
impl Change {
fn word(self) -> &'static str {
match self {
Change::Added => "added",
Change::Removed => "removed",
Change::Changed => "changed",
}
}
}
struct HeldLine {
spec_path: PathBuf,
entry: HistoryEntry,
}
impl HeldLine {
fn land(self) {
let _ = append_log_line(&self.spec_path, &self.entry, StepNote::Unnamed);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryWay(Cow<'static, str>);
impl HistoryWay {
pub const TERMINAL: HistoryWay = HistoryWay(Cow::Borrowed("terminal"));
pub const MCP: HistoryWay = HistoryWay(Cow::Borrowed("mcp"));
pub fn new(word: &str) -> Result<Self> {
let refuse = |detail: String| Error::HistoryWay {
way: word.to_string(),
detail,
};
if let Some(fault) = word_fault(word) {
return Err(refuse(fault));
}
for (own, name) in [(Self::TERMINAL, "TERMINAL"), (Self::MCP, "MCP")] {
if own.as_str().eq_ignore_ascii_case(word) {
return Err(refuse(format!(
"`{}` is arc's own way; pass `HistoryWay::{name}` to mean it",
own.as_str()
)));
}
}
Ok(HistoryWay(Cow::Owned(word.to_string())))
}
pub fn as_str(&self) -> &str {
&self.0
}
fn stored(word: &str) -> Option<Self> {
word_fault(word)
.is_none()
.then(|| HistoryWay(Cow::Owned(word.to_string())))
}
}
fn word_fault(word: &str) -> Option<String> {
if word.is_empty() {
return Some("a way is one word, and this is empty".to_string());
}
if let Some(c) = word
.chars()
.find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
{
return Some(format!("{c:?} is not an ASCII letter, a digit, `-` or `_`"));
}
if word.len() > WAY_MAX_LEN {
return Some(format!("a way is at most {WAY_MAX_LEN} characters"));
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub id: String,
pub kind: HistoryKind,
pub way: Option<HistoryWay>,
pub at: SystemTime,
pub bytes: u64,
}
#[derive(Debug, Clone)]
pub struct LocalHistory {
root: PathBuf,
way: Option<HistoryWay>,
}
impl LocalHistory {
pub fn open_default() -> Result<Self> {
resolve_root(std::env::var_os(HISTORY_DIR_ENV), dirs::home_dir()).map(Self::at_root)
}
pub fn at_root(root: impl Into<PathBuf>) -> Self {
LocalHistory {
root: root.into(),
way: None,
}
}
pub fn reached_by(self, way: HistoryWay) -> Self {
LocalHistory {
way: Some(way),
..self
}
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn record_save(&self, dir: &Path, text: &str) -> Result<Option<HistoryEntry>> {
self.record_noted(
dir,
text,
HistoryKind::Save,
SystemTime::now(),
true,
Some(StepNote::Compare),
)
}
pub fn record_save_unmerged(&self, dir: &Path, text: &str) -> Result<Option<HistoryEntry>> {
self.record_noted(
dir,
text,
HistoryKind::Save,
SystemTime::now(),
false,
Some(StepNote::Compare),
)
}
pub fn record_checkpoint(&self, dir: &Path, text: &str) -> Result<Option<HistoryEntry>> {
self.record(dir, text, HistoryKind::Checkpoint, SystemTime::now(), false)
}
pub fn entries(&self, dir: &Path) -> Result<Vec<HistoryEntry>> {
let (key_dir, _) = self.key_dir(dir)?;
entries_in(&key_dir)
}
pub fn read(&self, dir: &Path, id: &str) -> Result<String> {
let (key_dir, _) = self.key_dir(dir)?;
read_entry(&key_dir, id)
}
pub fn restore(&self, dir: &Path, id: &str) -> Result<String> {
self.restore_keyed(self.key_dir(dir)?, &dir.join(MANIFEST_FILENAME), id)
}
pub fn record_save_for_file(&self, file: &Path, text: &str) -> Result<Option<HistoryEntry>> {
self.record_keyed(
self.file_key(file)?,
text,
HistoryKind::Save,
SystemTime::now(),
true,
Some(StepNote::Compare),
)
}
pub fn record_save_unmerged_for_file(
&self,
file: &Path,
text: &str,
) -> Result<Option<HistoryEntry>> {
self.record_keyed(
self.file_key(file)?,
text,
HistoryKind::Save,
SystemTime::now(),
false,
Some(StepNote::Compare),
)
}
pub fn record_checkpoint_for_file(
&self,
file: &Path,
text: &str,
) -> Result<Option<HistoryEntry>> {
self.record_keyed(
self.file_key(file)?,
text,
HistoryKind::Checkpoint,
SystemTime::now(),
false,
Some(StepNote::Unnamed),
)
}
pub fn record_unsaved_for_file(&self, file: &Path, text: &str) -> Result<Option<HistoryEntry>> {
self.record_keyed(
self.file_key(file)?,
text,
HistoryKind::Unsaved,
SystemTime::now(),
false,
Some(StepNote::Unnamed),
)
}
pub fn entries_for_file(&self, file: &Path) -> Result<Vec<HistoryEntry>> {
let (key_dir, _) = self.file_key(file)?;
entries_in(&key_dir)
}
pub fn read_for_file(&self, file: &Path, id: &str) -> Result<String> {
let (key_dir, _) = self.file_key(file)?;
read_entry(&key_dir, id)
}
pub fn restore_for_file(&self, file: &Path, id: &str) -> Result<String> {
self.restore_keyed(self.file_key(file)?, file, id)
}
fn restore_keyed(&self, key: (PathBuf, PathBuf), target: &Path, id: &str) -> Result<String> {
let text = read_entry(&key.0, id)?;
let spec_path = key.1.clone();
let mut held = None;
if target.exists() {
let current = std::fs::read_to_string(target).map_err(|e| Error::FileRead {
path: target.to_path_buf(),
source: e,
})?;
held = self
.record_keyed(
key,
¤t,
HistoryKind::Checkpoint,
SystemTime::now(),
false,
None,
)?
.map(|entry| HeldLine { spec_path, entry });
}
write_atomic(target, text.as_bytes())?;
if let Some(held) = held {
held.land();
}
Ok(text)
}
fn record(
&self,
dir: &Path,
text: &str,
kind: HistoryKind,
now: SystemTime,
merge: bool,
) -> Result<Option<HistoryEntry>> {
self.record_noted(dir, text, kind, now, merge, Some(StepNote::Unnamed))
}
fn record_noted(
&self,
dir: &Path,
text: &str,
kind: HistoryKind,
now: SystemTime,
merge: bool,
line: Option<StepNote>,
) -> Result<Option<HistoryEntry>> {
self.record_keyed(self.key_dir(dir)?, text, kind, now, merge, line)
}
fn checkpoint_held(&self, dir: &Path, text: &str) -> Result<Option<HeldLine>> {
let entry = self.record_noted(
dir,
text,
HistoryKind::Checkpoint,
SystemTime::now(),
false,
None,
)?;
Ok(entry.map(|entry| HeldLine {
spec_path: dir.join(MANIFEST_FILENAME),
entry,
}))
}
fn record_keyed(
&self,
(key_dir, spec_path): (PathBuf, PathBuf),
text: &str,
kind: HistoryKind,
now: SystemTime,
merge: bool,
line: Option<StepNote>,
) -> Result<Option<HistoryEntry>> {
std::fs::create_dir_all(&key_dir)?;
let marker = key_dir.join(SPEC_PATH_FILE);
if !marker.exists() {
write_atomic(&marker, format!("{}\n", spec_path.display()).as_bytes())?;
}
let existing = entries_in(&key_dir)?;
let newest = existing.last();
let before = match newest {
Some(newest) => Some(read_at(&key_dir, newest)?),
None => None,
};
if before.as_deref() == Some(text) {
return Ok(None);
}
let merge_into = newest
.filter(|n| {
merge
&& kind == HistoryKind::Save
&& n.kind == HistoryKind::Save
&& now
.duration_since(n.at)
.is_ok_and(|gap| gap <= HISTORY_MERGE_WINDOW)
})
.cloned();
let now_millis = now
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let (millis, seq) = next_stamp(newest, now_millis);
let entry = HistoryEntry {
id: entry_id(millis, seq, kind),
kind,
way: self.way.clone(),
at: UNIX_EPOCH + Duration::from_millis(millis),
bytes: text.len() as u64,
};
write_atomic(&entry_path(&key_dir, &entry), text.as_bytes())?;
if let Some(old) = merge_into {
let _ = std::fs::remove_file(entry_path(&key_dir, &old));
}
prune(&key_dir)?;
if let Some(note) = line {
let note = match note {
StepNote::Compare => name_steps(&spec_path, before.as_deref(), text),
other => other,
};
let _ = append_log_line(&spec_path, &entry, note);
}
Ok(Some(entry))
}
fn key_dir(&self, dir: &Path) -> Result<(PathBuf, PathBuf)> {
let canonical = dir.canonicalize().map_err(|e| Error::FileRead {
path: dir.to_path_buf(),
source: e,
})?;
Ok(self.adopted(self.key_for(canonical.join(MANIFEST_FILENAME))))
}
fn file_key(&self, file: &Path) -> Result<(PathBuf, PathBuf)> {
let refuse = |why: &str| Error::FileRead {
path: file.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, why),
};
let name = file
.file_name()
.ok_or_else(|| refuse("the path names no file"))?;
if file.is_dir() {
return Err(refuse(
"the path is a directory; local history keeps a file's versions",
));
}
let parent = match file.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let canonical = parent.canonicalize().map_err(|e| Error::FileRead {
path: parent.to_path_buf(),
source: e,
})?;
Ok(self.adopted(self.key_for(canonical.join(name))))
}
fn adopted(&self, key: (PathBuf, PathBuf)) -> (PathBuf, PathBuf) {
let _ = self.adopt(&key.0, &key.1);
key
}
fn adopt(&self, key_dir: &Path, file: &Path) -> Result<()> {
let Some((folder, name)) = log_place(file) else {
return Ok(());
};
let newest = entries_in(key_dir)?
.last()
.and_then(|entry| parse_id(&entry.id));
let wanted: Vec<String> = logged_versions(&folder.join(LOG_FILENAME), &name)
.into_iter()
.filter(|(stamp, _)| newest.is_none_or(|(millis, seq, _)| *stamp > (millis, seq)))
.map(|(_, id)| id)
.collect();
if wanted.is_empty() {
return Ok(());
}
let holders = self.holders(&name, &wanted);
let found: Vec<(&PathBuf, &HistoryEntry)> = wanted
.iter()
.filter_map(|id| {
holders.iter().find_map(|(holder, entries)| {
entries
.iter()
.find(|entry| entry.id == *id)
.map(|entry| (holder, entry))
})
})
.collect();
if found.is_empty() {
return Ok(());
}
std::fs::create_dir_all(key_dir)?;
let marker = format!("{}\n", file.display());
write_atomic(&key_dir.join(SPEC_PATH_FILE), marker.as_bytes())?;
for (holder, entry) in found {
let text = read_at(holder, entry)?;
write_atomic(&entry_path(key_dir, entry), text.as_bytes())?;
}
prune(key_dir)
}
fn holders(&self, name: &str, wanted: &[String]) -> Vec<(PathBuf, Vec<HistoryEntry>)> {
let Ok(listing) = std::fs::read_dir(&self.root) else {
return Vec::new();
};
let mut keys: Vec<PathBuf> = listing.flatten().map(|entry| entry.path()).collect();
keys.sort();
let mut holders = Vec::new();
for key in keys {
let Ok(path) = std::fs::read_to_string(key.join(SPEC_PATH_FILE)) else {
continue;
};
let path = path.strip_suffix('\n').unwrap_or(&path);
if !Path::new(path).ends_with(name) {
continue;
}
if let Ok(entries) = entries_in(&key) {
holders.push((key, entries));
}
}
holders.sort_by_key(|(_, entries)| {
std::cmp::Reverse(entries.iter().filter(|e| wanted.contains(&e.id)).count())
});
holders
}
fn key_for(&self, spec_path: PathBuf) -> (PathBuf, PathBuf) {
let digest = Sha256::digest(spec_path.as_os_str().as_encoded_bytes());
let mut key = String::with_capacity(16);
for byte in &digest[..8] {
key.push_str(&format!("{byte:02x}"));
}
(self.root.join(key), spec_path)
}
}
pub fn edit_spec_with_history(
dir: &Path,
edits: &[SpecEdit],
history: &LocalHistory,
) -> Result<ValidatedSpec> {
let path = dir.join(MANIFEST_FILENAME);
if !path.exists() {
return Err(Error::ManifestNotFound);
}
let original = std::fs::read_to_string(&path).map_err(|e| Error::FileRead {
path: path.clone(),
source: e,
})?;
let validated = apply_edits(&original, edits)?;
let held = history.checkpoint_held(dir, &original)?;
validated.write_to(dir)?;
if let Some(held) = held {
held.land();
}
let _ = history.record(
dir,
validated.text(),
HistoryKind::Save,
SystemTime::now(),
false,
);
Ok(validated)
}
pub fn record_step_with_history(
dir: &Path,
step: &RecordedStep,
history: &LocalHistory,
) -> Result<(PathBuf, ValidatedSpec)> {
record_with_history(dir, history, &step.name, || {
crate::record::record_step(dir, step)
})
}
pub(crate) fn record_sql_step_with_history(
dir: &Path,
step: &crate::record::SqlStep,
history: &LocalHistory,
) -> Result<(PathBuf, ValidatedSpec)> {
record_with_history(dir, history, step.name, || {
crate::record::record_sql_step(dir, step)
})
}
fn record_with_history(
dir: &Path,
history: &LocalHistory,
name: &str,
record: impl FnOnce() -> Result<(PathBuf, ValidatedSpec)>,
) -> Result<(PathBuf, ValidatedSpec)> {
let path = dir.join(MANIFEST_FILENAME);
if !path.exists() {
return Err(Error::ManifestNotFound);
}
let original = std::fs::read_to_string(&path).map_err(|e| Error::FileRead {
path: path.clone(),
source: e,
})?;
let held = history.checkpoint_held(dir, &original)?;
let (sql_rel, validated) = record()?;
if let Some(held) = held {
held.land();
}
let _ = history.record_noted(
dir,
validated.text(),
HistoryKind::Save,
SystemTime::now(),
false,
Some(StepNote::Added(name)),
);
Ok((sql_rel, validated))
}
pub(crate) fn policy_line(root: &Path) -> String {
format!(
"policy: keeps the last {HISTORY_MAX_ENTRIES} snapshots per file (oldest pruned first); \
saves within {}s of the newest save merge into it; the snapshots are stored outside \
the protocol at {}, keyed to the protocol's path and found again through its log when \
the folder is renamed, moved or copied, and never promoted to git",
HISTORY_MERGE_WINDOW.as_secs(),
root.display()
)
}
pub(crate) fn log_place_line(file: &Path) -> String {
let dir = match file.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent,
_ => Path::new("."),
};
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let folder = file
.file_name()
.and_then(|name| protocol_folder(&dir.join(name)).map(Path::to_path_buf));
match folder {
Some(folder) => {
let log = folder.join(LOG_FILENAME);
format!(
"log: one line per version, naming its file and no contents, at {}{} — inside \
the protocol, so it goes with the folder and `git add` stages it",
log.display(),
if log.exists() {
""
} else {
" (no line written yet)"
}
)
}
None => format!(
"log: none — no folder at or above {} holds {MANIFEST_FILENAME}, so no line names \
its versions",
dir.display()
),
}
}
fn protocol_folder(file: &Path) -> Option<&Path> {
let dir = file.parent()?;
if file.file_name() == Some(std::ffi::OsStr::new(MANIFEST_FILENAME)) {
return Some(dir);
}
dir.ancestors()
.find(|dir| dir.join(MANIFEST_FILENAME).is_file())
}
fn name_steps(spec_path: &Path, before: Option<&str>, after: &str) -> StepNote<'static> {
let is_spec = spec_path.file_name() == Some(std::ffi::OsStr::new(MANIFEST_FILENAME));
let (true, Some(before)) = (is_spec, before) else {
return StepNote::Unnamed;
};
let (Some(before), Some(after)) = (steps_of(before), steps_of(after)) else {
return StepNote::Unnamed;
};
let mut found = Vec::new();
for (name, step) in &after {
let change = match before.iter().find(|(earlier, _)| earlier == name) {
None => Change::Added,
Some((_, earlier)) if earlier != step => Change::Changed,
Some(_) => continue,
};
found.push(StepChange {
step: name.clone(),
change,
});
}
for (name, _) in &before {
if !after.iter().any(|(kept, _)| kept == name) {
found.push(StepChange {
step: name.clone(),
change: Change::Removed,
});
}
}
if found.is_empty() {
StepNote::Unnamed
} else {
StepNote::Steps(found)
}
}
fn steps_of(text: &str) -> Option<Vec<(String, serde_yaml::Value)>> {
let spec = match serde_yaml::from_str::<serde_yaml::Value>(text) {
Ok(serde_yaml::Value::Mapping(spec)) => spec,
_ => return None,
};
let steps = match spec.get("steps") {
None | Some(serde_yaml::Value::Null) => return Some(Vec::new()),
Some(serde_yaml::Value::Sequence(steps)) => steps,
Some(_) => return None,
};
let mut named: Vec<(String, serde_yaml::Value)> = Vec::new();
for step in steps {
let name = step.get("name")?.as_str()?.to_string();
if named.iter().any(|(earlier, _)| *earlier == name) {
return None;
}
named.push((name, step.clone()));
}
Some(named)
}
fn append_log_line(file: &Path, entry: &HistoryEntry, note: StepNote) -> std::io::Result<()> {
use std::io::{Read, Seek, SeekFrom, Write};
let Some((folder, name)) = log_place(file) else {
return Ok(());
};
let mut log = std::fs::OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(folder.join(LOG_FILENAME))?;
let mut bytes = Vec::new();
if log.metadata()?.len() > 0 {
let mut last = [0u8; 1];
log.seek(SeekFrom::End(-1))?;
log.read_exact(&mut last)?;
if last[0] != b'\n' {
bytes.push(b'\n');
}
}
bytes.extend_from_slice(log_line(&name, entry, note).as_bytes());
log.write_all(&bytes)
}
fn log_place(file: &Path) -> Option<(&Path, String)> {
let folder = protocol_folder(file)?;
let inside = file.strip_prefix(folder).unwrap_or(file);
let name = inside
.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
Some((folder, name))
}
fn logged_versions(log: &Path, name: &str) -> Vec<((u64, u32), String)> {
#[derive(serde::Deserialize)]
struct Named {
file: String,
version: String,
}
let Ok(text) = std::fs::read_to_string(log) else {
return Vec::new();
};
let mut found: Vec<((u64, u32), String)> = text
.lines()
.filter_map(|line| serde_json::from_str::<Named>(line).ok())
.filter(|line| line.file == name)
.filter_map(|line| {
let (millis, seq, _) = parse_id(&line.version)?;
Some(((millis, seq), line.version))
})
.collect();
found.sort();
found.dedup();
found
}
fn log_line(name: &str, entry: &HistoryEntry, note: StepNote) -> String {
#[derive(serde::Serialize)]
struct Line<'a> {
at: String,
file: &'a str,
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
interface: Option<&'a str>,
version: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
step: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
change: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
steps: Option<Vec<Named<'a>>>,
}
#[derive(serde::Serialize)]
struct Named<'a> {
step: &'a str,
change: &'static str,
}
let (mut step, mut change, mut steps) = (None, None, None);
match ¬e {
StepNote::Unnamed | StepNote::Compare => {}
StepNote::Added(added) => (step, change) = (Some(*added), Some(Change::Added.word())),
StepNote::Steps(found) => match found.as_slice() {
[one] => (step, change) = (Some(one.step.as_str()), Some(one.change.word())),
several => {
steps = Some(
several
.iter()
.map(|one| Named {
step: &one.step,
change: one.change.word(),
})
.collect(),
)
}
},
}
let line = Line {
at: humantime::format_rfc3339_millis(entry.at).to_string(),
file: name,
kind: entry.kind.tag(),
interface: entry.way.as_ref().map(HistoryWay::as_str),
version: &entry.id,
step,
change,
steps,
};
let mut text = serde_json::to_string(&line).expect("a line of strings serialises");
text.push('\n');
text
}
fn resolve_root(env: Option<std::ffi::OsString>, home: Option<PathBuf>) -> Result<PathBuf> {
if let Some(dir) = env
&& !dir.is_empty()
{
return Ok(PathBuf::from(dir));
}
home.map(|home| home.join(".arcform").join("history"))
.ok_or(Error::HistoryRootMissing)
}
fn entry_id(millis: u64, seq: u32, kind: HistoryKind) -> String {
format!("{millis:013}-{seq:03}-{}", kind.tag())
}
type IdParts = (u64, u32, HistoryKind);
fn parse_id(id: &str) -> Option<IdParts> {
let mut parts = id.splitn(3, '-');
let millis = parts.next()?.parse().ok()?;
let seq = parts.next()?.parse().ok()?;
let kind = HistoryKind::from_tag(parts.next()?)?;
Some((millis, seq, kind))
}
fn parse_stem(stem: &str) -> Option<(&str, IdParts, Option<HistoryWay>)> {
let (id, way) = match stem.split_once('.') {
None => (stem, None),
Some((id, word)) => (id, Some(HistoryWay::stored(word)?)),
};
Some((id, parse_id(id)?, way))
}
fn entry_path(key_dir: &Path, entry: &HistoryEntry) -> PathBuf {
match &entry.way {
None => key_dir.join(format!("{}.yaml", entry.id)),
Some(way) => key_dir.join(format!("{}.{}.yaml", entry.id, way.as_str())),
}
}
fn next_stamp(newest: Option<&HistoryEntry>, now_millis: u64) -> (u64, u32) {
match newest.and_then(|n| parse_id(&n.id)) {
Some((millis, seq, _)) if now_millis <= millis => (millis, seq + 1),
_ => (now_millis, 0),
}
}
fn entries_in(key_dir: &Path) -> Result<Vec<HistoryEntry>> {
let read = match std::fs::read_dir(key_dir) {
Ok(read) => read,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut found = Vec::new();
for entry in read {
let entry = entry?;
let name = entry.file_name();
let Some(stem) = name.to_str().and_then(|n| n.strip_suffix(".yaml")) else {
continue;
};
let Some((id, (millis, seq, kind), way)) = parse_stem(stem) else {
continue;
};
found.push((
millis,
seq,
HistoryEntry {
id: id.to_string(),
kind,
way,
at: UNIX_EPOCH + Duration::from_millis(millis),
bytes: entry.metadata()?.len(),
},
));
}
found.sort_by_key(|(millis, seq, _)| (*millis, *seq));
Ok(found.into_iter().map(|(_, _, entry)| entry).collect())
}
fn read_entry(key_dir: &Path, id: &str) -> Result<String> {
let entry = entries_in(key_dir)?
.into_iter()
.find(|entry| entry.id == id)
.ok_or_else(|| Error::HistoryEntryNotFound { id: id.to_string() })?;
read_at(key_dir, &entry)
}
fn read_at(key_dir: &Path, entry: &HistoryEntry) -> Result<String> {
let path = entry_path(key_dir, entry);
std::fs::read_to_string(&path).map_err(|e| Error::FileRead { path, source: e })
}
fn prune(key_dir: &Path) -> Result<()> {
let entries = entries_in(key_dir)?;
if entries.len() > HISTORY_MAX_ENTRIES {
for entry in &entries[..entries.len() - HISTORY_MAX_ENTRIES] {
let _ = std::fs::remove_file(entry_path(key_dir, entry));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, LocalHistory) {
let tmp = tempfile::tempdir().unwrap();
let history = LocalHistory::at_root(tmp.path().join("history"));
(tmp, history)
}
fn protocol(tmp: &tempfile::TempDir) -> PathBuf {
let dir = tmp.path().join("protocol");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(MANIFEST_FILENAME), "name: fixture\n").unwrap();
dir
}
fn at(secs: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_secs(secs)
}
const T0: u64 = 1_700_000_000;
#[test]
fn saves_within_the_window_merge_and_the_newer_state_wins() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Save, at(T0), true)
.unwrap();
history
.record(&dir, "b\n", HistoryKind::Save, at(T0 + 5), true)
.unwrap();
let entries = history.entries(&dir).unwrap();
assert_eq!(entries.len(), 1, "rapid saves debounce to one entry");
assert_eq!(history.read(&dir, &entries[0].id).unwrap(), "b\n");
assert_eq!(entries[0].at, at(T0 + 5), "the merged entry is the newer");
}
#[test]
fn saves_outside_the_window_stay_separate() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Save, at(T0), true)
.unwrap();
history
.record(&dir, "b\n", HistoryKind::Save, at(T0 + 11), true)
.unwrap();
assert_eq!(history.entries(&dir).unwrap().len(), 2);
}
#[test]
fn checkpoints_never_merge_and_block_a_save_merge_across_them() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Checkpoint, at(T0), false)
.unwrap();
history
.record(&dir, "b\n", HistoryKind::Checkpoint, at(T0 + 1), false)
.unwrap();
assert_eq!(
history.entries(&dir).unwrap().len(),
2,
"checkpoints are each kept"
);
history
.record(&dir, "c\n", HistoryKind::Save, at(T0 + 2), true)
.unwrap();
assert_eq!(history.entries(&dir).unwrap().len(), 3);
}
#[test]
fn a_state_identical_to_the_newest_entry_is_not_recorded_again() {
let (tmp, history) = store();
let dir = protocol(&tmp);
let first = history
.record(&dir, "a\n", HistoryKind::Save, at(T0), true)
.unwrap();
assert!(first.is_some());
let repeat = history
.record(&dir, "a\n", HistoryKind::Checkpoint, at(T0 + 60), false)
.unwrap();
assert!(repeat.is_none(), "kind does not matter to the dedupe");
assert_eq!(history.entries(&dir).unwrap().len(), 1);
}
#[test]
fn the_bound_prunes_the_oldest_entries() {
let (tmp, history) = store();
let dir = protocol(&tmp);
for n in 0..HISTORY_MAX_ENTRIES + 5 {
history
.record(
&dir,
&format!("state {n}\n"),
HistoryKind::Save,
at(T0 + (n as u64) * 60),
true,
)
.unwrap();
}
let entries = history.entries(&dir).unwrap();
assert_eq!(entries.len(), HISTORY_MAX_ENTRIES);
assert_eq!(
history.read(&dir, &entries[0].id).unwrap(),
"state 5\n",
"the oldest five were pruned"
);
assert_eq!(
history.read(&dir, &entries.last().unwrap().id).unwrap(),
format!("state {}\n", HISTORY_MAX_ENTRIES + 4)
);
}
#[test]
fn a_stalled_or_backwards_clock_never_reorders_entries() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Checkpoint, at(T0), false)
.unwrap();
history
.record(&dir, "b\n", HistoryKind::Checkpoint, at(T0), false)
.unwrap();
history
.record(&dir, "c\n", HistoryKind::Checkpoint, at(T0 - 5), false)
.unwrap();
let entries = history.entries(&dir).unwrap();
let texts: Vec<String> = entries
.iter()
.map(|e| history.read(&dir, &e.id).unwrap())
.collect();
assert_eq!(texts, ["a\n", "b\n", "c\n"]);
let mut ids: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
let sorted = ids.clone();
ids.sort_unstable();
assert_eq!(ids, sorted, "id order and recorded order agree");
}
#[test]
fn a_machine_save_never_folds_away_the_state_it_replaced() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Save, at(T0), true)
.unwrap();
let deduped = history
.record(&dir, "a\n", HistoryKind::Checkpoint, at(T0 + 2), false)
.unwrap();
assert!(
deduped.is_none(),
"the checkpoint deduplicates against the save"
);
history
.record(&dir, "b\n", HistoryKind::Save, at(T0 + 2), false)
.unwrap();
let entries = history.entries(&dir).unwrap();
let texts: Vec<String> = entries
.iter()
.map(|e| history.read(&dir, &e.id).unwrap())
.collect();
assert_eq!(texts, ["a\n", "b\n"], "the replaced state must survive");
}
#[test]
fn the_root_resolves_env_first_then_home_then_refuses() {
assert_eq!(
resolve_root(Some("/elsewhere".into()), Some(PathBuf::from("/home/x"))).unwrap(),
PathBuf::from("/elsewhere")
);
assert_eq!(
resolve_root(None, Some(PathBuf::from("/home/x"))).unwrap(),
PathBuf::from("/home/x/.arcform/history")
);
assert_eq!(
resolve_root(Some("".into()), Some(PathBuf::from("/home/x"))).unwrap(),
PathBuf::from("/home/x/.arcform/history"),
"an empty env var is not a root"
);
assert!(matches!(
resolve_root(None, None),
Err(Error::HistoryRootMissing)
));
}
#[test]
fn entry_ids_round_trip_and_refuse_path_shapes() {
let id = entry_id(1_700_000_000_123, 7, HistoryKind::Checkpoint);
assert_eq!(id, "1700000000123-007-checkpoint");
assert_eq!(
parse_id(&id),
Some((1_700_000_000_123, 7, HistoryKind::Checkpoint))
);
for bad in ["", "x", "123-000", "123-000-commit", "../123-000-save"] {
assert_eq!(parse_id(bad), None, "{bad:?} must not parse");
}
}
#[test]
fn an_entry_file_names_its_way_after_its_id_and_an_earlier_arcs_names_none() {
let entry = |way| HistoryEntry {
id: "1700000000123-007-save".to_string(),
kind: HistoryKind::Save,
way,
at: UNIX_EPOCH,
bytes: 0,
};
let key_dir = Path::new("/store/key");
assert_eq!(
entry_path(key_dir, &entry(None)),
key_dir.join("1700000000123-007-save.yaml")
);
assert_eq!(
entry_path(key_dir, &entry(Some(HistoryWay::MCP))),
key_dir.join("1700000000123-007-save.mcp.yaml")
);
let parts = (1_700_000_000_123, 7, HistoryKind::Save);
assert_eq!(
parse_stem("1700000000123-007-save"),
Some(("1700000000123-007-save", parts, None))
);
assert_eq!(
parse_stem("1700000000123-007-save.my-App_2"),
Some((
"1700000000123-007-save",
parts,
Some(HistoryWay::new("my-App_2").unwrap())
))
);
assert_eq!(
parse_stem("1700000000123-007-save.terminal"),
Some(("1700000000123-007-save", parts, Some(HistoryWay::TERMINAL))),
"arc's own words are read back as arc's own"
);
for bad in [
"1700000000123-007-save.",
"1700000000123-007-save.a b",
"1700000000123-007-save.a.b",
"1700000000123-007-commit.app",
"x.app",
] {
assert_eq!(parse_stem(bad), None, "{bad:?} is not an entry");
}
}
#[test]
fn a_store_that_cannot_be_listed_is_not_reported_as_a_missing_entry() {
let (tmp, history) = store();
let dir = protocol(&tmp);
let (key_dir, _) = history.key_dir(&dir).unwrap();
std::fs::create_dir_all(key_dir.parent().unwrap()).unwrap();
std::fs::write(&key_dir, "not a directory\n").unwrap();
for err in [
history.read(&dir, "1700000000000-000-save").unwrap_err(),
history.restore(&dir, "1700000000000-000-save").unwrap_err(),
] {
assert!(matches!(err, Error::Io(_)), "{err}");
}
}
#[test]
fn a_merged_save_keeps_the_way_of_the_state_that_replaced_it() {
let (tmp, history) = store();
let dir = protocol(&tmp);
let app = history.clone().reached_by(HistoryWay::new("app").unwrap());
let terminal = history.reached_by(HistoryWay::TERMINAL);
app.record(&dir, "a\n", HistoryKind::Save, at(T0), true)
.unwrap();
terminal
.record(&dir, "b\n", HistoryKind::Save, at(T0 + 5), true)
.unwrap();
let entries = terminal.entries(&dir).unwrap();
assert_eq!(entries.len(), 1, "the saves merged: {entries:?}");
assert_eq!(entries[0].way, Some(HistoryWay::TERMINAL));
assert_eq!(terminal.read(&dir, &entries[0].id).unwrap(), "b\n");
let again = app
.record(&dir, "b\n", HistoryKind::Checkpoint, at(T0 + 60), false)
.unwrap();
assert_eq!(again, None);
}
#[test]
fn the_policy_line_states_the_bound_the_window_and_the_git_stance() {
let line = policy_line(Path::new("/somewhere"));
assert!(line.contains(&format!("{HISTORY_MAX_ENTRIES} snapshots per file")));
assert!(line.contains(&format!("{}s", HISTORY_MERGE_WINDOW.as_secs())));
assert!(line.ends_with(
"the snapshots are stored outside the protocol at /somewhere, keyed to the \
protocol's path and found again through its log when the folder is renamed, \
moved or copied, and never promoted to git"
));
}
fn renamed(dir: &Path, name: &str) -> PathBuf {
let to = dir.with_file_name(name);
std::fs::rename(dir, &to).unwrap();
to
}
fn stranger(history: &LocalHistory, id: &str, text: &str) {
let key = history.root().join("0000000000000000");
std::fs::create_dir_all(&key).unwrap();
std::fs::write(key.join(SPEC_PATH_FILE), "/elsewhere/arcform.yaml\n").unwrap();
std::fs::write(key.join(format!("{id}.yaml")), text).unwrap();
}
fn texts(history: &LocalHistory, dir: &Path) -> Vec<String> {
let entries = history.entries(dir).unwrap();
entries
.iter()
.map(|entry| history.read(dir, &entry.id).unwrap())
.collect()
}
fn checkpoints(history: &LocalHistory, dir: &Path, texts: &[&str]) {
for (secs, text) in (0..).zip(texts) {
history
.record(dir, text, HistoryKind::Checkpoint, at(T0 + secs), false)
.unwrap();
}
}
#[test]
fn a_version_is_read_from_the_key_holding_most_of_the_log_not_another_with_its_id() {
let (tmp, history) = store();
let dir = protocol(&tmp);
checkpoints(&history, &dir, &["a\n", "b\n", "c\n"]);
let newest = history.entries(&dir).unwrap().pop().unwrap();
stranger(&history, &newest.id, "stranger\n");
let dir = renamed(&dir, "renamed");
assert_eq!(texts(&history, &dir), ["a\n", "b\n", "c\n"]);
}
#[test]
fn the_newest_version_a_key_holds_is_not_taken_again() {
let (tmp, history) = store();
let dir = protocol(&tmp);
checkpoints(&history, &dir, &["a\n"]);
let dir = renamed(&dir, "renamed");
let held = history.entries(&dir).unwrap();
assert_eq!(held.len(), 1, "the rename keeps the one version");
stranger(&history, &held[0].id, "stranger\n");
assert_eq!(texts(&history, &dir), ["a\n"]);
}
#[test]
fn a_version_of_another_file_with_the_same_id_is_not_taken() {
let (tmp, history) = store();
let dir = protocol(&tmp);
std::fs::create_dir_all(dir.join("panels")).unwrap();
let chart = Path::new("panels").join("a.yaml");
history
.record(&dir, "spec\n", HistoryKind::Save, at(T0), false)
.unwrap();
let key = history.file_key(&dir.join(&chart)).unwrap();
history
.record_keyed(
key,
"chart\n",
HistoryKind::Save,
at(T0),
false,
Some(StepNote::Unnamed),
)
.unwrap();
std::fs::remove_dir_all(history.key_dir(&dir).unwrap().0).unwrap();
let dir = renamed(&dir, "renamed");
assert_eq!(history.entries(&dir).unwrap(), []);
let chart = dir.join(&chart);
let entries = history.entries_for_file(&chart).unwrap();
assert_eq!(entries.len(), 1, "the chart's own version follows it");
assert_eq!(
history.read_for_file(&chart, &entries[0].id).unwrap(),
"chart\n"
);
}
#[test]
fn a_snapshot_that_cannot_be_read_stops_the_copy_and_a_later_call_resumes_it() {
let (tmp, history) = store();
let dir = protocol(&tmp);
checkpoints(&history, &dir, &["a\n", "b\n", "c\n"]);
let (key, _) = history.key_dir(&dir).unwrap();
let second = entry_path(&key, &history.entries(&dir).unwrap()[1]);
let bytes = std::fs::read(&second).unwrap();
std::fs::remove_file(&second).unwrap();
std::fs::create_dir(&second).unwrap();
let dir = renamed(&dir, "renamed");
assert_eq!(texts(&history, &dir), ["a\n"]);
std::fs::remove_dir(&second).unwrap();
std::fs::write(&second, bytes).unwrap();
assert_eq!(texts(&history, &dir), ["a\n", "b\n", "c\n"]);
}
#[test]
fn a_save_the_debounce_folded_away_is_not_taken_again_after_a_move() {
let (tmp, history) = store();
let dir = protocol(&tmp);
history
.record(&dir, "a\n", HistoryKind::Checkpoint, at(T0), false)
.unwrap();
history
.record(&dir, "b\n", HistoryKind::Save, at(T0 + 1), true)
.unwrap();
let dir = renamed(&dir, "renamed");
history
.record(&dir, "c\n", HistoryKind::Save, at(T0 + 3), true)
.unwrap();
assert_eq!(texts(&history, &dir), ["a\n", "c\n"]);
}
#[test]
fn versions_taken_from_two_keys_are_held_to_the_bound() {
let (tmp, history) = store();
let dir = protocol(&tmp);
let all: Vec<String> = (0..HISTORY_MAX_ENTRIES + 10)
.map(|n| format!("n: {n}\n"))
.collect();
let all: Vec<&str> = all.iter().map(String::as_str).collect();
let (first, rest) = all.split_at(HISTORY_MAX_ENTRIES);
checkpoints(&history, &dir, first);
let dir = renamed(&dir, "moved");
for (secs, text) in (HISTORY_MAX_ENTRIES as u64..).zip(rest) {
history
.record(&dir, text, HistoryKind::Checkpoint, at(T0 + secs), false)
.unwrap();
}
let kept = texts(&history, &dir);
assert_eq!(kept, all[10..]);
let copy = tmp.path().join("copy");
std::fs::create_dir_all(©).unwrap();
for name in [MANIFEST_FILENAME, LOG_FILENAME] {
std::fs::copy(dir.join(name), copy.join(name)).unwrap();
}
assert_eq!(texts(&history, ©), kept);
}
}