yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The one home for every filesystem-path derivation in yog (DESIGN §15 Y2,
//! §5.1). Balls, lernie, brazen and yog all locate their state through XDG
//! (or, for brazen's credentials/cache, per-OS) folds; this module reproduces
//! each fold once, over an injected [`Env`] snapshot.
//!
//! The discipline that makes it testable: **no fold reads the process
//! environment.** They read only the [`Env`] handed to them, so a test drives
//! every branch with a hermetic env and Linux tarpaulin covers the macOS
//! path arms (the OS is a runtime [`Os`] parameter, never `#[cfg]`). The sole
//! bridge to the real environment is [`Env::from_env`]; nothing else in the
//! crate may read env for paths.

use std::collections::HashMap;
use std::path::PathBuf;

/// The operating system a per-OS fold targets. A runtime value, not a
/// compile-time `cfg`, so both arms are exercised on one host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Os {
    Linux,
    MacOs,
}

/// The OS this binary was compiled for, as a runtime [`Os`]. Two `#[cfg]`
/// arms rather than a `cfg!` branch: exactly one compiles per target, so
/// neither leaves an uncovered arm under coverage.
#[cfg(not(target_os = "macos"))]
pub fn current_os() -> Os {
    Os::Linux
}

/// The OS this binary was compiled for (macOS build).
#[cfg(target_os = "macos")]
pub fn current_os() -> Os {
    Os::MacOs
}

/// An immutable snapshot of the environment variables the folds consult.
/// Constructed once from the process (`from_env`) or explicitly (`from_pairs`).
#[derive(Debug, Clone)]
pub struct Env {
    vars: HashMap<String, String>,
}

impl Env {
    /// Snapshot the live process environment. The only env read in the crate.
    pub fn from_env() -> Self {
        Self {
            vars: std::env::vars().collect(),
        }
    }

    /// Build from explicit pairs — the hermetic path for tests (the sole
    /// non-`from_env` constructor; production reads the process env once).
    #[cfg(test)]
    pub(crate) fn from_pairs<I, K, V>(pairs: I) -> Self
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<String>,
        V: Into<String>,
    {
        let mut vars = HashMap::new();
        for (k, v) in pairs {
            vars.insert(k.into(), v.into());
        }
        Self { vars }
    }

    /// Derive a new snapshot from this one with the given `(key, value)` pairs
    /// overridden — inserted, or replaced when already present. The composition
    /// seam for the nested world (§16.2, [`crate::world`]): the world layers its
    /// fixed override set (`LERNIE_HOME`/`XDG_STATE_HOME`) over
    /// the ambient snapshot, and the result is itself an `Env`, so every fold
    /// above re-derives through the world with no second code path. A concrete
    /// slice (not `impl IntoIterator`) keeps the seam off the crate's generic
    /// public surface; `pub(crate)` — an internal composition point, not API.
    pub(crate) fn with_overrides(&self, overrides: &[(&str, &str)]) -> Env {
        let mut vars = self.vars.clone();
        for &(k, v) in overrides {
            vars.insert(k.to_owned(), v.to_owned());
        }
        Env { vars }
    }

    /// A present, non-empty variable. Empty reads as absent — the XDG
    /// convention, and lernie's `LERNIE_HOME` "empty falls through" semantics,
    /// unified into one rule.
    fn get(&self, key: &str) -> Option<&str> {
        self.vars
            .get(key)
            .map(String::as_str)
            .filter(|v| !v.is_empty())
    }

    /// `$HOME/<tail>`, or a bare relative `<tail>` when HOME is absent.
    fn home(&self, tail: &str) -> PathBuf {
        match self.get("HOME") {
            Some(h) => PathBuf::from(h).join(tail),
            None => PathBuf::from(tail),
        }
    }

    /// `$<var>/<sub>` when `var` is set, else `$HOME/<default_tail>/<sub>`.
    fn xdg(&self, var: &str, default_tail: &str, sub: &str) -> PathBuf {
        let base = match self.get(var) {
            Some(base) => PathBuf::from(base),
            None => self.home(default_tail),
        };
        base.join(sub)
    }

    /// Balls state root: `$XDG_STATE_HOME/balls` else `$HOME/.local/state/balls`.
    pub fn balls_state_root(&self) -> PathBuf {
        self.xdg("XDG_STATE_HOME", ".local/state", "balls")
    }

    /// The per-project clones dir under the balls state root.
    pub fn balls_clones_dir(&self) -> PathBuf {
        self.balls_state_root().join("clones")
    }

    /// `$LERNIE_HOME` when set collapses both lernie roots onto that dir.
    fn lernie_home(&self) -> Option<PathBuf> {
        self.get("LERNIE_HOME").map(PathBuf::from)
    }

