runtime-cli 0.1.0

Command-line client for managing git projects on Runtime
Documentation
//! On-disk config: `~/.config/runtime/config.json`.
//!
//! The file holds the host URL, username, and PAT minted via
//! `runtime auth login`. The PAT is the only secret on the filesystem;
//! file mode is clamped to `0600` on Unix. Callers that want to point
//! the CLI at a different file (tests, sandboxes) set
//! `RUNTIME_CONFIG_HOME` to override the parent directory.

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

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
    pub host: String,
    pub username: String,
    pub token: String,
}

impl Config {
    /// Compute the config-file path the CLI uses by default. Honors
    /// `RUNTIME_CONFIG_HOME` first (tests), then `XDG_CONFIG_HOME`,
    /// then falls back to `~/.config`.
    pub fn default_path() -> Result<PathBuf> {
        if let Ok(dir) = std::env::var("RUNTIME_CONFIG_HOME") {
            return Ok(PathBuf::from(dir).join("config.json"));
        }
        let base = std::env::var("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .ok()
            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
            .ok_or_else(|| {
                anyhow!("could not resolve config dir: set $HOME or $XDG_CONFIG_HOME")
            })?;
        Ok(base.join("runtime").join("config.json"))
    }

    /// Load the config from `default_path()`. Returns an error if the
    /// file is missing — callers may surface a "run `runtime auth login`"
    /// hint.
    pub fn load() -> Result<Self> {
        let path = Self::default_path()?;
        Self::load_from(&path)
    }

    pub fn load_from(path: &Path) -> Result<Self> {
        let bytes = fs::read(path).with_context(|| {
            format!(
                "no Runtime config at {} — run `runtime auth login` first",
                path.display()
            )
        })?;
        let cfg: Config = serde_json::from_slice(&bytes)
            .with_context(|| format!("invalid config JSON at {}", path.display()))?;
        Ok(cfg)
    }

    /// Persist this config, creating the parent directory and clamping
    /// permissions to `0600` on Unix. Overwrites any previous file.
    pub fn save(&self) -> Result<PathBuf> {
        let path = Self::default_path()?;
        self.save_to(&path)?;
        Ok(path)
    }

    pub fn save_to(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
        }
        let json = serde_json::to_vec_pretty(self)?;
        fs::write(path, &json).with_context(|| format!("write {}", path.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = fs::metadata(path)?.permissions();
            perms.set_mode(0o600);
            fs::set_permissions(path, perms)?;
        }
        Ok(())
    }

    /// Remove the on-disk config. Returns `Ok(false)` if there was no
    /// file to delete.
    pub fn delete() -> Result<bool> {
        let path = Self::default_path()?;
        match fs::remove_file(&path) {
            Ok(()) => Ok(true),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
            Err(e) => Err(e).with_context(|| format!("delete {}", path.display())),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn save_then_load_round_trips() {
        let tmp = tempfile::tempdir().expect("tmp");
        let path = tmp.path().join("config.json");
        let cfg = Config {
            host: "https://runtime.example".into(),
            username: "alice".into(),
            token: "pat_xyz".into(),
        };
        cfg.save_to(&path).expect("save");
        let loaded = Config::load_from(&path).expect("load");
        assert_eq!(loaded, cfg);
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
            assert_eq!(mode, 0o600);
        }
    }

    #[test]
    fn load_missing_returns_helpful_error() {
        let tmp = tempfile::tempdir().expect("tmp");
        let path = tmp.path().join("nope.json");
        let err = Config::load_from(&path).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("runtime auth login"), "{msg}");
    }
}