sylphx-cli 0.1.3

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Local credential store for the Sylphx CLI.
//!
//! Resolution order for Management tokens (highest wins):
//! 1. `--token` CLI flag
//! 2. `SYLPHX_TOKEN` environment variable
//! 3. credentials file written by `sylphx login`
//!
//! Default path: `$XDG_CONFIG_HOME/sylphx/credentials.json`
//! (fallback `~/.config/sylphx/credentials.json`).
//! Override with `SYLPHX_CONFIG_DIR`.

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

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

const FILE_NAME: &str = "credentials.json";

/// On-disk credential document.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct CredentialsFile {
    /// Management API bearer token (`svc_*` or user access JWT).
    pub token: String,
    /// Optional Management base URL remembered at login.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub api_url: Option<String>,
    /// How the token was obtained (`token` | `device`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
}

/// Config directory root (overrideable for tests via `SYLPHX_CONFIG_DIR`).
pub fn config_dir() -> Result<PathBuf> {
    if let Ok(dir) = std::env::var("SYLPHX_CONFIG_DIR") {
        let p = PathBuf::from(dir);
        if !p.as_os_str().is_empty() {
            return Ok(p);
        }
    }
    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
        if !xdg.is_empty() {
            return Ok(PathBuf::from(xdg).join("sylphx"));
        }
    }
    let home = dirs::home_dir().ok_or_else(|| anyhow!("cannot resolve home directory"))?;
    Ok(home.join(".config").join("sylphx"))
}

/// Path to the credentials JSON file.
pub fn credentials_path() -> Result<PathBuf> {
    Ok(config_dir()?.join(FILE_NAME))
}

/// Load credentials from disk if present.
pub fn load() -> Result<Option<CredentialsFile>> {
    let path = credentials_path()?;
    if !path.exists() {
        return Ok(None);
    }
    let raw = fs::read_to_string(&path)
        .with_context(|| format!("read credentials {}", path.display()))?;
    let parsed: CredentialsFile = serde_json::from_str(&raw)
        .with_context(|| format!("parse credentials {}", path.display()))?;
    if parsed.token.trim().is_empty() {
        return Ok(None);
    }
    Ok(Some(parsed))
}

/// Persist credentials (creates parent dirs, mode 0600 when possible).
pub fn save(creds: &CredentialsFile) -> Result<PathBuf> {
    let dir = config_dir()?;
    fs::create_dir_all(&dir).with_context(|| format!("create config dir {}", dir.display()))?;
    let path = dir.join(FILE_NAME);
    let body = serde_json::to_string_pretty(creds)?;
    write_private(&path, body.as_bytes())?;
    Ok(path)
}

/// Remove credentials file if present.
pub fn clear() -> Result<bool> {
    let path = credentials_path()?;
    if path.exists() {
        fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?;
        return Ok(true);
    }
    Ok(false)
}

/// Resolve token: explicit CLI/env value, else credentials file.
pub fn resolve_token(cli_token: Option<&str>) -> Result<String> {
    if let Some(t) = cli_token.map(str::trim).filter(|t| !t.is_empty()) {
        return Ok(t.to_string());
    }
    if let Ok(t) = std::env::var("SYLPHX_TOKEN") {
        let t = t.trim().to_string();
        if !t.is_empty() {
            return Ok(t);
        }
    }
    if let Some(c) = load()? {
        return Ok(c.token);
    }
    Err(anyhow!(
        "no Management token: run `sylphx login --token <svc_…>` or `sylphx login` (device flow), or set SYLPHX_TOKEN"
    ))
}

/// Resolve API URL: CLI flag, env, credentials file, then default.
pub fn resolve_api_url(cli_url: Option<&str>, default_url: &str) -> String {
    if let Some(u) = cli_url.map(str::trim).filter(|u| !u.is_empty()) {
        return u.to_string();
    }
    if let Ok(u) = std::env::var("SYLPHX_API_URL") {
        let u = u.trim().to_string();
        if !u.is_empty() {
            return u;
        }
    }
    if let Ok(Some(c)) = load() {
        if let Some(u) = c.api_url.filter(|u| !u.trim().is_empty()) {
            return u;
        }
    }
    default_url.to_string()
}

fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
    use std::io::Write;
    let mut f = fs::File::create(path).with_context(|| format!("create {}", path.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
    }
    f.write_all(bytes)?;
    f.sync_all()?;
    Ok(())
}

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

    static LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn save_load_clear_roundtrip() {
        let _g = LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        std::env::set_var("SYLPHX_CONFIG_DIR", dir.path());
        let creds = CredentialsFile {
            token: "svc_test_abc".into(),
            api_url: Some("http://127.0.0.1:9/v1".into()),
            source: Some("token".into()),
        };
        let path = save(&creds).unwrap();
        assert!(path.exists());
        let loaded = load().unwrap().unwrap();
        assert_eq!(loaded, creds);
        assert!(clear().unwrap());
        assert!(load().unwrap().is_none());
        std::env::remove_var("SYLPHX_CONFIG_DIR");
    }

    #[test]
    fn resolve_token_prefers_cli_over_file() {
        let _g = LOCK.lock().unwrap();
        let dir = tempfile::tempdir().unwrap();
        std::env::set_var("SYLPHX_CONFIG_DIR", dir.path());
        std::env::remove_var("SYLPHX_TOKEN");
        save(&CredentialsFile {
            token: "svc_file".into(),
            api_url: None,
            source: Some("token".into()),
        })
        .unwrap();
        assert_eq!(resolve_token(Some("svc_cli")).unwrap(), "svc_cli");
        assert_eq!(resolve_token(None).unwrap(), "svc_file");
        let _ = clear();
        std::env::remove_var("SYLPHX_CONFIG_DIR");
    }
}