use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::policy::POLICY_FILE;
pub const HOME_VAR: &str = "RALON_HOME";
pub const CONFIG_FILE: &str = "config.yaml";
const WORKSPACES_FILE: &str = "workspaces.json";
pub const DEFAULT_MAX_DEPTH: usize = 8;
pub const SKIPPED: &[&str] = &[
".git",
".hg",
".svn",
"node_modules",
"target",
"vendor",
"dist",
"build",
".venv",
"venv",
"__pycache__",
".next",
".nuxt",
".cache",
".gradle",
"Pods",
"DerivedData",
"Library",
"AppData",
"Application Data",
"$Recycle.Bin",
"System Volume Information",
"Windows",
"Program Files",
"Program Files (x86)",
"ProgramData",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeChange {
Added { replaced: Vec<PathBuf> },
AlreadyCovered { by: PathBuf },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub roots: Vec<PathBuf>,
#[serde(default = "default_depth")]
pub max_depth: usize,
#[serde(default = "default_hooks")]
pub hooks: bool,
}
fn default_depth() -> usize {
DEFAULT_MAX_DEPTH
}
fn default_hooks() -> bool {
true
}
impl Default for Config {
fn default() -> Config {
Config {
roots: Vec::new(),
max_depth: DEFAULT_MAX_DEPTH,
hooks: true,
}
}
}
impl Config {
pub fn covers(&self, path: &Path) -> bool {
self.roots.iter().any(|root| path.starts_with(root))
}
pub fn covering(&self, path: &Path) -> Option<&Path> {
self.roots
.iter()
.find(|root| path.starts_with(root))
.map(PathBuf::as_path)
}
pub fn add(&mut self, canonical: PathBuf) -> ScopeChange {
if let Some(existing) = self.covering(&canonical) {
return ScopeChange::AlreadyCovered {
by: existing.to_path_buf(),
};
}
let replaced: Vec<PathBuf> = self
.roots
.iter()
.filter(|root| root.starts_with(&canonical))
.cloned()
.collect();
self.roots.retain(|root| !root.starts_with(&canonical));
self.roots.push(canonical);
self.roots.sort();
ScopeChange::Added { replaced }
}
pub fn remove(&mut self, canonical: &Path) -> bool {
let before = self.roots.len();
self.roots.retain(|root| root != canonical);
self.roots.len() != before
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum State {
Enforced,
Paused { until: Option<u64> },
Failed { reason: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub root: PathBuf,
#[serde(flatten)]
pub state: State,
#[serde(default)]
pub applied: Vec<PathBuf>,
}
pub struct Registry {
home: PathBuf,
pub config: Config,
pub workspaces: Vec<Workspace>,
}
impl Registry {
pub fn load() -> Result<Registry> {
let home = home()?;
Ok(Registry {
config: read_config(&home)?,
workspaces: read_workspaces(&home),
home,
})
}
pub fn home(&self) -> &Path {
&self.home
}
pub fn config_path(&self) -> PathBuf {
self.home.join(CONFIG_FILE)
}
pub fn log_path(&self) -> PathBuf {
self.home.join("supervisor.log")
}
pub fn find(&self, root: &Path) -> Option<&Workspace> {
self.workspaces.iter().find(|entry| entry.root == root)
}
pub fn set(&mut self, root: &Path, state: State, applied: Vec<PathBuf>) {
let entry = Workspace {
root: root.to_path_buf(),
state,
applied,
};
match self
.workspaces
.iter_mut()
.find(|existing| existing.root == root)
{
Some(existing) => *existing = entry,
None => self.workspaces.push(entry),
}
}
pub fn forget(&mut self, root: &Path) {
self.workspaces.retain(|entry| entry.root != root);
}
pub fn save_config(&self) -> Result<()> {
std::fs::create_dir_all(&self.home)
.with_context(|| format!("failed to create {}", self.home.display()))?;
let text = serde_yaml_ng::to_string(&self.config)?;
write_atomically(&self.config_path(), text.as_bytes())
}
pub fn save_workspaces(&self) -> Result<()> {
std::fs::create_dir_all(&self.home)
.with_context(|| format!("failed to create {}", self.home.display()))?;
let text = serde_json::to_string_pretty(&self.workspaces)?;
write_atomically(&self.home.join(WORKSPACES_FILE), text.as_bytes())
}
}
static OVERRIDE: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn set_home(path: PathBuf) {
let _ = OVERRIDE.set(path);
}
pub fn home() -> Result<PathBuf> {
if let Some(explicit) = OVERRIDE.get() {
return Ok(explicit.clone());
}
if let Some(override_path) = std::env::var_os(HOME_VAR) {
return Ok(PathBuf::from(override_path));
}
#[cfg(windows)]
let base = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.map(|path| path.join("Ralon"));
#[cfg(target_os = "macos")]
let base = user_home().map(|home| home.join("Library/Application Support/Ralon"));
#[cfg(not(any(windows, target_os = "macos")))]
let base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| user_home().map(|home| home.join(".local/state")))
.map(|path| path.join("ralon"));
base.with_context(|| {
format!("could not work out where to keep Ralon's state — set {HOME_VAR} to a directory")
})
}
pub fn user_home() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(PathBuf::from)
}
}
fn read_config(home: &Path) -> Result<Config> {
let path = home.join(CONFIG_FILE);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(Config::default());
};
serde_yaml_ng::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))
}
fn read_workspaces(home: &Path) -> Vec<Workspace> {
std::fs::read_to_string(home.join(WORKSPACES_FILE))
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
let temporary = path.with_extension("tmp");
std::fs::write(&temporary, bytes)
.with_context(|| format!("failed to write {}", temporary.display()))?;
std::fs::rename(&temporary, path)
.with_context(|| format!("failed to replace {}", path.display()))
}
pub fn sweep(config: &Config) -> BTreeSet<PathBuf> {
let mut found = BTreeSet::new();
for root in &config.roots {
sweep_into(root, config.max_depth, &mut found);
}
found
}
fn sweep_into(directory: &Path, remaining: usize, found: &mut BTreeSet<PathBuf>) {
if directory.join(POLICY_FILE).is_file() {
found.insert(std::fs::canonicalize(directory).unwrap_or_else(|_| directory.to_path_buf()));
return;
}
if remaining == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let Ok(kind) = entry.file_type() else {
continue;
};
if !kind.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with('.') && name != ".config" || SKIPPED.contains(&name.as_ref()) {
continue;
}
sweep_into(&entry.path(), remaining - 1, found);
}
}
pub fn display(path: &Path) -> String {
let text = path.display().to_string();
match text.strip_prefix(r"\\?\") {
Some(rest) if !rest.starts_with("UNC\\") => rest.to_string(),
_ => text,
}
}
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(0)
}
pub fn timestamp(seconds: u64) -> String {
let days = (seconds / 86_400) as i64;
let time = seconds % 86_400;
let shifted = days + 719_468;
let era = shifted.div_euclid(146_097);
let day_of_era = shifted.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let march_month = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * march_month + 2) / 5 + 1;
let month = if march_month < 10 {
march_month + 3
} else {
march_month - 9
};
let year = year_of_era + era * 400 + i64::from(month <= 2);
format!(
"{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}Z",
time / 3600,
(time % 3600) / 60,
time % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_path_outside_every_root_is_not_a_workspace() {
let config = Config {
roots: vec![PathBuf::from("/home/dev/code")],
max_depth: DEFAULT_MAX_DEPTH,
hooks: true,
};
assert!(config.covers(Path::new("/home/dev/code/app")));
assert!(!config.covers(Path::new("/tmp/downloaded/app")));
}
#[test]
fn no_roots_covers_nothing() {
assert!(!Config::default().covers(Path::new("/anywhere")));
}
#[cfg(windows)]
const HOME: &str = r"C:\Users\me\Projects";
#[cfg(windows)]
const SECOND: &str = r"D:\Projects";
#[cfg(windows)]
const THIRD: &str = r"E:\Work";
#[cfg(windows)]
const UNRELATED: &str = r"F:\Elsewhere";
#[cfg(not(windows))]
const HOME: &str = "/home/me/Projects";
#[cfg(not(windows))]
const SECOND: &str = "/mnt/data/Projects";
#[cfg(not(windows))]
const THIRD: &str = "/media/work";
#[cfg(not(windows))]
const UNRELATED: &str = "/srv/elsewhere";
fn under(root: &str, child: &str) -> PathBuf {
Path::new(root).join(child)
}
fn scoped(roots: &[&str]) -> Config {
let mut config = Config::default();
for root in roots {
config.add(PathBuf::from(root));
}
config
}
#[test]
fn scopes_on_separate_roots_are_all_covered() {
let config = scoped(&[HOME, SECOND, THIRD]);
assert!(config.covers(&under(HOME, "app")));
assert!(config.covers(&under(SECOND, "app")));
assert!(config.covers(&under(THIRD, "client/app")));
assert!(!config.covers(&under(UNRELATED, "app")));
}
#[test]
fn where_ralon_is_installed_does_not_decide_what_is_covered() {
let config = scoped(&[SECOND]);
assert!(config.covers(&under(SECOND, "app")));
assert!(!config.covers(&under(HOME, "app")));
}
#[test]
fn a_scope_inside_a_scope_is_not_added_twice() {
let mut config = scoped(&[SECOND]);
assert_eq!(
config.add(under(SECOND, "client")),
ScopeChange::AlreadyCovered {
by: PathBuf::from(SECOND)
}
);
assert_eq!(config.roots, [PathBuf::from(SECOND)]);
}
#[test]
fn the_same_scope_added_twice_changes_nothing() {
let mut config = scoped(&[SECOND]);
assert!(matches!(
config.add(PathBuf::from(SECOND)),
ScopeChange::AlreadyCovered { .. }
));
assert_eq!(config.roots.len(), 1);
}
#[test]
fn a_broader_scope_absorbs_the_narrower_ones() {
let mut config = Config::default();
config.add(under(SECOND, "one"));
config.add(under(SECOND, "two"));
config.add(PathBuf::from(THIRD));
let change = config.add(PathBuf::from(SECOND));
assert_eq!(
change,
ScopeChange::Added {
replaced: vec![under(SECOND, "one"), under(SECOND, "two")]
}
);
assert_eq!(
config.roots,
[PathBuf::from(SECOND), PathBuf::from(THIRD)]
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
);
}
#[test]
fn a_sibling_with_a_shared_prefix_is_not_covered() {
let config = scoped(&[SECOND]);
assert!(!config.covers(Path::new(&format!("{SECOND}-old"))));
}
#[test]
fn removing_a_scope_takes_only_that_one() {
let mut config = scoped(&[SECOND, THIRD]);
assert!(config.remove(Path::new(SECOND)));
assert_eq!(config.roots, [PathBuf::from(THIRD)]);
assert!(!config.remove(Path::new(SECOND)));
}
#[test]
fn removing_something_that_is_not_a_scope_says_so() {
let mut config = scoped(&[SECOND]);
assert!(!config.remove(&under(SECOND, "app")));
assert_eq!(config.roots, [PathBuf::from(SECOND)]);
}
#[test]
fn timestamps_are_readable_and_correct() {
assert_eq!(timestamp(0), "1970-01-01 00:00:00Z");
assert_eq!(timestamp(951_782_400), "2000-02-29 00:00:00Z");
assert_eq!(timestamp(951_868_800), "2000-03-01 00:00:00Z");
assert_eq!(timestamp(4_107_542_400), "2100-03-01 00:00:00Z");
assert_eq!(timestamp(1_787_762_051), "2026-08-26 16:34:11Z");
}
#[test]
fn state_round_trips_through_json() {
let entry = Workspace {
root: PathBuf::from("/p"),
state: State::Paused { until: Some(42) },
applied: vec![PathBuf::from("/p/.env")],
};
let text = serde_json::to_string(&entry).unwrap();
let back: Workspace = serde_json::from_str(&text).unwrap();
assert_eq!(back.state, State::Paused { until: Some(42) });
assert_eq!(back.applied, [PathBuf::from("/p/.env")]);
}
}