`AppConfig::save_to_file` had its own copy of the temp-file + rename + cleanup-on-failure dance. `storage::atomic_write` is already `pub(crate)` and does exactly that — `google_tasks.rs` was migrated to use it earlier. Drop the duplicate so there's one canonical atomic write path in the crate.
288 lines
10 KiB
Rust
288 lines
10 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
use crate::error::{Error, Result};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum WorkspaceMode {
|
|
#[default]
|
|
Local,
|
|
Webdav,
|
|
GoogleTasks,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WorkspaceConfig {
|
|
pub name: String,
|
|
pub path: PathBuf,
|
|
#[serde(default)]
|
|
pub mode: WorkspaceMode,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub webdav_url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub webdav_path: Option<String>,
|
|
/// Display name / email of the connected Google account (GoogleTasks workspaces only).
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub google_account: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub last_sync: Option<chrono::DateTime<chrono::Utc>>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub theme: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub sync_interval_secs: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub sync_interval_unfocused_secs: Option<u64>,
|
|
}
|
|
|
|
impl WorkspaceConfig {
|
|
pub fn new(name: String, path: PathBuf) -> Self {
|
|
Self { name, path, mode: WorkspaceMode::Local, webdav_url: None, webdav_path: None, google_account: None, last_sync: None, theme: None, sync_interval_secs: None, sync_interval_unfocused_secs: None }
|
|
}
|
|
}
|
|
|
|
/// Workspaces keyed by UUID string.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct AppConfig {
|
|
pub workspaces: HashMap<String, WorkspaceConfig>,
|
|
pub current_workspace: Option<String>,
|
|
}
|
|
|
|
impl AppConfig {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
workspaces: HashMap::new(),
|
|
current_workspace: None,
|
|
}
|
|
}
|
|
|
|
pub fn add_workspace(&mut self, config: WorkspaceConfig) -> String {
|
|
let id = Uuid::new_v4().to_string();
|
|
self.workspaces.insert(id.clone(), config);
|
|
id
|
|
}
|
|
|
|
pub fn remove_workspace(&mut self, id: &str) -> Option<WorkspaceConfig> {
|
|
if self.current_workspace.as_deref() == Some(id) {
|
|
self.current_workspace = None;
|
|
}
|
|
self.workspaces.remove(id)
|
|
}
|
|
|
|
pub fn rename_workspace(&mut self, id: &str, new_name: String) -> Result<()> {
|
|
let ws = self.workspaces.get_mut(id)
|
|
.ok_or_else(|| Error::InvalidData(format!("Workspace '{}' not found", id)))?;
|
|
ws.name = new_name;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_workspace(&self, id: &str) -> Option<&WorkspaceConfig> {
|
|
self.workspaces.get(id)
|
|
}
|
|
|
|
pub fn get_current_workspace(&self) -> Result<(&String, &WorkspaceConfig)> {
|
|
let id = self.current_workspace.as_ref()
|
|
.ok_or_else(|| Error::WorkspaceNotFound("No current workspace set".to_string()))?;
|
|
let config = self.workspaces.get(id)
|
|
.ok_or_else(|| Error::WorkspaceNotFound(id.clone()))?;
|
|
Ok((id, config))
|
|
}
|
|
|
|
pub fn set_current_workspace(&mut self, id: String) -> Result<()> {
|
|
if !self.workspaces.contains_key(&id) {
|
|
return Err(Error::WorkspaceNotFound(id));
|
|
}
|
|
self.current_workspace = Some(id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Find a workspace by display name. Returns (id, config) of the first match.
|
|
pub fn find_by_name(&self, name: &str) -> Option<(&String, &WorkspaceConfig)> {
|
|
self.workspaces.iter().find(|(_, ws)| ws.name == name)
|
|
}
|
|
|
|
pub fn load_from_file(path: &PathBuf) -> Result<Self> {
|
|
if !path.exists() {
|
|
return Ok(Self::new());
|
|
}
|
|
let content = std::fs::read_to_string(path)?;
|
|
let config = serde_json::from_str(&content)?;
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn save_to_file(&self, path: &PathBuf) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let content = serde_json::to_string_pretty(&self)?;
|
|
crate::storage::atomic_write(path, content.as_bytes())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_config_path() -> PathBuf {
|
|
directories::ProjectDirs::from("", "", "onyx")
|
|
.map(|dirs| dirs.config_dir().join("config.json"))
|
|
.unwrap_or_else(|| PathBuf::from("onyx-config.json"))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
#[test]
|
|
fn test_get_current_workspace_none_set() {
|
|
let config = AppConfig::new();
|
|
let result = config.get_current_workspace();
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_current_workspace_id_points_to_removed_workspace() {
|
|
let mut config = AppConfig::new();
|
|
let id = config.add_workspace(WorkspaceConfig::new("test".into(), PathBuf::from("/tmp")));
|
|
config.current_workspace = Some(id.clone());
|
|
config.workspaces.remove(&id);
|
|
|
|
let result = config.get_current_workspace();
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_current_workspace_nonexistent() {
|
|
let mut config = AppConfig::new();
|
|
let result = config.set_current_workspace("ghost".to_string());
|
|
assert!(result.is_err());
|
|
assert!(matches!(result.unwrap_err(), Error::WorkspaceNotFound(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_current_workspace_valid() {
|
|
let mut config = AppConfig::new();
|
|
let id = config.add_workspace(WorkspaceConfig::new("real".into(), PathBuf::from("/tmp")));
|
|
assert!(config.set_current_workspace(id.clone()).is_ok());
|
|
assert_eq!(config.current_workspace.as_deref(), Some(id.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_current_workspace_clears_current() {
|
|
let mut config = AppConfig::new();
|
|
let id = config.add_workspace(WorkspaceConfig::new("ws".into(), PathBuf::from("/tmp")));
|
|
config.set_current_workspace(id.clone()).unwrap();
|
|
|
|
config.remove_workspace(&id);
|
|
assert!(config.current_workspace.is_none());
|
|
assert!(config.get_workspace(&id).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_noncurrent_workspace_keeps_current() {
|
|
let mut config = AppConfig::new();
|
|
let id_a = config.add_workspace(WorkspaceConfig::new("a".into(), PathBuf::from("/a")));
|
|
let id_b = config.add_workspace(WorkspaceConfig::new("b".into(), PathBuf::from("/b")));
|
|
config.set_current_workspace(id_a.clone()).unwrap();
|
|
|
|
config.remove_workspace(&id_b);
|
|
assert_eq!(config.current_workspace.as_deref(), Some(id_a.as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_and_load_roundtrip() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("config.json");
|
|
|
|
let mut config = AppConfig::new();
|
|
let id1 = config.add_workspace(WorkspaceConfig::new("ws1".into(), PathBuf::from("/path/one")));
|
|
let _id2 = config.add_workspace(WorkspaceConfig::new("ws2".into(), PathBuf::from("/path/two")));
|
|
config.set_current_workspace(id1.clone()).unwrap();
|
|
config.save_to_file(&config_path).unwrap();
|
|
|
|
let loaded = AppConfig::load_from_file(&config_path).unwrap();
|
|
assert_eq!(loaded.current_workspace.as_deref(), Some(id1.as_str()));
|
|
assert_eq!(loaded.workspaces.len(), 2);
|
|
assert_eq!(loaded.get_workspace(&id1).unwrap().path, PathBuf::from("/path/one"));
|
|
assert_eq!(loaded.get_workspace(&id1).unwrap().name, "ws1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_missing_file_returns_default() {
|
|
let config = AppConfig::load_from_file(&PathBuf::from("/nonexistent/config.json")).unwrap();
|
|
assert!(config.workspaces.is_empty());
|
|
assert!(config.current_workspace.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_corrupt_file() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("config.json");
|
|
std::fs::write(&config_path, "not valid json {{{").unwrap();
|
|
|
|
let result = AppConfig::load_from_file(&config_path);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_creates_parent_dirs() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("nested").join("dir").join("config.json");
|
|
|
|
let config = AppConfig::new();
|
|
assert!(config.save_to_file(&config_path).is_ok());
|
|
assert!(config_path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_duplicate_names_allowed() {
|
|
let mut config = AppConfig::new();
|
|
let id1 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/a")));
|
|
let id2 = config.add_workspace(WorkspaceConfig::new("Onyx".into(), PathBuf::from("/b")));
|
|
|
|
assert_ne!(id1, id2);
|
|
assert_eq!(config.workspaces.len(), 2);
|
|
assert_eq!(config.get_workspace(&id1).unwrap().name, "Onyx");
|
|
assert_eq!(config.get_workspace(&id2).unwrap().name, "Onyx");
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_by_name() {
|
|
let mut config = AppConfig::new();
|
|
let id = config.add_workspace(WorkspaceConfig::new("Tasks".into(), PathBuf::from("/tasks")));
|
|
|
|
let found = config.find_by_name("Tasks");
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().0, &id);
|
|
|
|
assert!(config.find_by_name("Nonexistent").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_rename_workspace() {
|
|
let mut config = AppConfig::new();
|
|
let id = config.add_workspace(WorkspaceConfig::new("Old".into(), PathBuf::from("/tmp")));
|
|
config.rename_workspace(&id, "New".into()).unwrap();
|
|
assert_eq!(config.get_workspace(&id).unwrap().name, "New");
|
|
}
|
|
|
|
#[test]
|
|
fn test_workspace_config_with_webdav_fields_roundtrip() {
|
|
let temp_dir = TempDir::new().unwrap();
|
|
let config_path = temp_dir.path().join("config.json");
|
|
|
|
let mut config = AppConfig::new();
|
|
let mut ws = WorkspaceConfig::new("synced".into(), PathBuf::from("/tasks"));
|
|
ws.webdav_url = Some("https://dav.example.com/tasks".to_string());
|
|
ws.last_sync = Some(chrono::Utc::now());
|
|
let id = config.add_workspace(ws);
|
|
config.save_to_file(&config_path).unwrap();
|
|
|
|
let loaded = AppConfig::load_from_file(&config_path).unwrap();
|
|
let ws = loaded.get_workspace(&id).unwrap();
|
|
assert_eq!(ws.webdav_url.as_deref(), Some("https://dav.example.com/tasks"));
|
|
assert!(ws.last_sync.is_some());
|
|
}
|
|
}
|