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};
9
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct StateFile {
12    #[serde(default)]
13    pub daemons: BTreeMap<DaemonId, Daemon>,
14    #[serde(default)]
15    pub disabled: BTreeSet<DaemonId>,
16    #[serde(default)]
17    pub shell_dirs: BTreeMap<String, PathBuf>,
18    #[serde(skip)]
19    pub(crate) path: PathBuf,
20}
21
22impl StateFile {
23    pub fn new(path: PathBuf) -> Self {
24        Self {
25            daemons: Default::default(),
26            disabled: Default::default(),
27            shell_dirs: Default::default(),
28            path,
29        }
30    }
31
32    pub fn get() -> &'static Self {
33        static STATE_FILE: Lazy<StateFile> = Lazy::new(|| {
34            let path = &*env::PITCHFORK_STATE_FILE;
35            StateFile::read(path).unwrap_or_else(|e| {
36                error!(
37                    "failed to read state file {}: {}. Falling back to in-memory empty state",
38                    path.display(),
39                    e
40                );
41                StateFile::new(path.to_path_buf())
42            })
43        });
44        &STATE_FILE
45    }
46
47    pub fn read<P: AsRef<Path>>(path: P) -> Result<Self> {
48        let path = path.as_ref();
49        if !path.exists() {
50            return Ok(Self::new(path.to_path_buf()));
51        }
52        let canonical_path = normalized_lock_path(path);
53        let _lock = xx::fslock::get(&canonical_path, false)?;
54        let raw = xx::file::read_to_string(path).unwrap_or_else(|e| {
55            warn!("Error reading state file {path:?}: {e}");
56            String::new()
57        });
58
59        // Try to parse directly (new format with qualified IDs)
60        match toml::from_str::<Self>(&raw) {
61            Ok(mut state_file) => {
62                state_file.path = path.to_path_buf();
63                for (id, daemon) in state_file.daemons.iter_mut() {
64                    daemon.id = id.clone();
65                }
66                Ok(state_file)
67            }
68            Err(parse_err) => {
69                if Self::looks_like_old_format(&raw) {
70                    // Silent migration: attempt to rewrite bare keys as legacy/<name>
71                    debug!(
72                        "State file at {} appears to be in old format, attempting silent migration",
73                        path.display()
74                    );
75                    match Self::migrate_old_format(&raw) {
76                        Ok(migrated) => {
77                            let mut state_file = migrated;
78                            state_file.path = path.to_path_buf();
79                            // Persist migrated state while we still hold the lock
80                            if let Err(e) = state_file.write_unlocked() {
81                                warn!("State file migration write failed: {e}");
82                            }
83                            debug!("State file migrated successfully");
84                            return Ok(state_file);
85                        }
86                        Err(e) => {
87                            error!(
88                                "State file migration failed: {e}. \
89                                 Raw content preserved at {}. Starting with empty state.",
90                                path.display()
91                            );
92                            return Err(miette::miette!(
93                                "Failed to migrate state file {}: {e}",
94                                path.display()
95                            ));
96                        }
97                    }
98                }
99                // New-format parse failure: do NOT silently discard state.
100                Err(miette::miette!(
101                    "Failed to parse state file {}: {parse_err}",
102                    path.display()
103                ))
104            }
105        }
106    }
107
108    /// Returns true if the TOML looks like the old state file format, i.e. the
109    /// `daemons` table has at least one key that is missing the `namespace/`
110    /// prefix.  Detection is done by parsing as a generic `toml::Value` so it
111    /// works regardless of how the table headers are written.
112    fn looks_like_old_format(raw: &str) -> bool {
113        use toml::Value;
114        let Ok(Value::Table(doc)) = toml::from_str::<Value>(raw) else {
115            return false;
116        };
117        let Some(Value::Table(daemons)) = doc.get("daemons") else {
118            return false;
119        };
120        // Old format: at least one daemon key has no '/'
121        !daemons.is_empty() && daemons.keys().any(|k| !k.contains('/'))
122    }
123
124    /// Parse old-format state TOML (bare daemon names) and return a new-format
125    /// `StateFile` with daemon IDs qualified under the `"legacy"` namespace.
126    fn migrate_old_format(raw: &str) -> Result<Self> {
127        use toml::Value;
128
129        const LEGACY_NAMESPACE: &str = "legacy";
130
131        // Parse as generic TOML value
132        let mut doc: toml::map::Map<String, Value> = toml::from_str(raw)
133            .map_err(|e| miette::miette!("failed to parse old state file: {e}"))?;
134
135        // Re-key [daemons] entries: "name" -> "legacy/name"
136        if let Some(Value::Table(daemons)) = doc.get_mut("daemons") {
137            let old_keys: Vec<String> = daemons.keys().cloned().collect();
138            for key in old_keys {
139                if !key.contains('/')
140                    && let Some(val) = daemons.remove(&key)
141                {
142                    let mut new_key = format!("{LEGACY_NAMESPACE}/{key}");
143                    // Preserve data on collision by assigning a unique migrated key.
144                    if daemons.contains_key(&new_key) {
145                        let base = format!("{key}-legacy");
146                        let mut candidate = format!("{LEGACY_NAMESPACE}/{base}");
147                        let mut n: u32 = 2;
148                        while daemons.contains_key(&candidate) {
149                            candidate = format!("{LEGACY_NAMESPACE}/{base}-{n}");
150                            n += 1;
151                        }
152                        warn!(
153                            "Legacy daemon key '{}' collides with '{}'; migrating as '{}'",
154                            key,
155                            format_args!("{LEGACY_NAMESPACE}/{key}"),
156                            candidate
157                        );
158                        new_key = candidate;
159                    }
160                    // Update the inner `id` field too
161                    let val = if let Value::Table(mut tbl) = val {
162                        tbl.insert("id".to_string(), Value::String(new_key.clone()));
163                        Value::Table(tbl)
164                    } else {
165                        val
166                    };
167                    daemons.insert(new_key, val);
168                }
169            }
170        }
171
172        // Re-key [disabled] set entries the same way
173        if let Some(Value::Array(disabled)) = doc.get_mut("disabled") {
174            for entry in disabled.iter_mut() {
175                if let Value::String(s) = entry
176                    && !s.contains('/')
177                {
178                    *s = format!("{LEGACY_NAMESPACE}/{s}");
179                }
180            }
181        }
182
183        let new_raw =
184            toml::to_string(&Value::Table(doc)).map_err(|e| FileError::SerializeError {
185                path: PathBuf::new(),
186                source: e,
187            })?;
188
189        let mut state_file: Self = toml::from_str(&new_raw)
190            .map_err(|e| miette::miette!("failed to parse migrated state file: {e}"))?;
191        // Sync inner daemon id fields
192        for (id, daemon) in state_file.daemons.iter_mut() {
193            daemon.id = id.clone();
194        }
195        Ok(state_file)
196    }
197
198    pub fn write(&self) -> Result<()> {
199        if let Some(parent) = self.path.parent() {
200            std::fs::create_dir_all(parent).map_err(|e| FileError::WriteError {
201                path: parent.to_path_buf(),
202                details: Some(format!("failed to create state file directory: {e}")),
203            })?;
204        }
205        let canonical_path = normalized_lock_path(&self.path);
206        let _lock = xx::fslock::get(&canonical_path, false)?;
207        self.write_unlocked()
208    }
209
210    /// Write the state file without acquiring the lock.
211    /// Used internally when the lock is already held (e.g., during migration in read()).
212    fn write_unlocked(&self) -> Result<()> {
213        let raw = toml::to_string(self).map_err(|e| FileError::SerializeError {
214            path: self.path.clone(),
215            source: e,
216        })?;
217
218        // Use atomic write: write to temp file first, then rename
219        // This prevents readers from seeing partially written content
220        let temp_path = self.path.with_extension("toml.tmp");
221        xx::file::write(&temp_path, &raw).map_err(|e| FileError::WriteError {
222            path: temp_path.clone(),
223            details: Some(e.to_string()),
224        })?;
225        std::fs::rename(&temp_path, &self.path).map_err(|e| FileError::WriteError {
226            path: self.path.clone(),
227            details: Some(format!("failed to rename temp file: {e}")),
228        })?;
229        Ok(())
230    }
231}
232
233fn normalized_lock_path(path: &Path) -> PathBuf {
234    if let Ok(canonical) = path.canonicalize() {
235        return canonical;
236    }
237
238    if let Some(parent) = path.parent()
239        && let Ok(canonical_parent) = parent.canonicalize()
240        && let Some(file_name) = path.file_name()
241    {
242        return canonical_parent.join(file_name);
243    }
244
245    path.to_path_buf()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::daemon_status::DaemonStatus;
252
253    #[test]
254    fn test_state_file_toml_roundtrip_stopped() {
255        let mut state = StateFile::new(PathBuf::from("/tmp/test.toml"));
256        let daemon_id = DaemonId::new("project", "test");
257        state.daemons.insert(
258            daemon_id.clone(),
259            Daemon {
260                id: daemon_id,
261                title: None,
262                pid: None,
263                shell_pid: None,
264                status: DaemonStatus::Stopped,
265                dir: None,
266                cmd: None,
267                autostop: false,
268                cron_schedule: None,
269                cron_retrigger: None,
270                last_cron_triggered: None,
271                last_exit_success: Some(true),
272                retry: 0,
273                retry_count: 0,
274                ready_delay: None,
275                ready_output: None,
276                ready_http: None,
277                ready_port: None,
278                ready_cmd: None,
279                expected_port: vec![],
280                auto_bump_port: false,
281                port_bump_attempts: 0,
282                resolved_port: vec![],
283                depends: vec![],
284                env: None,
285                watch: vec![],
286                watch_base_dir: None,
287                mise: false,
288                memory_limit: None,
289                cpu_limit: None,
290            },
291        );
292
293        let toml_str = toml::to_string(&state).unwrap();
294        println!("Serialized TOML:\n{}", toml_str);
295
296        let parsed: StateFile = toml::from_str(&toml_str).expect("Failed to parse TOML");
297        println!("Parsed: {:?}", parsed);
298
299        assert!(
300            parsed
301                .daemons
302                .contains_key(&DaemonId::new("project", "test"))
303        );
304    }
305
306    #[test]
307    fn test_looks_like_old_format_bare_names() {
308        let old = r#"
309[daemons.api]
310id = "api"
311autostop = false
312retry = 0
313retry_count = 0
314status = "stopped"
315"#;
316        assert!(StateFile::looks_like_old_format(old));
317    }
318
319    #[test]
320    fn test_looks_like_old_format_new_format() {
321        let new = r#"
322    disabled = []
323
324    [daemons."legacy/api"]
325    id = "legacy/api"
326autostop = false
327retry = 0
328retry_count = 0
329status = "stopped"
330"#;
331        assert!(!StateFile::looks_like_old_format(new));
332    }
333
334    #[test]
335    fn test_looks_like_old_format_empty() {
336        assert!(!StateFile::looks_like_old_format(""));
337        assert!(!StateFile::looks_like_old_format("[shell_dirs]"));
338    }
339
340    #[test]
341    fn test_migrate_old_format_basic() {
342        let old = r#"
343[daemons.api]
344id = "api"
345autostop = false
346retry = 0
347retry_count = 0
348status = "stopped"
349
350[daemons.worker]
351id = "worker"
352autostop = false
353retry = 0
354retry_count = 0
355status = "stopped"
356last_exit_success = true
357"#;
358        let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
359        assert!(
360            migrated
361                .daemons
362                .contains_key(&DaemonId::new("legacy", "api")),
363            "api should be migrated to legacy/api"
364        );
365        assert!(
366            migrated
367                .daemons
368                .contains_key(&DaemonId::new("legacy", "worker")),
369            "worker should be migrated to legacy/worker"
370        );
371        assert_eq!(migrated.daemons.len(), 2);
372    }
373
374    #[test]
375    fn test_migrate_old_format_preserves_disabled() {
376        let old = r#"
377disabled = ["api", "worker"]
378
379[daemons.api]
380id = "api"
381autostop = false
382retry = 0
383retry_count = 0
384status = "stopped"
385"#;
386        let migrated = StateFile::migrate_old_format(old).expect("migration should succeed");
387        assert!(
388            migrated.disabled.contains(&DaemonId::new("legacy", "api")),
389            "disabled 'api' should become 'legacy/api'"
390        );
391        assert!(
392            migrated
393                .disabled
394                .contains(&DaemonId::new("legacy", "worker")),
395            "disabled 'worker' should become 'legacy/worker'"
396        );
397    }
398
399    #[test]
400    fn test_migrate_old_format_already_qualified_unchanged() {
401        // If somehow a key already has a namespace, it should not be double-prefixed
402        let mixed = r#"
403[daemons.bare]
404id = "bare"
405autostop = false
406retry = 0
407retry_count = 0
408status = "stopped"
409"#;
410        let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
411        // "bare" -> "legacy/bare", not "legacy/legacy/bare"
412        assert!(
413            migrated
414                .daemons
415                .contains_key(&DaemonId::new("legacy", "bare")),
416            "bare key should become legacy/bare"
417        );
418        // Should not have double-prefixed entry
419        assert_eq!(migrated.daemons.len(), 1);
420    }
421
422    #[test]
423    fn test_migrate_old_format_does_not_overwrite_existing_qualified_entry() {
424        let mixed = r#"
425[daemons.api]
426id = "api"
427cmd = ["echo", "old"]
428autostop = false
429retry = 0
430retry_count = 0
431status = "stopped"
432
433[daemons."legacy/api"]
434id = "legacy/api"
435cmd = ["echo", "new"]
436autostop = false
437retry = 0
438retry_count = 0
439status = "stopped"
440"#;
441
442        let migrated = StateFile::migrate_old_format(mixed).expect("migration should succeed");
443        let key = DaemonId::new("legacy", "api");
444        let daemon = migrated.daemons.get(&key).expect("legacy/api should exist");
445
446        let cmd = daemon.cmd.as_ref().expect("cmd should exist");
447        assert_eq!(cmd, &vec!["echo".to_string(), "new".to_string()]);
448
449        // Colliding bare key should be preserved under a unique migrated key.
450        let preserved = DaemonId::new("legacy", "api-legacy");
451        let preserved_daemon = migrated
452            .daemons
453            .get(&preserved)
454            .expect("colliding bare key should be preserved as legacy/api-legacy");
455        let preserved_cmd = preserved_daemon
456            .cmd
457            .as_ref()
458            .expect("preserved cmd should exist");
459        assert_eq!(preserved_cmd, &vec!["echo".to_string(), "old".to_string()]);
460        assert_eq!(migrated.daemons.len(), 2);
461    }
462}