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}