exeora-cli 0.8.3

Native Exeora CLI and local tool executor
Documentation
use anyhow::{Context, Result};
use atomic_write_file::AtomicWriteFile;
use serde::{Deserialize, Serialize};
use std::{
    env, fs,
    io::Write,
    path::{Path, PathBuf},
};

pub const DEFAULT_GATEWAY: &str = "https://exeora.dev";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProjectEntry {
    pub id: String,
    pub slug: String,
    pub name: String,
    pub root: PathBuf,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StarPrompt {
    pub runs: u64,
    pub ask_at: u64,
    pub asked: u64,
    pub done: bool,
}

impl Default for StarPrompt {
    fn default() -> Self {
        Self {
            runs: 0,
            ask_at: 3,
            asked: 0,
            done: false,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigData {
    #[serde(default = "default_gateway")]
    pub gateway_url: String,
    #[serde(default)]
    pub device_id: Option<String>,
    #[serde(default)]
    pub device_name: Option<String>,
    #[serde(default)]
    pub projects: Vec<ProjectEntry>,
    #[serde(default)]
    pub star: StarPrompt,
}

fn default_gateway() -> String {
    DEFAULT_GATEWAY.to_owned()
}

impl Default for ConfigData {
    fn default() -> Self {
        Self {
            gateway_url: env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| default_gateway()),
            device_id: None,
            device_name: None,
            projects: Vec::new(),
            star: StarPrompt::default(),
        }
    }
}

#[derive(Debug)]
pub struct ConfigStore {
    path: PathBuf,
    data: ConfigData,
}

impl ConfigStore {
    pub fn load() -> Result<Self> {
        Self::load_from(config_path()?)
    }

    pub fn load_from(path: PathBuf) -> Result<Self> {
        let data = match fs::read(&path) {
            Ok(bytes) => serde_json::from_slice(&bytes)
                .with_context(|| format!("Could not parse {}", path.display()))?,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => ConfigData::default(),
            Err(error) => {
                return Err(error).with_context(|| format!("Could not read {}", path.display()));
            }
        };
        Ok(Self { path, data })
    }

    pub fn path(&self) -> &Path {
        &self.path
    }
    pub fn data(&self) -> &ConfigData {
        &self.data
    }
    pub fn data_mut(&mut self) -> &mut ConfigData {
        &mut self.data
    }

    pub fn gateway_url(&self) -> String {
        env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| self.data.gateway_url.clone())
    }

    pub fn gateway_source(&self) -> &'static str {
        if env::var_os("EXEORA_GATEWAY_URL").is_some() {
            "env"
        } else if self.data.gateway_url == DEFAULT_GATEWAY {
            "default"
        } else {
            "config"
        }
    }

    pub fn find_project(&self, id: &str) -> Option<&ProjectEntry> {
        self.data.projects.iter().find(|entry| entry.id == id)
    }

    pub fn upsert_project(&mut self, project: ProjectEntry) {
        self.data.projects.retain(|entry| entry.id != project.id);
        self.data.projects.push(project);
    }

    pub fn remove_project(&mut self, id: &str) {
        self.data.projects.retain(|entry| entry.id != id);
    }

    pub fn forget_local_state(&mut self) {
        self.data.device_id = None;
        self.data.device_name = None;
        self.data.projects.clear();
    }

    pub fn save(&self) -> Result<()> {
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent)?;
        }
        let mut file = AtomicWriteFile::options()
            .open(&self.path)
            .with_context(|| format!("Could not open {}", self.path.display()))?;
        file.write_all(&serde_json::to_vec_pretty(&self.data)?)?;
        file.write_all(b"\n")?;
        file.commit()
            .with_context(|| format!("Could not save {}", self.path.display()))?;
        Ok(())
    }
}

pub fn config_path() -> Result<PathBuf> {
    if let Some(path) = env::var_os("EXEORA_CONFIG_PATH") {
        return Ok(PathBuf::from(path));
    }

    #[cfg(target_os = "windows")]
    {
        let base = env::var_os("APPDATA")
            .or_else(|| env::var_os("USERPROFILE"))
            .context("Neither APPDATA nor USERPROFILE is set")?;
        return Ok(PathBuf::from(base).join("exeora/config.json"));
    }
    #[cfg(target_os = "macos")]
    {
        return Ok(home_dir()?.join("Library/Preferences/exeora/config.json"));
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        let base = env::var_os("XDG_CONFIG_HOME")
            .map(PathBuf::from)
            .unwrap_or(home_dir()?.join(".config"));
        Ok(base.join("exeora/config.json"))
    }
}

pub fn credential_fallback_path() -> Result<PathBuf> {
    if let Some(path) = env::var_os("EXEORA_CREDENTIAL_PATH") {
        return Ok(PathBuf::from(path));
    }
    #[cfg(target_os = "windows")]
    let base = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or(home_dir()?);
    #[cfg(not(target_os = "windows"))]
    let base = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or(home_dir()?.join(".config"));
    Ok(base.join("exeora/credentials.json"))
}

fn home_dir() -> Result<PathBuf> {
    env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
        .map(PathBuf::from)
        .context("Could not determine the home directory")
}