Skip to main content

exeora_cli/
config.rs

1use anyhow::{Context, Result};
2use atomic_write_file::AtomicWriteFile;
3use serde::{Deserialize, Serialize};
4use std::{
5    env, fs,
6    io::Write,
7    path::{Path, PathBuf},
8};
9
10pub const DEFAULT_GATEWAY: &str = "https://exeora.dev";
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13#[serde(rename_all = "camelCase")]
14pub struct ProjectEntry {
15    pub id: String,
16    pub slug: String,
17    pub name: String,
18    pub root: PathBuf,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct StarPrompt {
24    pub runs: u64,
25    pub ask_at: u64,
26    pub asked: u64,
27    pub done: bool,
28}
29
30impl Default for StarPrompt {
31    fn default() -> Self {
32        Self {
33            runs: 0,
34            ask_at: 3,
35            asked: 0,
36            done: false,
37        }
38    }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct ConfigData {
44    #[serde(default = "default_gateway")]
45    pub gateway_url: String,
46    #[serde(default)]
47    pub device_id: Option<String>,
48    #[serde(default)]
49    pub device_name: Option<String>,
50    #[serde(default)]
51    pub projects: Vec<ProjectEntry>,
52    #[serde(default)]
53    pub star: StarPrompt,
54}
55
56fn default_gateway() -> String {
57    DEFAULT_GATEWAY.to_owned()
58}
59
60impl Default for ConfigData {
61    fn default() -> Self {
62        Self {
63            gateway_url: env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| default_gateway()),
64            device_id: None,
65            device_name: None,
66            projects: Vec::new(),
67            star: StarPrompt::default(),
68        }
69    }
70}
71
72#[derive(Debug)]
73pub struct ConfigStore {
74    path: PathBuf,
75    data: ConfigData,
76}
77
78impl ConfigStore {
79    pub fn load() -> Result<Self> {
80        Self::load_from(config_path()?)
81    }
82
83    pub fn load_from(path: PathBuf) -> Result<Self> {
84        let data = match fs::read(&path) {
85            Ok(bytes) => serde_json::from_slice(&bytes)
86                .with_context(|| format!("Could not parse {}", path.display()))?,
87            Err(error) if error.kind() == std::io::ErrorKind::NotFound => ConfigData::default(),
88            Err(error) => {
89                return Err(error).with_context(|| format!("Could not read {}", path.display()));
90            }
91        };
92        Ok(Self { path, data })
93    }
94
95    pub fn path(&self) -> &Path {
96        &self.path
97    }
98    pub fn data(&self) -> &ConfigData {
99        &self.data
100    }
101    pub fn data_mut(&mut self) -> &mut ConfigData {
102        &mut self.data
103    }
104
105    pub fn gateway_url(&self) -> String {
106        env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| self.data.gateway_url.clone())
107    }
108
109    pub fn gateway_source(&self) -> &'static str {
110        if env::var_os("EXEORA_GATEWAY_URL").is_some() {
111            "env"
112        } else if self.data.gateway_url == DEFAULT_GATEWAY {
113            "default"
114        } else {
115            "config"
116        }
117    }
118
119    pub fn find_project(&self, id: &str) -> Option<&ProjectEntry> {
120        self.data.projects.iter().find(|entry| entry.id == id)
121    }
122
123    pub fn upsert_project(&mut self, project: ProjectEntry) {
124        self.data.projects.retain(|entry| entry.id != project.id);
125        self.data.projects.push(project);
126    }
127
128    pub fn remove_project(&mut self, id: &str) {
129        self.data.projects.retain(|entry| entry.id != id);
130    }
131
132    pub fn forget_local_state(&mut self) {
133        self.data.device_id = None;
134        self.data.device_name = None;
135        self.data.projects.clear();
136    }
137
138    pub fn save(&self) -> Result<()> {
139        if let Some(parent) = self.path.parent() {
140            fs::create_dir_all(parent)?;
141        }
142        let mut file = AtomicWriteFile::options()
143            .open(&self.path)
144            .with_context(|| format!("Could not open {}", self.path.display()))?;
145        file.write_all(&serde_json::to_vec_pretty(&self.data)?)?;
146        file.write_all(b"\n")?;
147        file.commit()
148            .with_context(|| format!("Could not save {}", self.path.display()))?;
149        Ok(())
150    }
151}
152
153pub fn config_path() -> Result<PathBuf> {
154    if let Some(path) = env::var_os("EXEORA_CONFIG_PATH") {
155        return Ok(PathBuf::from(path));
156    }
157
158    #[cfg(target_os = "windows")]
159    {
160        let base = env::var_os("APPDATA")
161            .or_else(|| env::var_os("USERPROFILE"))
162            .context("Neither APPDATA nor USERPROFILE is set")?;
163        return Ok(PathBuf::from(base).join("exeora-nodejs/Config/config.json"));
164    }
165    #[cfg(target_os = "macos")]
166    {
167        return Ok(home_dir()?.join("Library/Preferences/exeora-nodejs/config.json"));
168    }
169    #[cfg(all(unix, not(target_os = "macos")))]
170    {
171        let base = env::var_os("XDG_CONFIG_HOME")
172            .map(PathBuf::from)
173            .unwrap_or(home_dir()?.join(".config"));
174        Ok(base.join("exeora-nodejs/config.json"))
175    }
176}
177
178pub fn credential_fallback_path() -> Result<PathBuf> {
179    if let Some(path) = env::var_os("EXEORA_CREDENTIAL_PATH") {
180        return Ok(PathBuf::from(path));
181    }
182    #[cfg(target_os = "windows")]
183    let base = env::var_os("XDG_CONFIG_HOME")
184        .map(PathBuf::from)
185        .unwrap_or(home_dir()?);
186    #[cfg(not(target_os = "windows"))]
187    let base = env::var_os("XDG_CONFIG_HOME")
188        .map(PathBuf::from)
189        .unwrap_or(home_dir()?.join(".config"));
190    Ok(base.join("exeora/credentials.json"))
191}
192
193fn home_dir() -> Result<PathBuf> {
194    env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
195        .map(PathBuf::from)
196        .context("Could not determine the home directory")
197}