zc2 0.0.23

P2P compute broker with credit-based billing, WAL, and broker mesh support
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;

/// Serialises the remaining tests that redirect `HOME` / `ZAKURO_HOME`.
///
/// Environment variables are process-global and `cargo test` is multi-threaded
/// by default, so without this two such tests interleave and the failure
/// surfaces in whichever one lost the race rather than the one that moved the
/// variable.
///
/// **This lock is a mitigation, not a guarantee.** It only orders tests that
/// take it, and `envs::update()` calls `set_var` over whatever keys a config
/// file carries -- so any test that boots a broker can still move these
/// variables without ever touching the lock. That is why serialising alone
/// did not fix CI. The durable answer is not to depend on the environment:
/// see [`dir_from`] and `NodeKey::load_or_create_in`, which take their inputs
/// as arguments. Prefer those in new tests; this remains for the ones that
/// genuinely exercise the env-reading path.
///
/// Poisoning is ignored on purpose -- a panic in one test must not cascade
/// into unrelated failures in every other test that wants the lock.
#[cfg(test)]
pub(crate) static HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Where zc keeps per-node state: the Ed25519 node key, the roster cache, the
/// peer list.
///
/// `ZAKURO_HOME` wins when set, and is the state directory ITSELF -- the same
/// meaning `common::create_directories` already gives it when it makes
/// `config/`, `lib/`, `logs/` directly underneath. Honouring it here is not a
/// convenience: a containerised broker has no persistent `$HOME`, so mounting
/// a volume and pointing `ZAKURO_HOME` at it was the documented way to keep a
/// node's identity across restarts -- and this function ignoring the variable
/// silently defeated it. The k8s fleet ran that way for weeks: the volume was
/// mounted and empty, every restart minted a fresh keypair, re-registered as a
/// brand-new node, and orphaned the previous roster entry. Three accounts had
/// accumulated 25+ dead keys each by the time anyone looked.
///
/// Falls back to `$HOME/.zakuro`, which is what every non-container install
/// uses and what the LXC fleet pins via a systemd `Environment=HOME=/root`.
pub fn dir() -> Option<PathBuf> {
    dir_from(std::env::var_os("ZAKURO_HOME"), std::env::var_os("HOME"))
}

/// The precedence rule itself, as a pure function of the two values.
///
/// Split out so it can be tested without touching process-global environment
/// variables. That is not tidiness: `envs::update()` calls `set_var` over
/// whatever keys a config file carries, so ANY concurrently running test that
/// boots a broker can move `ZAKURO_HOME` out from under a test that set it.
/// A test that mutates the environment cannot be made reliable by locking,
/// because the writers it races with do not take the lock.
pub fn dir_from(
    zakuro_home: Option<std::ffi::OsString>,
    home: Option<std::ffi::OsString>,
) -> Option<PathBuf> {
    if let Some(h) = zakuro_home {
        if !h.is_empty() {
            return Some(PathBuf::from(h));
        }
    }
    home.map(|h| PathBuf::from(h).join(".zakuro"))
}

/// Production dashboard API base URL.
pub const PROD_API_URL: &str = "https://my.zakuro-ai.com";
/// Staging dashboard API base URL.
///
/// stg.hub, not the retired stg-my: the hub serves the whole dashboard API
/// in-process (including `/api/auth/cli/token` and `/api/auth/cli/join`, which
/// `enroll` needs), and stg-my's DNS goes away once the fleet is upgraded.
pub const STAGING_API_URL: &str = "https://stg.hub.zakuro-ai.com";

