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    /// Set a shell working directory and mark the state dirty.
353    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    /// Remove a shell working directory and mark the state dirty.
359    /// Returns true if the entry existed.
360    #[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    /// Insert or replace a project session for the given host PID and directory
370    /// and mark the state dirty. Returns the previous session, if any.
371    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    /// Remove a project session for the given host PID and directory and mark
384    /// the state dirty if it existed. Returns the removed session, if any.
385    /// Empty per-PID subtables are pruned so the persisted TOML stays tidy.
386    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    /// Look up a project session for the given host PID and directory.
405    #[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    /// Flat iterator over all project sessions yielding `(pid_str, dir, session)`
413    /// for every entry. Used by the supervisor refresh loop to evaluate liveness.
414    /// Every directory a tracked shell or project session is in.
415    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    /// Retain only daemons matching the predicate and mark the state dirty
435    /// if any were removed.
436    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    /// Synchronous force-write. Clears the dirty flag. If the serialized
448    /// content matches the last written content, the disk write is skipped to
449    /// avoid unnecessary I/O. Used during shutdown and migration where async
450    /// flushing is not available.
451    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            // No real change — just clear the dirty flag
466            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    /// Put `daemon`'s entry back into the state file at `path` if the file no
476    /// longer has it (with the same PID), leaving the rest of the file as it
477    /// is now. The check and the write happen under one lock, so a concurrent
478    /// writer's update is not lost. Blocking; run it off the async workers.
479    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        // A missing file (deleted since) just gets the entry back; any other
483        // read failure must not be mistaken for an empty file, which would
484        // replace the existing records.
485        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    /// Forget what this instance last wrote, because the file has changed
512    /// since: the next write must not be skipped as unchanged.
513    pub(crate) fn forget_written_snapshot(&self) {
514        *self.last_content.lock().unwrap() = None;
515    }
516
517    /// Rewrite the file with this state on the next flush.
518    pub(crate) fn force_next_write(&self) {
519        self.forget_written_snapshot();
520        self.mark_dirty();
521    }
522
523    /// Write the state file without acquiring the lock.
524    /// Used internally when the lock is already held (e.g., during migration in read()).
525    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    /// Perform the actual file I/O (temp file + atomic rename).
537    /// **The caller MUST hold the file lock** (via `xx::fslock::get`) before
538    /// calling this function; otherwise concurrent writes may corrupt the file.
539    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        // If somehow a key already has a namespace, it should not be double-prefixed
708        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        // "bare" -> "legacy/bare", not "legacy/legacy/bare"
718        assert!(
719            migrated
720                .daemons
721                .contains_key(&DaemonId::new("legacy", "bare")),
722            "bare key should become legacy/bare"
723        );
724        // Should not have double-prefixed entry
725        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        // Colliding bare key should be preserved under a unique migrated key.
756        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        // All three sessions survive.
798        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        // Removal prunes the per-PID subtable when it becomes empty.
816        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}