···11+[package]
22+name = "alarma-core"
33+version = "0.1.0"
44+edition = "2021"
55+description = "Core domain models and business logic for Alarma calendar tool"
66+77+[dependencies]
88+chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
99+serde = { version = "1.0", features = ["derive"], optional = true }
1010+1111+[features]
1212+default = []
1313+serde = ["dep:serde", "chrono/serde"]
+196
crates/alarma-core/src/domain/event.rs
···11+//! Domain model for calendar events
22+//!
33+//! This module defines the Event type and related structures.
44+55+use chrono::{DateTime, Local, NaiveDate};
66+77+#[cfg(feature = "serde")]
88+use serde::{Deserialize, Deserializer, Serialize, Serializer};
99+1010+#[cfg(feature = "serde")]
1111+mod datetime_local_serde {
1212+ use super::*;
1313+ use chrono::{DateTime, Local, TimeZone};
1414+1515+ pub fn serialize<S>(dt: &DateTime<Local>, serializer: S) -> Result<S::Ok, S::Error>
1616+ where
1717+ S: Serializer,
1818+ {
1919+ serializer.serialize_str(&dt.to_rfc3339())
2020+ }
2121+2222+ pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Local>, D::Error>
2323+ where
2424+ D: Deserializer<'de>,
2525+ {
2626+ let s = String::deserialize(deserializer)?;
2727+ DateTime::parse_from_rfc3339(&s)
2828+ .map(|dt| dt.with_timezone(&Local))
2929+ .map_err(serde::de::Error::custom)
3030+ }
3131+}
3232+3333+/// Represents the time information for an event
3434+///
3535+/// Events can be either all-day (represented by a date) or timed (represented by a datetime).
3636+#[derive(Debug, Clone, PartialEq, Eq)]
3737+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
3838+#[cfg_attr(
3939+ feature = "serde",
4040+ serde(tag = "type", content = "value", rename_all = "lowercase")
4141+)]
4242+pub enum EventTime {
4343+ /// All-day event (no specific time)
4444+ Date(NaiveDate),
4545+ /// Timed event with specific datetime
4646+ #[cfg_attr(feature = "serde", serde(with = "datetime_local_serde"))]
4747+ DateTime(DateTime<Local>),
4848+}
4949+5050+impl EventTime {
5151+ /// Formats the event time using the given format string
5252+ pub fn format(&self, fmt: &str) -> String {
5353+ match self {
5454+ EventTime::DateTime(dt) => dt.format(fmt).to_string(),
5555+ EventTime::Date(date) => date.format(fmt).to_string(),
5656+ }
5757+ }
5858+5959+ /// Converts to Unix timestamp (seconds since epoch)
6060+ pub fn timestamp(&self) -> i64 {
6161+ match self {
6262+ EventTime::DateTime(dt) => dt.timestamp(),
6363+ EventTime::Date(date) => date
6464+ .and_hms_opt(0, 0, 0)
6565+ .and_then(|dt| dt.and_local_timezone(Local).single())
6666+ .map(|dt| dt.timestamp())
6767+ .unwrap_or(0),
6868+ }
6969+ }
7070+7171+ /// Returns true if this is an all-day event
7272+ pub fn is_all_day(&self) -> bool {
7373+ matches!(self, EventTime::Date(_))
7474+ }
7575+}
7676+7777+/// Represents a calendar event
7878+#[derive(Debug, Clone)]
7979+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
8080+pub struct CalendarEvent {
8181+ /// Unique identifier for the event
8282+ pub uid: String,
8383+ /// Event title/summary
8484+ pub summary: String,
8585+ /// Optional detailed description
8686+ pub description: Option<String>,
8787+ /// Optional location
8888+ pub location: Option<String>,
8989+ /// Start time
9090+ pub start: EventTime,
9191+ /// End time
9292+ pub end: EventTime,
9393+ /// Source UID (which calendar this event belongs to)
9494+ pub source_uid: String,
9595+}
9696+9797+impl CalendarEvent {
9898+ /// Creates a new calendar event
9999+ pub fn new(
100100+ uid: String,
101101+ summary: String,
102102+ start: EventTime,
103103+ end: EventTime,
104104+ source_uid: String,
105105+ ) -> Self {
106106+ Self {
107107+ uid,
108108+ summary,
109109+ description: None,
110110+ location: None,
111111+ start,
112112+ end,
113113+ source_uid,
114114+ }
115115+ }
116116+117117+ /// Sets the description
118118+ pub fn with_description(mut self, description: Option<String>) -> Self {
119119+ self.description = description;
120120+ self
121121+ }
122122+123123+ /// Sets the location
124124+ pub fn with_location(mut self, location: Option<String>) -> Self {
125125+ self.location = location;
126126+ self
127127+ }
128128+129129+ /// Returns true if this is an all-day event
130130+ pub fn is_all_day(&self) -> bool {
131131+ self.start.is_all_day() && self.end.is_all_day()
132132+ }
133133+134134+ /// Returns the duration of the event in seconds
135135+ pub fn duration_seconds(&self) -> i64 {
136136+ self.end.timestamp() - self.start.timestamp()
137137+ }
138138+}
139139+140140+#[cfg(test)]
141141+mod tests {
142142+ use super::*;
143143+ use chrono::TimeZone;
144144+145145+ #[test]
146146+ fn test_all_day_event() {
147147+ let start = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
148148+ let end = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
149149+150150+ let event = CalendarEvent::new(
151151+ "test-uid".to_string(),
152152+ "Test Event".to_string(),
153153+ start,
154154+ end,
155155+ "source-uid".to_string(),
156156+ );
157157+158158+ assert!(event.is_all_day());
159159+ }
160160+161161+ #[test]
162162+ fn test_timed_event() {
163163+ let start = EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap());
164164+ let end = EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 11, 0, 0).unwrap());
165165+166166+ let event = CalendarEvent::new(
167167+ "test-uid".to_string(),
168168+ "Test Event".to_string(),
169169+ start,
170170+ end,
171171+ "source-uid".to_string(),
172172+ );
173173+174174+ assert!(!event.is_all_day());
175175+ assert_eq!(event.duration_seconds(), 3600); // 1 hour
176176+ }
177177+178178+ #[test]
179179+ fn test_event_with_details() {
180180+ let start = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
181181+ let end = EventTime::Date(NaiveDate::from_ymd_opt(2026, 1, 15).unwrap());
182182+183183+ let event = CalendarEvent::new(
184184+ "test-uid".to_string(),
185185+ "Test Event".to_string(),
186186+ start,
187187+ end,
188188+ "source-uid".to_string(),
189189+ )
190190+ .with_description(Some("Test description".to_string()))
191191+ .with_location(Some("Test location".to_string()));
192192+193193+ assert_eq!(event.description, Some("Test description".to_string()));
194194+ assert_eq!(event.location, Some("Test location".to_string()));
195195+ }
196196+}
+7
crates/alarma-core/src/domain/mod.rs
···11+//! Domain models for calendar system
22+33+pub mod event;
44+pub mod source;
55+66+pub use event::{CalendarEvent, EventTime};
77+pub use source::CalendarSource;
+87
crates/alarma-core/src/domain/source.rs
···11+//! Domain model for calendar sources
22+//!
33+//! This module defines the CalendarSource type representing a calendar.
44+55+#[cfg(feature = "serde")]
66+use serde::{Deserialize, Serialize};
77+88+/// Represents a calendar source (e.g., a calendar in EDS, CalDAV, etc.)
99+#[derive(Debug, Clone, PartialEq, Eq)]
1010+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1111+pub struct CalendarSource {
1212+ /// Unique identifier for this source
1313+ pub uid: String,
1414+ /// Human-readable display name
1515+ pub display_name: String,
1616+ /// Optional description
1717+ pub description: Option<String>,
1818+ /// Color hint for UI (hex format, e.g., "#FF0000")
1919+ pub color: Option<String>,
2020+ /// Whether this source is enabled
2121+ pub enabled: bool,
2222+ /// Whether this source is read-only
2323+ pub read_only: bool,
2424+}
2525+2626+impl CalendarSource {
2727+ /// Creates a new calendar source with minimal information
2828+ pub fn new(uid: String, display_name: String) -> Self {
2929+ Self {
3030+ uid,
3131+ display_name,
3232+ description: None,
3333+ color: None,
3434+ enabled: true,
3535+ read_only: false,
3636+ }
3737+ }
3838+3939+ /// Creates a new calendar source with all fields
4040+ pub fn with_details(
4141+ uid: String,
4242+ display_name: String,
4343+ description: Option<String>,
4444+ color: Option<String>,
4545+ enabled: bool,
4646+ read_only: bool,
4747+ ) -> Self {
4848+ Self {
4949+ uid,
5050+ display_name,
5151+ description,
5252+ color,
5353+ enabled,
5454+ read_only,
5555+ }
5656+ }
5757+}
5858+5959+#[cfg(test)]
6060+mod tests {
6161+ use super::*;
6262+6363+ #[test]
6464+ fn test_create_minimal_source() {
6565+ let source = CalendarSource::new("uid-123".to_string(), "My Calendar".to_string());
6666+6767+ assert_eq!(source.uid, "uid-123");
6868+ assert_eq!(source.display_name, "My Calendar");
6969+ assert!(source.enabled);
7070+ assert!(!source.read_only);
7171+ }
7272+7373+ #[test]
7474+ fn test_create_detailed_source() {
7575+ let source = CalendarSource::with_details(
7676+ "uid-123".to_string(),
7777+ "My Calendar".to_string(),
7878+ Some("Personal calendar".to_string()),
7979+ Some("#FF0000".to_string()),
8080+ true,
8181+ false,
8282+ );
8383+8484+ assert_eq!(source.description, Some("Personal calendar".to_string()));
8585+ assert_eq!(source.color, Some("#FF0000".to_string()));
8686+ }
8787+}
+97
crates/alarma-core/src/error.rs
···11+//! Error types for Alarma
22+//!
33+//! This module defines all error types used throughout the application.
44+55+use std::fmt;
66+77+/// Main error type for Alarma operations
88+#[derive(Debug)]
99+pub enum AlarmaError {
1010+ /// Calendar source was not found
1111+ SourceNotFound(String),
1212+1313+ /// Failed to connect to a calendar source
1414+ ConnectionFailed(String),
1515+1616+ /// Failed to initialize the calendar system
1717+ InitializationFailed(String),
1818+1919+ /// Failed to query events
2020+ QueryFailed(String),
2121+2222+ /// Invalid date or time format
2323+ InvalidDateTime(String),
2424+2525+ /// Invalid date range (end before start)
2626+ InvalidDateRange(String),
2727+2828+ /// Date parsing error
2929+ DateParseError(chrono::ParseError),
3030+3131+ /// Generic I/O error
3232+ IoError(std::io::Error),
3333+3434+ /// Repository-specific error (from implementation layer)
3535+ RepositoryError(String),
3636+3737+ /// Configuration error
3838+ ConfigError(String),
3939+}
4040+4141+impl fmt::Display for AlarmaError {
4242+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4343+ match self {
4444+ AlarmaError::SourceNotFound(msg) => write!(f, "Calendar source not found: {}", msg),
4545+ AlarmaError::ConnectionFailed(msg) => {
4646+ write!(f, "Failed to connect to calendar: {}", msg)
4747+ }
4848+ AlarmaError::InitializationFailed(msg) => {
4949+ write!(f, "Failed to initialize calendar system: {}", msg)
5050+ }
5151+ AlarmaError::QueryFailed(msg) => write!(f, "Failed to query events: {}", msg),
5252+ AlarmaError::InvalidDateTime(msg) => write!(f, "Invalid date/time: {}", msg),
5353+ AlarmaError::InvalidDateRange(msg) => write!(f, "Invalid date range: {}", msg),
5454+ AlarmaError::DateParseError(err) => write!(f, "Failed to parse date: {}", err),
5555+ AlarmaError::IoError(err) => write!(f, "I/O error: {}", err),
5656+ AlarmaError::RepositoryError(msg) => write!(f, "Repository error: {}", msg),
5757+ AlarmaError::ConfigError(msg) => write!(f, "Configuration error: {}", msg),
5858+ }
5959+ }
6060+}
6161+6262+impl std::error::Error for AlarmaError {
6363+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
6464+ match self {
6565+ AlarmaError::DateParseError(err) => Some(err),
6666+ AlarmaError::IoError(err) => Some(err),
6767+ _ => None,
6868+ }
6969+ }
7070+}
7171+7272+impl From<chrono::ParseError> for AlarmaError {
7373+ fn from(err: chrono::ParseError) -> Self {
7474+ AlarmaError::DateParseError(err)
7575+ }
7676+}
7777+7878+impl From<std::io::Error> for AlarmaError {
7979+ fn from(err: std::io::Error) -> Self {
8080+ AlarmaError::IoError(err)
8181+ }
8282+}
8383+8484+/// Result type alias for Alarma operations
8585+pub type Result<T> = std::result::Result<T, AlarmaError>;
8686+8787+impl AlarmaError {
8888+ /// Creates a repository error with a custom message
8989+ pub fn repository<S: Into<String>>(msg: S) -> Self {
9090+ AlarmaError::RepositoryError(msg.into())
9191+ }
9292+9393+ /// Creates a configuration error with a custom message
9494+ pub fn config<S: Into<String>>(msg: S) -> Self {
9595+ AlarmaError::ConfigError(msg.into())
9696+ }
9797+}
+38
crates/alarma-core/src/lib.rs
···11+//! Alarma Core Library
22+//!
33+//! This library provides the core domain models, business logic, and abstractions
44+//! for the Alarma calendar management system. It is platform-agnostic and contains
55+//! no implementation-specific code.
66+//!
77+//! # Architecture
88+//!
99+//! - **domain**: Core domain models (Event, CalendarSource, etc.)
1010+//! - **repository**: Abstract repository trait for data access
1111+//! - **service**: Business logic and orchestration
1212+//! - **error**: Error types and result aliases
1313+//!
1414+//! # Example
1515+//!
1616+//! ```rust,no_run
1717+//! use alarma_core::{CalendarService, repository::DateRange};
1818+//!
1919+//! fn example(service: CalendarService) {
2020+//! // List all calendar sources
2121+//! let sources = service.list_sources().unwrap();
2222+//!
2323+//! // List events for the next 30 days
2424+//! let range = DateRange::from_now_to_days_ahead(30);
2525+//! let events = service.list_all_events(range).unwrap();
2626+//! }
2727+//! ```
2828+2929+pub mod domain;
3030+pub mod error;
3131+pub mod repository;
3232+pub mod service;
3333+3434+// Re-export commonly used types
3535+pub use domain::{CalendarEvent, CalendarSource, EventTime};
3636+pub use error::{AlarmaError, Result};
3737+pub use repository::{CalendarRepository, DateRange};
3838+pub use service::CalendarService;
+152
crates/alarma-core/src/repository.rs
···11+//! Repository trait defining calendar data access
22+//!
33+//! This module defines the abstract interface for calendar data access,
44+//! allowing different implementations (EDS, CalDAV, Google Calendar, etc.)
55+66+use crate::domain::{CalendarEvent, CalendarSource};
77+use crate::error::Result;
88+use chrono::{DateTime, Local};
99+1010+/// Represents a date range for querying events
1111+#[derive(Debug, Clone, Copy)]
1212+pub struct DateRange {
1313+ /// Start of the range (inclusive)
1414+ pub start: DateTime<Local>,
1515+ /// End of the range (exclusive)
1616+ pub end: DateTime<Local>,
1717+}
1818+1919+impl DateRange {
2020+ /// Creates a new date range
2121+ pub fn new(start: DateTime<Local>, end: DateTime<Local>) -> Result<Self> {
2222+ if end <= start {
2323+ return Err(crate::error::AlarmaError::InvalidDateRange(format!(
2424+ "end time {} is before start time {}",
2525+ end.to_rfc3339(),
2626+ start.to_rfc3339()
2727+ )));
2828+ }
2929+ Ok(Self { start, end })
3030+ }
3131+3232+ /// Creates a date range from now to the specified duration ahead
3333+ pub fn from_now_to_days_ahead(days: u32) -> Self {
3434+ let start = Local::now();
3535+ let end = start + chrono::Duration::days(days as i64);
3636+ Self { start, end }
3737+ }
3838+3939+ /// Converts start time to Unix timestamp
4040+ pub fn start_timestamp(&self) -> i64 {
4141+ self.start.timestamp()
4242+ }
4343+4444+ /// Converts end time to Unix timestamp
4545+ pub fn end_timestamp(&self) -> i64 {
4646+ self.end.timestamp()
4747+ }
4848+}
4949+5050+/// Abstract trait for calendar data access
5151+///
5252+/// This trait defines the interface that all calendar backends must implement.
5353+/// It allows the application to work with different calendar systems without
5454+/// coupling to any specific implementation.
5555+pub trait CalendarRepository: Send + Sync {
5656+ /// Lists all available calendar sources
5757+ ///
5858+ /// # Returns
5959+ ///
6060+ /// A vector of calendar sources available in the system
6161+ fn list_sources(&self) -> Result<Vec<CalendarSource>>;
6262+6363+ /// Finds a calendar source by its UID
6464+ ///
6565+ /// # Arguments
6666+ ///
6767+ /// * `uid` - The unique identifier of the source
6868+ ///
6969+ /// # Returns
7070+ ///
7171+ /// The calendar source if found, or an error if not found
7272+ fn find_source(&self, uid: &str) -> Result<CalendarSource> {
7373+ self.list_sources()?
7474+ .into_iter()
7575+ .find(|s| s.uid == uid)
7676+ .ok_or_else(|| crate::error::AlarmaError::SourceNotFound(uid.to_string()))
7777+ }
7878+7979+ /// Lists events from a specific source within a date range
8080+ ///
8181+ /// # Arguments
8282+ ///
8383+ /// * `source_uid` - The UID of the calendar source
8484+ /// * `range` - The date range to query
8585+ ///
8686+ /// # Returns
8787+ ///
8888+ /// A vector of events within the specified range
8989+ fn list_events(&self, source_uid: &str, range: DateRange) -> Result<Vec<CalendarEvent>>;
9090+9191+ /// Lists events from all sources within a date range
9292+ ///
9393+ /// # Arguments
9494+ ///
9595+ /// * `range` - The date range to query
9696+ ///
9797+ /// # Returns
9898+ ///
9999+ /// A vector of events from all sources within the specified range
100100+ fn list_all_events(&self, range: DateRange) -> Result<Vec<CalendarEvent>> {
101101+ let sources = self.list_sources()?;
102102+ let mut all_events = Vec::new();
103103+104104+ for source in sources {
105105+ match self.list_events(&source.uid, range) {
106106+ Ok(mut events) => all_events.append(&mut events),
107107+ Err(e) => {
108108+ // Log error but continue with other sources
109109+ eprintln!(
110110+ "Warning: Failed to get events from '{}': {}",
111111+ source.display_name, e
112112+ );
113113+ }
114114+ }
115115+ }
116116+117117+ Ok(all_events)
118118+ }
119119+}
120120+121121+#[cfg(test)]
122122+mod tests {
123123+ use super::*;
124124+ use chrono::TimeZone;
125125+126126+ #[test]
127127+ fn test_date_range_creation() {
128128+ let start = Local.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
129129+ let end = Local.with_ymd_and_hms(2026, 12, 31, 23, 59, 59).unwrap();
130130+131131+ let range = DateRange::new(start, end).unwrap();
132132+ assert_eq!(range.start, start);
133133+ assert_eq!(range.end, end);
134134+ }
135135+136136+ #[test]
137137+ fn test_invalid_date_range() {
138138+ let start = Local.with_ymd_and_hms(2026, 12, 31, 0, 0, 0).unwrap();
139139+ let end = Local.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
140140+141141+ let result = DateRange::new(start, end);
142142+ assert!(result.is_err());
143143+ }
144144+145145+ #[test]
146146+ fn test_date_range_from_now() {
147147+ let range = DateRange::from_now_to_days_ahead(30);
148148+ let expected_duration = range.end.timestamp() - range.start.timestamp();
149149+ // Should be approximately 30 days (allowing for some milliseconds difference)
150150+ assert!((expected_duration - (30 * 24 * 3600)).abs() < 2);
151151+ }
152152+}
+170
crates/alarma-core/src/service.rs
···11+//! Business logic and service layer
22+//!
33+//! This module provides high-level operations for calendar management.
44+55+use crate::domain::{CalendarEvent, CalendarSource};
66+use crate::error::Result;
77+use crate::repository::{CalendarRepository, DateRange};
88+99+/// Service for calendar operations
1010+///
1111+/// This service encapsulates business logic and coordinates between
1212+/// the domain layer and repository implementations.
1313+pub struct CalendarService {
1414+ repository: Box<dyn CalendarRepository>,
1515+}
1616+1717+impl CalendarService {
1818+ /// Creates a new calendar service with the given repository
1919+ pub fn new(repository: Box<dyn CalendarRepository>) -> Self {
2020+ Self { repository }
2121+ }
2222+2323+ /// Lists all available calendar sources
2424+ pub fn list_sources(&self) -> Result<Vec<CalendarSource>> {
2525+ self.repository.list_sources()
2626+ }
2727+2828+ /// Finds a calendar source by UID
2929+ pub fn find_source(&self, uid: &str) -> Result<CalendarSource> {
3030+ self.repository.find_source(uid)
3131+ }
3232+3333+ /// Lists events from a specific source
3434+ pub fn list_events(&self, source_uid: &str, range: DateRange) -> Result<Vec<CalendarEvent>> {
3535+ // Validate that the source exists first
3636+ let _source = self.repository.find_source(source_uid)?;
3737+3838+ self.repository.list_events(source_uid, range)
3939+ }
4040+4141+ /// Lists events from all sources
4242+ pub fn list_all_events(&self, range: DateRange) -> Result<Vec<CalendarEvent>> {
4343+ let mut events = self.repository.list_all_events(range)?;
4444+4545+ // Sort events by start time
4646+ events.sort_by_key(|e| e.start.timestamp());
4747+4848+ Ok(events)
4949+ }
5050+5151+ /// Lists events from multiple specific sources
5252+ pub fn list_events_from_sources(
5353+ &self,
5454+ source_uids: &[String],
5555+ range: DateRange,
5656+ ) -> Result<Vec<CalendarEvent>> {
5757+ let mut all_events = Vec::new();
5858+5959+ for uid in source_uids {
6060+ match self.list_events(uid, range) {
6161+ Ok(mut events) => all_events.append(&mut events),
6262+ Err(e) => {
6363+ eprintln!("Warning: Failed to get events from '{}': {}", uid, e);
6464+ }
6565+ }
6666+ }
6767+6868+ // Sort events by start time
6969+ all_events.sort_by_key(|e| e.start.timestamp());
7070+7171+ Ok(all_events)
7272+ }
7373+}
7474+7575+#[cfg(test)]
7676+mod tests {
7777+ use super::*;
7878+ use crate::domain::{CalendarEvent, CalendarSource, EventTime};
7979+ use chrono::{Local, TimeZone};
8080+8181+ // Mock repository for testing
8282+ struct MockRepository {
8383+ sources: Vec<CalendarSource>,
8484+ events: Vec<CalendarEvent>,
8585+ }
8686+8787+ impl CalendarRepository for MockRepository {
8888+ fn list_sources(&self) -> Result<Vec<CalendarSource>> {
8989+ Ok(self.sources.clone())
9090+ }
9191+9292+ fn list_events(&self, source_uid: &str, _range: DateRange) -> Result<Vec<CalendarEvent>> {
9393+ Ok(self
9494+ .events
9595+ .iter()
9696+ .filter(|e| e.source_uid == source_uid)
9797+ .cloned()
9898+ .collect())
9999+ }
100100+ }
101101+102102+ fn create_test_service() -> CalendarService {
103103+ let sources = vec![
104104+ CalendarSource::new("source1".to_string(), "Calendar 1".to_string()),
105105+ CalendarSource::new("source2".to_string(), "Calendar 2".to_string()),
106106+ ];
107107+108108+ let events = vec![
109109+ CalendarEvent::new(
110110+ "event1".to_string(),
111111+ "Meeting".to_string(),
112112+ EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap()),
113113+ EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 11, 0, 0).unwrap()),
114114+ "source1".to_string(),
115115+ ),
116116+ CalendarEvent::new(
117117+ "event2".to_string(),
118118+ "Lunch".to_string(),
119119+ EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap()),
120120+ EventTime::DateTime(Local.with_ymd_and_hms(2026, 1, 15, 13, 0, 0).unwrap()),
121121+ "source2".to_string(),
122122+ ),
123123+ ];
124124+125125+ let repo = MockRepository { sources, events };
126126+ CalendarService::new(Box::new(repo))
127127+ }
128128+129129+ #[test]
130130+ fn test_list_sources() {
131131+ let service = create_test_service();
132132+ let sources = service.list_sources().unwrap();
133133+ assert_eq!(sources.len(), 2);
134134+ }
135135+136136+ #[test]
137137+ fn test_find_source() {
138138+ let service = create_test_service();
139139+ let source = service.find_source("source1").unwrap();
140140+ assert_eq!(source.display_name, "Calendar 1");
141141+ }
142142+143143+ #[test]
144144+ fn test_find_nonexistent_source() {
145145+ let service = create_test_service();
146146+ let result = service.find_source("nonexistent");
147147+ assert!(result.is_err());
148148+ }
149149+150150+ #[test]
151151+ fn test_list_events() {
152152+ let service = create_test_service();
153153+ let range = DateRange::from_now_to_days_ahead(60);
154154+ let events = service.list_events("source1", range).unwrap();
155155+ assert_eq!(events.len(), 1);
156156+ assert_eq!(events[0].summary, "Meeting");
157157+ }
158158+159159+ #[test]
160160+ fn test_list_all_events_sorted() {
161161+ let service = create_test_service();
162162+ let range = DateRange::from_now_to_days_ahead(60);
163163+ let events = service.list_all_events(range).unwrap();
164164+165165+ // Events should be sorted by start time
166166+ assert_eq!(events.len(), 2);
167167+ assert_eq!(events[0].summary, "Meeting");
168168+ assert_eq!(events[1].summary, "Lunch");
169169+ }
170170+}
···11+# alarma-eds
22+33+Evolution Data Server implementation for Alarma calendar tool.
44+55+## Build Requirements
66+77+This crate requires the following system libraries:
88+- `libedataserver-1.2`
99+- `libecal-2.0`
1010+- `libical-glib`
1111+1212+These are checked via `pkg-config` at build time.
1313+1414+## FFI Bindings
1515+1616+The FFI bindings are **checked into version control** in `src/bindings/eds_bindings.rs`. This means:
1717+1818+- **No need for bindgen or clang** during normal builds
1919+- **Faster builds** - no binding generation step
2020+- **More stable** - bindings don't change unless explicitly regenerated
2121+2222+### Regenerating Bindings
2323+2424+If you need to regenerate the bindings (e.g., after updating EDS version):
2525+2626+```bash
2727+./generate_bindings.sh
2828+```
2929+3030+This requires:
3131+- `bindgen` CLI tool (`cargo install bindgen-cli`)
3232+- EDS development headers
3333+- `clang`/`libclang`
+14
crates/alarma-eds/build.rs
···11+fn main() {
22+ // Link to required libraries using pkg-config
33+ pkg_config::Config::new()
44+ .probe("libedataserver-1.2")
55+ .expect("libedataserver-1.2 not found");
66+77+ pkg_config::Config::new()
88+ .probe("libecal-2.0")
99+ .expect("libecal-2.0 not found");
1010+1111+ pkg_config::Config::new()
1212+ .probe("libical-glib")
1313+ .expect("libical-glib not found");
1414+}
+71
crates/alarma-eds/generate_bindings.sh
···11+#!/bin/bash
22+# Script to regenerate FFI bindings for Evolution Data Server
33+#
44+# This script generates the eds_bindings.rs file from the Evolution Data Server
55+# C headers. You only need to run this when updating to a new version of EDS
66+# or when changing which functions/types are exposed.
77+#
88+# Requirements:
99+# - libedataserver-1.2-dev
1010+# - libecal-2.0-dev
1111+# - libical-glib-dev
1212+# - clang/libclang
1313+#
1414+# Usage:
1515+# ./generate_bindings.sh
1616+1717+set -e
1818+1919+echo "Generating FFI bindings for Evolution Data Server..."
2020+2121+# Get include paths from pkg-config
2222+EDS_CFLAGS=$(pkg-config --cflags libedataserver-1.2 libecal-2.0 libical-glib)
2323+2424+# Create temporary wrapper header
2525+cat > /tmp/wrapper.h << 'EOF'
2626+#include <libedataserver/libedataserver.h>
2727+#include <libecal/libecal.h>
2828+#include <libical-glib/libical-glib.h>
2929+EOF
3030+3131+# Generate bindings using bindgen
3232+bindgen /tmp/wrapper.h \
3333+ --output src/bindings/eds_bindings.rs \
3434+ --allowlist-function 'e_source_registry_.*' \
3535+ --allowlist-function 'e_source_.*' \
3636+ --allowlist-function 'e_cal_client_.*' \
3737+ --allowlist-function 'i_cal_.*' \
3838+ --allowlist-function 'isodate_from_time_t' \
3939+ --allowlist-var 'E_SOURCE_EXTENSION_.*' \
4040+ --allowlist-type 'ECalClientSourceType' \
4141+ --blocklist-type 'GError' \
4242+ --blocklist-type 'GList' \
4343+ --blocklist-type 'GSList' \
4444+ --raw-line 'pub use glib::ffi::GError;' \
4545+ --raw-line 'pub use glib::ffi::GList;' \
4646+ --raw-line 'pub use glib::ffi::GSList;' \
4747+ -- $EDS_CFLAGS
4848+4949+# Add header comment to generated file
5050+cat > /tmp/header.txt << 'EOF'
5151+// This file is generated by generate_bindings.sh
5252+// DO NOT EDIT BY HAND - Run ./generate_bindings.sh to regenerate
5353+//
5454+// Generated FFI bindings for Evolution Data Server
5555+5656+#![allow(non_upper_case_globals)]
5757+#![allow(non_camel_case_types)]
5858+#![allow(non_snake_case)]
5959+#![allow(dead_code)]
6060+#![allow(improper_ctypes)]
6161+6262+EOF
6363+6464+cat /tmp/header.txt src/bindings/eds_bindings.rs > /tmp/eds_bindings_with_header.rs
6565+mv /tmp/eds_bindings_with_header.rs src/bindings/eds_bindings.rs
6666+6767+echo "Bindings generated successfully in src/bindings/eds_bindings.rs"
6868+echo "File size: $(wc -l < src/bindings/eds_bindings.rs) lines"
6969+7070+# Cleanup
7171+rm -f /tmp/wrapper.h /tmp/header.txt
···11+//! FFI bindings for Evolution Data Server
22+//!
33+//! This module contains the raw bindings for EDS C APIs.
44+//! Do not use these directly - use the safe wrappers in the ffi module instead.
55+//!
66+//! To regenerate these bindings, run: ./generate_bindings.sh
77+88+mod eds_bindings;
99+pub use eds_bindings::*;
+384
crates/alarma-eds/src/ffi/mod.rs
···11+//! Safe FFI wrappers over Evolution Data Server C APIs
22+//!
33+//! This module provides RAII types and safe abstractions over the raw bindings.
44+55+use crate::bindings;
66+use alarma_core::{AlarmaError, Result};
77+use std::ffi::{CStr, CString};
88+use std::ptr;
99+1010+/// RAII wrapper for ESourceRegistry
1111+pub struct SourceRegistry {
1212+ ptr: *mut bindings::ESourceRegistry,
1313+}
1414+1515+impl SourceRegistry {
1616+ /// Creates a new source registry
1717+ pub fn new() -> Result<Self> {
1818+ unsafe {
1919+ let mut error: *mut bindings::GError = ptr::null_mut();
2020+ let registry = bindings::e_source_registry_new_sync(ptr::null_mut(), &mut error);
2121+2222+ if !error.is_null() {
2323+ let err_msg = if !(*error).message.is_null() {
2424+ CStr::from_ptr((*error).message)
2525+ .to_string_lossy()
2626+ .into_owned()
2727+ } else {
2828+ "Unknown error".to_string()
2929+ };
3030+ glib::ffi::g_error_free(error);
3131+ return Err(AlarmaError::InitializationFailed(err_msg));
3232+ }
3333+3434+ if registry.is_null() {
3535+ return Err(AlarmaError::InitializationFailed(
3636+ "Failed to create source registry".to_string(),
3737+ ));
3838+ }
3939+4040+ Ok(SourceRegistry { ptr: registry })
4141+ }
4242+ }
4343+4444+ /// Lists all calendar sources
4545+ pub fn list_calendar_sources(&self) -> Result<Vec<Source>> {
4646+ unsafe {
4747+ let extension_name = CString::new("Calendar").unwrap();
4848+ let sources_list =
4949+ bindings::e_source_registry_list_sources(self.ptr, extension_name.as_ptr());
5050+5151+ if sources_list.is_null() {
5252+ return Ok(Vec::new());
5353+ }
5454+5555+ let mut sources = Vec::new();
5656+ let mut current = sources_list;
5757+5858+ while !current.is_null() {
5959+ let source_ptr = (*current).data as *mut bindings::ESource;
6060+ if !source_ptr.is_null() {
6161+ // Increment reference count since we're keeping it
6262+ glib::gobject_ffi::g_object_ref(source_ptr as *mut _);
6363+ sources.push(Source { ptr: source_ptr });
6464+ }
6565+ current = (*current).next;
6666+ }
6767+6868+ // Free the list (but not the objects, as we ref'd them)
6969+ glib::ffi::g_list_free(sources_list);
7070+7171+ Ok(sources)
7272+ }
7373+ }
7474+7575+ pub fn as_ptr(&self) -> *mut bindings::ESourceRegistry {
7676+ self.ptr
7777+ }
7878+}
7979+8080+impl Drop for SourceRegistry {
8181+ fn drop(&mut self) {
8282+ unsafe {
8383+ if !self.ptr.is_null() {
8484+ glib::gobject_ffi::g_object_unref(self.ptr as *mut _);
8585+ }
8686+ }
8787+ }
8888+}
8989+9090+unsafe impl Send for SourceRegistry {}
9191+unsafe impl Sync for SourceRegistry {}
9292+9393+/// RAII wrapper for ESource
9494+pub struct Source {
9595+ ptr: *mut bindings::ESource,
9696+}
9797+9898+impl Source {
9999+ /// Gets the display name of the source
100100+ pub fn display_name(&self) -> String {
101101+ unsafe {
102102+ let name_ptr = bindings::e_source_get_display_name(self.ptr);
103103+ if name_ptr.is_null() {
104104+ String::new()
105105+ } else {
106106+ CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
107107+ }
108108+ }
109109+ }
110110+111111+ /// Gets the UID of the source
112112+ pub fn uid(&self) -> String {
113113+ unsafe {
114114+ let uid_ptr = bindings::e_source_get_uid(self.ptr);
115115+ if uid_ptr.is_null() {
116116+ String::new()
117117+ } else {
118118+ CStr::from_ptr(uid_ptr).to_string_lossy().into_owned()
119119+ }
120120+ }
121121+ }
122122+123123+ pub fn as_ptr(&self) -> *mut bindings::ESource {
124124+ self.ptr
125125+ }
126126+}
127127+128128+impl Drop for Source {
129129+ fn drop(&mut self) {
130130+ unsafe {
131131+ if !self.ptr.is_null() {
132132+ glib::gobject_ffi::g_object_unref(self.ptr as *mut _);
133133+ }
134134+ }
135135+ }
136136+}
137137+138138+unsafe impl Send for Source {}
139139+unsafe impl Sync for Source {}
140140+141141+/// RAII wrapper for ECalClient
142142+pub struct CalClient {
143143+ ptr: *mut bindings::EClient, // e_cal_client_connect_sync returns EClient
144144+}
145145+146146+impl CalClient {
147147+ /// Connects to a calendar source
148148+ pub fn connect(source: &Source) -> Result<Self> {
149149+ unsafe {
150150+ let mut error: *mut bindings::GError = ptr::null_mut();
151151+ let client = bindings::e_cal_client_connect_sync(
152152+ source.as_ptr(),
153153+ bindings::ECalClientSourceType_E_CAL_CLIENT_SOURCE_TYPE_EVENTS,
154154+ 30, // wait_for_connected_seconds
155155+ ptr::null_mut(),
156156+ &mut error,
157157+ );
158158+159159+ if !error.is_null() {
160160+ let err_msg = if !(*error).message.is_null() {
161161+ CStr::from_ptr((*error).message)
162162+ .to_string_lossy()
163163+ .into_owned()
164164+ } else {
165165+ "Unknown error".to_string()
166166+ };
167167+ glib::ffi::g_error_free(error);
168168+ return Err(AlarmaError::ConnectionFailed(format!(
169169+ "Failed to connect to '{}': {}",
170170+ source.display_name(),
171171+ err_msg
172172+ )));
173173+ }
174174+175175+ if client.is_null() {
176176+ return Err(AlarmaError::ConnectionFailed(format!(
177177+ "Failed to connect to '{}': Client pointer is null",
178178+ source.display_name()
179179+ )));
180180+ }
181181+182182+ Ok(CalClient { ptr: client })
183183+ }
184184+ }
185185+186186+ /// Lists events within a time range
187187+ pub fn list_events(&self, start_time: i64, end_time: i64) -> Result<Vec<ICalComponent>> {
188188+ unsafe {
189189+ // Create ISO date strings
190190+ let iso_start = bindings::isodate_from_time_t(start_time);
191191+ let iso_end = bindings::isodate_from_time_t(end_time);
192192+193193+ if iso_start.is_null() || iso_end.is_null() {
194194+ return Err(AlarmaError::QueryFailed(
195195+ "Failed to convert timestamps to ISO dates".to_string(),
196196+ ));
197197+ }
198198+199199+ // Build S-expression query
200200+ let query_fmt =
201201+ CString::new("(occur-in-time-range? (make-time \"%s\") (make-time \"%s\"))")
202202+ .unwrap();
203203+ let query = glib::ffi::g_strdup_printf(query_fmt.as_ptr(), iso_start, iso_end);
204204+205205+ let mut ical_components: *mut bindings::GSList = ptr::null_mut();
206206+ let mut error: *mut bindings::GError = ptr::null_mut();
207207+208208+ let success = bindings::e_cal_client_get_object_list_sync(
209209+ self.ptr as *mut bindings::ECalClient, // Cast EClient to ECalClient
210210+ query,
211211+ &mut ical_components,
212212+ ptr::null_mut(),
213213+ &mut error,
214214+ );
215215+216216+ // Clean up query strings
217217+ glib::ffi::g_free(query as *mut _);
218218+ glib::ffi::g_free(iso_start as *mut _);
219219+ glib::ffi::g_free(iso_end as *mut _);
220220+221221+ if !error.is_null() {
222222+ let err_msg = if !(*error).message.is_null() {
223223+ CStr::from_ptr((*error).message)
224224+ .to_string_lossy()
225225+ .into_owned()
226226+ } else {
227227+ "Unknown error".to_string()
228228+ };
229229+ glib::ffi::g_error_free(error);
230230+ return Err(AlarmaError::QueryFailed(err_msg));
231231+ }
232232+233233+ let mut components = Vec::new();
234234+235235+ if success != 0 && !ical_components.is_null() {
236236+ let mut current = ical_components;
237237+ while !current.is_null() {
238238+ let icalcomp = (*current).data as *mut bindings::ICalComponent;
239239+ if !icalcomp.is_null() {
240240+ // Ref the component before taking ownership
241241+ glib::gobject_ffi::g_object_ref(icalcomp as *mut _);
242242+ components.push(ICalComponent { ptr: icalcomp });
243243+ }
244244+ current = (*current).next;
245245+ }
246246+247247+ // Free the list (components are ref'd separately)
248248+ // Cast g_object_unref to the function pointer type expected by g_slist_free_full
249249+ glib::ffi::g_slist_free_full(
250250+ ical_components,
251251+ Some(std::mem::transmute::<
252252+ unsafe extern "C" fn(*mut glib::gobject_ffi::GObject),
253253+ unsafe extern "C" fn(*mut libc::c_void),
254254+ >(glib::gobject_ffi::g_object_unref as _))
255255+ );
256256+ }
257257+258258+ Ok(components)
259259+ }
260260+ }
261261+}
262262+263263+impl Drop for CalClient {
264264+ fn drop(&mut self) {
265265+ unsafe {
266266+ if !self.ptr.is_null() {
267267+ glib::gobject_ffi::g_object_unref(self.ptr as *mut _);
268268+ }
269269+ }
270270+ }
271271+}
272272+273273+unsafe impl Send for CalClient {}
274274+unsafe impl Sync for CalClient {}
275275+276276+/// RAII wrapper for ICalComponent
277277+pub struct ICalComponent {
278278+ ptr: *mut bindings::ICalComponent,
279279+}
280280+281281+impl ICalComponent {
282282+ pub fn summary(&self) -> Option<String> {
283283+ unsafe {
284284+ let ptr = bindings::i_cal_component_get_summary(self.ptr);
285285+ if ptr.is_null() {
286286+ None
287287+ } else {
288288+ Some(CStr::from_ptr(ptr).to_string_lossy().into_owned())
289289+ }
290290+ }
291291+ }
292292+293293+ pub fn description(&self) -> Option<String> {
294294+ unsafe {
295295+ let ptr = bindings::i_cal_component_get_description(self.ptr);
296296+ if ptr.is_null() {
297297+ None
298298+ } else {
299299+ let desc = CStr::from_ptr(ptr).to_string_lossy().into_owned();
300300+ if desc.is_empty() {
301301+ None
302302+ } else {
303303+ Some(desc)
304304+ }
305305+ }
306306+ }
307307+ }
308308+309309+ pub fn location(&self) -> Option<String> {
310310+ unsafe {
311311+ let ptr = bindings::i_cal_component_get_location(self.ptr);
312312+ if ptr.is_null() {
313313+ None
314314+ } else {
315315+ let loc = CStr::from_ptr(ptr).to_string_lossy().into_owned();
316316+ if loc.is_empty() {
317317+ None
318318+ } else {
319319+ Some(loc)
320320+ }
321321+ }
322322+ }
323323+ }
324324+325325+ pub fn uid(&self) -> String {
326326+ unsafe {
327327+ let ptr = bindings::i_cal_component_get_uid(self.ptr);
328328+ if ptr.is_null() {
329329+ String::new()
330330+ } else {
331331+ CStr::from_ptr(ptr).to_string_lossy().into_owned()
332332+ }
333333+ }
334334+ }
335335+336336+ pub fn dtstart(&self) -> ICalTime {
337337+ unsafe {
338338+ let ptr = bindings::i_cal_component_get_dtstart(self.ptr);
339339+ ICalTime { ptr }
340340+ }
341341+ }
342342+343343+ pub fn dtend(&self) -> ICalTime {
344344+ unsafe {
345345+ let ptr = bindings::i_cal_component_get_dtend(self.ptr);
346346+ ICalTime { ptr }
347347+ }
348348+ }
349349+}
350350+351351+impl Drop for ICalComponent {
352352+ fn drop(&mut self) {
353353+ unsafe {
354354+ if !self.ptr.is_null() {
355355+ glib::gobject_ffi::g_object_unref(self.ptr as *mut _);
356356+ }
357357+ }
358358+ }
359359+}
360360+361361+/// RAII wrapper for ICalTime
362362+pub struct ICalTime {
363363+ ptr: *mut bindings::ICalTime,
364364+}
365365+366366+impl ICalTime {
367367+ pub fn is_date(&self) -> bool {
368368+ unsafe { bindings::i_cal_time_is_date(self.ptr) != 0 }
369369+ }
370370+371371+ pub fn as_timet(&self) -> i64 {
372372+ unsafe { bindings::i_cal_time_as_timet(self.ptr) }
373373+ }
374374+}
375375+376376+impl Drop for ICalTime {
377377+ fn drop(&mut self) {
378378+ unsafe {
379379+ if !self.ptr.is_null() {
380380+ glib::gobject_ffi::g_object_unref(self.ptr as *mut _);
381381+ }
382382+ }
383383+ }
384384+}
+31
crates/alarma-eds/src/lib.rs
···11+//! Alarma EDS (Evolution Data Server) Implementation
22+//!
33+//! This crate provides an implementation of the `CalendarRepository` trait
44+//! using Evolution Data Server as the backend.
55+//!
66+//! # Example
77+//!
88+//! ```rust,no_run
99+//! use alarma_core::{CalendarService, repository::DateRange};
1010+//! use alarma_eds::EdsRepository;
1111+//!
1212+//! fn main() -> alarma_core::Result<()> {
1313+//! // Create EDS repository
1414+//! let repository = EdsRepository::new()?;
1515+//!
1616+//! // Create service with the repository
1717+//! let service = CalendarService::new(Box::new(repository));
1818+//!
1919+//! // Use the service
2020+//! let sources = service.list_sources()?;
2121+//! println!("Found {} calendar sources", sources.len());
2222+//!
2323+//! Ok(())
2424+//! }
2525+//! ```
2626+2727+mod bindings;
2828+mod ffi;
2929+pub mod repository;
3030+3131+pub use repository::EdsRepository;
+127
crates/alarma-eds/src/repository.rs
···11+//! Evolution Data Server repository implementation
22+//!
33+//! This module implements the CalendarRepository trait using EDS as the backend.
44+55+use alarma_core::{
66+ domain::{CalendarEvent, CalendarSource, EventTime},
77+ repository::{CalendarRepository, DateRange},
88+ AlarmaError, Result,
99+};
1010+use chrono::{Local, TimeZone};
1111+1212+use crate::ffi::{CalClient, SourceRegistry};
1313+1414+/// Repository implementation using Evolution Data Server
1515+pub struct EdsRepository {
1616+ registry: SourceRegistry,
1717+}
1818+1919+impl EdsRepository {
2020+ /// Creates a new EDS repository
2121+ pub fn new() -> Result<Self> {
2222+ let registry = SourceRegistry::new()?;
2323+ Ok(Self { registry })
2424+ }
2525+2626+ /// Converts an ICalComponent to a CalendarEvent
2727+ fn ical_to_event(
2828+ component: &crate::ffi::ICalComponent,
2929+ source_uid: String,
3030+ ) -> Option<CalendarEvent> {
3131+ let summary = component
3232+ .summary()
3333+ .unwrap_or_else(|| "(No title)".to_string());
3434+ let description = component.description();
3535+ let location = component.location();
3636+ let uid = component.uid();
3737+3838+ let start_time = component.dtstart();
3939+ let end_time = component.dtend();
4040+4141+ let is_all_day = start_time.is_date();
4242+4343+ let (start_event_time, end_event_time) = if is_all_day {
4444+ // All-day event - use date only
4545+ let start_timestamp = start_time.as_timet();
4646+ let end_timestamp = end_time.as_timet();
4747+4848+ let start_dt = Local.timestamp_opt(start_timestamp, 0).single()?;
4949+ let end_dt = Local.timestamp_opt(end_timestamp, 0).single()?;
5050+5151+ (
5252+ EventTime::Date(start_dt.date_naive()),
5353+ EventTime::Date(end_dt.date_naive()),
5454+ )
5555+ } else {
5656+ // Timed event - use full datetime
5757+ let start_timestamp = start_time.as_timet();
5858+ let end_timestamp = end_time.as_timet();
5959+6060+ (
6161+ EventTime::DateTime(Local.timestamp_opt(start_timestamp, 0).single()?),
6262+ EventTime::DateTime(Local.timestamp_opt(end_timestamp, 0).single()?),
6363+ )
6464+ };
6565+6666+ Some(
6767+ CalendarEvent::new(uid, summary, start_event_time, end_event_time, source_uid)
6868+ .with_description(description)
6969+ .with_location(location),
7070+ )
7171+ }
7272+}
7373+7474+impl CalendarRepository for EdsRepository {
7575+ fn list_sources(&self) -> Result<Vec<CalendarSource>> {
7676+ let eds_sources = self.registry.list_calendar_sources()?;
7777+7878+ Ok(eds_sources
7979+ .iter()
8080+ .map(|source| CalendarSource::new(source.uid(), source.display_name()))
8181+ .collect())
8282+ }
8383+8484+ fn list_events(&self, source_uid: &str, range: DateRange) -> Result<Vec<CalendarEvent>> {
8585+ // First, find the source
8686+ let eds_sources = self.registry.list_calendar_sources()?;
8787+ let source = eds_sources
8888+ .iter()
8989+ .find(|s| s.uid() == source_uid)
9090+ .ok_or_else(|| AlarmaError::SourceNotFound(source_uid.to_string()))?;
9191+9292+ // Connect to the calendar
9393+ let client = CalClient::connect(source)?;
9494+9595+ // Query events
9696+ let components = client.list_events(range.start_timestamp(), range.end_timestamp())?;
9797+9898+ // Convert to domain events
9999+ Ok(components
100100+ .iter()
101101+ .filter_map(|comp| Self::ical_to_event(comp, source_uid.to_string()))
102102+ .collect())
103103+ }
104104+}
105105+106106+#[cfg(test)]
107107+mod tests {
108108+ use super::*;
109109+110110+ #[test]
111111+ #[ignore] // Requires EDS to be available
112112+ fn test_create_repository() {
113113+ let result = EdsRepository::new();
114114+ assert!(result.is_ok());
115115+ }
116116+117117+ #[test]
118118+ #[ignore] // Requires EDS to be available
119119+ fn test_list_sources() {
120120+ let repo = EdsRepository::new().unwrap();
121121+ let sources = repo.list_sources().unwrap();
122122+ println!("Found {} calendar sources", sources.len());
123123+ for source in sources {
124124+ println!(" - {} ({})", source.display_name, source.uid);
125125+ }
126126+ }
127127+}
···11-fn main() {
22- // Link against the Evolution Data Server libraries
33- pkg_config::Config::new()
44- .probe("libedataserver-1.2")
55- .unwrap();
66- pkg_config::Config::new().probe("libecal-2.0").unwrap();
77- pkg_config::Config::new().probe("glib-2.0").unwrap();
88-}
-11
crates/eds-cal-rs/src/constants.rs
···11-// Constants from Evolution Data Server C headers
22-//
33-// To regenerate, run:
44-// bindgen /usr/include/evolution-data-server/libedataserver/libedataserver.h \
55-// --allowlist-var E_SOURCE_EXTENSION_CALENDAR --no-layout-tests \
66-// -- $(pkg-config --cflags libedataserver-1.2) \
77-// > src/constants.rs
88-99-/* automatically generated by rust-bindgen 0.72.1 */
1010-1111-pub const E_SOURCE_EXTENSION_CALENDAR: &[u8; 9] = b"Calendar\0";
···11-//! EDS (Evolution Data Server) Rust bindings and utilities
22-//!
33-//! This library provides safe Rust wrappers around Evolution Data Server
44-//! C APIs for calendar and event management.
55-66-pub mod constants;
77-mod core;
88-mod event;
99-mod ffi;
1010-1111-// Re-export commonly used types for convenience
1212-pub use core::{CalendarClient, EdsError, GLibVersion, Result, Source, SourceRegistry};
1313-pub use event::{Event, EventTime};