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