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, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(rename_all = "camelCase")]
23pub enum WorktreeSyncState {
24    PendingUpsert,
25    #[default]
26    Active,
27    PendingDelete,
28    Disabled,
29    Removing,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "camelCase")]
34pub struct WorktreeEntry {
35    pub id: String,
36    pub project_id: String,
37    pub slug: String,
38    pub name: String,
39    #[serde(default)]
40    pub branch: Option<String>,
41    pub git_root: PathBuf,
42    pub root: PathBuf,
43    #[serde(default)]
44    pub managed: bool,
45    #[serde(default)]
46    pub sync_state: WorktreeSyncState,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct StarPrompt {
52    pub runs: u64,
53    pub ask_at: u64,
54    pub asked: u64,
55    pub done: bool,
56}
57
58impl Default for StarPrompt {
59    fn default() -> Self {
60        Self {
61            runs: 0,
62            ask_at: 3,
63            asked: 0,
64            done: false,
65        }
66    }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct ConfigData {
72    #[serde(default = "default_gateway")]
73    pub gateway_url: String,
74    #[serde(default)]
75    pub device_id: Option<String>,
76    #[serde(default)]
77    pub device_name: Option<String>,
78    #[serde(default)]
79    pub projects: Vec<ProjectEntry>,
80    #[serde(default)]
81    pub worktrees: Vec<WorktreeEntry>,
82    #[serde(default)]
83    pub worktree_root: Option<PathBuf>,
84    #[serde(default)]
85    pub star: StarPrompt,
86}
87
88fn default_gateway() -> String {
89    DEFAULT_GATEWAY.to_owned()
90}
91
92impl Default for ConfigData {
93    fn default() -> Self {
94        Self {
95            gateway_url: env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| default_gateway()),
96            device_id: None,
97            device_name: None,
98            projects: Vec::new(),
99            worktrees: Vec::new(),
100            worktree_root: None,
101            star: StarPrompt::default(),
102        }
103    }
104}
105
106#[derive(Debug)]
107pub struct ConfigStore {
108    path: PathBuf,
109    data: ConfigData,
110}
111
112impl ConfigStore {
113    pub fn load() -> Result<Self> {
114        Self::load_from(config_path()?)
115    }
116
117    pub fn load_from(path: PathBuf) -> Result<Self> {
118        let data = match fs::read(&path) {
119            Ok(bytes) => serde_json::from_slice(&bytes)
120                .with_context(|| format!("Could not parse {}", path.display()))?,
121            Err(error) if error.kind() == std::io::ErrorKind::NotFound => ConfigData::default(),
122            Err(error) => {
123                return Err(error).with_context(|| format!("Could not read {}", path.display()));
124            }
125        };
126        Ok(Self { path, data })
127    }
128
129    pub fn path(&self) -> &Path {
130        &self.path
131    }
132    pub fn data(&self) -> &ConfigData {
133        &self.data
134    }
135    pub fn data_mut(&mut self) -> &mut ConfigData {
136        &mut self.data
137    }
138
139    pub fn gateway_url(&self) -> String {
140        env::var("EXEORA_GATEWAY_URL").unwrap_or_else(|_| self.data.gateway_url.clone())
141    }
142
143    pub fn gateway_source(&self) -> &'static str {
144        if env::var_os("EXEORA_GATEWAY_URL").is_some() {
145            "env"
146        } else if self.data.gateway_url == DEFAULT_GATEWAY {
147            "default"
148        } else {
149            "config"
150        }
151    }
152
153    pub fn worktree_root(&self) -> Result<PathBuf> {
154        if let Some(path) = env::var_os("EXEORA_WORKTREE_ROOT") {
155            return absolute_path(PathBuf::from(path));
156        }
157        if let Some(path) = &self.data.worktree_root {
158            return absolute_path(path.clone());
159        }
160        default_worktree_root()
161    }
162
163    pub fn worktree_root_source(&self) -> &'static str {
164        if env::var_os("EXEORA_WORKTREE_ROOT").is_some() {
165            "env"
166        } else if self.data.worktree_root.is_some() {
167            "config"
168        } else {
169            "default"
170        }
171    }
172
173    pub fn find_project(&self, id: &str) -> Option<&ProjectEntry> {
174        self.data.projects.iter().find(|entry| entry.id == id)
175    }
176
177    pub fn upsert_project(&mut self, project: ProjectEntry) {
178        self.data.projects.retain(|entry| entry.id != project.id);
179        self.data.projects.push(project);
180    }
181
182    pub fn remove_project(&mut self, id: &str) {
183        self.data.projects.retain(|entry| entry.id != id);
184        self.data.worktrees.retain(|entry| entry.project_id != id);
185    }
186
187    pub fn upsert_worktree(&mut self, worktree: WorktreeEntry) {
188        self.data.worktrees.retain(|entry| entry.id != worktree.id);
189        self.data.worktrees.push(worktree);
190    }
191
192    pub fn remove_worktree(&mut self, id: &str) {
193        self.data.worktrees.retain(|entry| entry.id != id);
194    }
195
196    pub fn forget_local_state(&mut self) {
197        self.data.device_id = None;
198        self.data.device_name = None;
199        self.data.projects.clear();
200        self.data.worktrees.clear();
201    }
202
203    pub fn save(&self) -> Result<()> {
204        if let Some(parent) = self.path.parent() {
205            fs::create_dir_all(parent)?;
206        }
207        let _lock = ConfigLock::acquire(&self.path)?;
208        let mut file = AtomicWriteFile::options()
209            .open(&self.path)
210            .with_context(|| format!("Could not open {}", self.path.display()))?;
211        file.write_all(&serde_json::to_vec_pretty(&self.data)?)?;
212        file.write_all(b"\n")?;
213        file.commit()
214            .with_context(|| format!("Could not save {}", self.path.display()))?;
215        Ok(())
216    }
217}
218
219struct ConfigLock(PathBuf);
220
221impl ConfigLock {
222    fn acquire(config: &Path) -> Result<Self> {
223        let lock = config.with_extension("lock");
224        for _ in 0..100 {
225            match fs::OpenOptions::new()
226                .write(true)
227                .create_new(true)
228                .open(&lock)
229            {
230                Ok(_) => return Ok(Self(lock)),
231                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
232                    let stale = fs::metadata(&lock)
233                        .and_then(|metadata| metadata.modified())
234                        .ok()
235                        .and_then(|modified| modified.elapsed().ok())
236                        .is_some_and(|age| age.as_secs() >= 30);
237                    if stale {
238                        let _ = fs::remove_file(&lock);
239                    } else {
240                        std::thread::sleep(std::time::Duration::from_millis(20));
241                    }
242                }
243                Err(error) => return Err(error.into()),
244            }
245        }
246        anyhow::bail!("Timed out waiting to update {}", config.display())
247    }
248}
249
250impl Drop for ConfigLock {
251    fn drop(&mut self) {
252        let _ = fs::remove_file(&self.0);
253    }
254}
255
256fn absolute_path(path: PathBuf) -> Result<PathBuf> {
257    if path.is_absolute() {
258        Ok(path)
259    } else {
260        Ok(env::current_dir()?.join(path))
261    }
262}
263
264pub fn default_worktree_root() -> Result<PathBuf> {
265    #[cfg(target_os = "windows")]
266    {
267        let base = env::var_os("LOCALAPPDATA")
268            .or_else(|| env::var_os("APPDATA"))
269            .context("Neither LOCALAPPDATA nor APPDATA is set")?;
270        return Ok(PathBuf::from(base).join("Exeora/worktrees"));
271    }
272    #[cfg(target_os = "macos")]
273    {
274        return Ok(home_dir()?.join("Library/Application Support/Exeora/worktrees"));
275    }
276    #[cfg(all(unix, not(target_os = "macos")))]
277    {
278        let base = env::var_os("XDG_DATA_HOME")
279            .map(PathBuf::from)
280            .unwrap_or(home_dir()?.join(".local/share"));
281        Ok(base.join("exeora/worktrees"))
282    }
283}
284
285pub fn config_path() -> Result<PathBuf> {
286    if let Some(path) = env::var_os("EXEORA_CONFIG_PATH") {
287        return Ok(PathBuf::from(path));
288    }
289
290    #[cfg(target_os = "windows")]
291    {
292        let base = env::var_os("APPDATA")
293            .or_else(|| env::var_os("USERPROFILE"))
294            .context("Neither APPDATA nor USERPROFILE is set")?;
295        return Ok(PathBuf::from(base).join("exeora/config.json"));
296    }
297    #[cfg(target_os = "macos")]
298    {
299        return Ok(home_dir()?.join("Library/Preferences/exeora/config.json"));
300    }
301    #[cfg(all(unix, not(target_os = "macos")))]
302    {
303        let base = env::var_os("XDG_CONFIG_HOME")
304            .map(PathBuf::from)
305            .unwrap_or(home_dir()?.join(".config"));
306        Ok(base.join("exeora/config.json"))
307    }
308}
309
310pub fn credential_fallback_path() -> Result<PathBuf> {
311    if let Some(path) = env::var_os("EXEORA_CREDENTIAL_PATH") {
312        return Ok(PathBuf::from(path));
313    }
314    #[cfg(target_os = "windows")]
315    let base = env::var_os("XDG_CONFIG_HOME")
316        .map(PathBuf::from)
317        .unwrap_or(home_dir()?);
318    #[cfg(not(target_os = "windows"))]
319    let base = env::var_os("XDG_CONFIG_HOME")
320        .map(PathBuf::from)
321        .unwrap_or(home_dir()?.join(".config"));
322    Ok(base.join("exeora/credentials.json"))
323}
324
325fn home_dir() -> Result<PathBuf> {
326    env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
327        .map(PathBuf::from)
328        .context("Could not determine the home directory")
329}