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	#[serde(default)]
125	pub env: HashMap<String, String>,
126}
127
128pub fn load_global_config() -> GlobalConfig {
129	let path = config_dir().join("config.toml");
130	if path.exists() {
131		match std::fs::read_to_string(&path) {
132			Ok(content) => match toml::from_str(&content) {
133				Ok(config) => return config,
134				Err(e) => eprintln!("warning: failed to parse {}: {}", path.display(), e),
135			},
136			Err(e) => eprintln!("warning: failed to read {}: {}", path.display(), e),
137		}
138	}
139	GlobalConfig::default()
140}
141
142pub struct ServiceEntry {
143	pub name: String,
144	pub dir: PathBuf,
145	pub command: Option<String>,
146}
147
148pub fn load_projects() -> BTreeMap<String, ServiceEntry> {
149	let path = config_dir().join("projects");
150	let mut services = BTreeMap::new();
151	let content = match std::fs::read_to_string(&path) {
152		Ok(c) => c,
153		Err(_) => return services,
154	};
155	for line in content.lines() {
156		let line = line.trim();
157		if line.is_empty() || line.starts_with('#') {
158			continue;
159		}
160		let (name, dir_str) = if let Some(pos) = line.find(':') {
161			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
162		} else if let Some(pos) = line.find('\t') {
163			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
164		} else {
165			continue;
166		};
167		let dir = expand_tilde(&dir_str);
168		if !dir.exists() {
169			eprintln!("warning: directory does not exist for {}: {}", name, dir.display());
170			continue;
171		}
172		services.insert(name.clone(), ServiceEntry { name, dir, command: None });
173	}
174	services
175}
176
177pub fn load_commands() -> BTreeMap<String, ServiceEntry> {
178	let path = config_dir().join("commands");
179	let mut services = BTreeMap::new();
180	let content = match std::fs::read_to_string(&path) {
181		Ok(c) => c,
182		Err(_) => return services,
183	};
184	let commands_dir = config_dir().join("_commands");
185	for line in content.lines() {
186		let line = line.trim();
187		if line.is_empty() || line.starts_with('#') {
188			continue;
189		}
190		let (name, command) = if let Some(pos) = line.find(':') {
191			(line[..pos].trim().to_string(), line[pos + 1..].trim().to_string())
192		} else {
193			continue;
194		};
195		let dir = commands_dir.join(&name);
196		let _ = std::fs::create_dir_all(&dir);
197		let procfile = dir.join("Procfile");
198		let procfile_content = format!("{}: {}\n", name, command);
199		let needs_write = match std::fs::read_to_string(&procfile) {
200			Ok(existing) => existing != procfile_content,
201			Err(_) => true,
202		};
203		if needs_write {
204			let _ = std::fs::write(&procfile, &procfile_content);
205		}
206		services.insert(
207			name.clone(),
208			ServiceEntry { name, dir, command: Some(command) },
209		);
210	}
211	services
212}
213
214pub fn load_service_entries() -> BTreeMap<String, ServiceEntry> {
215	let mut services = load_projects();
216	services.extend(load_commands());
217	services
218}
219
220pub fn load_service(entry: &ServiceEntry, defaults: &DefaultsConfig) -> Service {
221	let mut processes = Vec::new();
222	let procfile_path = entry.dir.join("Procfile");
223	if let Ok(content) = std::fs::read_to_string(&procfile_path) {
224		for line in content.lines() {
225			let line = line.trim();
226			if line.is_empty() || line.starts_with('#') {
227				continue;
228			}
229			if let Some(pos) = line.find(':') {
230				let name = line[..pos].trim().to_string();
231				let command = line[pos + 1..].trim().to_string();
232				processes.push(ProcessDef {
233					name,
234					command,
235					restart: defaults.restart,
236					max_retries: defaults.max_retries,
237					restart_delay_secs: defaults.restart_delay,
238					env: defaults.env.clone(),
239				});
240			}
241		}
242	}
243
244	let toml_path = entry.dir.join(".ubermind.toml");
245	if let Ok(content) = std::fs::read_to_string(&toml_path) {
246		if let Ok(overrides) = toml::from_str::<ProjectToml>(&content) {
247			for proc in &mut processes {
248				if let Some(ov) = overrides.processes.get(&proc.name) {
249					if let Some(ref cmd) = ov.command {
250						proc.command = cmd.clone();
251					}
252					if let Some(r) = ov.restart {
253						proc.restart = r;
254					}
255					if let Some(r) = ov.max_retries {
256						proc.max_retries = r;
257					}
258					if let Some(r) = ov.restart_delay {
259						proc.restart_delay_secs = r;
260					}
261					proc.env.extend(ov.env.clone());
262				}
263			}
264			for (name, ov) in &overrides.processes {
265				if !processes.iter().any(|p| &p.name == name) {
266					if let Some(ref cmd) = ov.command {
267						processes.push(ProcessDef {
268							name: name.clone(),
269							command: cmd.clone(),
270							restart: ov.restart.unwrap_or(defaults.restart),
271							max_retries: ov.max_retries.unwrap_or(defaults.max_retries),
272							restart_delay_secs: ov.restart_delay.unwrap_or(defaults.restart_delay),
273							env: {
274								let mut e = defaults.env.clone();
275								e.extend(ov.env.clone());
276								e
277							},
278						});
279					}
280				}
281			}
282		}
283	}
284
285	Service {
286		name: entry.name.clone(),
287		dir: entry.dir.clone(),
288		processes,
289	}
290}
291
292fn expand_tilde(path: &str) -> PathBuf {
293	if let Some(rest) = path.strip_prefix("~/") {
294		if let Some(home) = std::env::var("HOME").ok() {
295			return PathBuf::from(home).join(rest);
296		}
297	}
298	PathBuf::from(path)
299}