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}