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 update_in_file<T>(
534 path: &Path,
535 f: impl FnOnce(&mut Self) -> (bool, T),
536 ) -> Result<T> {
537 let canonical_path = normalized_lock_path(path);
538 let _lock = xx::fslock::get(&canonical_path, false)?;
539 let raw = match std::fs::read_to_string(path) {
540 Ok(raw) => raw,
541 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
542 let (_, out) = f(&mut Self::new(path.to_path_buf()));
543 return Ok(out);
544 }
545 Err(source) => {
546 return Err(FileError::ReadError {
547 path: path.to_path_buf(),
548 source,
549 }
550 .into());
551 }
552 };
553 let mut sf: Self = toml::from_str(&raw)
554 .map_err(|e| miette::miette!("failed to parse state file {}: {e}", path.display()))?;
555 sf.path = path.to_path_buf();
556 for (id, daemon) in sf.daemons.iter_mut() {
557 daemon.id = id.clone();
558 }
559 let (changed, out) = f(&mut sf);
560 if changed {
561 let raw = toml::to_string(&sf).map_err(|e| FileError::SerializeError {
562 path: path.to_path_buf(),
563 source: e,
564 })?;
565 Self::write_raw(path, &raw)?;
566 }
567 Ok(out)
568 }
569
570 pub(crate) fn forget_written_snapshot(&self) {
573 *self.last_content.lock().unwrap() = None;
574 }
575
576 pub(crate) fn force_next_write(&self) {
578 self.forget_written_snapshot();
579 self.mark_dirty();
580 }
581
582 fn write_unlocked(&self) -> Result<()> {
585 let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
586 path: self.path.clone(),
587 source: e,
588 })?;
589 Self::write_raw(&self.path, &raw)?;
590 *self.last_content.lock().unwrap() = Some(raw);
591 self.dirty.store(false, Ordering::Relaxed);
592 Ok(())
593 }
594
595 pub(crate) fn write_raw(path: &Path, raw: &str) -> Result<()> {
599 if let Some(parent) = path.parent() {
600 std::fs::create_dir_all(parent).map_err(|e| FileError::WriteError {
601 path: parent.to_path_buf(),
602 details: Some(format!("failed to create state file directory: {e}")),
603 })?;
604 }
605 let temp_path = path.with_extension("toml.tmp");
606 xx::file::write(&temp_path, raw).map_err(|e| FileError::WriteError {
607 path: temp_path.clone(),
608 details: Some(e.to_string()),
609 })?;
610 std::fs::rename(&temp_path, path).map_err(|e| FileError::WriteError {
611 path: path.to_path_buf(),
612 details: Some(format!("failed to rename temp file: {e}")),
613 })?;
614 Ok(())
615 }
616}
617
618fn normalized_lock_path(path: &Path) -> PathBuf {
619 if let Ok(canonical) = path.canonicalize() {
620 return canonical;
621 }
622
623 if let Some(parent) = path.parent()
624 && let Ok(canonical_parent) = parent.canonicalize()
625 && let Some(file_name) = path.file_name()
626 {
627 return canonical_parent.join(file_name);
628 }
629
630 path.to_path_buf()
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::daemon_status::DaemonStatus;
637
638 #[test]
639 fn test_state_file_toml_roundtrip_stopped() {
640 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
641 let daemon_id = DaemonId::new("project", "test");
642 state.daemons.insert(
643 daemon_id.clone(),
644 Daemon {
645 id: daemon_id,
646 status: DaemonStatus::Stopped,
647 last_exit_success: Some(true),
648 user: Some("postgres".to_string()),
649 ..Daemon::default()
650 },
651 );
652
653 let toml_str = toml::to_string(&state).unwrap();
654 println!("Serialized TOML:\n{toml_str}");
655
656 let parsed: StateFile = toml::from_str(&toml_str).expect("Failed to parse TOML");
657 println!("Parsed: {parsed:?}");
658
659 assert!(
660 parsed
661 .daemons
662 .contains_key(&DaemonId::new("project", "test"))
663 );
664 let daemon = parsed
665 .daemons
666 .get(&DaemonId::new("project", "test"))
667 .unwrap();
668 assert_eq!(daemon.user.as_deref(), Some("postgres"));
669 }
670
671 #[test]
672 fn test_looks_like_old_format_bare_names() {
673 let old = r#"
674[daemons.api]
675id = "api"
676autostop = false
677retry = 0
678retry_count = 0
679status = "stopped"
680"#;
681 assert!(StateFile::looks_like_old_format(old));
682 }
683
684 #[test]
685 fn test_looks_like_old_format_new_format() {
686 let new = r#"
687 disabled = []
688
689 [daemons."legacy/api"]
690 id = "legacy/api"
691autostop = false
692retry = 0
693retry_count = 0
694status = "stopped"
695"#;
696 assert!(!StateFile::looks_like_old_format(new));
697 }
698
699 #[test]
700 fn test_looks_like_old_format_empty() {
701 assert!(!StateFile::looks_like_old_format(""));
702 assert!(!StateFile::looks_like_old_format("[shell_dirs]"));
703 }
704
705 #[test]
706 fn test_migrate_old_format_basic() {
707 let old = r#"
708[daemons.api]
709id = "api"
710autostop = false
711retry = 0
712retry_count = 0
713status = "stopped"
714
715[daemons.worker]
716id = "worker"
717autostop = false
718retry = 0
719retry_count = 0
720status = "stopped"
721last_exit_success = true
722"#;
723 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
724 assert!(
725 migrated
726 .daemons
727 .contains_key(&DaemonId::new("legacy", "api")),
728 "api should be migrated to legacy/api"
729 );
730 assert!(
731 migrated
732 .daemons
733 .contains_key(&DaemonId::new("legacy", "worker")),
734 "worker should be migrated to legacy/worker"
735 );
736 assert_eq!(migrated.daemons.len(), 2);
737 }
738
739 #[test]
740 fn test_migrate_old_format_preserves_disabled() {
741 let old = r#"
742disabled = ["api", "worker"]
743
744[daemons.api]
745id = "api"
746autostop = false
747retry = 0
748retry_count = 0
749status = "stopped"
750"#;
751 let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
752 assert!(
753 migrated.disabled.contains(&DaemonId::new("legacy", "api")),
754 "disabled 'api' should become 'legacy/api'"
755 );
756 assert!(
757 migrated
758 .disabled
759 .contains(&DaemonId::new("legacy", "worker")),
760 "disabled 'worker' should become 'legacy/worker'"
761 );
762 }
763
764 #[test]
765 fn test_migrate_old_format_already_qualified_unchanged() {
766 let mixed = r#"
768[daemons.bare]
769id = "bare"
770autostop = false
771retry = 0
772retry_count = 0
773status = "stopped"
774"#;
775 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
776 assert!(
778 migrated
779 .daemons
780 .contains_key(&DaemonId::new("legacy", "bare")),
781 "bare key should become legacy/bare"
782 );
783 assert_eq!(migrated.daemons.len(), 1);
785 }
786
787 #[test]
788 fn test_migrate_old_format_does_not_overwrite_existing_qualified_entry() {
789 let mixed = r#"
790[daemons.api]
791id = "api"
792cmd = ["echo", "old"]
793autostop = false
794retry = 0
795retry_count = 0
796status = "stopped"
797
798[daemons."legacy/api"]
799id = "legacy/api"
800cmd = ["echo", "new"]
801autostop = false
802retry = 0
803retry_count = 0
804status = "stopped"
805"#;
806
807 let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
808 let key = DaemonId::new("legacy", "api");
809 let daemon = migrated.daemons.get(&key).expect("legacy/api should exist");
810
811 let cmd = daemon.cmd.as_ref().expect("cmd should exist");
812 assert_eq!(cmd, &vec!["echo".to_string(), "new".to_string()]);
813
814 let preserved = DaemonId::new("legacy", "api-legacy");
816 let preserved_daemon = migrated
817 .daemons
818 .get(&preserved)
819 .expect("colliding bare key should be preserved as legacy/api-legacy");
820 let preserved_cmd = preserved_daemon
821 .cmd
822 .as_ref()
823 .expect("preserved cmd should exist");
824 assert_eq!(preserved_cmd, &vec!["echo".to_string(), "old".to_string()]);
825 assert_eq!(migrated.daemons.len(), 2);
826 }
827
828 #[test]
829 fn test_project_sessions_nested_map_roundtrip() {
830 let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
831 state.set_project_session(
832 1234,
833 PathBuf::from("/projects/a"),
834 ProjectSession {
835 liveness_title: Some("sleep".to_string()),
836 },
837 );
838 state.set_project_session(
839 1234,
840 PathBuf::from("/projects/b"),
841 ProjectSession {
842 liveness_title: None,
843 },
844 );
845 state.set_project_session(
846 5678,
847 PathBuf::from("/projects/a"),
848 ProjectSession {
849 liveness_title: Some("code".to_string()),
850 },
851 );
852
853 let toml_str = toml::to_string(&state).unwrap();
854 let parsed: StateFile = toml::from_str(&toml_str).expect("roundtrip parse");
855
856 assert_eq!(parsed.iter_project_sessions().len(), 3);
858 assert!(
859 parsed
860 .get_project_session(1234, &PathBuf::from("/projects/a"))
861 .is_some()
862 );
863 assert!(
864 parsed
865 .get_project_session(1234, &PathBuf::from("/projects/b"))
866 .is_some()
867 );
868 assert!(
869 parsed
870 .get_project_session(5678, &PathBuf::from("/projects/a"))
871 .is_some()
872 );
873
874 let mut state = parsed;
876 state.remove_project_session(5678, &PathBuf::from("/projects/a"));
877 assert!(
878 state
879 .get_project_session(5678, &PathBuf::from("/projects/a"))
880 .is_none()
881 );
882 assert!(!state.project_sessions.contains_key("5678"));
883 assert_eq!(state.iter_project_sessions().len(), 2);
884 }
885}