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_last_cron_run(
358 &mut self,
359 id: &DaemonId,
360 time: chrono::DateTime<chrono::Local>,
361 ) -> bool {
362 if let Some(d) = self.daemons.get_mut(id) {
363 d.last_cron_run = Some(time);
364 self.mark_dirty();
365 true
366 } else {
367 false
368 }
369 }
370
371 pub fn set_shell_dir(&mut self, shell_pid: u32, dir: PathBuf) {
373 self.shell_dirs.insert(shell_pid.to_string(), dir);
374 self.mark_dirty();
375 }
376
377 #[cfg(unix)]
380 pub fn remove_shell_dir(&mut self, shell_pid: u32) -> bool {
381 let removed = self.shell_dirs.remove(&shell_pid.to_string()).is_some();
382 if removed {
383 self.mark_dirty();
384 }
385 removed
386 }
387
388 pub fn set_project_session(
391 &mut self,
392 pid: u32,
393 dir: PathBuf,
394 session: ProjectSession,
395 ) -> Option<ProjectSession> {
396 let inner = self.project_sessions.entry(pid.to_string()).or_default();
397 let old = inner.insert(dir, session);
398 self.mark_dirty();
399 old
400 }
401
402 pub fn remove_project_session(&mut self, pid: u32, dir: &Path) -> Option<ProjectSession> {
406 let pid_str = pid.to_string();
407 if let std::collections::btree_map::Entry::Occupied(mut entry) =
408 self.project_sessions.entry(pid_str)
409 {
410 let removed = entry.get_mut().remove(dir);
411 if removed.is_some() {
412 if entry.get().is_empty() {
413 entry.remove();
414 }
415 self.mark_dirty();
416 }
417 removed
418 } else {
419 None
420 }
421 }
422
423 #[cfg(any(unix, test))]
425 pub fn get_project_session(&self, pid: u32, dir: &Path) -> Option<&ProjectSession> {
426 self.project_sessions
427 .get(&pid.to_string())
428 .and_then(|inner| inner.get(dir))
429 }
430
431 pub fn active_directories(&self) -> Vec<PathBuf> {
435 let mut dirs: std::collections::HashSet<PathBuf> =
436 self.shell_dirs.values().cloned().collect();
437 for (_, dir, _) in self.iter_project_sessions() {
438 dirs.insert(dir.clone());
439 }
440 dirs.into_iter().collect()
441 }
442
443 pub fn iter_project_sessions(&self) -> Vec<(&str, &PathBuf, &ProjectSession)> {
444 let mut out: Vec<(&str, &PathBuf, &ProjectSession)> = Vec::new();
445 for (pid_str, inner) in &self.project_sessions {
446 for (dir, session) in inner {
447 out.push((pid_str.as_str(), dir, session));
448 }
449 }
450 out
451 }
452
453 pub fn retain_daemons<F>(&mut self, mut f: F)
456 where
457 F: FnMut(&DaemonId, &Daemon) -> bool,
458 {
459 let before = self.daemons.len();
460 self.daemons.retain(|id, daemon| f(id, daemon));
461 if self.daemons.len() != before {
462 self.mark_dirty();
463 }
464 }
465
466 pub fn write(&self) -> Result<()> {
471 let canonical_path = normalized_lock_path(&self.path);
472 let _lock = xx::fslock::get(&canonical_path, false)?;
473 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
474 path: self.path.clone(),
475 source: e,
476 })?;
477 if self
478 .last_content
479 .lock()
480 .unwrap()
481 .as_ref()
482 .is_some_and(|last| last == &raw)
483 {
484 self.dirty.store(false, Ordering::Relaxed);
486 return Ok(());
487 }
488 Self::write_raw(&self.path, &raw)?;
489 *self.last_content.lock().unwrap() = Some(raw);
490 self.dirty.store(false, Ordering::Relaxed);
491 Ok(())
492 }
493
494 pub(crate) fn restore_daemon_in_file(path: &Path, daemon: &Daemon) -> Result<DiskRecord> {
499 let canonical_path = normalized_lock_path(path);
500 let _lock = xx::fslock::get(&canonical_path, false)?;
501 let raw = match std::fs::read_to_string(path) {
505 Ok(raw) => raw,
506 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
507 Err(source) => {
508 return Err(FileError::ReadError {
509 path: path.to_path_buf(),
510 source,
511 }
512 .into());
513 }
514 };
515 let Ok(mut on_disk) = toml::from_str::<Self>(&raw) else {
516 return Ok(DiskRecord::Unparseable);
517 };
518 if on_disk.daemons.get(&daemon.id).and_then(|d| d.pid) == daemon.pid {
519 return Ok(DiskRecord::Present);
520 }
521 on_disk.daemons.insert(daemon.id.clone(), daemon.clone());
522 let raw = toml::to_string(&on_disk).map_err(|e| FileError::SerializeError {
523 path: path.to_path_buf(),
524 source: e,
525 })?;
526 Self::write_raw(path, &raw)?;
527 Ok(DiskRecord::Restored)
528 }
529
530 pub(crate) fn forget_written_snapshot(&self) {
533 *self.last_content.lock().unwrap() = None;
534 }
535
536 pub(crate) fn force_next_write(&self) {
538 self.forget_written_snapshot();
539 self.mark_dirty();
540 }
541
542 fn write_unlocked(&self) -> Result<()> {
545 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
546 path: self.path.clone(),
547 source: e,
548 })?;
549 Self::write_raw(&self.path, &raw)?;
550 *self.last_content.lock().unwrap() = Some(raw);
551 self.dirty.store(false, Ordering::Relaxed);
552 Ok(())
553 }
554
555 pub(crate) fn write_raw(path: &Path, raw: &str) -> Result<()> {
559 if let Some(parent) = path.parent() {
560 std::fs::create_dir_all(parent).map_err(|e| FileError::WriteError {
561 path: parent.to_path_buf(),
562 details: Some(format!("failed to create state file directory: {e}")),
563 })?;
564 }
565 let temp_path = path.with_extension("toml.tmp");
566 xx::file::write(&temp_path, raw).map_err(|e| FileError::WriteError {
567 path: temp_path.clone(),
568 details: Some(e.to_string()),
569 })?;
570 std::fs::rename(&temp_path, path).map_err(|e| FileError::WriteError {
571 path: path.to_path_buf(),
572 details: Some(format!("failed to rename temp file: {e}")),
573 })?;
574 Ok(())
575 }
576}
577
578fn normalized_lock_path(path: &Path) -> PathBuf {
579 if let Ok(canonical) = path.canonicalize() {
580 return canonical;
581 }
582
583 if let Some(parent) = path.parent()
584 && let Ok(canonical_parent) = parent.canonicalize()
585 && let Some(file_name) = path.file_name()
586 {
587 return canonical_parent.join(file_name);
588 }
589
590 path.to_path_buf()
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::daemon_status::DaemonStatus;
597
598 #[test]
599 fn test_state_file_toml_roundtrip_stopped() {
600 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
601 let daemon_id = DaemonId::new("project", "test");
602 state.daemons.insert(
603 daemon_id.clone(),
604 Daemon {
605 id: daemon_id,
606 status: DaemonStatus::Stopped,
607 last_exit_success: Some(true),
608 user: Some("postgres".to_string()),
609 ..Daemon::default()
610 },
611 );
612
613 let toml_str = toml::to_string(&state).unwrap();
614 println!("Serialized TOML:\n{toml_str}");
615
616 let parsed: StateFile = toml::from_str(&toml_str).expect("Failed to parse TOML");
617 println!("Parsed: {parsed:?}");
618
619 assert!(
620 parsed
621 .daemons
622 .contains_key(&DaemonId::new("project", "test"))
623 );
624 let daemon = parsed
625 .daemons
626 .get(&DaemonId::new("project", "test"))
627 .unwrap();
628 assert_eq!(daemon.user.as_deref(), Some("postgres"));
629 }
630
631 #[test]
632 fn test_looks_like_old_format_bare_names() {
633 let old = r#"
634[daemons.api]
635id = "api"
636autostop = false
637retry = 0
638retry_count = 0
639status = "stopped"
640"#;
641 assert!(StateFile::looks_like_old_format(old));
642 }
643
644 #[test]
645 fn test_looks_like_old_format_new_format() {
646 let new = r#"
647 disabled = []
648
649 [daemons."legacy/api"]
650 id = "legacy/api"
651autostop = false
652retry = 0
653retry_count = 0
654status = "stopped"
655"#;
656 assert!(!StateFile::looks_like_old_format(new));
657 }
658
659 #[test]
660 fn test_looks_like_old_format_empty() {
661 assert!(!StateFile::looks_like_old_format(""));
662 assert!(!StateFile::looks_like_old_format("[shell_dirs]"));
663 }
664
665 #[test]
666 fn test_migrate_old_format_basic() {
667 let old = r#"
668[daemons.api]
669id = "api"
670autostop = false
671retry = 0
672retry_count = 0
673status = "stopped"
674
675[daemons.worker]
676id = "worker"
677autostop = false
678retry = 0
679retry_count = 0
680status = "stopped"
681last_exit_success = true
682"#;
683 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
684 assert!(
685 migrated
686 .daemons
687 .contains_key(&DaemonId::new("legacy", "api")),
688 "api should be migrated to legacy/api"
689 );
690 assert!(
691 migrated
692 .daemons
693 .contains_key(&DaemonId::new("legacy", "worker")),
694 "worker should be migrated to legacy/worker"
695 );
696 assert_eq!(migrated.daemons.len(), 2);
697 }
698
699 #[test]
700 fn test_migrate_old_format_preserves_disabled() {
701 let old = r#"
702disabled = ["api", "worker"]
703
704[daemons.api]
705id = "api"
706autostop = false
707retry = 0
708retry_count = 0
709status = "stopped"
710"#;
711 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
712 assert!(
713 migrated.disabled.contains(&DaemonId::new("legacy", "api")),
714 "disabled 'api' should become 'legacy/api'"
715 );
716 assert!(
717 migrated
718 .disabled
719 .contains(&DaemonId::new("legacy", "worker")),
720 "disabled 'worker' should become 'legacy/worker'"
721 );
722 }
723
724 #[test]
725 fn test_migrate_old_format_already_qualified_unchanged() {
726 let mixed = r#"
728[daemons.bare]
729id = "bare"
730autostop = false
731retry = 0
732retry_count = 0
733status = "stopped"
734"#;
735 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
736 assert!(
738 migrated
739 .daemons
740 .contains_key(&DaemonId::new("legacy", "bare")),
741 "bare key should become legacy/bare"
742 );
743 assert_eq!(migrated.daemons.len(), 1);
745 }
746
747 #[test]
748 fn test_migrate_old_format_does_not_overwrite_existing_qualified_entry() {
749 let mixed = r#"
750[daemons.api]
751id = "api"
752cmd = ["echo", "old"]
753autostop = false
754retry = 0
755retry_count = 0
756status = "stopped"
757
758[daemons."legacy/api"]
759id = "legacy/api"
760cmd = ["echo", "new"]
761autostop = false
762retry = 0
763retry_count = 0
764status = "stopped"
765"#;
766
767 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
768 let key = DaemonId::new("legacy", "api");
769 let daemon = migrated.daemons.get(&key).expect("legacy/api should exist");
770
771 let cmd = daemon.cmd.as_ref().expect("cmd should exist");
772 assert_eq!(cmd, &vec!["echo".to_string(), "new".to_string()]);
773
774 let preserved = DaemonId::new("legacy", "api-legacy");
776 let preserved_daemon = migrated
777 .daemons
778 .get(&preserved)
779 .expect("colliding bare key should be preserved as legacy/api-legacy");
780 let preserved_cmd = preserved_daemon
781 .cmd
782 .as_ref()
783 .expect("preserved cmd should exist");
784 assert_eq!(preserved_cmd, &vec!["echo".to_string(), "old".to_string()]);
785 assert_eq!(migrated.daemons.len(), 2);
786 }
787
788 #[test]
789 fn test_project_sessions_nested_map_roundtrip() {
790 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
791 state.set_project_session(
792 1234,
793 PathBuf::from("/projects/a"),
794 ProjectSession {
795 liveness_title: Some("sleep".to_string()),
796 },
797 );
798 state.set_project_session(
799 1234,
800 PathBuf::from("/projects/b"),
801 ProjectSession {
802 liveness_title: None,
803 },
804 );
805 state.set_project_session(
806 5678,
807 PathBuf::from("/projects/a"),
808 ProjectSession {
809 liveness_title: Some("code".to_string()),
810 },
811 );
812
813 let toml_str = toml::to_string(&state).unwrap();
814 let parsed: StateFile = toml::from_str(&toml_str).expect("roundtrip parse");
815
816 assert_eq!(parsed.iter_project_sessions().len(), 3);
818 assert!(
819 parsed
820 .get_project_session(1234, &PathBuf::from("/projects/a"))
821 .is_some()
822 );
823 assert!(
824 parsed
825 .get_project_session(1234, &PathBuf::from("/projects/b"))
826 .is_some()
827 );
828 assert!(
829 parsed
830 .get_project_session(5678, &PathBuf::from("/projects/a"))
831 .is_some()
832 );
833
834 let mut state = parsed;
836 state.remove_project_session(5678, &PathBuf::from("/projects/a"));
837 assert!(
838 state
839 .get_project_session(5678, &PathBuf::from("/projects/a"))
840 .is_none()
841 );
842 assert!(!state.project_sessions.contains_key("5678"));
843 assert_eq!(state.iter_project_sessions().len(), 2);
844 }
845}