use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
pub const SAFE_ALPHABET: [&str; 23] = [
"a", "b", "c", "d", "e", "f", "g", "h", "j", "k", "m", "n", "p", "q", "r", "s", "t", "u", "v",
"w", "x", "y", "z",
];
pub const FRONTIER_WINDOW: usize = 5;
pub const THIN_FRONTIER: usize = 2;
const STATE_ACTIVE: &str = "active";
const STATE_RETIRED: &str = "retired";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActorEntry {
pub name: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub claimed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retired: Option<String>,
}
impl ActorEntry {
pub fn is_active(&self) -> bool {
self.state == STATE_ACTIVE
}
pub fn is_retired(&self) -> bool {
self.state == STATE_RETIRED
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActorRegistry {
#[serde(default)]
pub actors: IndexMap<String, ActorEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClaimOutcome {
Created,
Reclaimed,
AlreadyOwned,
}
impl ActorRegistry {
pub fn never_used_frontier(&self) -> Vec<String> {
SAFE_ALPHABET
.iter()
.filter(|t| !self.actors.contains_key(**t))
.map(|t| t.to_string())
.collect()
}
pub fn frontier_window(&self) -> Vec<String> {
let mut frontier = self.never_used_frontier();
frontier.truncate(FRONTIER_WINDOW);
frontier
}
pub fn is_thin_frontier(&self) -> bool {
self.never_used_frontier().len() <= THIN_FRONTIER
}
pub fn auto_pick(&self) -> Option<String> {
let window = self.frontier_window();
if window.is_empty() {
return None;
}
let idx = random_index(window.len());
Some(window[idx].clone())
}
pub fn has_retired(&self) -> bool {
self.actors.values().any(|e| e.is_retired())
}
pub fn claim(
&mut self,
token: &str,
name: &str,
current: Option<&str>,
today: &str,
) -> Result<ClaimOutcome, String> {
match self.actors.get(token) {
None => {
let claimed = if token == "null" {
None
} else {
Some(today.to_string())
};
self.actors.insert(
token.to_string(),
ActorEntry {
name: name.to_string(),
state: STATE_ACTIVE.to_string(),
claimed,
retired: None,
},
);
Ok(ClaimOutcome::Created)
}
Some(entry) if entry.is_retired() => {
let entry = self.actors.get_mut(token).unwrap();
entry.state = STATE_ACTIVE.to_string();
entry.retired = None;
entry.name = name.to_string();
Ok(ClaimOutcome::Reclaimed)
}
Some(entry) => {
if current == Some(token) {
let entry = self.actors.get_mut(token).unwrap();
entry.name = name.to_string();
Ok(ClaimOutcome::AlreadyOwned)
} else {
Err(format!(
"token '{}' is already claimed by '{}' (active). \
Retire it first (`fr actor retire {}`) or choose a different token.",
token, entry.name, token
))
}
}
}
}
pub fn retire(&mut self, token: &str, today: &str) -> Result<(), String> {
match self.actors.get_mut(token) {
None => Err(format!("token '{}' is not in the registry", token)),
Some(entry) if entry.is_retired() => {
Err(format!("token '{}' is already retired", token))
}
Some(entry) => {
entry.state = STATE_RETIRED.to_string();
entry.retired = Some(today.to_string());
Ok(())
}
}
}
}
pub fn validate_token(token: &str) -> Result<Vec<String>, String> {
if token == "null" {
return Ok(Vec::new());
}
if token.is_empty() {
return Err("token cannot be empty".to_string());
}
if !token.chars().all(|c| c.is_ascii_alphabetic()) {
return Err(format!(
"invalid token '{}' — tokens must be letters only (a–z)",
token
));
}
if token.chars().any(|c| c.is_ascii_uppercase()) {
return Err(format!(
"invalid token '{}' — tokens must be lowercase",
token
));
}
if token.chars().count() == 1 {
if SAFE_ALPHABET.contains(&token) {
Ok(Vec::new())
} else {
Err(format!(
"'{}' is not a safe single-char token (i, l, o are excluded because they \
read as digits); use a multi-char token like '{}{}' instead",
token, token, token
))
}
} else {
let mut warnings = Vec::new();
if token.chars().any(|c| matches!(c, 'i' | 'l' | 'o')) {
warnings.push(format!(
"token '{}' contains i/l/o, which can be visually confused with digits",
token
));
}
Ok(warnings)
}
}
pub fn validate_registry_text(text: &str) -> Vec<String> {
let mut issues = Vec::new();
for dup in duplicate_token_headers(text) {
issues.push(format!("duplicate token entry: [actors.{}]", dup));
}
match toml::from_str::<ActorRegistry>(text) {
Ok(reg) => {
for (token, entry) in ®.actors {
if validate_token(token).is_err() {
issues.push(format!("invalid token key: '{}'", token));
}
if entry.state != STATE_ACTIVE && entry.state != STATE_RETIRED {
issues.push(format!(
"invalid state '{}' for token '{}'",
entry.state, token
));
}
}
}
Err(e) => issues.push(format!("parse error: {}", e)),
}
issues
}
fn duplicate_token_headers(text: &str) -> Vec<String> {
let mut counts: IndexMap<String, usize> = IndexMap::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("[actors.")
&& let Some(token) = rest.strip_suffix(']')
{
*counts.entry(token.to_string()).or_insert(0) += 1;
}
}
counts
.into_iter()
.filter(|(_, n)| *n > 1)
.map(|(token, _)| token)
.collect()
}
pub fn actors_path(frame_dir: &Path) -> PathBuf {
frame_dir.join("actors.toml")
}
pub fn actor_token_path(frame_dir: &Path) -> PathBuf {
frame_dir.join(".actor")
}
pub fn read_actors(frame_dir: &Path) -> Result<ActorRegistry, String> {
let path = actors_path(frame_dir);
if !path.exists() {
return Ok(ActorRegistry::default());
}
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
toml::from_str(&text).map_err(|e| format!("cannot parse {}: {}", path.display(), e))
}
pub fn write_actors(frame_dir: &Path, registry: &ActorRegistry) -> std::io::Result<()> {
let path = actors_path(frame_dir);
let text = toml::to_string_pretty(registry)
.map_err(|e| std::io::Error::other(format!("serialize actors.toml: {}", e)))?;
crate::io::recovery::atomic_write(&path, text.as_bytes())
}
#[derive(Debug, Clone)]
pub struct ResolvedToken {
pub token: String,
pub announcement: Option<String>,
}
pub fn resolve_actor_token(frame_dir: &Path) -> Result<ResolvedToken, String> {
if let Some(token) = read_actor_token(frame_dir) {
return Ok(ResolvedToken {
token,
announcement: None,
});
}
let mut reg = read_actors(frame_dir)?;
let token = match reg.auto_pick() {
Some(t) => t,
None => {
let hint = if reg.has_retired() {
"no unused actor tokens remain. Reclaim a retired token with `fr actor set <retired-token>` (see `fr actor list`), or claim a custom multi-char token with `fr actor set <aa|foo|…>`."
} else {
"no unused actor tokens remain. Claim a custom multi-char token with `fr actor set <aa|foo|…>`."
};
return Err(hint.to_string());
}
};
let name = default_name();
reg.claim(&token, &name, None, &today())?;
write_actors(frame_dir, ®).map_err(|e| format!("cannot write actors.toml: {}", e))?;
write_actor_token(frame_dir, &token).map_err(|e| format!("cannot write .actor: {}", e))?;
let announcement = Some(format!(
"Claimed actor token '{}' for this working copy",
token
));
Ok(ResolvedToken {
token,
announcement,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IdScope {
Mint(Option<crate::model::task_id::Token>),
Unclaimed,
}
pub fn id_scope(frame_dir: &Path) -> IdScope {
match read_actor_token(frame_dir) {
Some(t) => IdScope::Mint(crate::model::task_id::actor_namespace(&t)),
None => IdScope::Unclaimed,
}
}
pub fn read_actor_token(frame_dir: &Path) -> Option<String> {
let path = actor_token_path(frame_dir);
let text = std::fs::read_to_string(&path).ok()?;
let token = text.trim().to_string();
if token.is_empty() { None } else { Some(token) }
}
pub fn actor_label(token: Option<&str>) -> &str {
match token {
None => "unclaimed",
Some("null") => "primary",
Some(t) => t,
}
}
pub fn write_actor_token(frame_dir: &Path, token: &str) -> std::io::Result<()> {
let path = actor_token_path(frame_dir);
crate::io::recovery::atomic_write(&path, format!("{}\n", token).as_bytes())
}
pub fn default_name() -> String {
const LEN: usize = 256;
let mut buf = vec![0u8; LEN];
let res = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, LEN) };
if res != 0 {
return "unknown".to_string();
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(LEN);
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
if name.is_empty() {
"unknown".to_string()
} else {
name
}
}
pub fn today() -> String {
chrono::Local::now().format("%Y-%m-%d").to_string()
}
fn random_index(n: usize) -> usize {
use std::hash::{BuildHasher, Hasher};
use std::time::{SystemTime, UNIX_EPOCH};
let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
hasher.write_u64(nanos);
(hasher.finish() % n as u64) as usize
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn entry(name: &str, state: &str) -> ActorEntry {
ActorEntry {
name: name.to_string(),
state: state.to_string(),
claimed: Some("2026-06-20".to_string()),
retired: if state == STATE_RETIRED {
Some("2026-06-25".to_string())
} else {
None
},
}
}
fn reg_with(tokens: &[(&str, &str)]) -> ActorRegistry {
let mut reg = ActorRegistry::default();
for (tok, state) in tokens {
reg.actors.insert(tok.to_string(), entry("host", state));
}
reg
}
#[test]
fn frontier_empty_registry_starts_at_a() {
let reg = ActorRegistry::default();
assert_eq!(reg.never_used_frontier().len(), 23);
assert_eq!(reg.frontier_window(), vec!["a", "b", "c", "d", "e"]);
}
#[test]
fn frontier_excludes_active_and_retired() {
let reg = reg_with(&[("a", "active"), ("b", "retired")]);
let frontier = reg.never_used_frontier();
assert!(!frontier.contains(&"a".to_string()));
assert!(!frontier.contains(&"b".to_string())); assert_eq!(reg.frontier_window(), vec!["c", "d", "e", "f", "g"]);
}
#[test]
fn frontier_window_is_min_five() {
let reg = ActorRegistry::default();
assert_eq!(reg.frontier_window().len(), 5);
}
#[test]
fn frontier_window_fewer_than_five() {
let used: Vec<(&str, &str)> = SAFE_ALPHABET[..21].iter().map(|t| (*t, "active")).collect();
let reg = reg_with(&used);
let window = reg.frontier_window();
assert_eq!(window, vec!["y", "z"]);
assert!(reg.is_thin_frontier());
}
#[test]
fn auto_pick_returns_frontier_member() {
let reg = reg_with(&[("a", "active")]);
let frontier = reg.never_used_frontier();
for _ in 0..50 {
let pick = reg.auto_pick().unwrap();
assert!(frontier.contains(&pick), "{pick} not in frontier");
assert!(reg.frontier_window().contains(&pick));
}
}
#[test]
fn auto_pick_empty_frontier_with_retired_fails() {
let mut tokens: Vec<(&str, &str)> = SAFE_ALPHABET.iter().map(|t| (*t, "active")).collect();
tokens[0].1 = "retired";
let reg = reg_with(&tokens);
assert!(reg.never_used_frontier().is_empty());
assert!(reg.auto_pick().is_none());
assert!(reg.has_retired());
}
#[test]
fn auto_pick_empty_frontier_all_active_fails() {
let tokens: Vec<(&str, &str)> = SAFE_ALPHABET.iter().map(|t| (*t, "active")).collect();
let reg = reg_with(&tokens);
assert!(reg.auto_pick().is_none());
assert!(!reg.has_retired());
}
#[test]
fn claim_never_used_creates_active_row() {
let mut reg = ActorRegistry::default();
let outcome = reg.claim("a", "laptop", None, "2026-06-27").unwrap();
assert_eq!(outcome, ClaimOutcome::Created);
let e = reg.actors.get("a").unwrap();
assert!(e.is_active());
assert_eq!(e.name, "laptop");
assert_eq!(e.claimed.as_deref(), Some("2026-06-27"));
}
#[test]
fn claim_null_has_no_claimed_date() {
let mut reg = ActorRegistry::default();
reg.claim("null", "origin", None, "2026-06-27").unwrap();
let e = reg.actors.get("null").unwrap();
assert!(e.is_active());
assert!(e.claimed.is_none());
}
#[test]
fn claim_active_owned_by_another_refused() {
let mut reg = reg_with(&[("a", "active")]);
let err = reg.claim("a", "me", None, "2026-06-27").unwrap_err();
assert!(err.contains("already claimed"));
}
#[test]
fn claim_own_token_idempotent() {
let mut reg = reg_with(&[("a", "active")]);
let outcome = reg.claim("a", "newname", Some("a"), "2026-06-27").unwrap();
assert_eq!(outcome, ClaimOutcome::AlreadyOwned);
assert_eq!(reg.actors.get("a").unwrap().name, "newname");
}
#[test]
fn retire_tombstones_and_leaves_frontier() {
let mut reg = reg_with(&[("a", "active")]);
reg.retire("a", "2026-06-27").unwrap();
let e = reg.actors.get("a").unwrap();
assert!(e.is_retired());
assert_eq!(e.retired.as_deref(), Some("2026-06-27"));
assert!(!reg.never_used_frontier().contains(&"a".to_string()));
}
#[test]
fn retire_absent_or_already_retired_errors() {
let mut reg = reg_with(&[("a", "retired")]);
assert!(reg.retire("z", "2026-06-27").is_err());
assert!(reg.retire("a", "2026-06-27").is_err());
}
#[test]
fn reclaim_retired_flips_to_active() {
let mut reg = reg_with(&[("b", "retired")]);
let outcome = reg.claim("b", "desktop", None, "2026-06-27").unwrap();
assert_eq!(outcome, ClaimOutcome::Reclaimed);
let e = reg.actors.get("b").unwrap();
assert!(e.is_active());
assert!(e.retired.is_none());
}
#[test]
fn validate_rejects_uppercase_empty_nonletter() {
assert!(validate_token("A").is_err());
assert!(validate_token("").is_err());
assert!(validate_token("1").is_err());
assert!(validate_token("a1").is_err());
assert!(validate_token("a-b").is_err());
}
#[test]
fn validate_single_char_must_be_safe() {
assert!(validate_token("a").unwrap().is_empty());
assert!(validate_token("i").is_err());
assert!(validate_token("l").is_err());
assert!(validate_token("o").is_err());
}
#[test]
fn validate_multichar_lowercase_ok_with_ilo_warning() {
assert!(validate_token("aa").unwrap().is_empty());
assert!(validate_token("team").unwrap().is_empty());
assert!(!validate_token("foo").unwrap().is_empty());
assert!(!validate_token("oil").unwrap().is_empty());
}
#[test]
fn validate_null_ok() {
assert!(validate_token("null").unwrap().is_empty());
}
#[test]
fn validate_registry_text_reports_duplicates() {
let text = "\
[actors.null]
name = \"origin\"
state = \"active\"
[actors.a]
name = \"x\"
state = \"active\"
[actors.a]
name = \"y\"
state = \"active\"
";
let issues = validate_registry_text(text);
assert!(
issues
.iter()
.any(|i| i.contains("duplicate token entry: [actors.a]")),
"issues: {issues:?}"
);
}
#[test]
fn registry_round_trip_preserves_key_order() {
let mut reg = ActorRegistry::default();
reg.claim("null", "origin", None, "2026-06-01").unwrap();
reg.claim("c", "host-c", None, "2026-06-02").unwrap();
reg.claim("a", "host-a", None, "2026-06-03").unwrap();
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
write_actors(&frame_dir, ®).unwrap();
let loaded = read_actors(&frame_dir).unwrap();
let keys: Vec<&String> = loaded.actors.keys().collect();
assert_eq!(keys, vec!["null", "c", "a"], "key order must be stable");
assert_eq!(loaded.actors.get("c").unwrap().name, "host-c");
}
#[test]
fn read_missing_registry_is_empty() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
let reg = read_actors(&frame_dir).unwrap();
assert!(reg.actors.is_empty());
assert!(!actors_path(&frame_dir).exists());
}
#[test]
fn actor_token_file_round_trip() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
assert!(read_actor_token(&frame_dir).is_none());
write_actor_token(&frame_dir, "a").unwrap();
assert_eq!(read_actor_token(&frame_dir).as_deref(), Some("a"));
write_actor_token(&frame_dir, "null").unwrap();
assert_eq!(read_actor_token(&frame_dir).as_deref(), Some("null"));
}
#[test]
fn default_name_nonempty() {
assert!(!default_name().is_empty());
}
#[test]
fn resolve_returns_existing_actor_without_claiming() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
write_actor_token(&frame_dir, "null").unwrap();
let resolved = resolve_actor_token(&frame_dir).unwrap();
assert_eq!(resolved.token, "null");
assert!(resolved.announcement.is_none());
assert!(!actors_path(&frame_dir).exists());
}
#[test]
fn resolve_auto_claims_when_unclaimed() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
let resolved = resolve_actor_token(&frame_dir).unwrap();
assert_ne!(resolved.token, "null");
assert!(SAFE_ALPHABET.contains(&resolved.token.as_str()));
assert!(resolved.announcement.unwrap().contains(&resolved.token));
assert_eq!(
read_actor_token(&frame_dir).as_deref(),
Some(resolved.token.as_str())
);
let reg = read_actors(&frame_dir).unwrap();
assert!(reg.actors.get(&resolved.token).unwrap().is_active());
let again = resolve_actor_token(&frame_dir).unwrap();
assert_eq!(again.token, resolved.token);
assert!(again.announcement.is_none());
}
#[test]
fn resolve_errors_when_frontier_empty_and_unclaimed() {
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
let mut tokens: Vec<(&str, &str)> = SAFE_ALPHABET.iter().map(|t| (*t, "active")).collect();
tokens[0].1 = "retired";
write_actors(&frame_dir, ®_with(&tokens)).unwrap();
let err = resolve_actor_token(&frame_dir).unwrap_err();
assert!(err.contains("fr actor set"));
assert!(read_actor_token(&frame_dir).is_none());
}
#[test]
fn id_scope_distinguishes_unclaimed_from_null() {
use crate::model::task_id::Token;
let tmp = TempDir::new().unwrap();
let frame_dir = tmp.path().join("frame");
std::fs::create_dir_all(&frame_dir).unwrap();
assert_eq!(id_scope(&frame_dir), IdScope::Unclaimed);
assert!(read_actor_token(&frame_dir).is_none());
assert!(!actors_path(&frame_dir).exists());
write_actor_token(&frame_dir, "null").unwrap();
assert_eq!(id_scope(&frame_dir), IdScope::Mint(None));
write_actor_token(&frame_dir, "a").unwrap();
assert_eq!(id_scope(&frame_dir), IdScope::Mint(Token::new("a")));
}
}