cellos-ctl 0.5.0

cellctl — kubectl-style CLI for CellOS execution cells and formations. Thin HTTP client over cellos-server with apply/get/describe/logs/events/webui.
Documentation
//! cellctl configuration — persisted at `~/.cellctl/config` (TOML).
//!
//! The on-disk shape is intentionally tiny. cellctl never caches CellOS state
//! locally; the only legitimate client-local state is the server URL, the
//! optional API token, and (eventually, per Session 16) the NATS stream cursor.
//! Anything else would violate the "events are the source of truth" principle.

use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::exit::{CtlError, CtlResult};

const DEFAULT_SERVER: &str = "http://127.0.0.1:8080";

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    /// Base URL of cellos-server (e.g. `https://cellos.example.com`).
    pub server_url: Option<String>,
    /// Bearer token presented in `Authorization: Bearer <token>` on every request.
    pub api_token: Option<String>,
}

impl Config {
    /// Effective server URL: explicit config > $CELLCTL_SERVER > default loopback.
    pub fn effective_server(&self) -> String {
        self.server_url
            .clone()
            .or_else(|| std::env::var("CELLCTL_SERVER").ok())
            .unwrap_or_else(|| DEFAULT_SERVER.to_string())
    }

    /// Effective token: explicit config > $CELLCTL_TOKEN > none.
    pub fn effective_token(&self) -> Option<String> {
        self.api_token
            .clone()
            .or_else(|| std::env::var("CELLCTL_TOKEN").ok())
    }
}

/// Resolve the config file path, honoring `$CELLCTL_CONFIG` and `$XDG_CONFIG_HOME`.
pub fn config_path() -> CtlResult<PathBuf> {
    if let Ok(explicit) = std::env::var("CELLCTL_CONFIG") {
        return Ok(PathBuf::from(explicit));
    }
    let home =
        home::home_dir().ok_or_else(|| CtlError::usage("cannot determine home directory"))?;
    Ok(home.join(".cellctl").join("config"))
}

pub fn load() -> CtlResult<Config> {
    let path = config_path()?;
    if !path.exists() {
        return Ok(Config::default());
    }
    let raw = fs::read_to_string(&path)
        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
    let cfg: Config = toml::from_str(&raw)
        .map_err(|e| CtlError::usage(format!("parse {}: {e}", path.display())))?;
    Ok(cfg)
}

pub fn save(cfg: &Config) -> CtlResult<()> {
    let path = config_path()?;
    if let Some(parent) = path.parent() {
        ensure_dir(parent)?;
    }
    let raw = toml::to_string_pretty(cfg)
        .map_err(|e| CtlError::usage(format!("serialize config: {e}")))?;
    fs::write(&path, raw).map_err(|e| CtlError::usage(format!("write {}: {e}", path.display())))?;
    Ok(())
}

fn ensure_dir(p: &Path) -> CtlResult<()> {
    fs::create_dir_all(p).map_err(|e| CtlError::usage(format!("mkdir {}: {e}", p.display())))
}