Skip to main content

syncweb_core/daemon/
state.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io::{BufReader, Read, Seek, SeekFrom, Write},
4    path::{Path, PathBuf},
5    sync::Mutex,
6    time::{SystemTime, UNIX_EPOCH},
7};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::{Result, SyncwebError};
12use crate::storage::node_db::NodeDatabase;
13
14const LOCK_FILE_NAME: &str = "daemon.lock";
15const SOCKET_FILE_NAME: &str = "daemon.sock";
16const RUNTIME_SOCKET_FILE_PREFIX: &str = "syncweb-";
17
18/// The lifecycle state persisted by a running daemon.
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
20#[serde(rename_all = "lowercase")]
21#[non_exhaustive]
22pub enum DaemonStatus {
23    Starting,
24    Running,
25    Stopping,
26    Stopped,
27}
28
29/// Identifying and lifecycle information for a daemon instance.
30#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[non_exhaustive]
32pub struct DaemonState {
33    pub pid: u32,
34    pub node_id: String,
35    pub started_at: u64,
36    pub data_dir: PathBuf,
37    pub status: DaemonStatus,
38}
39
40/// A persisted snapshot of daemon activity and synchronization progress.
41#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
42#[non_exhaustive]
43pub struct DaemonStatusReport {
44    pub pid: u32,
45    pub node_id: String,
46    pub started_at: u64,
47    pub uptime_seconds: u64,
48    pub folders: Vec<FolderStatusReport>,
49    pub bandwidth: BandwidthSnapshot,
50    pub schedule: Option<ScheduleStatus>,
51    pub rayon_threads: usize,
52}
53
54impl DaemonStatusReport {
55    #[must_use]
56    pub fn from_state(
57        state: &DaemonState,
58        uptime_seconds: u64,
59        folders: Vec<FolderStatusReport>,
60        bandwidth: BandwidthSnapshot,
61        schedule: Option<ScheduleStatus>,
62        rayon_threads: usize,
63    ) -> Self {
64        Self {
65            pid: state.pid,
66            node_id: state.node_id.clone(),
67            started_at: state.started_at,
68            uptime_seconds,
69            folders,
70            bandwidth,
71            schedule,
72            rayon_threads,
73        }
74    }
75}
76
77/// Activity and error information for one managed folder.
78#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
79#[non_exhaustive]
80pub struct FolderStatusReport {
81    pub namespace: String,
82    pub path: PathBuf,
83    #[serde(default = "default_kind")]
84    pub kind: String,
85    pub session_active: bool,
86    pub last_sync_at: Option<u64>,
87    #[serde(default)]
88    pub sync_count: u64,
89    pub entries_synced: u64,
90    pub errors: Vec<String>,
91}
92
93fn default_kind() -> String {
94    "folder".to_owned()
95}
96
97impl FolderStatusReport {
98    #[must_use]
99    pub fn new(
100        namespace: impl Into<String>,
101        path: impl Into<PathBuf>,
102        session_active: bool,
103        last_sync_at: Option<u64>,
104        entries_synced: u64,
105        errors: Vec<String>,
106    ) -> Self {
107        Self {
108            namespace: namespace.into(),
109            path: path.into(),
110            kind: "folder".to_owned(),
111            session_active,
112            last_sync_at,
113            sync_count: 0,
114            entries_synced,
115            errors,
116        }
117    }
118}
119
120/// Aggregate transfer counters included in a daemon status report.
121#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
122#[non_exhaustive]
123pub struct BandwidthSnapshot {
124    pub upload_total: u64,
125    pub download_total: u64,
126    pub upload_rate: u64,
127    pub download_rate: u64,
128}
129
130/// The current schedule state included in a daemon status report.
131#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
132#[non_exhaustive]
133pub struct ScheduleStatus {
134    pub in_active_window: bool,
135    pub next_window_start: Option<u64>,
136}
137
138impl DaemonState {
139    #[must_use]
140    pub fn new(
141        pid: u32,
142        node_id: impl Into<String>,
143        started_at: u64,
144        data_dir: impl Into<PathBuf>,
145        status: DaemonStatus,
146    ) -> Self {
147        Self {
148            pid,
149            node_id: node_id.into(),
150            started_at,
151            data_dir: data_dir.into(),
152            status,
153        }
154    }
155}
156
157/// SQLite-backed persistence for daemon state and status.
158#[derive(Clone, Debug)]
159pub struct StateFile {
160    data_dir: PathBuf,
161}
162
163impl StateFile {
164    /// Store the data directory path. The database is not opened yet —
165    /// call [`db()`](Self::db) to open it lazily.
166    #[must_use]
167    pub fn new(data_dir: &Path) -> Self {
168        Self {
169            data_dir: data_dir.to_path_buf(),
170        }
171    }
172
173    /// Open (or create) the `SQLite` node database in `data_dir`.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the database cannot be opened or initialized.
178    fn db(&self) -> Result<NodeDatabase> {
179        NodeDatabase::open(self.data_dir.join("node.db"))
180    }
181
182    /// Persist the daemon lifecycle state.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error when the database cannot be written.
187    pub fn save(&self, state: &DaemonState) -> Result<()> {
188        let db = self.db()?;
189        db.save_lifecycle(state)
190    }
191
192    /// Load the daemon lifecycle state, returning `None` if absent.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error when the database cannot be read.
197    pub fn load(&self) -> Result<Option<DaemonState>> {
198        let db = self.db()?;
199        db.load_lifecycle()
200    }
201
202    /// Persist the current daemon activity report.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error when the database cannot be written.
207    pub fn save_status(&self, report: &DaemonStatusReport) -> Result<()> {
208        let db = self.db()?;
209        db.save_status(report)
210    }
211
212    /// Load the last daemon activity report.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error when the database cannot be read.
217    pub fn load_status(&self) -> Result<Option<DaemonStatusReport>> {
218        let db = self.db()?;
219        db.load_status()
220    }
221
222    /// Check whether a lifecycle row exists (equivalent to the old file check).
223    #[must_use]
224    pub fn exists(&self) -> bool {
225        self.db()
226            .ok()
227            .and_then(|db| db.load_lifecycle().ok())
228            .flatten()
229            .is_some()
230    }
231
232    /// Remove the daemon lifecycle row.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error when the database operation fails.
237    pub fn remove(&self) -> Result<()> {
238        let db = self.db()?;
239        db.remove_lifecycle()
240    }
241
242    /// Remove the daemon status rows.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error when the database operation fails.
247    pub fn remove_status(&self) -> Result<()> {
248        let db = self.db()?;
249        db.remove_status()
250    }
251
252    /// Return whether the stored lifecycle indicates a still-running daemon.
253    ///
254    /// # Errors
255    ///
256    /// Returns an error when the database cannot be read.
257    pub fn is_running(&self) -> Result<bool> {
258        Ok(self.load()?.is_some_and(|state| {
259            matches!(state.status, DaemonStatus::Starting | DaemonStatus::Running) && pid_is_alive(state.pid)
260        }))
261    }
262}
263
264/// Exclusive process lock preventing multiple daemon instances on the same
265/// data directory.
266#[derive(Debug)]
267pub struct PidLock {
268    lock_path: PathBuf,
269    lock: Mutex<Option<File>>,
270}
271
272impl PidLock {
273    #[must_use]
274    pub fn new(data_dir: &Path) -> Self {
275        Self {
276            lock_path: data_dir.join(LOCK_FILE_NAME),
277            lock: Mutex::new(None),
278        }
279    }
280
281    /// Try to acquire and retain the lock for this process.
282    ///
283    /// The PID is written only after the OS-level lock succeeds. A `false`
284    /// result means another process currently owns the lock.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error when the lock file cannot be opened or updated.
289    pub fn try_acquire(&self) -> Result<bool> {
290        let mut held = self
291            .lock
292            .lock()
293            .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
294        if held.is_some() {
295            return Ok(true);
296        }
297        if let Some(parent) = self.lock_path.parent() {
298            fs::create_dir_all(parent)?;
299        }
300        let mut candidate = OpenOptions::new()
301            .create(true)
302            .truncate(false)
303            .read(true)
304            .write(true)
305            .open(&self.lock_path)?;
306        if let Err(lock_result) = fs4::FileExt::try_lock(&candidate) {
307            match lock_result {
308                fs4::TryLockError::WouldBlock => return Ok(false),
309                fs4::TryLockError::Error(lock_error) => return Err(lock_error.into()),
310            }
311        }
312        candidate.set_len(0)?;
313        writeln!(candidate, "{}", std::process::id())?;
314        candidate.sync_all()?;
315        *held = Some(candidate);
316        drop(held);
317        Ok(true)
318    }
319
320    /// Release the lock held by this instance.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error when the OS refuses to release the lock.
325    pub fn release(&self) -> Result<()> {
326        let mut held = self
327            .lock
328            .lock()
329            .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
330        if let Some(file) = held.take() {
331            fs4::FileExt::unlock(&file)?;
332        }
333        drop(held);
334        Ok(())
335    }
336
337    /// Read the last PID written to the lock file.
338    ///
339    /// # Errors
340    ///
341    /// Returns an error when the lock file cannot be read or its contents are
342    /// not a decimal process ID.
343    pub fn owner_pid(&self) -> Result<Option<u32>> {
344        let mut held = self
345            .lock
346            .lock()
347            .map_err(|error| SyncwebError::operation("daemon lock mutex is poisoned", error))?;
348        if let Some(file) = held.as_mut() {
349            file.seek(SeekFrom::Start(0))?;
350            let mut contents = String::new();
351            let mut reader = BufReader::new(&mut *file);
352            reader.read_to_string(&mut contents)?;
353            return Self::parse_pid_from_contents(&contents);
354        }
355        let contents = match fs::read_to_string(&self.lock_path) {
356            Ok(contents) => contents,
357            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
358            Err(error) => return Err(error.into()),
359        };
360        drop(held);
361        Self::parse_pid_from_contents(&contents)
362    }
363
364    fn parse_pid_from_contents(contents: &str) -> Result<Option<u32>> {
365        contents
366            .split_whitespace()
367            .next()
368            .map(|value| {
369                value
370                    .parse()
371                    .map_err(|error| SyncwebError::operation("invalid daemon lock PID", error))
372            })
373            .transpose()
374    }
375
376    #[must_use]
377    pub fn lock_path(&self) -> &Path {
378        &self.lock_path
379    }
380}
381
382impl Drop for PidLock {
383    fn drop(&mut self) {
384        if let Ok(mut held) = self.lock.lock()
385            && let Some(file) = held.take()
386        {
387            let _ = fs4::FileExt::unlock(&file);
388        }
389    }
390}
391
392/// Return the socket path used for a data directory.
393///
394/// Runtime sockets belong in `XDG_RUNTIME_DIR`; the data directory remains
395/// the fallback for environments without an XDG runtime directory. On macOS
396/// the Unix socket path limit (103 chars) can be exceeded with deeply nested
397/// temp directories; in that case a hashed name in the system temp dir is
398/// used instead.
399#[must_use]
400pub fn daemon_socket_path(data_dir: &Path) -> PathBuf {
401    if let Some(runtime_dir) = std::env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
402        let canonical_data_dir = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
403        let digest = blake3::hash(canonical_data_dir.to_string_lossy().as_bytes());
404        let suffix: String = digest.to_hex().chars().take(16).collect();
405        return PathBuf::from(runtime_dir).join(format!("{RUNTIME_SOCKET_FILE_PREFIX}{suffix}.sock"));
406    }
407    let socket = data_dir.join(SOCKET_FILE_NAME);
408    #[cfg(target_os = "macos")]
409    if socket.as_os_str().len() >= 100 {
410        let canonical_data_dir = fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
411        let digest = blake3::hash(canonical_data_dir.to_string_lossy().as_bytes());
412        let suffix: String = digest.to_hex().chars().take(16).collect();
413        return std::env::temp_dir().join(format!("{RUNTIME_SOCKET_FILE_PREFIX}{suffix}.sock"));
414    }
415    socket
416}
417
418/// Check whether a process ID is currently present on the local system.
419#[must_use]
420pub fn pid_is_alive(pid: u32) -> bool {
421    if pid == 0 {
422        return false;
423    }
424    if pid == std::process::id() {
425        return true;
426    }
427    #[cfg(target_os = "linux")]
428    {
429        Path::new("/proc").join(pid.to_string()).exists()
430    }
431    #[cfg(all(unix, not(target_os = "linux")))]
432    {
433        use libc::kill;
434        let Ok(pid_i32) = i32::try_from(pid) else {
435            return false;
436        };
437        // SAFETY: signal 0 is a no-op that only checks if the process exists.
438        // It is safe even if the PID has been reused because we only get
439        // a boolean result (ESRCH means no such process).
440        unsafe { kill(pid_i32, 0) == 0 }
441    }
442    #[cfg(windows)]
443    {
444        fn is_process_alive(pid: u32) -> bool {
445            use std::ffi::c_void;
446
447            const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
448            const STILL_ACTIVE: u32 = 259;
449
450            type HANDLE = *mut c_void;
451
452            unsafe extern "system" {
453                fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> HANDLE;
454                fn GetExitCodeProcess(hProcess: HANDLE, lpExitCode: *mut u32) -> i32;
455                fn CloseHandle(hObject: HANDLE) -> i32;
456            }
457
458            let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
459            if handle.is_null() {
460                return false;
461            }
462            let mut exit_code: u32 = 0;
463            let result = unsafe { GetExitCodeProcess(handle, &mut exit_code) };
464            unsafe { CloseHandle(handle) };
465            result != 0 && exit_code == STILL_ACTIVE
466        }
467        is_process_alive(pid)
468    }
469    #[cfg(not(any(unix, windows)))]
470    {
471        true
472    }
473}
474
475#[must_use]
476pub fn current_timestamp() -> u64 {
477    SystemTime::now()
478        .duration_since(UNIX_EPOCH)
479        .map_or(0, |duration| duration.as_secs())
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn test_dir() -> PathBuf {
487        std::env::temp_dir().join(format!("syncweb-state-test-{}", uuid::Uuid::new_v4()))
488    }
489
490    #[test]
491    fn daemon_status_serialization() {
492        let cases = [
493            (DaemonStatus::Starting, r#""starting""#),
494            (DaemonStatus::Running, r#""running""#),
495            (DaemonStatus::Stopping, r#""stopping""#),
496            (DaemonStatus::Stopped, r#""stopped""#),
497        ];
498        for (status, expected) in cases {
499            let json = serde_json::to_string(&status).unwrap();
500            assert_eq!(json, expected);
501            let deserialized: DaemonStatus = serde_json::from_str(&json).unwrap();
502            assert_eq!(deserialized, status);
503        }
504    }
505
506    #[test]
507    fn daemon_state_serialization() {
508        let state = DaemonState::new(
509            12345,
510            "node-id",
511            1000,
512            PathBuf::from("/tmp/data"),
513            DaemonStatus::Running,
514        );
515        let json = serde_json::to_string_pretty(&state).unwrap();
516        let deserialized: DaemonState = serde_json::from_str(&json).unwrap();
517        assert_eq!(deserialized, state);
518    }
519
520    #[test]
521    fn state_file_save_and_load() {
522        let dir = test_dir();
523        let state_file = StateFile::new(&dir);
524        let state = DaemonState::new(std::process::id(), "test-node", 42, dir.clone(), DaemonStatus::Running);
525        state_file.save(&state).unwrap();
526        assert!(state_file.exists());
527        let loaded = state_file.load().unwrap().expect("state should exist");
528        assert_eq!(loaded.pid, state.pid);
529        assert_eq!(loaded.node_id, state.node_id);
530        assert_eq!(loaded.started_at, state.started_at);
531        assert_eq!(loaded.status, state.status);
532        let _ = std::fs::remove_dir_all(&dir);
533    }
534
535    #[test]
536    fn state_file_missing_returns_none() {
537        let dir = test_dir();
538        let state_file = StateFile::new(&dir);
539        assert!(!state_file.exists());
540        let loaded = state_file.load().unwrap();
541        assert!(loaded.is_none());
542        let _ = std::fs::remove_dir_all(&dir);
543    }
544
545    #[test]
546    fn state_file_corrupted_returns_error() {
547        let dir = test_dir();
548        let state_file = StateFile::new(&dir);
549        state_file
550            .save(&DaemonState::new(1, "node", 1, dir.clone(), DaemonStatus::Starting))
551            .unwrap();
552        let loaded = state_file.load().unwrap();
553        assert!(loaded.is_some());
554        assert_eq!(loaded.unwrap().pid, 1);
555
556        state_file.remove().unwrap();
557        let loaded_after_remove = state_file.load().unwrap();
558        assert!(loaded_after_remove.is_none());
559        let _ = std::fs::remove_dir_all(&dir);
560    }
561
562    #[test]
563    fn state_file_remove() {
564        let dir = test_dir();
565        let state_file = StateFile::new(&dir);
566        let state = DaemonState::new(1, "node", 1, dir.clone(), DaemonStatus::Starting);
567        state_file.save(&state).unwrap();
568        assert!(state_file.exists());
569        state_file.remove().unwrap();
570        assert!(!state_file.exists());
571        state_file.remove().unwrap();
572        let _ = std::fs::remove_dir_all(&dir);
573    }
574
575    #[test]
576    fn state_file_is_running_returns_false_for_stale() {
577        let dir = test_dir();
578        let state_file = StateFile::new(&dir);
579        let state = DaemonState::new(u32::MAX, "stale", 1, dir.clone(), DaemonStatus::Running);
580        state_file.save(&state).unwrap();
581        assert!(!state_file.is_running().unwrap());
582        let _ = std::fs::remove_dir_all(&dir);
583    }
584
585    #[test]
586    fn status_report_save_and_load() {
587        let dir = test_dir();
588        let state_file = StateFile::new(&dir);
589        let report = DaemonStatusReport::from_state(
590            &DaemonState::new(1, "node", 100, dir.clone(), DaemonStatus::Running),
591            10,
592            vec![],
593            BandwidthSnapshot::default(),
594            None,
595            4,
596        );
597        state_file.save_status(&report).unwrap();
598        let loaded = state_file.load_status().unwrap().expect("report should exist");
599        assert_eq!(loaded.pid, report.pid);
600        assert_eq!(loaded.node_id, report.node_id);
601        assert_eq!(loaded.uptime_seconds, report.uptime_seconds);
602        let _ = std::fs::remove_dir_all(&dir);
603    }
604
605    #[test]
606    fn pid_lock_acquire_success() {
607        let dir = test_dir();
608        std::fs::create_dir_all(&dir).unwrap();
609        let lock = PidLock::new(&dir);
610        assert!(lock.try_acquire().unwrap());
611        assert!(lock.lock_path().exists());
612        let pid = lock.owner_pid().unwrap().expect("pid should be written");
613        assert_eq!(pid, std::process::id());
614        lock.release().unwrap();
615        let _ = std::fs::remove_dir_all(&dir);
616    }
617
618    #[cfg(unix)]
619    #[test]
620    fn pid_lock_acquire_conflict() {
621        let dir = test_dir();
622        std::fs::create_dir_all(&dir).unwrap();
623        let first = PidLock::new(&dir);
624        assert!(first.try_acquire().unwrap());
625        let second = PidLock::new(&dir);
626        assert!(!second.try_acquire().unwrap());
627        first.release().unwrap();
628        let _ = std::fs::remove_dir_all(&dir);
629    }
630
631    #[test]
632    fn pid_lock_release_releases_lock() {
633        let dir = test_dir();
634        std::fs::create_dir_all(&dir).unwrap();
635        let lock = PidLock::new(&dir);
636        assert!(lock.try_acquire().unwrap());
637        lock.release().unwrap();
638        let reacquire = PidLock::new(&dir);
639        assert!(reacquire.try_acquire().unwrap());
640        reacquire.release().unwrap();
641        let _ = std::fs::remove_dir_all(&dir);
642    }
643
644    #[test]
645    fn pid_lock_drop_releases() {
646        let dir = test_dir();
647        std::fs::create_dir_all(&dir).unwrap();
648        {
649            let lock = PidLock::new(&dir);
650            assert!(lock.try_acquire().unwrap());
651        }
652        let reacquire = PidLock::new(&dir);
653        assert!(reacquire.try_acquire().unwrap());
654        reacquire.release().unwrap();
655        let _ = std::fs::remove_dir_all(&dir);
656    }
657
658    #[test]
659    fn pid_is_alive_checks_current_and_zero() {
660        assert!(pid_is_alive(std::process::id()));
661        assert!(!pid_is_alive(0));
662    }
663
664    #[test]
665    fn daemon_socket_path_returns_valid_path() {
666        let dir = test_dir();
667        let path = daemon_socket_path(&dir);
668        assert!(path.file_name().unwrap().to_string_lossy().ends_with(".sock"));
669        let _ = std::fs::remove_dir_all(&dir);
670    }
671
672    #[test]
673    fn pid_lock_stale_detection() {
674        let dir = test_dir();
675        std::fs::create_dir_all(&dir).unwrap();
676        let lock = PidLock::new(&dir);
677        assert!(lock.try_acquire().unwrap());
678        let pid = lock.owner_pid().unwrap().expect("pid should be written");
679        assert_eq!(pid, std::process::id());
680        lock.release().unwrap();
681        assert!(!pid_is_alive(99999_u32));
682        let state_file = StateFile::new(&dir);
683        state_file
684            .save(&DaemonState::new(99999, "stale-daemon", 1, &dir, DaemonStatus::Running))
685            .unwrap();
686        assert!(!state_file.is_running().unwrap());
687        let _ = std::fs::remove_dir_all(&dir);
688    }
689
690    #[test]
691    fn pid_lock_state_file_consistency() {
692        let dir = test_dir();
693        std::fs::create_dir_all(&dir).unwrap();
694        let lock = PidLock::new(&dir);
695        assert!(lock.try_acquire().unwrap());
696        let state_file = StateFile::new(&dir);
697        state_file
698            .save(&DaemonState::new(
699                std::process::id(),
700                "consistent",
701                current_timestamp(),
702                &dir,
703                DaemonStatus::Running,
704            ))
705            .unwrap();
706        assert!(state_file.exists());
707        assert!(lock.lock_path().exists());
708        assert_eq!(lock.owner_pid().unwrap(), Some(std::process::id()));
709        lock.release().unwrap();
710        state_file.remove().unwrap();
711        let _ = std::fs::remove_dir_all(&dir);
712    }
713
714    #[cfg(unix)]
715    #[test]
716    fn pid_lock_concurrent_start_race() {
717        let dir = test_dir();
718        std::fs::create_dir_all(&dir).unwrap();
719        let first = PidLock::new(&dir);
720        assert!(first.try_acquire().unwrap());
721        let second = PidLock::new(&dir);
722        assert!(!second.try_acquire().unwrap());
723        first.release().unwrap();
724        assert!(second.try_acquire().unwrap());
725        second.release().unwrap();
726        let _ = std::fs::remove_dir_all(&dir);
727    }
728
729    #[test]
730    fn status_report_serialization_json() {
731        let report = DaemonStatusReport::from_state(
732            &DaemonState::new(42, "ns-id", 100, PathBuf::from("/data"), DaemonStatus::Running),
733            10,
734            vec![FolderStatusReport::new(
735                "folder1",
736                PathBuf::from("/foo"),
737                true,
738                Some(200),
739                5,
740                vec![],
741            )],
742            BandwidthSnapshot::default(),
743            Some(ScheduleStatus {
744                in_active_window: true,
745                next_window_start: Some(300),
746            }),
747            4,
748        );
749        let json = serde_json::to_string_pretty(&report).unwrap();
750        assert!(json.contains("folder1"));
751        assert!(json.contains("uptime_seconds"));
752        let deserialized: DaemonStatusReport = serde_json::from_str(&json).unwrap();
753        assert_eq!(deserialized.pid, report.pid);
754        assert_eq!(deserialized.folders.len(), 1);
755        assert!(deserialized.schedule.unwrap().in_active_window);
756    }
757
758    #[test]
759    fn daemon_status_report_includes_folders() {
760        let report = DaemonStatusReport::from_state(
761            &DaemonState::new(1, "node", 1, PathBuf::from("/tmp"), DaemonStatus::Running),
762            5,
763            vec![
764                FolderStatusReport::new("ns1", PathBuf::from("/a"), true, None, 3, vec![]),
765                FolderStatusReport::new("ns2", PathBuf::from("/b"), false, Some(10), 0, vec!["error1".into()]),
766            ],
767            BandwidthSnapshot::default(),
768            None,
769            2,
770        );
771        assert_eq!(report.folders.len(), 2);
772        assert!(report.folders.iter().any(|f| f.namespace == "ns1"));
773        assert!(
774            report
775                .folders
776                .iter()
777                .any(|f| f.namespace == "ns2" && !f.session_active && f.errors.len() == 1)
778        );
779    }
780}