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}