exeora-cli 0.10.0

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, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum WorktreeSyncState {
    PendingUpsert,
    #[default]
    Active,
    PendingDelete,
    Disabled,
    Removing,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct WorktreeEntry {
    pub id: String,
    pub project_id: String,
    pub slug: String,
    pub name: String,
    #[serde(default)]
    pub branch: Option<String>,
    pub git_root: PathBuf,
    pub root: PathBuf,
    #[serde(default)]
    pub managed: bool,
    #[serde(default)]
    pub sync_state: WorktreeSyncState,
}

#[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 worktrees: Vec<WorktreeEntry>,
    #[serde(default)]
    pub worktree_root: Option<PathBuf>,
    #[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(),
            worktrees: Vec::new(),
            worktree_root: None,
            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 worktree_root(&self) -> Result<PathBuf> {
        if let Some(path) = env::var_os("EXEORA_WORKTREE_ROOT") {
            return absolute_path(PathBuf::from(path));
        }
        if let Some(path) = &self.data.worktree_root {
            return absolute_path(path.clone());
        }
        default_worktree_root()
    }

    pub fn worktree_root_source(&self) -> &'static str {
        if env::var_os("EXEORA_WORKTREE_ROOT").is_some() {
            "env"
        } else if self.data.worktree_root.is_some() {
            "config"
        } else {
            "default"
        }
    }

    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);
        self.data.worktrees.retain(|entry| entry.project_id != id);
    }

    pub fn upsert_worktree(&mut self, worktree: WorktreeEntry) {
        self.data.worktrees.retain(|entry| entry.id != worktree.id);
        self.data.worktrees.push(worktree);
    }

    pub fn remove_worktree(&mut self, id: &str) {
        self.data.worktrees.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();
        self.data.worktrees.clear();
    }

    pub fn save(&self) -> Result<()> {
        if let Some(parent) = self.path.parent() {
            fs::create_dir_all(parent)?;
        }
        let _lock = ConfigLock::acquire(&self.path)?;
        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(())
    }
}

struct ConfigLock(PathBuf);

impl ConfigLock {
    fn acquire(config: &Path) -> Result<Self> {
        let lock = config.with_extension("lock");
        for _ in 0..100 {
            match fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .open(&lock)
            {
                Ok(_) => return Ok(Self(lock)),
                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
                    let stale = fs::metadata(&lock)
                        .and_then(|metadata| metadata.modified())
                        .ok()
                        .and_then(|modified| modified.elapsed().ok())
                        .is_some_and(|age| age.as_secs() >= 30);
                    if stale {
                        let _ = fs::remove_file(&lock);
                    } else {
                        std::thread::sleep(std::time::Duration::from_millis(20));
                    }
                }
                Err(error) => return Err(error.into()),
            }
        }
        anyhow::bail!("Timed out waiting to update {}", config.display())
    }
}

impl Drop for ConfigLock {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.0);
    }
}

fn absolute_path(path: PathBuf) -> Result<PathBuf> {
    if path.is_absolute() {
        Ok(path)
    } else {
        Ok(env::current_dir()?.join(path))
    }
}

pub fn default_worktree_root() -> Result<PathBuf> {
    #[cfg(target_os = "windows")]
    {
        let base = env::var_os("LOCALAPPDATA")
            .or_else(|| env::var_os("APPDATA"))
            .context("Neither LOCALAPPDATA nor APPDATA is set")?;
        return Ok(PathBuf::from(base).join("Exeora/worktrees"));
    }
    #[cfg(target_os = "macos")]
    {
        return Ok(home_dir()?.join("Library/Application Support/Exeora/worktrees"));
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        let base = env::var_os("XDG_DATA_HOME")
            .map(PathBuf::from)
            .unwrap_or(home_dir()?.join(".local/share"));
        Ok(base.join("exeora/worktrees"))
    }
}

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")
}