/// Resolve the dashboard API base URL, in precedence order:
///   1. `ZAKURO_API_URL` (explicit override) — used verbatim when non-empty.
///   2. `ZAKURO_ENV=staging|stg|stage` → the staging endpoint.
///   3. otherwise → production.
///
/// This is the single place the `my.zakuro-ai.com` / `stg.hub.zakuro-ai.com`
/// switch lives, so `ZAKURO_ENV=staging` flips every zc command (init, broker,
/// enroll, info, …) to staging at once.
pub fn default_api_url() -> String {
    if let Ok(u) = std::env::var("ZAKURO_API_URL") {
        if !u.trim().is_empty() {
            return u;
        }
    }
    match std::env::var("ZAKURO_ENV")
        .unwrap_or_default()
        .to_lowercase()
        .as_str()
    {
        "staging" | "stg" | "stage" => STAGING_API_URL.to_string(),
        _ => PROD_API_URL.to_string(),
    }
}

pub fn path() -> Option<PathBuf> {
    dir().map(|d| d.join("credentials"))
}

pub fn parse(text: &str) -> HashMap<String, String> {
    let mut m = HashMap::new();
    for line in text.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let line = line.strip_prefix("export ").unwrap_or(line);
        if let Some((k, v)) = line.split_once('=') {
            m.insert(k.trim().to_string(), v.trim().to_string());
        }
    }
    m
}

pub fn save(api_key: &str, api_url: Option<&str>) -> std::io::Result<()> {
    let d = dir().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no HOME"))?;
    std::fs::create_dir_all(&d)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&d, std::fs::Permissions::from_mode(0o700)).ok();
    }
    let p = d.join("credentials");
    let mut body = format!("api_key={}\n", api_key);
    if let Some(u) = api_url {
        body.push_str(&format!("api_url={}\n", u));
    }
    let mut f = std::fs::File::create(&p)?;
    f.write_all(body.as_bytes())?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).ok();
    }
    Ok(())
}

/// Fill ZAKURO_API_KEY / ZAKURO_API_URL from stored config when unset.
/// Precedence: process env > ~/.zakuro/credentials > ~/.zakuro/env.
pub fn load_into_env() {
    if let Some(p) = path() {
        if let Ok(text) = std::fs::read_to_string(&p) {
            let m = parse(&text);
            set_if_unset("ZAKURO_API_KEY", m.get("api_key"));
            set_if_unset("ZAKURO_API_URL", m.get("api_url"));
        }
    }
    if let Some(d) = dir() {
        if let Ok(text) = std::fs::read_to_string(d.join("env")) {
            let m = parse(&text);
            set_if_unset("ZAKURO_API_KEY", m.get("ZAKURO_API_KEY"));
            set_if_unset("ZAKURO_API_URL", m.get("ZAKURO_API_URL"));
        }
    }
}

fn set_if_unset(var: &str, value: Option<&String>) {
    if std::env::var(var).is_err() {
        if let Some(v) = value {
            if !v.is_empty() {
                std::env::set_var(var, v);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn parse_prefers_env_else_file() {
        let txt = "api_key=zk_1_abc\napi_url=http://x\n";
        let m = super::parse(txt);
        assert_eq!(m.get("api_key").unwrap(), "zk_1_abc");
        assert_eq!(m.get("api_url").unwrap(), "http://x");
    }

    #[test]
    fn parse_strips_export_prefix() {
        let m = super::parse("export ZAKURO_API_URL=https://stg\n# c\nZAKURO_API_KEY=zk_1_a\n");
        assert_eq!(m.get("ZAKURO_API_URL").unwrap(), "https://stg");
        assert_eq!(m.get("ZAKURO_API_KEY").unwrap(), "zk_1_a");
    }

    /// stg-my is being decommissioned in favour of stg.hub. This constant is
    /// compiled into every installed binary, so it is pinned by value here --
    /// the pre-existing `envs_are_prod_then_staging` test compares against the
    /// constant itself and so would pass no matter what host it names.
    #[test]
    fn staging_points_at_stg_hub_not_the_retired_stg_my() {
        assert_eq!(
            super::STAGING_API_URL,
            "https://stg.hub.zakuro-ai.com",
            "staging must address stg.hub; stg-my is decommissioned"
        );
        assert_eq!(super::PROD_API_URL, "https://my.zakuro-ai.com");
    }
}