use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionConfig {
pub count: u32,
pub last_updated: DateTime<Utc>,
pub sessions: Vec<String>,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
count: 0,
last_updated: Utc::now(),
sessions: Vec::new(),
}
}
}
impl SessionConfig {
pub fn new() -> Self {
Self::default()
}
pub fn increment(&mut self) {
self.count = self.count.saturating_add(1);
self.last_updated = Utc::now();
}
pub fn decrement(&mut self) {
self.count = self.count.saturating_sub(1);
self.last_updated = Utc::now();
}
pub fn add_session(&mut self, session_id: String) {
if !self.sessions.contains(&session_id) {
self.sessions.push(session_id);
}
}
pub fn remove_session(&mut self, session_id: &str) {
self.sessions.retain(|id| id != session_id);
}
}
pub fn get_config_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir().context("Failed to get home directory")?;
Ok(home_dir.join(".sessions.json"))
}
pub fn read_config() -> Result<SessionConfig> {
let config_path = get_config_path()?;
if !config_path.exists() {
return Ok(SessionConfig::default());
}
let mut file = File::open(&config_path)
.with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
#[cfg(unix)]
{
nix_flock(&file, nix::fcntl::FlockArg::LockShared)?;
}
let mut contents = String::new();
file.read_to_string(&mut contents)
.context("Failed to read config file")?;
if contents.trim().is_empty() {
return Ok(SessionConfig::default());
}
let config: SessionConfig =
serde_json::from_str(&contents).context("Failed to parse config JSON")?;
Ok(config)
}
pub fn write_config(config: &SessionConfig) -> Result<()> {
let config_path = get_config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&config_path)
.with_context(|| {
format!(
"Failed to open config file for writing: {}",
config_path.display()
)
})?;
#[cfg(unix)]
{
nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
}
let json_content =
serde_json::to_string_pretty(config).context("Failed to serialize config to JSON")?;
file.write_all(json_content.as_bytes())
.context("Failed to write config to file")?;
file.sync_all().context("Failed to sync file to disk")?;
Ok(())
}
pub fn update_config<F>(mut updater: F) -> Result<SessionConfig>
where
F: FnMut(&mut SessionConfig),
{
let config_path = get_config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create config directory")?;
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false) .open(&config_path)
.with_context(|| format!("Failed to open config file: {}", config_path.display()))?;
#[cfg(unix)]
{
nix_flock(&file, nix::fcntl::FlockArg::LockExclusive)?;
}
let mut contents = String::new();
file.read_to_string(&mut contents)
.context("Failed to read config file")?;
let mut config = if contents.trim().is_empty() {
SessionConfig::default()
} else {
serde_json::from_str(&contents).context("Failed to parse config JSON")?
};
updater(&mut config);
file.seek(SeekFrom::Start(0))
.context("Failed to seek to start of file")?;
file.set_len(0).context("Failed to truncate file")?;
let json_content =
serde_json::to_string_pretty(&config).context("Failed to serialize config to JSON")?;
file.write_all(json_content.as_bytes())
.context("Failed to write config to file")?;
file.sync_all().context("Failed to sync file to disk")?;
Ok(config)
}
#[cfg(unix)]
fn nix_flock(file: &File, arg: nix::fcntl::FlockArg) -> Result<()> {
use std::os::unix::io::AsRawFd;
#[allow(deprecated)]
{
nix::fcntl::flock(file.as_raw_fd(), arg).context("Failed to acquire file lock")?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[allow(dead_code)] fn setup_test_config_path(temp_dir: &TempDir) -> PathBuf {
temp_dir.path().join(".sessions.json")
}
#[test]
fn test_session_config_new() {
let config = SessionConfig::new();
assert_eq!(config.count, 0);
assert!(config.sessions.is_empty());
}
#[test]
fn test_session_config_increment() {
let mut config = SessionConfig::new();
let initial_time = config.last_updated;
std::thread::sleep(std::time::Duration::from_millis(1));
config.increment();
assert_eq!(config.count, 1);
assert!(config.last_updated > initial_time);
config.increment();
assert_eq!(config.count, 2);
}
#[test]
fn test_session_config_decrement() {
let mut config = SessionConfig::new();
config.count = 2;
config.decrement();
assert_eq!(config.count, 1);
config.decrement();
assert_eq!(config.count, 0);
config.decrement();
assert_eq!(config.count, 0);
}
#[test]
fn test_session_config_add_remove_session() {
let mut config = SessionConfig::new();
config.add_session("session-1".to_string());
assert_eq!(config.sessions.len(), 1);
assert!(config.sessions.contains(&"session-1".to_string()));
config.add_session("session-1".to_string());
assert_eq!(config.sessions.len(), 1);
config.add_session("session-2".to_string());
assert_eq!(config.sessions.len(), 2);
config.remove_session("session-1");
assert_eq!(config.sessions.len(), 1);
assert!(!config.sessions.contains(&"session-1".to_string()));
assert!(config.sessions.contains(&"session-2".to_string()));
}
#[test]
fn test_config_serialization() {
let config = SessionConfig {
count: 5,
last_updated: DateTime::parse_from_rfc3339("2025-01-12T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
sessions: vec!["session-1".to_string(), "session-2".to_string()],
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: SessionConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn test_read_nonexistent_config() {
let config = SessionConfig::default();
assert_eq!(config.count, 0);
assert!(config.sessions.is_empty());
}
}