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