1use crate::daemon::Daemon;
2use crate::daemon_id::DaemonId;
3use crate::error::FileError;
4use crate::{Result, env};
5use once_cell::sync::Lazy;
6use std::collections::{BTreeMap, BTreeSet};
7use std::fmt::Debug;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct StateFile {
12 #[serde(default)]
13 pub daemons: BTreeMap<DaemonId, Daemon>,
14 #[serde(default)]
15 pub disabled: BTreeSet<DaemonId>,
16 #[serde(default)]
17 pub shell_dirs: BTreeMap<String, PathBuf>,
18 #[serde(skip)]
19 pub(crate) path: PathBuf,
20}
21
22impl StateFile {
23 pub fn new(path: PathBuf) -> Self {
24 Self {
25 daemons: Default::default(),
26 disabled: Default::default(),
27 shell_dirs: Default::default(),
28 path,
29 }
30 }
31
32 pub fn get() -> &'static Self {
33 static STATE_FILE: Lazy<StateFile> = Lazy::new(|| {
34 let path = &*env::PITCHFORK_STATE_FILE;
35 StateFile::read(path).unwrap_or_else(|e| {
36 error!(
37 "failed to read state file {}: {}. Falling back to in-memory empty state",
38 path.display(),
39 e
40 );
41 StateFile::new(path.to_path_buf())
42 })
43 });
44 &STATE_FILE
45 }
46
47 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
48 let path = path.as_ref();
49 if !path.exists() {
50 return Ok(Self::new(path.to_path_buf()));
51 }
52 let canonical_path = normalized_lock_path(path);
53 let _lock = xx::fslock::get(&canonical_path, false)?;
54 let raw = xx::file::read_to_string(path).unwrap_or_else(|e| {
55 warn!("Error reading state file {path:?}: {e}");
56 String::new()
57 });
58
59 match toml::from_str::<Self>(&raw) {
61 Ok(mut state_file) => {
62 state_file.path = path.to_path_buf();
63 for (id, daemon) in state_file.daemons.iter_mut() {
64 daemon.id = id.clone();
65 }
66 Ok(state_file)
67 }
68 Err(parse_err) => {
69 if Self::looks_like_old_format(&raw) {
70 debug!(
72 "State file at {} appears to be in old format, attempting silent migration",
73 path.display()
74 );
75 match Self::migrate_old_format(&raw) {
76 Ok(migrated) => {
77 let mut state_file = migrated;
78 state_file.path = path.to_path_buf();
79 if let Err(e) = state_file.write_unlocked() {
81 warn!("State file migration write failed: {e}");
82 }
83 debug!("State file migrated successfully");
84 return Ok(state_file);
85 }
86 Err(e) => {
87 error!(
88 "State file migration failed: {e}. \
89 Raw content preserved at {}. Starting with empty state.",
90 path.display()
91 );
92 return Err(miette::miette!(
93 "Failed to migrate state file {}: {e}",
94 path.display()
95 ));
96 }
97 }
98 }
99 Err(miette::miette!(
101 "Failed to parse state file {}: {parse_err}",
102 path.display()
103 ))
104 }
105 }
106 }
107
108 fn looks_like_old_format(raw: &str) -> bool {
113 use toml::Value;
114 let Ok(Value::Table(doc)) = toml::from_str::<Value>(raw) else {
115 return false;
116 };
117 let Some(Value::Table(daemons)) = doc.get("daemons") else {
118 return false;
119 };
120 !daemons.is_empty() && daemons.keys().any(|k| !k.contains('/'))
122 }
123
124 fn migrate_old_format(raw: &str) -> Result<Self> {
127 use toml::Value;
128
129 const LEGACY_NAMESPACE: &str = "legacy";
130
131 let mut doc: toml::map::Map<String, Value> = toml::from_str(raw)
133 .map_err(|e| miette::miette!("failed to parse old state file: {e}"))?;
134
135 if let Some(Value::Table(daemons)) = doc.get_mut("daemons") {
137 let old_keys: Vec<String> = daemons.keys().cloned().collect();
138 for key in old_keys {
139 if !key.contains('/')
140 && let Some(val) = daemons.remove(&key)
141 {
142 let mut new_key = format!("{LEGACY_NAMESPACE}/{key}");
143 if daemons.contains_key(&new_key) {
145 let base = format!("{key}-legacy");
146 let mut candidate = format!("{LEGACY_NAMESPACE}/{base}");
147 let mut n: u32 = 2;
148 while daemons.contains_key(&candidate) {
149 candidate = format!("{LEGACY_NAMESPACE}/{base}-{n}");
150 n += 1;
151 }
152 warn!(
153 "Legacy daemon key '{}' collides with '{}'; migrating as '{}'",
154 key,
155 format_args!("{LEGACY_NAMESPACE}/{key}"),
156 candidate
157 );
158 new_key = candidate;
159 }
160 let val = if let Value::Table(mut tbl) = val {
162 tbl.insert("id".to_string(), Value::String(new_key.clone()));
163 Value::Table(tbl)
164 } else {
165 val
166 };
167 daemons.insert(new_key, val);
168 }
169 }
170 }
171
172 if let Some(Value::Array(disabled)) = doc.get_mut("disabled") {
174 for entry in disabled.iter_mut() {
175 if let Value::String(s) = entry
176 && !s.contains('/')
177 {
178 *s = format!("{LEGACY_NAMESPACE}/{s}");
179 }
180 }
181 }
182
183 let new_raw =
184 toml::to_string(&Value::Table(doc)).map_err(|e| FileError::SerializeError {
185 path: PathBuf::new(),
186 source: e,
187 })?;
188
189 let mut state_file: Self = toml::from_str(&new_raw)
190 .map_err(|e| miette::miette!("failed to parse migrated state file: {e}"))?;
191 for (id, daemon) in state_file.daemons.iter_mut() {
193 daemon.id = id.clone();
194 }
195 Ok(state_file)
196 }
197
198 pub fn write(&self) -> Result<()> {
199 if let Some(parent) = self.path.parent() {
200 std::fs::create_dir_all(parent).map_err(|e| FileError::WriteError {
201 path: parent.to_path_buf(),
202 details: Some(format!("failed to create state file directory: {e}")),
203 })?;
204 }
205 let canonical_path = normalized_lock_path(&self.path);
206 let _lock = xx::fslock::get(&canonical_path, false)?;
207 self.write_unlocked()
208 }
209
210 fn write_unlocked(&self) -> Result<()> {
213 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
214 path: self.path.clone(),
215 source: e,
216 })?;
217
218 let temp_path = self.path.with_extension("toml.tmp");
221 xx::file::write(&temp_path, &raw).map_err(|e| FileError::WriteError {
222 path: temp_path.clone(),
223 details: Some(e.to_string()),
224 })?;
225 std::fs::rename(&temp_path, &self.path).map_err(|e| FileError::WriteError {
226 path: self.path.clone(),
227 details: Some(format!("failed to rename temp file: {e}")),
228 })?;
229 Ok(())
230 }
231}
232
233fn normalized_lock_path(path: &Path) -> PathBuf {
234 if let Ok(canonical) = path.canonicalize() {
235 return canonical;
236 }
237
238 if let Some(parent) = path.parent()
239 && let Ok(canonical_parent) = parent.canonicalize()
240 && let Some(file_name) = path.file_name()
241 {
242 return canonical_parent.join(file_name);
243 }
244
245 path.to_path_buf()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::daemon_status::DaemonStatus;
252
253 #[test]
254 fn test_state_file_toml_roundtrip_stopped() {
255 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
256 let daemon_id = DaemonId::new("project", "test");
257 state.daemons.insert(
258 daemon_id.clone(),
259 Daemon {
260 id: daemon_id,
261 title: None,
262 pid: None,
263 shell_pid: None,
264 status: DaemonStatus::Stopped,
265 dir: None,
266 cmd: None,
267 autostop: false,
268 cron_schedule: None,
269 cron_retrigger: None,
270 last_cron_triggered: None,
271 last_exit_success: Some(true),
272 retry: 0,
273 retry_count: 0,
274 ready_delay: None,
275 ready_output: None,
276 ready_http: None,
277 ready_port: None,
278 ready_cmd: None,
279 expected_port: vec![],
280 auto_bump_port: false,
281 port_bump_attempts: 0,
282 resolved_port: vec![],
283 depends: vec![],
284 env: None,
285 watch: vec![],
286 watch_mode: crate::pitchfork_toml::WatchMode::default(),
287 watch_base_dir: None,
288 mise: None,
289 user: Some("postgres".to_string()),
290 active_port: None,
291 slug: None,
292 proxy: None,
293 memory_limit: None,
294 cpu_limit: None,
295 },
296 );
297
298 let toml_str = toml::to_string(&state).unwrap();
299 println!("Serialized TOML:\n{toml_str}");
300
301 let parsed: StateFile = toml::from_str(&toml_str).expect("Failed to parse TOML");
302 println!("Parsed: {parsed:?}");
303
304 assert!(
305 parsed
306 .daemons
307 .contains_key(&DaemonId::new("project", "test"))
308 );
309 let daemon = parsed
310 .daemons
311 .get(&DaemonId::new("project", "test"))
312 .unwrap();
313 assert_eq!(daemon.user.as_deref(), Some("postgres"));
314 }
315
316 #[test]
317 fn test_looks_like_old_format_bare_names() {
318 let old = r#"
319[daemons.api]
320id = "api"
321autostop = false
322retry = 0
323retry_count = 0
324status = "stopped"
325"#;
326 assert!(StateFile::looks_like_old_format(old));
327 }
328
329 #[test]
330 fn test_looks_like_old_format_new_format() {
331 let new = r#"
332 disabled = []
333
334 [daemons."legacy/api"]
335 id = "legacy/api"
336autostop = false
337retry = 0
338retry_count = 0
339status = "stopped"
340"#;
341 assert!(!StateFile::looks_like_old_format(new));
342 }
343
344 #[test]
345 fn test_looks_like_old_format_empty() {
346 assert!(!StateFile::looks_like_old_format(""));
347 assert!(!StateFile::looks_like_old_format("[shell_dirs]"));
348 }
349
350 #[test]
351 fn test_migrate_old_format_basic() {
352 let old = r#"
353[daemons.api]
354id = "api"
355autostop = false
356retry = 0
357retry_count = 0
358status = "stopped"
359
360[daemons.worker]
361id = "worker"
362autostop = false
363retry = 0
364retry_count = 0
365status = "stopped"
366last_exit_success = true
367"#;
368 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
369 assert!(
370 migrated
371 .daemons
372 .contains_key(&DaemonId::new("legacy", "api")),
373 "api should be migrated to legacy/api"
374 );
375 assert!(
376 migrated
377 .daemons
378 .contains_key(&DaemonId::new("legacy", "worker")),
379 "worker should be migrated to legacy/worker"
380 );
381 assert_eq!(migrated.daemons.len(), 2);
382 }
383
384 #[test]
385 fn test_migrate_old_format_preserves_disabled() {
386 let old = r#"
387disabled = ["api", "worker"]
388
389[daemons.api]
390id = "api"
391autostop = false
392retry = 0
393retry_count = 0
394status = "stopped"
395"#;
396 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
397 assert!(
398 migrated.disabled.contains(&DaemonId::new("legacy", "api")),
399 "disabled 'api' should become 'legacy/api'"
400 );
401 assert!(
402 migrated
403 .disabled
404 .contains(&DaemonId::new("legacy", "worker")),
405 "disabled 'worker' should become 'legacy/worker'"
406 );
407 }
408
409 #[test]
410 fn test_migrate_old_format_already_qualified_unchanged() {
411 let mixed = r#"
413[daemons.bare]
414id = "bare"
415autostop = false
416retry = 0
417retry_count = 0
418status = "stopped"
419"#;
420 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
421 assert!(
423 migrated
424 .daemons
425 .contains_key(&DaemonId::new("legacy", "bare")),
426 "bare key should become legacy/bare"
427 );
428 assert_eq!(migrated.daemons.len(), 1);
430 }
431
432 #[test]
433 fn test_migrate_old_format_does_not_overwrite_existing_qualified_entry() {
434 let mixed = r#"
435[daemons.api]
436id = "api"
437cmd = ["echo", "old"]
438autostop = false
439retry = 0
440retry_count = 0
441status = "stopped"
442
443[daemons."legacy/api"]
444id = "legacy/api"
445cmd = ["echo", "new"]
446autostop = false
447retry = 0
448retry_count = 0
449status = "stopped"
450"#;
451
452 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
453 let key = DaemonId::new("legacy", "api");
454 let daemon = migrated.daemons.get(&key).expect("legacy/api should exist");
455
456 let cmd = daemon.cmd.as_ref().expect("cmd should exist");
457 assert_eq!(cmd, &vec!["echo".to_string(), "new".to_string()]);
458
459 let preserved = DaemonId::new("legacy", "api-legacy");
461 let preserved_daemon = migrated
462 .daemons
463 .get(&preserved)
464 .expect("colliding bare key should be preserved as legacy/api-legacy");
465 let preserved_cmd = preserved_daemon
466 .cmd
467 .as_ref()
468 .expect("preserved cmd should exist");
469 assert_eq!(preserved_cmd, &vec!["echo".to_string(), "old".to_string()]);
470 assert_eq!(migrated.daemons.len(), 2);
471 }
472}