Skip to main content

ubermind_core/
config.rs

1use crate::protocol::config_dir;
2use crate::types::{ProcessDef, Service};
3use serde::Deserialize;
4use std::collections::{BTreeMap, HashMap};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Deserialize, Default)]
8pub struct GlobalConfig {
9	#[serde(default)]
10	pub daemon: DaemonConfig,
11	#[serde(default)]
12	pub logs: LogsConfig,
13	#[serde(default)]
14	pub defaults: DefaultsConfig,
15}
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct DaemonConfig {
19	#[serde(default = "default_idle_timeout")]
20	pub idle_timeout: u64,
21	pub log_dir: Option<String>,
22	#[serde(default = "default_port")]
23	pub port: u16,
24}
25
26impl Default for DaemonConfig {
27	fn default() -> Self {
28		Self {
29			idle_timeout: default_idle_timeout(),
30			log_dir: None,
31			port: default_port(),
32		}
33	}
34}
35
36fn default_idle_timeout() -> u64 {
37	300
38}
39fn default_port() -> u16 {
40	13369
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct LogsConfig {
45	#[serde(default = "default_max_size")]
46	pub max_size_bytes: u64,
47	#[serde(default = "default_max_age_days")]
48	pub max_age_days: u32,
49	#[serde(default = "default_max_files")]
50	pub max_files: u32,
51}
52
53impl Default for LogsConfig {
54	fn default() -> Self {
55		Self {
56			max_size_bytes: default_max_size(),
57			max_age_days: default_max_age_days(),
58			max_files: default_max_files(),
59		}
60	}
61}
62
63fn default_max_size() -> u64 {
64	10 * 1024 * 1024
65}
66fn default_max_age_days() -> u32 {
67	7
68}
69fn default_max_files() -> u32 {
70	5
71}
72
73#[derive(Debug, Clone, Deserialize)]
74pub struct DefaultsConfig {
75	#[serde(default = "default_true")]
76	pub restart: bool,
77	#[serde(default = "default_max_retries")]
78	pub max_retries: u32,
79	#[serde(default = "default_restart_delay")]
80	pub restart_delay: u64,
81	#[serde(default = "default_env")]
82	pub env: HashMap<String, String>,
83}
84
85impl Default for DefaultsConfig {
86	fn default() -> Self {
87		Self {
88			restart: true,
89			max_retries: default_max_retries(),
90			restart_delay: default_restart_delay(),
91			env: default_env(),
92		}
93	}
94}
95
96fn default_true() -> bool {
97	true
98}
99fn default_max_retries() -> u32 {
100	3
101}
102fn default_restart_delay() -> u64 {
103	1
104}
105fn default_env() -> HashMap<String, String> {
106	let mut env = HashMap::new();
107	env.insert("FORCE_COLOR".into(), "1".into());
108	env.insert("CLICOLOR_FORCE".into(), "1".into());
109	env
110}
111
112#[derive(Debug, Clone, Deserialize, Default)]
113pub struct ProjectToml {
114	#[serde(default)]
115	pub processes: HashMap<String, ProcessOverride>,
116}
117
118#[derive(Debug, Clone, Deserialize, Default)]
119pub struct ProcessOverride {
120	pub command: Option<String>,
121	pub restart: Option<bool>,
122	pub max_retries: Option<u32>,
123	pub restart_delay: Option<u64>,
124	pub autostart: Option<bool>,
125	#[serde(default)]
126	pub env: HashMap<String, String>,
127}
128
129pub fn load_global_config() -> GlobalConfig {
130	let path = config_dir().join("config.toml");
131	if path.exists() {
132		match std::fs::read_to_string(&path) {
133			Ok(content) => match toml::from_str(&content) {
134				Ok(config) => return config,
135				Err(e) => eprintln!("warning: failed to parse {}: {}", path.display(), e),
136			},
137			Err(e) => eprintln!("warning: failed to read {}: {}", path.display(), e),
138		}
139	}
140	GlobalConfig::default()
141}
142
143pub struct ServiceEntry {
144	pub name: String,
145	pub dir: PathBuf,
146	pub command: Option<String>,
147}
148
149pub fn load_projects() -> BTreeMap<String, ServiceEntry> {
150	let path = config_dir().join("projects");
151	let mut services = BTreeMap::new();
152	let content = match std::fs::read_to_string(&path) {
153		Ok(c) => c,
154		Err(_) => return services,
155	};
156	for line in content.lines() {
157		let line = line.trim();
158		if line.is_empty() || line.starts_with('#') {
159			continue;
160		}
161		let (name, dir_str) = if let Some(pos) = line.find(':') {
162			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
163		} else if let Some(pos) = line.find('\t') {
164			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
165		} else {
166			continue;
167		};
168		let dir = expand_tilde(&dir_str);
169		if !dir.exists() {
170			eprintln!("warning: directory does not exist for {}: {}", name, dir.display());
171			continue;
172		}
173		services.insert(name.clone(), ServiceEntry { name, dir, command: None });
174	}
175	services
176}
177
178pub fn load_commands() -> BTreeMap<String, ServiceEntry> {
179	let path = config_dir().join("commands");
180	let mut services = BTreeMap::new();
181	let content = match std::fs::read_to_string(&path) {
182		Ok(c) => c,
183		Err(_) => return services,
184	};
185	let commands_dir = config_dir().join("_commands");
186	for line in content.lines() {
187		let line = line.trim();
188		if line.is_empty() || line.starts_with('#') {
189			continue;
190		}
191		let (name, command) = if let Some(pos) = line.find(':') {
192			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
193		} else {
194			continue;
195		};
196		let dir = commands_dir.join(&name);
197		let _ = std::fs::create_dir_all(&dir);
198		let procfile = dir.join("Procfile");
199		let procfile_content = format!("{}: {}\n", name, command);
200		let needs_write = match std::fs::read_to_string(&procfile) {
201			Ok(existing) => existing != procfile_content,
202			Err(_) => true,
203		};
204		if needs_write {
205			let _ = std::fs::write(&procfile, &procfile_content);
206		}
207		services.insert(
208			name.clone(),
209			ServiceEntry { name, dir, command: Some(command) },
210		);
211	}
212	services
213}
214
215pub fn load_service_entries() -> BTreeMap<String, ServiceEntry> {
216	let mut services = load_projects();
217	services.extend(load_commands());
218	services
219}
220
221pub fn load_service(entry: &ServiceEntry, defaults: &DefaultsConfig) -> Service {
222	let mut processes = Vec::new();
223	let procfile_path = entry.dir.join("Procfile");
224	if let Ok(content) = std::fs::read_to_string(&procfile_path) {
225		for line in content.lines() {
226			let line = line.trim();
227			if line.is_empty() {
228				continue;
229			}
230
231			let (proc_line, autostart) = if line.starts_with('#') {
232				let after_hash = line[1..].trim_start();
233				if let Some(rest) = after_hash.strip_prefix('~') {
234					(rest.trim(), false)
235				} else {
236					continue;
237				}
238			} else {
239				(line, true)
240			};
241
242			if let Some(pos) = proc_line.find(':') {
243				let name = proc_line[..pos].trim().to_string();
244				let command = proc_line[pos + 1..].trim().to_string();
245				if name.is_empty() || command.is_empty() {
246					continue;
247				}
248				processes.push(ProcessDef {
249					name,
250					command,
251					restart: defaults.restart,
252					max_retries: defaults.max_retries,
253					restart_delay_secs: defaults.restart_delay,
254					env: defaults.env.clone(),
255					autostart,
256				});
257			}
258		}
259	}
260
261	let toml_path = entry.dir.join(".ubermind.toml");
262	if let Ok(content) = std::fs::read_to_string(&toml_path) {
263		if let Ok(overrides) = toml::from_str::<ProjectToml>(&content) {
264			for proc in &mut processes {
265				if let Some(ov) = overrides.processes.get(&proc.name) {
266					if let Some(ref cmd) = ov.command {
267						proc.command = cmd.clone();
268					}
269					if let Some(r) = ov.restart {
270						proc.restart = r;
271					}
272					if let Some(r) = ov.max_retries {
273						proc.max_retries = r;
274					}
275					if let Some(r) = ov.restart_delay {
276						proc.restart_delay_secs = r;
277					}
278					if let Some(a) = ov.autostart {
279						proc.autostart = a;
280					}
281					proc.env.extend(ov.env.clone());
282				}
283			}
284			for (name, ov) in &overrides.processes {
285				if !processes.iter().any(|p| &p.name == name) {
286					if let Some(ref cmd) = ov.command {
287						processes.push(ProcessDef {
288							name: name.clone(),
289							command: cmd.clone(),
290							restart: ov.restart.unwrap_or(defaults.restart),
291							max_retries: ov.max_retries.unwrap_or(defaults.max_retries),
292							restart_delay_secs: ov.restart_delay.unwrap_or(defaults.restart_delay),
293							env: {
294								let mut e = defaults.env.clone();
295								e.extend(ov.env.clone());
296								e
297							},
298							autostart: ov.autostart.unwrap_or(true),
299						});
300					}
301				}
302			}
303		}
304	}
305
306	Service {
307		name: entry.name.clone(),
308		dir: entry.dir.clone(),
309		processes,
310	}
311}
312
313fn expand_tilde(path: &str) -> PathBuf {
314	if let Some(rest) = path.strip_prefix("~/") {
315		if let Some(home) = std::env::var("HOME").ok() {
316			return PathBuf::from(home).join(rest);
317		}
318	}
319	PathBuf::from(path)
320}