Skip to main content

syncweb_core/daemon/
state.rs

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