#![allow(dead_code)]
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::delegator::DelegationStatus;
use super::{Priority, TabId, TabMetadata, TabType};
pub const CURRENT_SCHEMA_VERSION: u32 = 1;
pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedTab {
pub id: u64,
pub title: String,
pub tab_type: TabType,
pub created_at: DateTime<Utc>,
pub last_active: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedGroup {
pub id: String,
pub name: String,
pub color: super::group::GroupColor,
pub tab_ids: Vec<TabId>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedDelegation {
pub task_id: String,
pub from_tab: u64,
pub to_tab: u64,
pub description: String,
pub priority: Priority,
pub status: DelegationStatus,
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
pub result: Option<String>,
pub was_successful: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedTabState {
pub version: u32,
pub saved_at: DateTime<Utc>,
pub active_tab_index: Option<usize>,
pub tabs: Vec<PersistedTab>,
pub delegations: Vec<PersistedDelegation>,
#[serde(default)]
pub groups: Vec<PersistedGroup>,
}
impl Default for PersistedTabState {
fn default() -> Self {
Self {
version: 1,
saved_at: Utc::now(),
active_tab_index: None,
tabs: Vec::new(),
delegations: Vec::new(),
groups: Vec::new(),
}
}
}
pub fn default_tab_state_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".codewhale").join("tabs.json"))
}
pub fn save_to_file(state: &PersistedTabState, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(state)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, json)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn load_from_file(path: &Path) -> std::io::Result<PersistedTabState> {
if !path.exists() {
return Ok(PersistedTabState::default());
}
let metadata = std::fs::metadata(path)?;
if metadata.len() > MAX_FILE_SIZE {
tracing::error!(
size = metadata.len(),
max = MAX_FILE_SIZE,
path = %path.display(),
"Tab state file too large, refusing to load"
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Tab state file size {} exceeds maximum allowed size {}",
metadata.len(),
MAX_FILE_SIZE
),
));
}
let content = std::fs::read_to_string(path)?;
let state: PersistedTabState = serde_json::from_str(&content).map_err(|e| {
tracing::error!(
?e,
path = %path.display(),
"Failed to parse tab state file"
);
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
if state.version > CURRENT_SCHEMA_VERSION {
tracing::warn!(
file_version = state.version,
current = CURRENT_SCHEMA_VERSION,
"Tab state file is from a newer version; some data may be ignored"
);
} else if state.version < CURRENT_SCHEMA_VERSION {
tracing::info!(
file_version = state.version,
current = CURRENT_SCHEMA_VERSION,
"Migrating tab state from older schema"
);
}
Ok(state)
}
pub fn from_metadata(meta: &TabMetadata) -> PersistedTab {
PersistedTab {
id: meta.id.0,
title: meta.title.clone(),
tab_type: meta.tab_type,
created_at: meta.created_at,
last_active: meta.last_active,
}
}
pub fn to_metadata(persisted: &PersistedTab) -> TabMetadata {
let mut meta = TabMetadata::new(
TabId::new(persisted.id),
persisted.title.clone(),
persisted.tab_type,
);
meta.created_at = persisted.created_at;
meta.last_active = persisted.last_active;
meta
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roundtrip() {
let state = PersistedTabState {
version: 1,
saved_at: Utc::now(),
active_tab_index: Some(0),
tabs: vec![
PersistedTab {
id: 1,
title: "Tab 1".to_string(),
tab_type: TabType::Chat,
created_at: Utc::now(),
last_active: Utc::now(),
},
PersistedTab {
id: 2,
title: "Tab 2".to_string(),
tab_type: TabType::Meeting,
created_at: Utc::now(),
last_active: Utc::now(),
},
],
delegations: vec![PersistedDelegation {
task_id: "delegation_1".to_string(),
from_tab: 1,
to_tab: 2,
description: "Review code".to_string(),
priority: Priority::High,
status: DelegationStatus::Pending,
created_at: Utc::now(),
completed_at: None,
result: None,
was_successful: None,
}],
groups: vec![],
};
let json = serde_json::to_string(&state).unwrap();
let parsed: PersistedTabState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.tabs.len(), 2);
assert_eq!(parsed.tabs[0].title, "Tab 1");
assert_eq!(parsed.tabs[1].tab_type, TabType::Meeting);
assert_eq!(parsed.delegations.len(), 1);
assert_eq!(parsed.delegations[0].priority, Priority::High);
}
#[test]
fn test_metadata_conversion() {
let meta = TabMetadata::new(TabId::new(42), "Test".to_string(), TabType::Review);
let persisted = from_metadata(&meta);
assert_eq!(persisted.id, 42);
assert_eq!(persisted.title, "Test");
assert_eq!(persisted.tab_type, TabType::Review);
let restored = to_metadata(&persisted);
assert_eq!(restored.id, TabId::new(42));
assert_eq!(restored.title, "Test");
assert_eq!(restored.tab_type, TabType::Review);
}
#[test]
fn test_load_missing_file() {
let result = load_from_file(Path::new("/nonexistent/path/tabs.json"));
assert!(result.is_ok());
let state = result.unwrap();
assert!(state.tabs.is_empty());
assert!(state.delegations.is_empty());
}
#[test]
fn test_save_and_load() {
let dir = std::env::temp_dir().join("codewhale_tab_test");
let path = dir.join("tabs.json");
let _ = std::fs::remove_file(&path);
let state = PersistedTabState {
version: 1,
saved_at: Utc::now(),
active_tab_index: Some(1),
tabs: vec![PersistedTab {
id: 1,
title: "Test".to_string(),
tab_type: TabType::Delegation,
created_at: Utc::now(),
last_active: Utc::now(),
}],
delegations: vec![],
groups: vec![],
};
save_to_file(&state, &path).unwrap();
let loaded = load_from_file(&path).unwrap();
assert_eq!(loaded.active_tab_index, Some(1));
assert_eq!(loaded.tabs.len(), 1);
assert_eq!(loaded.tabs[0].tab_type, TabType::Delegation);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_oversized_file_rejected() {
let dir = std::env::temp_dir().join("codewhale_tab_oversize");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
let mut content = String::from(
r#"{"version":1,"saved_at":"2026-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#,
);
while content.len() < (MAX_FILE_SIZE as usize) + 100 {
content.push(' ');
}
std::fs::write(&path, content).unwrap();
let result = load_from_file(&path);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_corrupted_file() {
let dir = std::env::temp_dir().join("codewhale_tab_corrupt");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
std::fs::write(&path, "{ not valid json :::").unwrap();
let result = load_from_file(&path);
assert!(result.is_err());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_save_is_atomic() {
let dir = std::env::temp_dir().join("codewhale_tab_atomic");
let path = dir.join("tabs.json");
let tmp_path = path.with_extension("json.tmp");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&tmp_path);
let state = PersistedTabState::default();
save_to_file(&state, &path).unwrap();
assert!(path.exists(), "Final file should exist");
assert!(!tmp_path.exists(), "Temp file should be cleaned up");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn test_load_newer_schema_logs_warning_but_loads() {
let dir = std::env::temp_dir().join("codewhale_tab_newer");
let path = dir.join("tabs.json");
std::fs::create_dir_all(&dir).unwrap();
let _ = std::fs::remove_file(&path);
let json = r#"{"version":99,"saved_at":"2027-01-01T00:00:00Z","active_tab_index":null,"tabs":[],"delegations":[]}"#;
std::fs::write(&path, json).unwrap();
let loaded = load_from_file(&path).unwrap();
assert_eq!(loaded.version, 99);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
}