    /// Lernie config root: `$LERNIE_HOME` else `$XDG_CONFIG_HOME/lernie` else
    /// `$HOME/.config/lernie`.
    pub fn lernie_config_root(&self) -> PathBuf {
        self.lernie_home()
            .unwrap_or_else(|| self.xdg("XDG_CONFIG_HOME", ".config", "lernie"))
    }

    /// Lernie data root: `$LERNIE_HOME` else `$XDG_DATA_HOME/lernie` else
    /// `$HOME/.local/share/lernie`.
    pub fn lernie_data_root(&self) -> PathBuf {
        self.lernie_home()
            .unwrap_or_else(|| self.xdg("XDG_DATA_HOME", ".local/share", "lernie"))
    }

    /// Brazen config path: `$BRAZEN_CONFIG` else `$XDG_CONFIG_HOME/brazen/
    /// config.toml` else `$HOME/.config/brazen/config.toml`. Pure XDG on every
    /// platform — brazen does not branch per-OS for config.
    pub fn brazen_config_path(&self) -> PathBuf {
        match self.get("BRAZEN_CONFIG") {
            Some(p) => PathBuf::from(p),
            None => self.xdg("XDG_CONFIG_HOME", ".config", "brazen/config.toml"),
        }
    }

    /// Brazen credentials dir. Linux: `$XDG_DATA_HOME/brazen/credentials` else
    /// `~/.local/share/...`. macOS: `~/Library/Application Support/brazen/
    /// credentials`.
    pub fn brazen_credentials_dir(&self, os: Os) -> PathBuf {
        match os {
            Os::Linux => self.xdg("XDG_DATA_HOME", ".local/share", "brazen/credentials"),
            Os::MacOs => self.home("Library/Application Support/brazen/credentials"),
        }
    }

    /// Brazen models-cache dir. Linux: `$XDG_CACHE_HOME/brazen/models` else
    /// `~/.cache/...`. macOS: `~/Library/Caches/brazen/models`.
    pub fn brazen_models_cache_dir(&self, os: Os) -> PathBuf {
        match os {
            Os::Linux => self.xdg("XDG_CACHE_HOME", ".cache", "brazen/models"),
            Os::MacOs => self.home("Library/Caches/brazen/models"),
        }
    }

    /// The operator's home dir (`$HOME`), else the filesystem root `/` — the bare
    /// rung's driver cwd (`~`, §3.4). Unset HOME falls back to a real, always-present
    /// directory, never the empty path that would spawn the loop into cwd `""`. Read
    /// from the snapshot, never the live env.
    pub fn home_dir(&self) -> PathBuf {
        self.get("HOME")
            .map_or_else(|| PathBuf::from("/"), PathBuf::from)
    }

    /// Yog data root: `$XDG_DATA_HOME/yog` else `~/.local/share/yog`.
    pub fn yog_data_root(&self) -> PathBuf {
        self.xdg("XDG_DATA_HOME", ".local/share", "yog")
    }

    /// Yog state root: `$XDG_STATE_HOME/yog` else `~/.local/state/yog`.
    pub fn yog_state_root(&self) -> PathBuf {
        self.xdg("XDG_STATE_HOME", ".local/state", "yog")
    }

    /// The invoking user (`$USER`) — the default claim identity when `ui.json`
    /// records none (§4.1 `identity_last_used`: "default `$USER` when absent").
    /// Read from the snapshot, never the live process env (the module rule).
    pub fn user(&self) -> Option<String> {
        self.get("USER").map(str::to_owned)
    }

    /// Yog scripted-editor staging root: `<yog-state-root>/stage` (§9.3,
    /// §5.2). The per-edit `<nonce>/` dirs live under it; leftovers older
    /// than 24 h are swept at startup.
    pub fn yog_stage_root(&self) -> PathBuf {
        self.yog_state_root().join("stage")
    }
}

/// A hex nibble, or `None` for a non-hex byte.
fn hex_val(c: u8) -> Option<u8> {
    match c {
        b'0'..=b'9' => Some(c - b'0'),
        b'a'..=b'f' => Some(c - b'a' + 10),
        b'A'..=b'F' => Some(c - b'A' + 10),
        _ => None,
    }
}

/// Hand-rolled `%XX` decoder for balls clone-dir names. An invalid escape
/// (`%` at the end, or not followed by two hex digits) passes through
/// verbatim rather than erroring; the inverse is balls' concern, not ours.
pub fn percent_decode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    // `get` at each offset both bounds-checks and reads: the loop ends when the
    // current byte is absent, and a short/invalid `%XX` tail falls through.
    while let Some(&cur) = b.get(i) {
        if cur == b'%'
            && let (Some(&hi), Some(&lo)) = (b.get(i + 1), b.get(i + 2))
            && let (Some(h), Some(l)) = (hex_val(hi), hex_val(lo))
        {
            out.push((h << 4) | l);
            i += 3;
            continue;
        }
        out.push(cur);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

#[cfg(test)]
mod tests;