sessions_cli/
lib.rs

1//! Session counter library for Claude Code hook system
2//!
3//! This library provides utilities for tracking active Claude Code sessions
4//! using a JSON configuration file with file locking for concurrent access.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::fs::{File, OpenOptions};
10use std::io::{Read, Seek, SeekFrom, Write};
11use std::path::PathBuf;
12
13/// Configuration structure for session tracking
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct SessionConfig {
16    /// Current count of active sessions
17    pub count: u32,
18    /// Timestamp of last update in UTC
19    pub last_updated: DateTime<Utc>,
20    /// Optional list of session IDs for detailed tracking
21    pub sessions: Vec<String>,
22}
23
24impl Default for SessionConfig {
25    fn default() -> Self {
26        Self {
27            count: 0,
28            last_updated: Utc::now(),
29            sessions: Vec::new(),
30        }
31    }
32}
33
34impl SessionConfig {
35    /// Create a new session config with current timestamp
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Increment the session count by 1
41    pub fn increment(&mut self) {
42        self.count = self.count.saturating_add(1);
43        self.last_updated = Utc::now();
44    }
45
46    /// Decrement the session count by 1, ensuring it doesn't go below 0
47    pub fn decrement(&mut self) {
48        self.count = self.count.saturating_sub(1);
49        self.last_updated = Utc::now();
50    }
51
52    /// Add a session ID to the tracking list
53    pub fn add_session(&mut self, session_id: String) {
54        if !self.sessions.contains(&session_id) {
55            self.sessions.push(session_id);
56        }
57    }
58
59    /// Remove a session ID from the tracking list
60    pub fn remove_session(&mut self, session_id: &str) {
61        self.sessions.retain(|id| id != session_id);
62    }
63}
64
65/// Get the path to the session count configuration file
66pub fn get_config_path() -> Result<PathBuf> {
67    let home_dir = dirs::home_dir().context("Failed to get home directory")?;
68    Ok(home_dir.join(".sessions.json"))
69}
70
71/// Read the session configuration from file with file locking
72pub fn read_config() -> Result<SessionConfig> {
73    let config_path = get_config_path()?;
74
75    // If file doesn't exist, return default config
76    if !config_path.exists() {
77        return Ok(SessionConfig::default());
78    }
79
80    let mut file = File::open(&config_path)
81        .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
82
83    // Lock the file for reading
84    #[cfg(unix)]
85    {
86        // Advisory lock - other processes should respect it
87        nix_flock(&file, nix::fcntl::FlockArg::LockShared)?;
88    }
89
90    let mut contents = String::new();
91    file.read_to_string(&mut contents)
92        .context("Failed to read config file")?;
93
94    if contents.trim().is_empty() {
95        return Ok(SessionConfig::default());
96    }
97
98    let config: SessionConfig =
99        serde_json::from_str(&contents).context("Failed to parse config JSON")?;
100
101    Ok(config)
102}
103
104/// Write the session configuration to file with file locking
105pub fn write_config(config: &SessionConfig) -> Result<()> {
106    let config_path = get_config_path()?;
107
108    // Ensure parent directory exists
109    if let Some(parent) = config_path.parent() {
110        std::fs::create_dir_all(parent).context("Failed to create config directory")?;
111    }
112
113    // Open file for writing, create if doesn't exist
114    let mut file = OpenOptions::new()
115        .write(true)
116        .create(true)
117        .truncate(true)
118        .open(&config_path)
119        .with_context(|| {
120            format!(
121                "Failed to open config file for writing: {}",
122                config_path.display()
123            )
124        })?;
125
126    // Lock the file for writing
127    #[cfg(unix)]
128    {
129        nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
130    }
131
132    let json_content =
133        serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?;
134
135    file.write_all(json_content.as_bytes())
136        .context("Failed to write config to file")?;
137
138    file.sync_all().context("Failed to sync file to disk")?;
139
140    Ok(())
141}
142
143/// Atomically update the session configuration
144pub fn update_config<F>(mut updater: F) -> Result<SessionConfig>
145where
146    F: FnMut(&mut SessionConfig),
147{
148    let config_path = get_config_path()?;
149
150    // Ensure parent directory exists
151    if let Some(parent) = config_path.parent() {
152        std::fs::create_dir_all(parent).context("Failed to create config directory")?;
153    }
154
155    // Open or create the file
156    let mut file = OpenOptions::new()
157        .read(true)
158        .write(true)
159        .create(true)
160        .truncate(false) // We'll manually truncate after reading
161        .open(&config_path)
162        .with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
163
164    // Lock the file exclusively
165    #[cfg(unix)]
166    {
167        nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
168    }
169
170    // Read current config
171    let mut contents = String::new();
172    file.read_to_string(&mut contents)
173        .context("Failed to read config file")?;
174
175    let mut config = if contents.trim().is_empty() {
176        SessionConfig::default()
177    } else {
178        serde_json::from_str(&contents).context("Failed to parse config JSON")?
179    };
180
181    // Apply the update
182    updater(&mut config);
183
184    // Write back to file
185    file.seek(SeekFrom::Start(0))
186        .context("Failed to seek to start of file")?;
187
188    file.set_len(0).context("Failed to truncate file")?;
189
190    let json_content =
191        serde_json::to_string_pretty(&config).context("Failed to serialize config to JSON")?;
192
193    file.write_all(json_content.as_bytes())
194        .context("Failed to write config to file")?;
195
196    file.sync_all().context("Failed to sync file to disk")?;
197
198    Ok(config)
199}
200
201#[cfg(unix)]
202fn nix_flock(file: &File, arg: nix::fcntl::FlockArg) -> Result<()> {
203    use std::os::unix::io::AsRawFd;
204    #[allow(deprecated)]
205    {
206        nix::fcntl::flock(file.as_raw_fd(), arg).context("Failed to acquire file lock")?;
207    }
208    Ok(())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use tempfile::TempDir;
215
216    #[allow(dead_code)] // Helper function for potential future tests
217    fn setup_test_config_path(temp_dir: &TempDir) -> PathBuf {
218        temp_dir.path().join(".sessions.json")
219    }
220
221    #[test]
222    fn test_session_config_new() {
223        let config = SessionConfig::new();
224        assert_eq!(config.count, 0);
225        assert!(config.sessions.is_empty());
226    }
227
228    #[test]
229    fn test_session_config_increment() {
230        let mut config = SessionConfig::new();
231        let initial_time = config.last_updated;
232
233        // Small delay to ensure timestamp changes
234        std::thread::sleep(std::time::Duration::from_millis(1));
235
236        config.increment();
237        assert_eq!(config.count, 1);
238        assert!(config.last_updated > initial_time);
239
240        config.increment();
241        assert_eq!(config.count, 2);
242    }
243
244    #[test]
245    fn test_session_config_decrement() {
246        let mut config = SessionConfig::new();
247        config.count = 2;
248
249        config.decrement();
250        assert_eq!(config.count, 1);
251
252        config.decrement();
253        assert_eq!(config.count, 0);
254
255        // Should not go below 0
256        config.decrement();
257        assert_eq!(config.count, 0);
258    }
259
260    #[test]
261    fn test_session_config_add_remove_session() {
262        let mut config = SessionConfig::new();
263
264        config.add_session("session-1".to_string());
265        assert_eq!(config.sessions.len(), 1);
266        assert!(config.sessions.contains(&"session-1".to_string()));
267
268        // Adding same session should not duplicate
269        config.add_session("session-1".to_string());
270        assert_eq!(config.sessions.len(), 1);
271
272        config.add_session("session-2".to_string());
273        assert_eq!(config.sessions.len(), 2);
274
275        config.remove_session("session-1");
276        assert_eq!(config.sessions.len(), 1);
277        assert!(!config.sessions.contains(&"session-1".to_string()));
278        assert!(config.sessions.contains(&"session-2".to_string()));
279    }
280
281    #[test]
282    fn test_config_serialization() {
283        let config = SessionConfig {
284            count: 5,
285            last_updated: DateTime::parse_from_rfc3339("2025-01-12T10:30:00Z")
286                .unwrap()
287                .with_timezone(&Utc),
288            sessions: vec!["session-1".to_string(), "session-2".to_string()],
289        };
290
291        let json = serde_json::to_string(&config).unwrap();
292        let deserialized: SessionConfig = serde_json::from_str(&json).unwrap();
293
294        assert_eq!(config, deserialized);
295    }
296
297    #[test]
298    fn test_read_nonexistent_config() {
299        // Temporarily override the config path function for testing
300        // This test would need to mock the home directory or use a different approach
301        // For now, we'll test the config creation logic directly
302        let config = SessionConfig::default();
303        assert_eq!(config.count, 0);
304        assert!(config.sessions.is_empty());
305    }
306}