use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::io::lock::FileLock;
use crate::model::task_id::Token;
const SHARED_STORE: &str = "frame-ids.toml";
const SHARED_LOCK: &str = "frame-ids.lock";
pub const LOCAL_STORE: &str = ".ids.toml";
pub const LOCAL_LOCK: &str = ".ids.lock";
const FORMAT_VERSION: u32 = 1;
const LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const NULL_NAMESPACE: &str = "null";
const HEADER: &str = "\
# frame ID frontier — the highest task number handed out per project, prefix and
# actor namespace. Machine-local, never committed; shared by every git worktree
# of this clone so two worktrees can't mint the same task ID.
#
# Generated by frame. Safe to delete: minting falls back to scanning tracks and
# archives, which is correct but not collision-proof across worktrees.
";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreLocation {
pub data: PathBuf,
pub lock: PathBuf,
pub project: String,
}
pub fn locate(frame_dir: &Path) -> StoreLocation {
let Some(paths) = crate::io::git::repo_paths(frame_dir) else {
return StoreLocation {
data: frame_dir.join(LOCAL_STORE),
lock: frame_dir.join(LOCAL_LOCK),
project: ".".to_string(),
};
};
let project = frame_dir
.canonicalize()
.ok()
.as_deref()
.unwrap_or(frame_dir)
.strip_prefix(&paths.toplevel)
.map(|rel| rel.to_string_lossy().into_owned())
.unwrap_or_else(|_| ".".to_string());
StoreLocation {
data: paths.common_dir.join(SHARED_STORE),
lock: paths.common_dir.join(SHARED_LOCK),
project,
}
}
fn namespace_key(token: Option<&Token>) -> &str {
token.map_or(NULL_NAMESPACE, |t| t.as_str())
}
type Projects = BTreeMap<String, BTreeMap<String, BTreeMap<String, u32>>>;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Frontier {
#[serde(default)]
version: u32,
#[serde(default)]
projects: Projects,
}
impl Frontier {
fn get(&self, project: &str, prefix: &str, namespace: &str) -> u32 {
self.projects
.get(project)
.and_then(|p| p.get(prefix))
.and_then(|p| p.get(namespace))
.copied()
.unwrap_or(0)
}
fn raise(&mut self, project: &str, prefix: &str, namespace: &str, high: u32) {
let slot = self
.projects
.entry(project.to_string())
.or_default()
.entry(prefix.to_string())
.or_default()
.entry(namespace.to_string())
.or_insert(0);
*slot = (*slot).max(high);
}
}
fn backup_path(data: &Path) -> PathBuf {
data.with_extension("toml.bak")
}
fn parse(path: &Path) -> Result<Option<Frontier>, String> {
let Ok(text) = fs::read_to_string(path) else {
return Ok(None);
};
toml::from_str::<Frontier>(&text)
.map(Some)
.map_err(|e| e.to_string())
}
fn read_or_reset(path: &Path) -> Frontier {
match parse(path) {
Ok(Some(frontier)) => frontier,
Ok(None) => Frontier::default(),
Err(_) => {
let _ = fs::rename(path, backup_path(path));
Frontier::default()
}
}
}
fn write(path: &Path, frontier: &Frontier) {
let Ok(body) = toml::to_string_pretty(frontier) else {
return;
};
let content = format!("{}\n{}", HEADER, body);
let _ = crate::io::recovery::atomic_write(path, content.as_bytes());
}
pub fn recorded(frame_dir: &Path, prefix: &str, token: Option<&Token>) -> u32 {
let at = locate(frame_dir);
parse(&at.data)
.ok()
.flatten()
.map(|f| f.get(&at.project, prefix, namespace_key(token)))
.unwrap_or(0)
}
pub fn recorded_by_prefix(frame_dir: &Path, token: Option<&Token>) -> BTreeMap<String, u32> {
let at = locate(frame_dir);
let namespace = namespace_key(token);
let Ok(Some(frontier)) = parse(&at.data) else {
return BTreeMap::new();
};
frontier
.projects
.get(&at.project)
.map(|prefixes| {
prefixes
.iter()
.filter_map(|(prefix, namespaces)| {
namespaces.get(namespace).map(|n| (prefix.clone(), *n))
})
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoreState {
Absent,
Ok,
Unparsable(String),
}
#[derive(Debug, Clone)]
pub struct StoreHealth {
pub path: PathBuf,
pub state: StoreState,
pub reset_backup: Option<PathBuf>,
}
pub fn health(frame_dir: &Path) -> StoreHealth {
let at = locate(frame_dir);
let state = match parse(&at.data) {
Ok(Some(_)) => StoreState::Ok,
Ok(None) => StoreState::Absent,
Err(detail) => StoreState::Unparsable(detail),
};
let backup = backup_path(&at.data);
StoreHealth {
path: at.data,
state,
reset_backup: backup.is_file().then_some(backup),
}
}
pub fn reserve(frame_dir: &Path, prefix: &str, token: Option<&Token>, floor: u32, n: u32) -> u32 {
let n = n.max(1);
let at = locate(frame_dir);
let namespace = namespace_key(token);
let Ok(_guard) = FileLock::acquire_at(&at.lock, LOCK_TIMEOUT) else {
return floor.max(recorded(frame_dir, prefix, token)) + 1;
};
let mut frontier = read_or_reset(&at.data);
let start = floor.max(frontier.get(&at.project, prefix, namespace)) + 1;
frontier.version = FORMAT_VERSION;
frontier.raise(&at.project, prefix, namespace, start + n - 1);
write(&at.data, &frontier);
start
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn frame_dir(tmp: &TempDir) -> PathBuf {
let dir = tmp.path().join("frame");
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reserve_advances_past_the_floor_and_records_it() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
assert_eq!(reserve(&frame, "T", None, 7, 1), 8);
assert_eq!(recorded(&frame, "T", None), 8);
assert_eq!(reserve(&frame, "T", None, 7, 1), 9);
assert_eq!(recorded(&frame, "T", None), 9);
}
#[test]
fn reserve_n_hands_out_a_block() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
assert_eq!(reserve(&frame, "T", None, 0, 5), 1);
assert_eq!(recorded(&frame, "T", None), 5);
assert_eq!(reserve(&frame, "T", None, 0, 1), 6);
}
#[test]
fn a_higher_floor_still_wins() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
reserve(&frame, "T", None, 0, 1); assert_eq!(reserve(&frame, "T", None, 40, 1), 41);
assert_eq!(recorded(&frame, "T", None), 41);
}
#[test]
fn namespaces_and_prefixes_are_independent() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
let token = Token::new("b").unwrap();
assert_eq!(reserve(&frame, "T", None, 0, 3), 1);
assert_eq!(reserve(&frame, "T", Some(&token), 0, 1), 1);
assert_eq!(reserve(&frame, "OTH", None, 0, 1), 1);
assert_eq!(recorded(&frame, "T", None), 3);
assert_eq!(recorded(&frame, "T", Some(&token)), 1);
assert_eq!(recorded(&frame, "OTH", None), 1);
}
#[test]
fn a_missing_store_reads_as_empty() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
assert_eq!(recorded(&frame, "T", None), 0);
}
#[test]
fn a_corrupt_store_is_moved_aside_and_minting_continues() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
reserve(&frame, "T", None, 0, 9); let at = locate(&frame);
fs::write(&at.data, "this is not toml {{{").unwrap();
assert_eq!(reserve(&frame, "T", None, 3, 1), 4);
assert!(at.data.with_extension("toml.bak").exists());
assert_eq!(recorded(&frame, "T", None), 4);
}
#[test]
fn a_deleted_store_falls_back_to_the_floor() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
reserve(&frame, "T", None, 0, 9);
let at = locate(&frame);
fs::remove_file(&at.data).unwrap();
assert_eq!(reserve(&frame, "T", None, 2, 1), 3);
}
#[test]
fn every_worktree_of_a_clone_shares_one_store() {
let tmp = TempDir::new().unwrap();
let Some((main, worktree)) = crate::io::git::testutil::repo_with_worktree(tmp.path())
else {
return; };
let from_main = locate(&main);
let from_worktree = locate(&worktree);
assert_eq!(from_main, from_worktree);
assert_eq!(from_main.project, "frame");
assert!(from_main.data.ends_with(SHARED_STORE));
assert_eq!(reserve(&main, "T", None, 0, 1), 1);
assert_eq!(reserve(&worktree, "T", None, 0, 1), 2);
assert_eq!(reserve(&main, "T", None, 0, 1), 3);
}
#[test]
fn a_non_git_project_keeps_its_store_local() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
if crate::io::git::repo_paths(&frame).is_some() {
return; }
let at = locate(&frame);
assert_eq!(at.data, frame.join(LOCAL_STORE));
assert_eq!(at.lock, frame.join(LOCAL_LOCK));
assert_eq!(at.project, ".");
}
#[test]
fn health_reports_absent_ok_and_unparsable_without_touching_the_store() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
assert_eq!(health(&frame).state, StoreState::Absent);
assert!(health(&frame).reset_backup.is_none());
reserve(&frame, "T", None, 0, 1);
assert_eq!(health(&frame).state, StoreState::Ok);
let at = locate(&frame);
fs::write(&at.data, "not toml {{{").unwrap();
assert!(matches!(health(&frame).state, StoreState::Unparsable(_)));
assert!(at.data.is_file());
assert!(!backup_path(&at.data).exists());
assert_eq!(recorded(&frame, "T", None), 0);
assert!(recorded_by_prefix(&frame, None).is_empty());
}
#[test]
fn health_surfaces_a_leftover_reset_backup() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
reserve(&frame, "T", None, 0, 5);
let at = locate(&frame);
fs::write(&at.data, "not toml {{{").unwrap();
reserve(&frame, "T", None, 0, 1);
let health = health(&frame);
assert_eq!(health.state, StoreState::Ok);
assert_eq!(health.reset_backup, Some(backup_path(&at.data)));
}
#[test]
fn recorded_by_prefix_lists_one_namespace() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
let token = Token::new("c").unwrap();
reserve(&frame, "DEM", None, 0, 4);
reserve(&frame, "ENG", None, 0, 2);
reserve(&frame, "DEM", Some(&token), 0, 9);
let null_ns = recorded_by_prefix(&frame, None);
assert_eq!(null_ns.get("DEM"), Some(&4));
assert_eq!(null_ns.get("ENG"), Some(&2));
assert_eq!(null_ns.len(), 2);
let c_ns = recorded_by_prefix(&frame, Some(&token));
assert_eq!(c_ns.get("DEM"), Some(&9));
assert_eq!(c_ns.len(), 1);
}
#[test]
fn the_store_is_readable_toml_with_a_header() {
let tmp = TempDir::new().unwrap();
let frame = frame_dir(&tmp);
reserve(&frame, "DEM", None, 0, 1);
let text = fs::read_to_string(locate(&frame).data).unwrap();
assert!(text.starts_with("# frame ID frontier"));
assert!(text.contains("version = 1"));
assert!(text.contains("DEM"));
assert!(text.contains(NULL_NAMESPACE));
}
}