Skip to main content

pitchfork_cli/
state_file.rs

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    /// Project sessions keyed by host PID (as string, matching `shell_dirs`)
21    /// and then by canonical directory. `#[serde(default)]` keeps older
22    /// state files (that predate project sessions) parseable.
23    #[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    /// Snapshot of the last written TOML content. Used by `write()` to skip
30    /// redundant disk I/O when the serialized state hasn't changed.
31    /// Guarded by the file lock in practice; `Mutex` is used only to satisfy
32    /// `Sync` since `write` takes `&self`.
33    #[serde(skip)]
34    pub(crate) last_content: Mutex<Option<String>>,
35}
36
37/// What [`StateFile::restore_daemon_in_file`] found in the file on disk.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub(crate) enum DiskRecord {
40    /// The file already had the entry.
41    Present,
42    /// The entry was missing and has been put back.
43    Restored,
44    /// The file could not be parsed and was left untouched.
45    Unparseable,
46}
47
48/// A project session entry. The owning host PID and tracked directory live in
49/// the nested map key, so the value only needs the liveness title snapshot
50/// used to mitigate PID reuse.
51#[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        // Try to parse directly (new format with qualified IDs)
98        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                // Seed last_content with the raw TOML so the first write() can
106                // skip disk I/O when the state hasn't actually changed.
107                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                    // Silent migration: attempt to rewrite bare keys as legacy/<name>
113                    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                            // Persist migrated state while we still hold the lock
122                            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                // New-format parse failure: do NOT silently discard state.
142                Err(miette::miette!(
143                    "Failed to parse state file {}: {parse_err}",
144                    path.display()
145                ))
146            }
147        }
148    }
149
150    /// Returns true if the TOML looks like the old state file format, i.e. the
151    /// `daemons` table has at least one key that is missing the `namespace/`
152    /// prefix.  Detection is done by parsing as a generic `toml::Value` so it
153    /// works regardless of how the table headers are written.
154    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        // Old format: at least one daemon key has no '/'
163        !daemons.is_empty() && daemons.keys().any(|k| !k.contains('/'))
164    }
165
166    /// Parse old-format state TOML (bare daemon names) and return a new-format
167    /// `StateFile` with daemon IDs qualified under the `"legacy"` namespace.
168    fn migrate_old_format(raw: &str) -> Result<Self> {
169        use toml::Value;
170
171        const LEGACY_NAMESPACE: &str = "legacy";
172
173        // Parse as generic TOML value
174        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        // Re-key [daemons] entries: "name" -> "legacy/name"
178        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                    // Preserve data on collision by assigning a unique migrated key.
186                    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                    // Update the inner `id` field too
203                    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        // Re-key [disabled] set entries the same way
215        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        // Sync inner daemon id fields
234        for (id, daemon) in state_file.daemons.iter_mut() {
235            daemon.id = id.clone();
236        }
237        Ok(state_file)
238    }
239
240    /// Mark the state file as dirty so the background flush task will
241    /// persist it on the next tick.
242    fn mark_dirty(&self) {
243        self.dirty.store(true, Ordering::Relaxed);
244    }
245
246    /// Check whether the state file needs to be flushed.
247    pub fn is_dirty(&self) -> bool {
248        self.dirty.load(Ordering::Relaxed)
249    }
250
251    /// Insert or replace a daemon entry and mark the state dirty.
252    pub fn insert_daemon(&mut self, id: &DaemonId, daemon: Daemon) {
253        self.daemons.insert(id.clone(), daemon);
254        self.mark_dirty();
255    }
256
257    /// Set a daemon's status, marking the state dirty. Returns whether the
258    /// daemon exists.
259    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    /// Clear a daemon's idle-shutdown ownership, marking the state dirty if it
273    /// had any. Returns true if it did.
274    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    /// Remove a daemon entry and mark the state dirty if the daemon existed.
286    pub fn remove_daemon(&mut self, id: &DaemonId) {
287        if self.daemons.remove(id).is_some() {
288            self.mark_dirty();
289        }
290    }
291
292    /// Disable a daemon (add to disabled set) and mark the state dirty.
293    /// Returns true if the daemon was not already disabled.
294    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    /// Enable a daemon (remove from disabled set) and mark the state dirty.
303    /// Returns true if the daemon was previously disabled.
304    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    /// Set the active port for a daemon and mark the state dirty.
313    /// Returns true if the daemon was found and updated.
314    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    /// Clear the active port for a daemon and mark the state dirty.
325    /// Returns true if the daemon was found and updated.
326    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    /// Update the last cron trigger time for a daemon and mark the state dirty.
337    /// Returns true if the daemon was found and updated.
338    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    /// Record that the cron watcher actually started a daemon, and mark the
353    /// state dirty. Returns true if the daemon was found and updated.
354    ///
355    /// Separate from `set_last_cron_triggered` because a tick and a run are
356    /// not the same event: see `Daemon::last_cron_run`.
357    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    /// Set a shell working directory and mark the state dirty.
372    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    /// Remove a shell working directory and mark the state dirty.
378    /// Returns true if the entry existed.
379    #[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    /// Insert or replace a project session for the given host PID and directory
389    /// and mark the state dirty. Returns the previous session, if any.
390    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    /// Remove a project session for the given host PID and directory and mark
403    /// the state dirty if it existed. Returns the removed session, if any.
404    /// Empty per-PID subtables are pruned so the persisted TOML stays tidy.
405    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    /// Look up a project session for the given host PID and directory.
424    #[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    /// Flat iterator over all project sessions yielding `(pid_str, dir, session)`
432    /// for every entry. Used by the supervisor refresh loop to evaluate liveness.
433    /// Every directory a tracked shell or project session is in.
434    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    /// Retain only daemons matching the predicate and mark the state dirty
454    /// if any were removed.
455    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    /// Synchronous force-write. Clears the dirty flag. If the serialized
467    /// content matches the last written content, the disk write is skipped to
468    /// avoid unnecessary I/O. Used during shutdown and migration where async
469    /// flushing is not available.
470    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            // No real change — just clear the dirty flag
485            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    /// Put `daemon`'s entry back into the state file at `path` if the file no
495    /// longer has it (with the same PID), leaving the rest of the file as it
496    /// is now. The check and the write happen under one lock, so a concurrent
497    /// writer's update is not lost. Blocking; run it off the async workers.
498    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        // A missing file (deleted since) just gets the entry back; any other
502        // read failure must not be mistaken for an empty file, which would
503        // replace the existing records.
504        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    /// Forget what this instance last wrote, because the file has changed
531    /// since: the next write must not be skipped as unchanged.
532    pub(crate) fn forget_written_snapshot(&self) {
533        *self.last_content.lock().unwrap() = None;
534    }
535
536    /// Rewrite the file with this state on the next flush.
537    pub(crate) fn force_next_write(&self) {
538        self.forget_written_snapshot();
539        self.mark_dirty();
540    }
541
542    /// Write the state file without acquiring the lock.
543    /// Used internally when the lock is already held (e.g., during migration in read()).
544    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    /// Perform the actual file I/O (temp file + atomic rename).
556    /// **The caller MUST hold the file lock** (via `xx::fslock::get`) before
557    /// calling this function; otherwise concurrent writes may corrupt the file.
558    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        // If somehow a key already has a namespace, it should not be double-prefixed
727        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        // "bare" -> "legacy/bare", not "legacy/legacy/bare"
737        assert!(
738            migrated
739                .daemons
740                .contains_key(&DaemonId::new("legacy", "bare")),
741            "bare key should become legacy/bare"
742        );
743        // Should not have double-prefixed entry
744        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        // Colliding bare key should be preserved under a unique migrated key.
775        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        // All three sessions survive.
817        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        // Removal prunes the per-PID subtable when it becomes empty.
835        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}