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};
9use std::sync::Mutex;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12#[derive(Debug, serde::Serialize, serde::Deserialize)]
13pub struct StateFile {
14 #[serde(default)]
15 pub daemons: BTreeMap<DaemonId, Daemon>,
16 #[serde(default)]
17 pub disabled: BTreeSet<DaemonId>,
18 #[serde(default)]
19 pub shell_dirs: BTreeMap<String, PathBuf>,
20 #[serde(default)]
24 pub project_sessions: BTreeMap<String, BTreeMap<PathBuf, ProjectSession>>,
25 #[serde(skip)]
26 pub(crate) path: PathBuf,
27 #[serde(skip)]
28 pub(crate) dirty: AtomicBool,
29 #[serde(skip)]
34 pub(crate) last_content: Mutex<Option<String>>,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub(crate) enum DiskRecord {
40 Present,
42 Restored,
44 Unparseable,
46}
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
52pub struct ProjectSession {
53 #[serde(skip_serializing_if = "Option::is_none", default)]
54 pub liveness_title: Option<String>,
55}
56
57impl StateFile {
58 pub fn new(path: PathBuf) -> Self {
59 Self {
60 daemons: Default::default(),
61 disabled: Default::default(),
62 shell_dirs: Default::default(),
63 project_sessions: Default::default(),
64 path,
65 dirty: AtomicBool::new(false),
66 last_content: Mutex::new(None),
67 }
68 }
69
70 pub fn get() -> &'static Self {
71 static STATE_FILE: Lazy<StateFile> = Lazy::new(|| {
72 let path = &*env::PITCHFORK_STATE_FILE;
73 StateFile::read(path).unwrap_or_else(|e| {
74 error!(
75 "failed to read state file {}: {}. Falling back to in-memory empty state",
76 path.display(),
77 e
78 );
79 StateFile::new(path.to_path_buf())
80 })
81 });
82 &STATE_FILE
83 }
84
85 pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
86 let path = path.as_ref();
87 if !path.exists() {
88 return Ok(Self::new(path.to_path_buf()));
89 }
90 let canonical_path = normalized_lock_path(path);
91 let _lock = xx::fslock::get(&canonical_path, false)?;
92 let raw = xx::file::read_to_string(path).unwrap_or_else(|e| {
93 warn!("Error reading state file {path:?}: {e}");
94 String::new()
95 });
96
97 match toml::from_str::<Self>(&raw) {
99 Ok(mut state_file) => {
100 state_file.path = path.to_path_buf();
101 state_file.dirty = AtomicBool::new(false);
102 for (id, daemon) in state_file.daemons.iter_mut() {
103 daemon.id = id.clone();
104 }
105 state_file.last_content = Mutex::new(Some(raw));
108 Ok(state_file)
109 }
110 Err(parse_err) => {
111 if Self::looks_like_old_format(&raw) {
112 debug!(
114 "State file at {} appears to be in old format, attempting silent migration",
115 path.display()
116 );
117 match Self::migrate_old_format(&raw) {
118 Ok(migrated) => {
119 let mut state_file = migrated;
120 state_file.path = path.to_path_buf();
121 if let Err(e) = state_file.write_unlocked() {
123 warn!("State file migration write failed: {e}");
124 }
125 debug!("State file migrated successfully");
126 return Ok(state_file);
127 }
128 Err(e) => {
129 error!(
130 "State file migration failed: {e}. \
131 Raw content preserved at {}. Starting with empty state.",
132 path.display()
133 );
134 return Err(miette::miette!(
135 "Failed to migrate state file {}: {e}",
136 path.display()
137 ));
138 }
139 }
140 }
141 Err(miette::miette!(
143 "Failed to parse state file {}: {parse_err}",
144 path.display()
145 ))
146 }
147 }
148 }
149
150 fn looks_like_old_format(raw: &str) -> bool {
155 use toml::Value;
156 let Ok(Value::Table(doc)) = toml::from_str::<Value>(raw) else {
157 return false;
158 };
159 let Some(Value::Table(daemons)) = doc.get("daemons") else {
160 return false;
161 };
162 !daemons.is_empty() && daemons.keys().any(|k| !k.contains('/'))
164 }
165
166 fn migrate_old_format(raw: &str) -> Result<Self> {
169 use toml::Value;
170
171 const LEGACY_NAMESPACE: &str = "legacy";
172
173 let mut doc: toml::map::Map<String, Value> = toml::from_str(raw)
175 .map_err(|e| miette::miette!("failed to parse old state file: {e}"))?;
176
177 if let Some(Value::Table(daemons)) = doc.get_mut("daemons") {
179 let old_keys: Vec<String> = daemons.keys().cloned().collect();
180 for key in old_keys {
181 if !key.contains('/')
182 && let Some(val) = daemons.remove(&key)
183 {
184 let mut new_key = format!("{LEGACY_NAMESPACE}/{key}");
185 if daemons.contains_key(&new_key) {
187 let base = format!("{key}-legacy");
188 let mut candidate = format!("{LEGACY_NAMESPACE}/{base}");
189 let mut n: u32 = 2;
190 while daemons.contains_key(&candidate) {
191 candidate = format!("{LEGACY_NAMESPACE}/{base}-{n}");
192 n += 1;
193 }
194 warn!(
195 "Legacy daemon key '{}' collides with '{}'; migrating as '{}'",
196 key,
197 format_args!("{LEGACY_NAMESPACE}/{key}"),
198 candidate
199 );
200 new_key = candidate;
201 }
202 let val = if let Value::Table(mut tbl) = val {
204 tbl.insert("id".to_string(), Value::String(new_key.clone()));
205 Value::Table(tbl)
206 } else {
207 val
208 };
209 daemons.insert(new_key, val);
210 }
211 }
212 }
213
214 if let Some(Value::Array(disabled)) = doc.get_mut("disabled") {
216 for entry in disabled.iter_mut() {
217 if let Value::String(s) = entry
218 && !s.contains('/')
219 {
220 *s = format!("{LEGACY_NAMESPACE}/{s}");
221 }
222 }
223 }
224
225 let new_raw =
226 toml::to_string(&Value::Table(doc)).map_err(|e| FileError::SerializeError {
227 path: PathBuf::new(),
228 source: e,
229 })?;
230
231 let mut state_file: Self = toml::from_str(&new_raw)
232 .map_err(|e| miette::miette!("failed to parse migrated state file: {e}"))?;
233 for (id, daemon) in state_file.daemons.iter_mut() {
235 daemon.id = id.clone();
236 }
237 Ok(state_file)
238 }
239
240 fn mark_dirty(&self) {
243 self.dirty.store(true, Ordering::Relaxed);
244 }
245
246 pub fn is_dirty(&self) -> bool {
248 self.dirty.load(Ordering::Relaxed)
249 }
250
251 pub fn insert_daemon(&mut self, id: &DaemonId, daemon: Daemon) {
253 self.daemons.insert(id.clone(), daemon);
254 self.mark_dirty();
255 }
256
257 pub fn set_status(
260 &mut self,
261 id: &DaemonId,
262 status: crate::daemon_status::DaemonStatus,
263 ) -> bool {
264 let Some(daemon) = self.daemons.get_mut(id) else {
265 return false;
266 };
267 daemon.status = status;
268 self.mark_dirty();
269 true
270 }
271
272 pub fn clear_proxy_idle_timeout(&mut self, id: &DaemonId) -> bool {
275 let cleared = self
276 .daemons
277 .get_mut(id)
278 .is_some_and(|d| d.proxy_idle_timeout_ms.take().is_some());
279 if cleared {
280 self.mark_dirty();
281 }
282 cleared
283 }
284
285 pub fn remove_daemon(&mut self, id: &DaemonId) {
287 if self.daemons.remove(id).is_some() {
288 self.mark_dirty();
289 }
290 }
291
292 pub fn disable_daemon(&mut self, id: &DaemonId) -> bool {
295 let inserted = self.disabled.insert(id.clone());
296 if inserted {
297 self.mark_dirty();
298 }
299 inserted
300 }
301
302 pub fn enable_daemon(&mut self, id: &DaemonId) -> bool {
305 let removed = self.disabled.remove(id);
306 if removed {
307 self.mark_dirty();
308 }
309 removed
310 }
311
312 pub fn set_active_port(&mut self, id: &DaemonId, port: u16) -> bool {
315 if let Some(d) = self.daemons.get_mut(id) {
316 d.active_port = Some(port);
317 self.mark_dirty();
318 true
319 } else {
320 false
321 }
322 }
323
324 pub fn clear_active_port(&mut self, id: &DaemonId) -> bool {
327 if let Some(d) = self.daemons.get_mut(id) {
328 d.active_port = None;
329 self.mark_dirty();
330 true
331 } else {
332 false
333 }
334 }
335
336 pub fn set_last_cron_triggered(
339 &mut self,
340 id: &DaemonId,
341 time: chrono::DateTime<chrono::Local>,
342 ) -> bool {
343 if let Some(d) = self.daemons.get_mut(id) {
344 d.last_cron_triggered = Some(time);
345 self.mark_dirty();
346 true
347 } else {
348 false
349 }
350 }
351
352 pub fn set_shell_dir(&mut self, shell_pid: u32, dir: PathBuf) {
354 self.shell_dirs.insert(shell_pid.to_string(), dir);
355 self.mark_dirty();
356 }
357
358 #[cfg(unix)]
361 pub fn remove_shell_dir(&mut self, shell_pid: u32) -> bool {
362 let removed = self.shell_dirs.remove(&shell_pid.to_string()).is_some();
363 if removed {
364 self.mark_dirty();
365 }
366 removed
367 }
368
369 pub fn set_project_session(
372 &mut self,
373 pid: u32,
374 dir: PathBuf,
375 session: ProjectSession,
376 ) -> Option<ProjectSession> {
377 let inner = self.project_sessions.entry(pid.to_string()).or_default();
378 let old = inner.insert(dir, session);
379 self.mark_dirty();
380 old
381 }
382
383 pub fn remove_project_session(&mut self, pid: u32, dir: &Path) -> Option<ProjectSession> {
387 let pid_str = pid.to_string();
388 if let std::collections::btree_map::Entry::Occupied(mut entry) =
389 self.project_sessions.entry(pid_str)
390 {
391 let removed = entry.get_mut().remove(dir);
392 if removed.is_some() {
393 if entry.get().is_empty() {
394 entry.remove();
395 }
396 self.mark_dirty();
397 }
398 removed
399 } else {
400 None
401 }
402 }
403
404 #[cfg(any(unix, test))]
406 pub fn get_project_session(&self, pid: u32, dir: &Path) -> Option<&ProjectSession> {
407 self.project_sessions
408 .get(&pid.to_string())
409 .and_then(|inner| inner.get(dir))
410 }
411
412 pub fn active_directories(&self) -> Vec<PathBuf> {
416 let mut dirs: std::collections::HashSet<PathBuf> =
417 self.shell_dirs.values().cloned().collect();
418 for (_, dir, _) in self.iter_project_sessions() {
419 dirs.insert(dir.clone());
420 }
421 dirs.into_iter().collect()
422 }
423
424 pub fn iter_project_sessions(&self) -> Vec<(&str, &PathBuf, &ProjectSession)> {
425 let mut out: Vec<(&str, &PathBuf, &ProjectSession)> = Vec::new();
426 for (pid_str, inner) in &self.project_sessions {
427 for (dir, session) in inner {
428 out.push((pid_str.as_str(), dir, session));
429 }
430 }
431 out
432 }
433
434 pub fn retain_daemons<F>(&mut self, mut f: F)
437 where
438 F: FnMut(&DaemonId, &Daemon) -> bool,
439 {
440 let before = self.daemons.len();
441 self.daemons.retain(|id, daemon| f(id, daemon));
442 if self.daemons.len() != before {
443 self.mark_dirty();
444 }
445 }
446
447 pub fn write(&self) -> Result<()> {
452 let canonical_path = normalized_lock_path(&self.path);
453 let _lock = xx::fslock::get(&canonical_path, false)?;
454 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
455 path: self.path.clone(),
456 source: e,
457 })?;
458 if self
459 .last_content
460 .lock()
461 .unwrap()
462 .as_ref()
463 .is_some_and(|last| last == &raw)
464 {
465 self.dirty.store(false, Ordering::Relaxed);
467 return Ok(());
468 }
469 Self::write_raw(&self.path, &raw)?;
470 *self.last_content.lock().unwrap() = Some(raw);
471 self.dirty.store(false, Ordering::Relaxed);
472 Ok(())
473 }
474
475 pub(crate) fn restore_daemon_in_file(path: &Path, daemon: &Daemon) -> Result<DiskRecord> {
480 let canonical_path = normalized_lock_path(path);
481 let _lock = xx::fslock::get(&canonical_path, false)?;
482 let raw = match std::fs::read_to_string(path) {
486 Ok(raw) => raw,
487 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
488 Err(source) => {
489 return Err(FileError::ReadError {
490 path: path.to_path_buf(),
491 source,
492 }
493 .into());
494 }
495 };
496 let Ok(mut on_disk) = toml::from_str::<Self>(&raw) else {
497 return Ok(DiskRecord::Unparseable);
498 };
499 if on_disk.daemons.get(&daemon.id).and_then(|d| d.pid) == daemon.pid {
500 return Ok(DiskRecord::Present);
501 }
502 on_disk.daemons.insert(daemon.id.clone(), daemon.clone());
503 let raw = toml::to_string(&on_disk).map_err(|e| FileError::SerializeError {
504 path: path.to_path_buf(),
505 source: e,
506 })?;
507 Self::write_raw(path, &raw)?;
508 Ok(DiskRecord::Restored)
509 }
510
511 pub(crate) fn forget_written_snapshot(&self) {
514 *self.last_content.lock().unwrap() = None;
515 }
516
517 pub(crate) fn force_next_write(&self) {
519 self.forget_written_snapshot();
520 self.mark_dirty();
521 }
522
523 fn write_unlocked(&self) -> Result<()> {
526 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
527 path: self.path.clone(),
528 source: e,
529 })?;
530 Self::write_raw(&self.path, &raw)?;
531 *self.last_content.lock().unwrap() = Some(raw);
532 self.dirty.store(false, Ordering::Relaxed);
533 Ok(())
534 }
535
536 pub(crate) fn write_raw(path: &Path, raw: &str) -> Result<()> {
540 if let Some(parent) = path.parent() {
541 std::fs::create_dir_all(parent).map_err(|e| FileError::WriteError {
542 path: parent.to_path_buf(),
543 details: Some(format!("failed to create state file directory: {e}")),
544 })?;
545 }
546 let temp_path = path.with_extension("toml.tmp");
547 xx::file::write(&temp_path, raw).map_err(|e| FileError::WriteError {
548 path: temp_path.clone(),
549 details: Some(e.to_string()),
550 })?;
551 std::fs::rename(&temp_path, path).map_err(|e| FileError::WriteError {
552 path: path.to_path_buf(),
553 details: Some(format!("failed to rename temp file: {e}")),
554 })?;
555 Ok(())
556 }
557}
558
559fn normalized_lock_path(path: &Path) -> PathBuf {
560 if let Ok(canonical) = path.canonicalize() {
561 return canonical;
562 }
563
564 if let Some(parent) = path.parent()
565 && let Ok(canonical_parent) = parent.canonicalize()
566 && let Some(file_name) = path.file_name()
567 {
568 return canonical_parent.join(file_name);
569 }
570
571 path.to_path_buf()
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use crate::daemon_status::DaemonStatus;
578
579 #[test]
580 fn test_state_file_toml_roundtrip_stopped() {
581 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
582 let daemon_id = DaemonId::new("project", "test");
583 state.daemons.insert(
584 daemon_id.clone(),
585 Daemon {
586 id: daemon_id,
587 status: DaemonStatus::Stopped,
588 last_exit_success: Some(true),
589 user: Some("postgres".to_string()),
590 ..Daemon::default()
591 },
592 );
593
594 let toml_str = toml::to_string(&state).unwrap();
595 println!("Serialized TOML:\n{toml_str}");
596
597 let parsed: StateFile = toml::from_str(&toml_str).expect("Failed to parse TOML");
598 println!("Parsed: {parsed:?}");
599
600 assert!(
601 parsed
602 .daemons
603 .contains_key(&DaemonId::new("project", "test"))
604 );
605 let daemon = parsed
606 .daemons
607 .get(&DaemonId::new("project", "test"))
608 .unwrap();
609 assert_eq!(daemon.user.as_deref(), Some("postgres"));
610 }
611
612 #[test]
613 fn test_looks_like_old_format_bare_names() {
614 let old = r#"
615[daemons.api]
616id = "api"
617autostop = false
618retry = 0
619retry_count = 0
620status = "stopped"
621"#;
622 assert!(StateFile::looks_like_old_format(old));
623 }
624
625 #[test]
626 fn test_looks_like_old_format_new_format() {
627 let new = r#"
628 disabled = []
629
630 [daemons."legacy/api"]
631 id = "legacy/api"
632autostop = false
633retry = 0
634retry_count = 0
635status = "stopped"
636"#;
637 assert!(!StateFile::looks_like_old_format(new));
638 }
639
640 #[test]
641 fn test_looks_like_old_format_empty() {
642 assert!(!StateFile::looks_like_old_format(""));
643 assert!(!StateFile::looks_like_old_format("[shell_dirs]"));
644 }
645
646 #[test]
647 fn test_migrate_old_format_basic() {
648 let old = r#"
649[daemons.api]
650id = "api"
651autostop = false
652retry = 0
653retry_count = 0
654status = "stopped"
655
656[daemons.worker]
657id = "worker"
658autostop = false
659retry = 0
660retry_count = 0
661status = "stopped"
662last_exit_success = true
663"#;
664 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
665 assert!(
666 migrated
667 .daemons
668 .contains_key(&DaemonId::new("legacy", "api")),
669 "api should be migrated to legacy/api"
670 );
671 assert!(
672 migrated
673 .daemons
674 .contains_key(&DaemonId::new("legacy", "worker")),
675 "worker should be migrated to legacy/worker"
676 );
677 assert_eq!(migrated.daemons.len(), 2);
678 }
679
680 #[test]
681 fn test_migrate_old_format_preserves_disabled() {
682 let old = r#"
683disabled = ["api", "worker"]
684
685[daemons.api]
686id = "api"
687autostop = false
688retry = 0
689retry_count = 0
690status = "stopped"
691"#;
692 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
693 assert!(
694 migrated.disabled.contains(&DaemonId::new("legacy", "api")),
695 "disabled 'api' should become 'legacy/api'"
696 );
697 assert!(
698 migrated
699 .disabled
700 .contains(&DaemonId::new("legacy", "worker")),
701 "disabled 'worker' should become 'legacy/worker'"
702 );
703 }
704
705 #[test]
706 fn test_migrate_old_format_already_qualified_unchanged() {
707 let mixed = r#"
709[daemons.bare]
710id = "bare"
711autostop = false
712retry = 0
713retry_count = 0
714status = "stopped"
715"#;
716 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
717 assert!(
719 migrated
720 .daemons
721 .contains_key(&DaemonId::new("legacy", "bare")),
722 "bare key should become legacy/bare"
723 );
724 assert_eq!(migrated.daemons.len(), 1);
726 }
727
728 #[test]
729 fn test_migrate_old_format_does_not_overwrite_existing_qualified_entry() {
730 let mixed = r#"
731[daemons.api]
732id = "api"
733cmd = ["echo", "old"]
734autostop = false
735retry = 0
736retry_count = 0
737status = "stopped"
738
739[daemons."legacy/api"]
740id = "legacy/api"
741cmd = ["echo", "new"]
742autostop = false
743retry = 0
744retry_count = 0
745status = "stopped"
746"#;
747
748 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
749 let key = DaemonId::new("legacy", "api");
750 let daemon = migrated.daemons.get(&key).expect("legacy/api should exist");
751
752 let cmd = daemon.cmd.as_ref().expect("cmd should exist");
753 assert_eq!(cmd, &vec!["echo".to_string(), "new".to_string()]);
754
755 let preserved = DaemonId::new("legacy", "api-legacy");
757 let preserved_daemon = migrated
758 .daemons
759 .get(&preserved)
760 .expect("colliding bare key should be preserved as legacy/api-legacy");
761 let preserved_cmd = preserved_daemon
762 .cmd
763 .as_ref()
764 .expect("preserved cmd should exist");
765 assert_eq!(preserved_cmd, &vec!["echo".to_string(), "old".to_string()]);
766 assert_eq!(migrated.daemons.len(), 2);
767 }
768
769 #[test]
770 fn test_project_sessions_nested_map_roundtrip() {
771 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
772 state.set_project_session(
773 1234,
774 PathBuf::from("/projects/a"),
775 ProjectSession {
776 liveness_title: Some("sleep".to_string()),
777 },
778 );
779 state.set_project_session(
780 1234,
781 PathBuf::from("/projects/b"),
782 ProjectSession {
783 liveness_title: None,
784 },
785 );
786 state.set_project_session(
787 5678,
788 PathBuf::from("/projects/a"),
789 ProjectSession {
790 liveness_title: Some("code".to_string()),
791 },
792 );
793
794 let toml_str = toml::to_string(&state).unwrap();
795 let parsed: StateFile = toml::from_str(&toml_str).expect("roundtrip parse");
796
797 assert_eq!(parsed.iter_project_sessions().len(), 3);
799 assert!(
800 parsed
801 .get_project_session(1234, &PathBuf::from("/projects/a"))
802 .is_some()
803 );
804 assert!(
805 parsed
806 .get_project_session(1234, &PathBuf::from("/projects/b"))
807 .is_some()
808 );
809 assert!(
810 parsed
811 .get_project_session(5678, &PathBuf::from("/projects/a"))
812 .is_some()
813 );
814
815 let mut state = parsed;
817 state.remove_project_session(5678, &PathBuf::from("/projects/a"));
818 assert!(
819 state
820 .get_project_session(5678, &PathBuf::from("/projects/a"))
821 .is_none()
822 );
823 assert!(!state.project_sessions.contains_key("5678"));
824 assert_eq!(state.iter_project_sessions().len(), 2);
825 }
826}