Skip to main content

cellos_ctl/
config.rs

1//! cellctl configuration — persisted at `~/.cellctl/config` (TOML).
2//!
3//! The on-disk shape is intentionally tiny. cellctl never caches CellOS state
4//! locally; the only legitimate client-local state is the server URL, the
5//! optional API token, and (eventually, per Session 16) the NATS stream cursor.
6//! Anything else would violate the "events are the source of truth" principle.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12
13use crate::exit::{CtlError, CtlResult};
14
15const DEFAULT_SERVER: &str = "http://127.0.0.1:8080";
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct Config {
19    /// Base URL of cellos-server (e.g. `https://cellos.example.com`).
20    pub server_url: Option<String>,
21    /// Bearer token presented in `Authorization: Bearer <token>` on every request.
22    pub api_token: Option<String>,
23}
24
25impl Config {
26    /// Effective server URL: explicit config > $CELLCTL_SERVER > default loopback.
27    pub fn effective_server(&self) -> String {
28        self.server_url
29            .clone()
30            .or_else(|| std::env::var("CELLCTL_SERVER").ok())
31            .unwrap_or_else(|| DEFAULT_SERVER.to_string())
32    }
33
34    /// Effective token: explicit config > $CELLCTL_TOKEN > none.
35    pub fn effective_token(&self) -> Option<String> {
36        self.api_token
37            .clone()
38            .or_else(|| std::env::var("CELLCTL_TOKEN").ok())
39    }
40}
41
42/// Resolve the config file path, honoring `$CELLCTL_CONFIG` and `$XDG_CONFIG_HOME`.
43pub fn config_path() -> CtlResult<PathBuf> {
44    if let Ok(explicit) = std::env::var("CELLCTL_CONFIG") {
45        return Ok(PathBuf::from(explicit));
46    }
47    let home =
48        home::home_dir().ok_or_else(|| CtlError::usage("cannot determine home directory"))?;
49    Ok(home.join(".cellctl").join("config"))
50}
51
52pub fn load() -> CtlResult<Config> {
53    let path = config_path()?;
54    if !path.exists() {
55        return Ok(Config::default());
56    }
57    let raw = fs::read_to_string(&path)
58        .map_err(|e| CtlError::usage(format!("read {}: {e}", path.display())))?;
59    let cfg: Config = toml::from_str(&raw)
60        .map_err(|e| CtlError::usage(format!("parse {}: {e}", path.display())))?;
61    Ok(cfg)
62}
63
64pub fn save(cfg: &Config) -> CtlResult<()> {
65    let path = config_path()?;
66    if let Some(parent) = path.parent() {
67        ensure_dir(parent)?;
68    }
69    let raw = toml::to_string_pretty(cfg)
70        .map_err(|e| CtlError::usage(format!("serialize config: {e}")))?;
71    fs::write(&path, raw).map_err(|e| CtlError::usage(format!("write {}: {e}", path.display())))?;
72    Ok(())
73}
74
75fn ensure_dir(p: &Path) -> CtlResult<()> {
76    fs::create_dir_all(p).map_err(|e| CtlError::usage(format!("mkdir {}: {e}", p.display())))
77}