htb-cli 0.1.0

Hack The Box CLI
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct HtbConfig {
    #[serde(default = "default_output")]
    pub output: String,

    #[serde(default)]
    pub vpn_server: Option<u32>,

    #[serde(default)]
    pub no_color: bool,
}

fn default_output() -> String {
    "table".into()
}

impl Default for HtbConfig {
    fn default() -> Self {
        Self {
            output: default_output(),
            vpn_server: None,
            no_color: false,
        }
    }
}

impl HtbConfig {
    pub fn load(path: Option<&Path>) -> anyhow::Result<Self> {
        let config_path = path
            .map(|p| p.to_path_buf())
            .unwrap_or_else(|| config_dir().join("config.toml"));

        if config_path.exists() {
            let contents = fs::read_to_string(&config_path)?;
            Ok(toml::from_str(&contents)?)
        } else {
            Ok(Self::default())
        }
    }
}

pub fn config_dir() -> PathBuf {
    dirs::home_dir()
        .expect("could not determine home directory")
        .join(".htb-cli")
}

pub fn token_path() -> PathBuf {
    config_dir().join(".token")
}

pub fn read_token() -> Result<String, crate::error::HtbError> {
    let path = token_path();
    if !path.exists() {
        return Err(crate::error::HtbError::NotAuthenticated);
    }
    let token = fs::read_to_string(&path)?.trim().to_string();
    if token.is_empty() {
        return Err(crate::error::HtbError::NotAuthenticated);
    }
    Ok(token)
}

pub fn save_token(token: &str) -> anyhow::Result<()> {
    let dir = config_dir();
    fs::create_dir_all(&dir)?;

    let path = token_path();

    #[cfg(unix)]
    {
        use std::io::Write;
        use std::os::unix::fs::OpenOptionsExt;
        let mut f = fs::OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .mode(0o600)
            .open(&path)?;
        f.write_all(token.as_bytes())?;
    }

    #[cfg(not(unix))]
    {
        fs::write(&path, token)?;
    }

    Ok(())
}

pub fn remove_token() -> anyhow::Result<()> {
    let path = token_path();
    if path.exists() {
        fs::remove_file(&path)?;
    }
    Ok(())
}

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

    #[test]
    fn default_config() {
        let config = HtbConfig::default();
        assert_eq!(config.output, "table");
        assert_eq!(config.vpn_server, None);
        assert!(!config.no_color);
    }

    #[test]
    fn toml_round_trip() {
        let config = HtbConfig {
            output: "json".into(),
            vpn_server: Some(1),
            no_color: true,
        };
        let serialized = toml::to_string(&config).unwrap();
        let deserialized: HtbConfig = toml::from_str(&serialized).unwrap();
        assert_eq!(deserialized.output, "json");
        assert_eq!(deserialized.vpn_server, Some(1));
        assert!(deserialized.no_color);
    }

    #[test]
    fn partial_toml_uses_defaults() {
        let input = r#"output = "json""#;
        let config: HtbConfig = toml::from_str(input).unwrap();
        assert_eq!(config.output, "json");
        assert_eq!(config.vpn_server, None);
        assert!(!config.no_color);
    }

    #[test]
    fn empty_toml_uses_defaults() {
        let config: HtbConfig = toml::from_str("").unwrap();
        assert_eq!(config.output, "table");
    }

    #[test]
    fn token_round_trip() {
        let dir = std::env::temp_dir().join("htb-cli-test-token");
        let _ = std::fs::create_dir_all(&dir);
        let path = dir.join(".token");

        std::fs::write(&path, "test-token-123").unwrap();
        let read_back = std::fs::read_to_string(&path).unwrap();
        assert_eq!(read_back.trim(), "test-token-123");

        let _ = std::fs::remove_dir_all(&dir);
    }
}