lore_cli/daemon/
state.rs

1//! Daemon state management.
2//!
3//! Manages the daemon's runtime state including PID file, socket path,
4//! and log file locations. Provides methods for checking if the daemon
5//! is running and managing its lifecycle.
6
7use anyhow::{Context, Result};
8use std::fs;
9use std::io::{Read, Write};
10use std::path::PathBuf;
11
12/// Manages daemon state including paths for PID file, socket, and logs.
13///
14/// The daemon uses files in `~/.lore/` to coordinate between the running
15/// daemon process and CLI commands that interact with it.
16pub struct DaemonState {
17    /// Path to the PID file (`~/.lore/daemon.pid`).
18    pub pid_file: PathBuf,
19    /// Path to the Unix socket (`~/.lore/daemon.sock`).
20    pub socket_path: PathBuf,
21    /// Path to the log file (`~/.lore/daemon.log`).
22    pub log_file: PathBuf,
23}
24
25impl DaemonState {
26    /// Creates a new DaemonState with default paths in `~/.lore/`.
27    ///
28    /// Creates the `~/.lore/` directory if it does not exist.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the home directory cannot be determined or
33    /// if the `.lore` directory cannot be created.
34    pub fn new() -> Result<Self> {
35        let lore_dir = dirs::home_dir()
36            .context("Could not find home directory")?
37            .join(".lore");
38
39        fs::create_dir_all(&lore_dir).context("Failed to create ~/.lore directory")?;
40
41        Ok(Self {
42            pid_file: lore_dir.join("daemon.pid"),
43            socket_path: lore_dir.join("daemon.sock"),
44            log_file: lore_dir.join("daemon.log"),
45        })
46    }
47
48    /// Checks if the daemon is currently running.
49    ///
50    /// Returns true if a PID file exists and the process with that PID
51    /// is still alive. Returns false if no PID file exists, the PID file
52    /// cannot be read, or the process is no longer running.
53    pub fn is_running(&self) -> bool {
54        match self.get_pid() {
55            Some(pid) => Self::process_exists(pid),
56            None => false,
57        }
58    }
59
60    /// Gets the PID of the running daemon, if available.
61    ///
62    /// Returns `None` if the PID file does not exist or cannot be parsed.
63    pub fn get_pid(&self) -> Option<u32> {
64        if !self.pid_file.exists() {
65            return None;
66        }
67
68        let mut file = fs::File::open(&self.pid_file).ok()?;
69        let mut contents = String::new();
70        file.read_to_string(&mut contents).ok()?;
71
72        contents.trim().parse().ok()
73    }
74
75    /// Writes the current process ID to the PID file.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the PID file cannot be created or written to.
80    pub fn write_pid(&self, pid: u32) -> Result<()> {
81        let mut file = fs::File::create(&self.pid_file).context("Failed to create PID file")?;
82        write!(file, "{pid}").context("Failed to write PID")?;
83        Ok(())
84    }
85
86    /// Removes the PID file.
87    ///
88    /// Does not return an error if the file does not exist.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the file exists but cannot be removed.
93    pub fn remove_pid(&self) -> Result<()> {
94        if self.pid_file.exists() {
95            fs::remove_file(&self.pid_file).context("Failed to remove PID file")?;
96        }
97        Ok(())
98    }
99
100    /// Removes the Unix socket file.
101    ///
102    /// Does not return an error if the file does not exist.
103    ///
104    /// # Errors
105    ///
106    /// Returns an error if the file exists but cannot be removed.
107    pub fn remove_socket(&self) -> Result<()> {
108        if self.socket_path.exists() {
109            fs::remove_file(&self.socket_path).context("Failed to remove socket file")?;
110        }
111        Ok(())
112    }
113
114    /// Cleans up all daemon state files (PID file and socket).
115    ///
116    /// Called during graceful shutdown.
117    pub fn cleanup(&self) -> Result<()> {
118        self.remove_pid()?;
119        self.remove_socket()?;
120        Ok(())
121    }
122
123    /// Checks if a process with the given PID exists.
124    ///
125    /// Uses the `kill(pid, 0)` system call which checks for process
126    /// existence without sending a signal.
127    fn process_exists(pid: u32) -> bool {
128        // On Unix, sending signal 0 checks if process exists
129        #[cfg(unix)]
130        {
131            // SAFETY: kill(pid, 0) is a safe system call that only checks
132            // if a process exists without sending any signal.
133            unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
134        }
135
136        #[cfg(not(unix))]
137        {
138            // On Windows, we would need a different approach
139            // For now, assume process exists if PID file exists
140            let _ = pid;
141            true
142        }
143    }
144}
145
146/// Statistics about the daemon's operation.
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
148pub struct DaemonStats {
149    /// Number of session files currently being watched.
150    pub files_watched: usize,
151    /// Total number of sessions imported since daemon started.
152    pub sessions_imported: u64,
153    /// Total number of messages imported since daemon started.
154    pub messages_imported: u64,
155    /// Timestamp when the daemon started.
156    pub started_at: chrono::DateTime<chrono::Utc>,
157    /// Number of errors encountered.
158    pub errors: u64,
159}
160
161impl Default for DaemonStats {
162    fn default() -> Self {
163        Self {
164            files_watched: 0,
165            sessions_imported: 0,
166            messages_imported: 0,
167            started_at: chrono::Utc::now(),
168            errors: 0,
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use tempfile::tempdir;
177
178    /// Creates a DaemonState with paths in a temporary directory.
179    fn create_test_state() -> (DaemonState, tempfile::TempDir) {
180        let dir = tempdir().expect("Failed to create temp directory");
181        let state = DaemonState {
182            pid_file: dir.path().join("daemon.pid"),
183            socket_path: dir.path().join("daemon.sock"),
184            log_file: dir.path().join("daemon.log"),
185        };
186        (state, dir)
187    }
188
189    #[test]
190    fn test_is_running_no_pid_file() {
191        let (state, _dir) = create_test_state();
192        assert!(
193            !state.is_running(),
194            "Should not be running without PID file"
195        );
196    }
197
198    #[test]
199    fn test_get_pid_no_file() {
200        let (state, _dir) = create_test_state();
201        assert!(
202            state.get_pid().is_none(),
203            "Should return None without PID file"
204        );
205    }
206
207    #[test]
208    fn test_write_and_get_pid() {
209        let (state, _dir) = create_test_state();
210
211        state.write_pid(12345).expect("Failed to write PID");
212
213        let pid = state.get_pid();
214        assert_eq!(pid, Some(12345), "PID should match written value");
215    }
216
217    #[test]
218    fn test_remove_pid() {
219        let (state, _dir) = create_test_state();
220
221        state.write_pid(12345).expect("Failed to write PID");
222        assert!(state.pid_file.exists(), "PID file should exist after write");
223
224        state.remove_pid().expect("Failed to remove PID");
225        assert!(
226            !state.pid_file.exists(),
227            "PID file should not exist after remove"
228        );
229    }
230
231    #[test]
232    fn test_remove_pid_nonexistent() {
233        let (state, _dir) = create_test_state();
234
235        // Should not error when file doesn't exist
236        state
237            .remove_pid()
238            .expect("Should not error on nonexistent file");
239    }
240
241    #[test]
242    fn test_remove_socket() {
243        let (state, _dir) = create_test_state();
244
245        // Create a fake socket file
246        fs::write(&state.socket_path, "").expect("Failed to create file");
247        assert!(state.socket_path.exists(), "Socket file should exist");
248
249        state.remove_socket().expect("Failed to remove socket");
250        assert!(
251            !state.socket_path.exists(),
252            "Socket file should not exist after remove"
253        );
254    }
255
256    #[test]
257    fn test_cleanup() {
258        let (state, _dir) = create_test_state();
259
260        state.write_pid(12345).expect("Failed to write PID");
261        fs::write(&state.socket_path, "").expect("Failed to create socket");
262
263        state.cleanup().expect("Failed to cleanup");
264
265        assert!(!state.pid_file.exists(), "PID file should be cleaned up");
266        assert!(
267            !state.socket_path.exists(),
268            "Socket file should be cleaned up"
269        );
270    }
271
272    #[test]
273    fn test_daemon_stats_default() {
274        let stats = DaemonStats::default();
275
276        assert_eq!(stats.files_watched, 0);
277        assert_eq!(stats.sessions_imported, 0);
278        assert_eq!(stats.messages_imported, 0);
279        assert_eq!(stats.errors, 0);
280    }
281
282    #[test]
283    fn test_is_running_with_invalid_pid() {
284        let (state, _dir) = create_test_state();
285
286        // Write an invalid PID (likely not a real process)
287        state.write_pid(999999999).expect("Failed to write PID");
288
289        // This should return false since the process likely doesn't exist
290        // (though it could theoretically be a valid PID on some systems)
291        let running = state.is_running();
292        // We can't assert definitively since the behavior depends on the system
293        // Just verify it doesn't panic
294        let _ = running;
295    }
296
297    #[test]
298    fn test_get_pid_invalid_content() {
299        let (state, _dir) = create_test_state();
300
301        // Write invalid content to PID file
302        fs::write(&state.pid_file, "not_a_number").expect("Failed to write");
303
304        assert!(
305            state.get_pid().is_none(),
306            "Should return None for invalid PID"
307        );
308    }
309}