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}