Trying very hard not to miss calendar events
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

One more refactor

Co-authored-by: Claude <noreply@anthropic.com>

+395 -305
+1
crates/alarma-cli/Cargo.toml
··· 18 18 [dependencies] 19 19 alarma-core = { path = "../alarma-core", features = ["serde"] } 20 20 alarma-eds = { path = "../alarma-eds" } 21 + glib = "0.20" 21 22 clap = { version = "4.5", features = ["derive"] } 22 23 chrono = "0.4" 23 24 serde = { version = "1.0", features = ["derive"] }
+20 -12
crates/alarma-cli/src/commands/events.rs
··· 1 1 //! Events command implementation 2 2 3 3 use crate::output::{print_events, OutputFormat}; 4 - use alarma_core::{repository::DateRange, CalendarService, Result}; 4 + use alarma_core::{repository::DateRange, CalendarRepository, Result}; 5 5 use chrono::{Local, NaiveDate}; 6 6 7 7 /// Arguments for listing events ··· 13 13 } 14 14 15 15 /// Executes the events list command 16 - pub fn execute_list(service: &CalendarService, args: ListEventsArgs) -> Result<()> { 16 + pub fn execute_list(repository: &dyn CalendarRepository, args: ListEventsArgs) -> Result<()> { 17 17 // Parse date range 18 18 let range = parse_date_range(&args.date_from, &args.date_until)?; 19 19 20 20 // Get events 21 - let events = if let Some(source_uid) = &args.source { 22 - service.list_events(source_uid, range)? 21 + let mut events = if let Some(source_uid) = &args.source { 22 + repository.list_events(source_uid, range)? 23 23 } else { 24 - service.list_all_events(range)? 24 + repository.list_all_events(range)? 25 25 }; 26 + 27 + // Sort events by start time (moved from service layer) 28 + events.sort_by_key(|e| e.start.timestamp()); 26 29 27 30 // Print results 28 31 print_events(&events, args.format)?; ··· 50 53 /// Parses a date string (YYYY-MM-DD) to a DateTime 51 54 fn parse_date_to_timestamp(date_str: &str) -> Result<chrono::DateTime<Local>> { 52 55 let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")?; 53 - let datetime = date 54 - .and_hms_opt(0, 0, 0) 55 - .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime("Invalid time".to_string()))?; 56 + let datetime = 57 + date.and_hms_opt(0, 0, 0) 58 + .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime { 59 + input: date_str.to_string(), 60 + expected_format: Some("YYYY-MM-DD".to_string()), 61 + })?; 56 62 57 - datetime 58 - .and_local_timezone(Local) 59 - .single() 60 - .ok_or_else(|| alarma_core::AlarmaError::InvalidDateTime("Ambiguous timezone".to_string())) 63 + datetime.and_local_timezone(Local).single().ok_or_else(|| { 64 + alarma_core::AlarmaError::InvalidDateTime { 65 + input: date_str.to_string(), 66 + expected_format: Some("YYYY-MM-DD".to_string()), 67 + } 68 + }) 61 69 } 62 70 63 71 #[cfg(test)]
+3 -3
crates/alarma-cli/src/commands/sources.rs
··· 1 1 //! Sources command implementation 2 2 3 3 use crate::output::{print_sources, OutputFormat}; 4 - use alarma_core::{CalendarService, Result}; 4 + use alarma_core::{CalendarRepository, Result}; 5 5 6 6 /// Executes the sources list command 7 - pub fn execute_list(service: &CalendarService, format: OutputFormat) -> Result<()> { 8 - let sources = service.list_sources()?; 7 + pub fn execute_list(repository: &dyn CalendarRepository, format: OutputFormat) -> Result<()> { 8 + let sources = repository.list_sources()?; 9 9 print_sources(&sources, format)?; 10 10 Ok(()) 11 11 }
+36 -36
crates/alarma-cli/src/main.rs
··· 9 9 10 10 use clap::{Args, Parser, Subcommand}; 11 11 12 - use alarma_core::CalendarService; 12 + use alarma_core::{builder::BackendType, builder::RepositoryRegistry, CalendarRepository}; 13 13 use alarma_eds::EdsRepository; 14 14 15 15 use alarma_cli::output::{self, OutputFormat}; 16 + 17 + /// Factory function for creating EDS repository 18 + fn create_eds_repository() -> alarma_core::Result<Box<dyn CalendarRepository>> { 19 + let repo = EdsRepository::new()?; 20 + Ok(Box::new(repo)) 21 + } 16 22 17 23 #[derive(Parser)] 18 24 #[command(name = "alarma")] ··· 76 82 fn main() -> Result<(), Box<dyn Error>> { 77 83 let cli = Cli::parse(); 78 84 85 + // Create repository registry and register EDS backend 86 + let mut registry = RepositoryRegistry::new(); 87 + registry.register(BackendType::Eds, create_eds_repository); 88 + 89 + // Create repository (only when needed) 90 + let repository = registry.create(BackendType::Eds)?; 91 + 79 92 // Execute command 80 93 match cli.command { 81 - Commands::Sources { action } => { 82 - // Create service (only when needed) 83 - let repository = EdsRepository::new() 84 - .map_err(|e| format!("Failed to initialize Evolution Data Server: {}", e))?; 85 - let service = CalendarService::new(Box::new(repository)); 86 - 87 - match action { 88 - SourcesCommands::List { format } => { 89 - let format = parse_output_format(&format)?; 90 - commands::sources::execute_list(&service, format)?; 91 - } 94 + Commands::Sources { action } => match action { 95 + SourcesCommands::List { format } => { 96 + let format = parse_output_format(&format)?; 97 + commands::sources::execute_list(repository.as_ref(), format)?; 92 98 } 93 - } 94 - Commands::Events { action } => { 95 - // Create service (only when needed) 96 - let repository = EdsRepository::new() 97 - .map_err(|e| format!("Failed to initialize Evolution Data Server: {}", e))?; 98 - let service = CalendarService::new(Box::new(repository)); 99 - 100 - match action { 101 - EventsCommands::List(args) => { 102 - let format = parse_output_format(&args.format)?; 103 - let cmd_args = commands::events::ListEventsArgs { 104 - source: args.source, 105 - date_from: args.date_from, 106 - date_until: args.date_until, 107 - format, 108 - }; 109 - commands::events::execute_list(&service, cmd_args)?; 110 - } 99 + }, 100 + Commands::Events { action } => match action { 101 + EventsCommands::List(args) => { 102 + let format = parse_output_format(&args.format)?; 103 + let cmd_args = commands::events::ListEventsArgs { 104 + source: args.source, 105 + date_from: args.date_from, 106 + date_until: args.date_until, 107 + format, 108 + }; 109 + commands::events::execute_list(repository.as_ref(), cmd_args)?; 111 110 } 112 - } 111 + }, 113 112 } 114 113 115 114 Ok(()) 116 115 } 117 116 118 117 /// Parses output format string 119 - fn parse_output_format(format_str: &str) -> Result<OutputFormat, String> { 120 - OutputFormat::from_str(format_str).ok_or_else(|| { 121 - format!( 122 - "Invalid output format: {}. Use 'table', 'json', or 'simple'", 118 + fn parse_output_format(format_str: &str) -> alarma_core::Result<OutputFormat> { 119 + OutputFormat::from_str(format_str).ok_or_else(|| alarma_core::AlarmaError::ConfigError { 120 + setting: "output_format".to_string(), 121 + reason: format!( 122 + "Invalid format '{}'. Use 'table', 'json', or 'simple'", 123 123 format_str 124 - ) 124 + ), 125 125 }) 126 126 }
+180
crates/alarma-core/src/builder.rs
··· 1 + //! Factory/Builder pattern for creating calendar repositories 2 + //! 3 + //! This module provides a builder pattern to abstract repository creation 4 + //! from the presentation layer, allowing the CLI to not depend on specific 5 + //! repository implementations. 6 + 7 + use crate::repository::CalendarRepository; 8 + use crate::Result; 9 + 10 + /// Backend types supported by the repository builder 11 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 12 + pub enum BackendType { 13 + /// Evolution Data Server backend (default on Linux) 14 + Eds, 15 + // Future backends can be added here: 16 + // CalDav, 17 + // GoogleCalendar, 18 + // ICloud, 19 + } 20 + 21 + impl Default for BackendType { 22 + fn default() -> Self { 23 + BackendType::Eds 24 + } 25 + } 26 + 27 + impl BackendType { 28 + /// Parse backend type from string 29 + pub fn from_str(s: &str) -> Option<Self> { 30 + match s.to_lowercase().as_str() { 31 + "eds" | "evolution" => Some(BackendType::Eds), 32 + _ => None, 33 + } 34 + } 35 + } 36 + 37 + /// Builder for creating calendar repositories 38 + /// 39 + /// This builder abstracts away the concrete repository implementation, 40 + /// allowing the CLI layer to remain ignorant of which backend is being used. 41 + /// 42 + /// # Example 43 + /// 44 + /// ```rust,no_run 45 + /// use alarma_core::RepositoryBuilder; 46 + /// 47 + /// let repository = RepositoryBuilder::new() 48 + /// .build() 49 + /// .expect("Failed to create repository"); 50 + /// ``` 51 + pub struct RepositoryBuilder { 52 + backend: BackendType, 53 + } 54 + 55 + impl Default for RepositoryBuilder { 56 + fn default() -> Self { 57 + Self::new() 58 + } 59 + } 60 + 61 + impl RepositoryBuilder { 62 + /// Creates a new repository builder with default settings 63 + pub fn new() -> Self { 64 + Self { 65 + backend: BackendType::default(), 66 + } 67 + } 68 + 69 + /// Sets the backend type to use 70 + pub fn with_backend(mut self, backend: BackendType) -> Self { 71 + self.backend = backend; 72 + self 73 + } 74 + 75 + /// Builds the repository based on the configured settings 76 + /// 77 + /// This method returns a boxed trait object, hiding the concrete 78 + /// implementation from the caller. 79 + pub fn build(self) -> Result<Box<dyn CalendarRepository>> { 80 + match self.backend { 81 + BackendType::Eds => { 82 + // The builder itself can't create concrete implementations 83 + // Use RepositoryRegistry with factory functions instead 84 + use crate::error::AlarmaError; 85 + Err(AlarmaError::InitializationFailed { 86 + system: "Repository Builder".to_string(), 87 + reason: "Use RepositoryRegistry with factory functions to create repositories" 88 + .to_string(), 89 + }) 90 + } 91 + } 92 + } 93 + } 94 + 95 + /// Factory function type for creating repositories 96 + /// 97 + /// This allows the CLI to register a factory function without coupling 98 + /// alarma-core to alarma-eds. 99 + pub type RepositoryFactory = fn() -> Result<Box<dyn CalendarRepository>>; 100 + 101 + /// Registry for repository factories 102 + /// 103 + /// This allows the CLI to register factory functions for different backends 104 + /// without alarma-core knowing about the concrete implementations. 105 + pub struct RepositoryRegistry { 106 + factories: std::collections::HashMap<BackendType, RepositoryFactory>, 107 + } 108 + 109 + impl RepositoryRegistry { 110 + /// Creates a new empty registry 111 + pub fn new() -> Self { 112 + Self { 113 + factories: std::collections::HashMap::new(), 114 + } 115 + } 116 + 117 + /// Registers a factory for a specific backend type 118 + pub fn register(&mut self, backend: BackendType, factory: RepositoryFactory) { 119 + self.factories.insert(backend, factory); 120 + } 121 + 122 + /// Creates a repository using the registered factory 123 + pub fn create(&self, backend: BackendType) -> Result<Box<dyn CalendarRepository>> { 124 + if let Some(factory) = self.factories.get(&backend) { 125 + factory() 126 + } else { 127 + use crate::error::AlarmaError; 128 + Err(AlarmaError::InitializationFailed { 129 + system: "Repository Registry".to_string(), 130 + reason: format!("No factory registered for backend: {:?}", backend), 131 + }) 132 + } 133 + } 134 + } 135 + 136 + impl Default for RepositoryRegistry { 137 + fn default() -> Self { 138 + Self::new() 139 + } 140 + } 141 + 142 + #[cfg(test)] 143 + mod tests { 144 + use super::*; 145 + use crate::domain::{CalendarEvent, CalendarSource}; 146 + use crate::repository::DateRange; 147 + 148 + // Mock repository for testing 149 + struct MockRepository; 150 + 151 + impl CalendarRepository for MockRepository { 152 + fn list_sources(&self) -> Result<Vec<CalendarSource>> { 153 + Ok(vec![]) 154 + } 155 + 156 + fn list_events(&self, _source_uid: &str, _range: DateRange) -> Result<Vec<CalendarEvent>> { 157 + Ok(vec![]) 158 + } 159 + } 160 + 161 + fn mock_factory() -> Result<Box<dyn CalendarRepository>> { 162 + Ok(Box::new(MockRepository)) 163 + } 164 + 165 + #[test] 166 + fn test_backend_from_str() { 167 + assert_eq!(BackendType::from_str("eds"), Some(BackendType::Eds)); 168 + assert_eq!(BackendType::from_str("Evolution"), Some(BackendType::Eds)); 169 + assert_eq!(BackendType::from_str("unknown"), None); 170 + } 171 + 172 + #[test] 173 + fn test_registry() { 174 + let mut registry = RepositoryRegistry::new(); 175 + registry.register(BackendType::Eds, mock_factory); 176 + 177 + let result = registry.create(BackendType::Eds); 178 + assert!(result.is_ok()); 179 + } 180 + }
+1 -1
crates/alarma-core/src/domain/event.rs
··· 10 10 #[cfg(feature = "serde")] 11 11 mod datetime_local_serde { 12 12 use super::*; 13 - use chrono::{DateTime, Local, TimeZone}; 13 + use chrono::{DateTime, Local}; 14 14 15 15 pub fn serialize<S>(dt: &DateTime<Local>, serializer: S) -> Result<S::Ok, S::Error> 16 16 where
+79 -24
crates/alarma-core/src/error.rs
··· 8 8 #[derive(Debug)] 9 9 pub enum AlarmaError { 10 10 /// Calendar source was not found 11 - SourceNotFound(String), 11 + SourceNotFound { 12 + uid: String, 13 + available_sources: Option<Vec<String>>, 14 + }, 12 15 13 16 /// Failed to connect to a calendar source 14 - ConnectionFailed(String), 17 + ConnectionFailed { source: String, reason: String }, 15 18 16 19 /// Failed to initialize the calendar system 17 - InitializationFailed(String), 20 + InitializationFailed { system: String, reason: String }, 18 21 19 22 /// Failed to query events 20 - QueryFailed(String), 23 + QueryFailed { 24 + source: Option<String>, 25 + reason: String, 26 + }, 21 27 22 28 /// Invalid date or time format 23 - InvalidDateTime(String), 29 + InvalidDateTime { 30 + input: String, 31 + expected_format: Option<String>, 32 + }, 24 33 25 34 /// Invalid date range (end before start) 26 - InvalidDateRange(String), 35 + InvalidDateRange { 36 + start: String, 37 + end: String, 38 + reason: String, 39 + }, 27 40 28 41 /// Date parsing error 29 42 DateParseError(chrono::ParseError), ··· 32 45 IoError(std::io::Error), 33 46 34 47 /// Repository-specific error (from implementation layer) 35 - RepositoryError(String), 48 + RepositoryError { operation: String, details: String }, 36 49 37 50 /// Configuration error 38 - ConfigError(String), 51 + ConfigError { setting: String, reason: String }, 39 52 } 40 53 41 54 impl fmt::Display for AlarmaError { 42 55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 56 match self { 44 - AlarmaError::SourceNotFound(msg) => write!(f, "Calendar source not found: {}", msg), 45 - AlarmaError::ConnectionFailed(msg) => { 46 - write!(f, "Failed to connect to calendar: {}", msg) 57 + AlarmaError::SourceNotFound { 58 + uid, 59 + available_sources, 60 + } => { 61 + write!(f, "Calendar source not found: {}", uid)?; 62 + if let Some(sources) = available_sources { 63 + if !sources.is_empty() { 64 + write!(f, ". Available sources: {}", sources.join(", "))?; 65 + } 66 + } 67 + Ok(()) 68 + } 69 + AlarmaError::ConnectionFailed { source, reason } => { 70 + write!(f, "Failed to connect to calendar '{}': {}", source, reason) 71 + } 72 + AlarmaError::InitializationFailed { system, reason } => { 73 + write!(f, "Failed to initialize {}: {}", system, reason) 74 + } 75 + AlarmaError::QueryFailed { source, reason } => { 76 + if let Some(src) = source { 77 + write!(f, "Failed to query events from '{}': {}", src, reason) 78 + } else { 79 + write!(f, "Failed to query events: {}", reason) 80 + } 47 81 } 48 - AlarmaError::InitializationFailed(msg) => { 49 - write!(f, "Failed to initialize calendar system: {}", msg) 82 + AlarmaError::InvalidDateTime { 83 + input, 84 + expected_format, 85 + } => { 86 + write!(f, "Invalid date/time: '{}'", input)?; 87 + if let Some(fmt) = expected_format { 88 + write!(f, " (expected format: {})", fmt)?; 89 + } 90 + Ok(()) 50 91 } 51 - AlarmaError::QueryFailed(msg) => write!(f, "Failed to query events: {}", msg), 52 - AlarmaError::InvalidDateTime(msg) => write!(f, "Invalid date/time: {}", msg), 53 - AlarmaError::InvalidDateRange(msg) => write!(f, "Invalid date range: {}", msg), 92 + AlarmaError::InvalidDateRange { start, end, reason } => { 93 + write!( 94 + f, 95 + "Invalid date range (start: {}, end: {}): {}", 96 + start, end, reason 97 + ) 98 + } 54 99 AlarmaError::DateParseError(err) => write!(f, "Failed to parse date: {}", err), 55 100 AlarmaError::IoError(err) => write!(f, "I/O error: {}", err), 56 - AlarmaError::RepositoryError(msg) => write!(f, "Repository error: {}", msg), 57 - AlarmaError::ConfigError(msg) => write!(f, "Configuration error: {}", msg), 101 + AlarmaError::RepositoryError { operation, details } => { 102 + write!(f, "Repository error during '{}': {}", operation, details) 103 + } 104 + AlarmaError::ConfigError { setting, reason } => { 105 + write!(f, "Configuration error for '{}': {}", setting, reason) 106 + } 58 107 } 59 108 } 60 109 } ··· 85 134 pub type Result<T> = std::result::Result<T, AlarmaError>; 86 135 87 136 impl AlarmaError { 88 - /// Creates a repository error with a custom message 89 - pub fn repository<S: Into<String>>(msg: S) -> Self { 90 - AlarmaError::RepositoryError(msg.into()) 137 + /// Creates a repository error with operation context 138 + pub fn repository<S: Into<String>>(operation: S, details: S) -> Self { 139 + AlarmaError::RepositoryError { 140 + operation: operation.into(), 141 + details: details.into(), 142 + } 91 143 } 92 144 93 - /// Creates a configuration error with a custom message 94 - pub fn config<S: Into<String>>(msg: S) -> Self { 95 - AlarmaError::ConfigError(msg.into()) 145 + /// Creates a configuration error with setting context 146 + pub fn config<S: Into<String>>(setting: S, reason: S) -> Self { 147 + AlarmaError::ConfigError { 148 + setting: setting.into(), 149 + reason: reason.into(), 150 + } 96 151 } 97 152 }
+12 -8
crates/alarma-core/src/lib.rs
··· 1 1 //! Alarma Core Library 2 2 //! 3 - //! This library provides the core domain models, business logic, and abstractions 3 + //! This library provides the core domain models and abstractions 4 4 //! for the Alarma calendar management system. It is platform-agnostic and contains 5 5 //! no implementation-specific code. 6 6 //! ··· 8 8 //! 9 9 //! - **domain**: Core domain models (Event, CalendarSource, etc.) 10 10 //! - **repository**: Abstract repository trait for data access 11 - //! - **service**: Business logic and orchestration 11 + //! - **builder**: Factory pattern for creating repositories 12 12 //! - **error**: Error types and result aliases 13 13 //! 14 14 //! # Example 15 15 //! 16 16 //! ```rust,no_run 17 - //! use alarma_core::{CalendarService, repository::DateRange}; 17 + //! use alarma_core::{RepositoryBuilder, repository::DateRange}; 18 18 //! 19 - //! fn example(service: CalendarService) { 19 + //! fn example() -> alarma_core::Result<()> { 20 + //! // Create repository via builder 21 + //! let repository = RepositoryBuilder::new().build()?; 22 + //! 20 23 //! // List all calendar sources 21 - //! let sources = service.list_sources().unwrap(); 24 + //! let sources = repository.list_sources()?; 22 25 //! 23 26 //! // List events for the next 30 days 24 27 //! let range = DateRange::from_now_to_days_ahead(30); 25 - //! let events = service.list_all_events(range).unwrap(); 28 + //! let events = repository.list_all_events(range)?; 29 + //! Ok(()) 26 30 //! } 27 31 //! ``` 28 32 33 + pub mod builder; 29 34 pub mod domain; 30 35 pub mod error; 31 36 pub mod repository; 32 - pub mod service; 33 37 34 38 // Re-export commonly used types 39 + pub use builder::RepositoryBuilder; 35 40 pub use domain::{CalendarEvent, CalendarSource, EventTime}; 36 41 pub use error::{AlarmaError, Result}; 37 42 pub use repository::{CalendarRepository, DateRange}; 38 - pub use service::CalendarService;
+13 -8
crates/alarma-core/src/repository.rs
··· 20 20 /// Creates a new date range 21 21 pub fn new(start: DateTime<Local>, end: DateTime<Local>) -> Result<Self> { 22 22 if end <= start { 23 - return Err(crate::error::AlarmaError::InvalidDateRange(format!( 24 - "end time {} is before start time {}", 25 - end.to_rfc3339(), 26 - start.to_rfc3339() 27 - ))); 23 + return Err(crate::error::AlarmaError::InvalidDateRange { 24 + start: start.to_rfc3339(), 25 + end: end.to_rfc3339(), 26 + reason: "end time is before or equal to start time".to_string(), 27 + }); 28 28 } 29 29 Ok(Self { start, end }) 30 30 } ··· 70 70 /// 71 71 /// The calendar source if found, or an error if not found 72 72 fn find_source(&self, uid: &str) -> Result<CalendarSource> { 73 - self.list_sources()? 74 - .into_iter() 73 + let sources = self.list_sources()?; 74 + sources 75 + .iter() 75 76 .find(|s| s.uid == uid) 76 - .ok_or_else(|| crate::error::AlarmaError::SourceNotFound(uid.to_string())) 77 + .cloned() 78 + .ok_or_else(|| crate::error::AlarmaError::SourceNotFound { 79 + uid: uid.to_string(), 80 + available_sources: Some(sources.iter().map(|s| s.uid.clone()).collect()), 81 + }) 77 82 } 78 83 79 84 /// Lists events from a specific source within a date range
-170
crates/alarma-core/src/service.rs
··· 1 - //! Business logic and service layer 2 - //! 3 - //! This module provides high-level operations for calendar management. 4 - 5 - use crate::domain::{CalendarEvent, CalendarSource}; 6 - use crate::error::Result; 7 - use crate::repository::{CalendarRepository, DateRange}; 8 - 9 - /// Service for calendar operations 10 - /// 11 - /// This service encapsulates business logic and coordinates between 12 - /// the domain layer and repository implementations. 13 - pub struct CalendarService { 14 - repository: Box<dyn CalendarRepository>, 15 - } 16 - 17 - impl CalendarService { 18 - /// Creates a new calendar service with the given repository 19 - pub fn new(repository: Box<dyn CalendarRepository>) -> Self { 20 - Self { repository } 21 - } 22 - 23 - /// Lists all available calendar sources 24 - pub fn list_sources(&self) -> Result<Vec<CalendarSource>> { 25 - self.repository.list_sources() 26 - } 27 - 28 - /// Finds a calendar source by UID 29 - pub fn find_source(&self, uid: &str) -> Result<CalendarSource> { 30 - self.repository.find_source(uid) 31 - } 32 - 33 - /// Lists events from a specific source 34 - pub fn list_events(&self, source_uid: &str, range: DateRange) -> Result<Vec<CalendarEvent>> { 35 - // Validate that the source exists first 36 - let _source = self.repository.find_source(source_uid)?; 37 - 38 - self.repository.list_events(source_uid, range) 39 - } 40 - 41 - /// Lists events from all sources 42 - pub fn list_all_events(&self, range: DateRange) -> Result<Vec<CalendarEvent>> { 43 - let mut events = self.repository.list_all_events(range)?; 44 - 45 - // Sort events by start time 46 - events.sort_by_key(|e| e.start.timestamp()); 47 - 48 - Ok(events) 49 - } 50 - 51 - /// Lists events from multiple specific sources 52 - pub fn list_events_from_sources( 53 - &self, 54 - source_uids: &[String], 55 - range: DateRange, 56 - ) -> Result<Vec<CalendarEvent>> { 57 - let mut all_events = Vec::new(); 58 - 59 - for uid in source_uids { 60 - match self.list_events(uid, range) { 61 - Ok(mut events) => all_events.append(&mut events), 62 - Err(e) => { 63 - eprintln!("Warning: Failed to get events from '{}': {}", uid, e); 64 - } 65 - } 66 - } 67 - 68 - // Sort events by start time 69 - all_events.sort_by_key(|e| e.start.timestamp()); 70 - 71 - Ok(all_events) 72 - } 73 - } 74 - 75 - #[cfg(test)] 76 - mod tests { 77 - use super::*; 78 - use crate::domain::{CalendarEvent, CalendarSource, EventTime}; 79 - use chrono::{Local, TimeZone}; 80 - 81 - // Mock repository for testing 82 - struct MockRepository { 83 - sources: Vec<CalendarSource>, 84 - events: Vec<CalendarEvent>, 85 - } 86 - 87 - impl CalendarRepository for MockRepository { 88 - fn list_sources(&self) -> Result<Vec<CalendarSource>> { 89 - Ok(self.sources.clone()) 90 - } 91 - 92 - fn list_events(&self, source_uid: &str, _range: DateRange) -> Result<Vec<CalendarEvent>> { 93 - Ok(self 94 - .events 95 - .iter() 96 - .filter(|e| e.source_uid == source_uid) 97 - .cloned() 98 - .collect()) 99 - } 100 - } 101 - 102 - fn create_test_service() -> CalendarService { 103 - let sources = vec![ 104 - CalendarSource::new("source1".to_string(), "Calendar 1".to_string()), 105 - CalendarSource::new("source2".to_string(), "Calendar 2".to_string()), 106 - ]; 107 - 108 - let events = vec![ 109 - CalendarEvent::new( 110 - "event1".to_string(), 111 - "Meeting".to_string(), 112 - EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap()), 113 - EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 11, 0, 0).unwrap()), 114 - "source1".to_string(), 115 - ), 116 - CalendarEvent::new( 117 - "event2".to_string(), 118 - "Lunch".to_string(), 119 - EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()), 120 - EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 13, 0, 0).unwrap()), 121 - "source2".to_string(), 122 - ), 123 - ]; 124 - 125 - let repo = MockRepository { sources, events }; 126 - CalendarService::new(Box::new(repo)) 127 - } 128 - 129 - #[test] 130 - fn test_list_sources() { 131 - let service = create_test_service(); 132 - let sources = service.list_sources().unwrap(); 133 - assert_eq!(sources.len(), 2); 134 - } 135 - 136 - #[test] 137 - fn test_find_source() { 138 - let service = create_test_service(); 139 - let source = service.find_source("source1").unwrap(); 140 - assert_eq!(source.display_name, "Calendar 1"); 141 - } 142 - 143 - #[test] 144 - fn test_find_nonexistent_source() { 145 - let service = create_test_service(); 146 - let result = service.find_source("nonexistent"); 147 - assert!(result.is_err()); 148 - } 149 - 150 - #[test] 151 - fn test_list_events() { 152 - let service = create_test_service(); 153 - let range = DateRange::from_now_to_days_ahead(60); 154 - let events = service.list_events("source1", range).unwrap(); 155 - assert_eq!(events.len(), 1); 156 - assert_eq!(events[0].summary, "Meeting"); 157 - } 158 - 159 - #[test] 160 - fn test_list_all_events_sorted() { 161 - let service = create_test_service(); 162 - let range = DateRange::from_now_to_days_ahead(60); 163 - let events = service.list_all_events(range).unwrap(); 164 - 165 - // Events should be sorted by start time 166 - assert_eq!(events.len(), 2); 167 - assert_eq!(events[0].summary, "Meeting"); 168 - assert_eq!(events[1].summary, "Lunch"); 169 - } 170 - }
+14 -16
crates/alarma-core/tests/integration_tests.rs
··· 3 3 use alarma_core::{ 4 4 domain::{CalendarEvent, CalendarSource, EventTime}, 5 5 repository::{CalendarRepository, DateRange}, 6 - CalendarService, Result, 6 + Result, 7 7 }; 8 8 use chrono::{Local, NaiveDate, TimeZone}; 9 9 ··· 66 66 } 67 67 68 68 #[test] 69 - fn test_service_list_sources() { 69 + fn test_repository_list_sources() { 70 70 let repo = MockRepository::new(); 71 - let service = CalendarService::new(Box::new(repo)); 72 71 73 - let sources = service.list_sources().unwrap(); 72 + let sources = repo.list_sources().unwrap(); 74 73 assert_eq!(sources.len(), 2); 75 74 assert_eq!(sources[0].display_name, "Personal"); 76 75 assert_eq!(sources[1].display_name, "Work"); 77 76 } 78 77 79 78 #[test] 80 - fn test_service_find_source() { 79 + fn test_repository_find_source() { 81 80 let repo = MockRepository::new(); 82 - let service = CalendarService::new(Box::new(repo)); 83 81 84 - let source = service.find_source("source-1").unwrap(); 82 + let source = repo.find_source("source-1").unwrap(); 85 83 assert_eq!(source.display_name, "Personal"); 86 84 } 87 85 88 86 #[test] 89 - fn test_service_find_nonexistent_source() { 87 + fn test_repository_find_nonexistent_source() { 90 88 let repo = MockRepository::new(); 91 - let service = CalendarService::new(Box::new(repo)); 92 89 93 - let result = service.find_source("nonexistent"); 90 + let result = repo.find_source("nonexistent"); 94 91 assert!(result.is_err()); 95 92 } 96 93 97 94 #[test] 98 - fn test_service_list_events_from_source() { 95 + fn test_repository_list_events_from_source() { 99 96 let repo = MockRepository::new(); 100 - let service = CalendarService::new(Box::new(repo)); 101 97 102 98 let range = DateRange::from_now_to_days_ahead(60); 103 - let events = service.list_events("source-2", range).unwrap(); 99 + let events = repo.list_events("source-2", range).unwrap(); 104 100 105 101 assert_eq!(events.len(), 2); 106 102 assert!(events.iter().any(|e| e.summary == "Team Meeting")); ··· 108 104 } 109 105 110 106 #[test] 111 - fn test_service_list_all_events_sorted() { 107 + fn test_repository_list_all_events() { 112 108 let repo = MockRepository::new(); 113 - let service = CalendarService::new(Box::new(repo)); 114 109 115 110 let range = DateRange::from_now_to_days_ahead(60); 116 - let events = service.list_all_events(range).unwrap(); 111 + let mut events = repo.list_all_events(range).unwrap(); 117 112 118 113 assert_eq!(events.len(), 3); 114 + 115 + // Sort by start time (what the CLI layer now does) 116 + events.sort_by_key(|e| e.start.timestamp()); 119 117 120 118 // Should be sorted by start time 121 119 assert_eq!(events[0].summary, "Team Meeting"); // Jan 20
+2 -2
crates/alarma-eds/build.rs
··· 3 3 pkg_config::Config::new() 4 4 .probe("libedataserver-1.2") 5 5 .expect("libedataserver-1.2 not found"); 6 - 6 + 7 7 pkg_config::Config::new() 8 8 .probe("libecal-2.0") 9 9 .expect("libecal-2.0 not found"); 10 - 10 + 11 11 pkg_config::Config::new() 12 12 .probe("libical-glib") 13 13 .expect("libical-glib not found");
+27 -18
crates/alarma-eds/src/ffi/mod.rs
··· 28 28 "Unknown error".to_string() 29 29 }; 30 30 glib::ffi::g_error_free(error); 31 - return Err(AlarmaError::InitializationFailed(err_msg)); 31 + return Err(AlarmaError::InitializationFailed { 32 + system: "Evolution Data Server".to_string(), 33 + reason: err_msg, 34 + }); 32 35 } 33 36 34 37 if registry.is_null() { 35 - return Err(AlarmaError::InitializationFailed( 36 - "Failed to create source registry".to_string(), 37 - )); 38 + return Err(AlarmaError::InitializationFailed { 39 + system: "Evolution Data Server".to_string(), 40 + reason: "Failed to create source registry".to_string(), 41 + }); 38 42 } 39 43 40 44 Ok(SourceRegistry { ptr: registry }) ··· 148 152 pub fn connect(source: &Source) -> Result<Self> { 149 153 unsafe { 150 154 let mut error: *mut bindings::GError = ptr::null_mut(); 155 + std::io::Write::flush(&mut std::io::stderr()).ok(); 156 + 151 157 let client = bindings::e_cal_client_connect_sync( 152 158 source.as_ptr(), 153 159 bindings::ECalClientSourceType_E_CAL_CLIENT_SOURCE_TYPE_EVENTS, ··· 165 171 "Unknown error".to_string() 166 172 }; 167 173 glib::ffi::g_error_free(error); 168 - return Err(AlarmaError::ConnectionFailed(format!( 169 - "Failed to connect to '{}': {}", 170 - source.display_name(), 171 - err_msg 172 - ))); 174 + return Err(AlarmaError::ConnectionFailed { 175 + source: source.display_name(), 176 + reason: err_msg, 177 + }); 173 178 } 174 179 175 180 if client.is_null() { 176 - return Err(AlarmaError::ConnectionFailed(format!( 177 - "Failed to connect to '{}': Client pointer is null", 178 - source.display_name() 179 - ))); 181 + return Err(AlarmaError::ConnectionFailed { 182 + source: source.display_name(), 183 + reason: "Client pointer is null".to_string(), 184 + }); 180 185 } 181 186 182 187 Ok(CalClient { ptr: client }) ··· 191 196 let iso_end = bindings::isodate_from_time_t(end_time); 192 197 193 198 if iso_start.is_null() || iso_end.is_null() { 194 - return Err(AlarmaError::QueryFailed( 195 - "Failed to convert timestamps to ISO dates".to_string(), 196 - )); 199 + return Err(AlarmaError::QueryFailed { 200 + source: None, 201 + reason: "Failed to convert timestamps to ISO dates".to_string(), 202 + }); 197 203 } 198 204 199 205 // Build S-expression query ··· 227 233 "Unknown error".to_string() 228 234 }; 229 235 glib::ffi::g_error_free(error); 230 - return Err(AlarmaError::QueryFailed(err_msg)); 236 + return Err(AlarmaError::QueryFailed { 237 + source: None, 238 + reason: err_msg, 239 + }); 231 240 } 232 241 233 242 let mut components = Vec::new(); ··· 251 260 Some(std::mem::transmute::< 252 261 unsafe extern "C" fn(*mut glib::gobject_ffi::GObject), 253 262 unsafe extern "C" fn(*mut libc::c_void), 254 - >(glib::gobject_ffi::g_object_unref as _)) 263 + >(glib::gobject_ffi::g_object_unref as _)), 255 264 ); 256 265 } 257 266
+3 -6
crates/alarma-eds/src/lib.rs
··· 6 6 //! # Example 7 7 //! 8 8 //! ```rust,no_run 9 - //! use alarma_core::{CalendarService, repository::DateRange}; 9 + //! use alarma_core::{CalendarRepository, repository::DateRange}; 10 10 //! use alarma_eds::EdsRepository; 11 11 //! 12 12 //! fn main() -> alarma_core::Result<()> { 13 13 //! // Create EDS repository 14 14 //! let repository = EdsRepository::new()?; 15 15 //! 16 - //! // Create service with the repository 17 - //! let service = CalendarService::new(Box::new(repository)); 18 - //! 19 - //! // Use the service 20 - //! let sources = service.list_sources()?; 16 + //! // Use the repository 17 + //! let sources = repository.list_sources()?; 21 18 //! println!("Found {} calendar sources", sources.len()); 22 19 //! 23 20 //! Ok(())
+4 -1
crates/alarma-eds/src/repository.rs
··· 87 87 let source = eds_sources 88 88 .iter() 89 89 .find(|s| s.uid() == source_uid) 90 - .ok_or_else(|| AlarmaError::SourceNotFound(source_uid.to_string()))?; 90 + .ok_or_else(|| AlarmaError::SourceNotFound { 91 + uid: source_uid.to_string(), 92 + available_sources: Some(eds_sources.iter().map(|s| s.uid()).collect()), 93 + })?; 91 94 92 95 // Connect to the calendar 93 96 let client = CalClient::connect(source)?;