Skip to main content

linear_motion/config/
models.rs

1use serde::{Deserialize, Serialize};
2use std::{collections::HashMap, path::PathBuf};
3
4use super::ConfigLoader;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct AppConfig {
8    pub motion_api_key: String,
9    pub sync_sources: Vec<SyncSource>,
10    pub global_sync_rules: SyncRules,
11    pub database_path: Option<String>,
12    pub polling_interval_seconds: u64,
13    pub schedule_overrides: Option<Vec<ScheduleOverride>>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SyncSource {
18    pub name: String,
19    pub linear_api_key: String,
20    pub projects: Option<Vec<String>>,
21    pub webhook_base_url: Option<String>,
22    pub sync_rules: Option<SyncRules>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SyncRules {
27    pub default_task_duration_mins: u32,
28    pub completed_linear_tag: String,
29    pub time_estimate_strategy: TimeEstimateStrategy,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TimeEstimateStrategy {
34    pub fibonacci: Option<HashMap<String, u32>>,
35    pub tshirt: Option<HashMap<String, u32>>,
36    pub linear: Option<HashMap<String, u32>>,
37    pub points: Option<HashMap<String, u32>>,
38    pub default_duration_mins: Option<u32>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ScheduleOverride {
43    pub name: String,
44    pub interval_seconds: u64,
45    pub start_time: String, // HH:MM format
46    pub end_time: String,   // HH:MM format
47    pub days: Vec<String>,  // mon, tue, wed, thu, fri, sat, sun
48}
49
50impl SyncSource {
51    pub fn effective_sync_rules(&self, global_rules: &SyncRules) -> SyncRules {
52        match &self.sync_rules {
53            Some(source_rules) => source_rules.clone(),
54            None => global_rules.clone(),
55        }
56    }
57}
58
59impl TimeEstimateStrategy {
60    pub fn convert_estimate(&self, estimate: f64, estimate_type: &str) -> Option<u32> {
61        let estimate_key = estimate.to_string();
62
63        match estimate_type.to_lowercase().as_str() {
64            "fibonacci" => self.fibonacci.as_ref()?.get(&estimate_key).copied(),
65            "tshirt" | "t-shirt" => self.tshirt.as_ref()?.get(&estimate_key).copied(),
66            "linear" => self.linear.as_ref()?.get(&estimate_key).copied(),
67            "points" => self.points.as_ref()?.get(&estimate_key).copied(),
68            _ => None,
69        }
70    }
71
72    pub fn convert_estimate_by_value(&self, estimate: f64) -> Option<u32> {
73        let estimate_key = estimate.to_string();
74
75        // Try each strategy in order
76        if let Some(mappings) = &self.fibonacci {
77            if let Some(duration) = mappings.get(&estimate_key) {
78                return Some(*duration);
79            }
80        }
81
82        if let Some(mappings) = &self.tshirt {
83            if let Some(duration) = mappings.get(&estimate_key) {
84                return Some(*duration);
85            }
86        }
87
88        if let Some(mappings) = &self.linear {
89            if let Some(duration) = mappings.get(&estimate_key) {
90                return Some(*duration);
91            }
92        }
93
94        if let Some(mappings) = &self.points {
95            if let Some(duration) = mappings.get(&estimate_key) {
96                return Some(*duration);
97            }
98        }
99
100        // Fall back to default duration
101        self.default_duration_mins
102    }
103}
104
105impl AppConfig {
106    pub fn validate(&self) -> crate::Result<()> {
107        use crate::Error;
108
109        // Validate Motion API key
110        if self.motion_api_key.trim().is_empty()
111            || self.motion_api_key == "your_motion_api_key_here"
112        {
113            return Err(Error::Validation("Motion API key is required".to_string()));
114        }
115
116        // Validate sync sources
117        if self.sync_sources.is_empty() {
118            return Err(Error::Validation(
119                "At least one sync source is required".to_string(),
120            ));
121        }
122
123        for (idx, source) in self.sync_sources.iter().enumerate() {
124            if source.linear_api_key.trim().is_empty()
125                || source.linear_api_key == "your_linear_api_key_here"
126            {
127                return Err(Error::Validation(format!(
128                    "Linear API key is required for sync source {} ({})",
129                    idx, source.name
130                )));
131            }
132
133            if let Some(projects) = &source.projects {
134                if projects.is_empty() {
135                    return Err(Error::Validation(format!(
136                        "At least one project is required for source projects filter {} ({})",
137                        idx, source.name
138                    )));
139                }
140            }
141
142            if source.name.trim().is_empty() {
143                return Err(Error::Validation(format!(
144                    "Name is required for sync source {}",
145                    idx
146                )));
147            }
148        }
149
150        // Validate database path
151        if let Some(true) = self.database_path.as_ref().map(|p| p.trim().is_empty()) {
152            return Err(Error::Validation("Database path is required".to_string()));
153        }
154
155        // Validate schedule overrides
156        if let Some(overrides) = &self.schedule_overrides {
157            for (idx, override_config) in overrides.iter().enumerate() {
158                self.validate_schedule_override(idx, override_config)?;
159            }
160        }
161
162        Ok(())
163    }
164
165    fn validate_schedule_override(
166        &self,
167        idx: usize,
168        override_config: &ScheduleOverride,
169    ) -> crate::Result<()> {
170        use crate::Error;
171
172        // Validate time format (HH:MM)
173        if !self.is_valid_time_format(&override_config.start_time) {
174            return Err(Error::Validation(format!(
175                "Invalid start_time format for schedule override {}: expected HH:MM",
176                idx
177            )));
178        }
179
180        if !self.is_valid_time_format(&override_config.end_time) {
181            return Err(Error::Validation(format!(
182                "Invalid end_time format for schedule override {}: expected HH:MM",
183                idx
184            )));
185        }
186
187        // Validate days
188        let valid_days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
189        for day in &override_config.days {
190            if !valid_days.contains(&day.as_str()) {
191                return Err(Error::Validation(format!(
192                    "Invalid day '{}' in schedule override {}. Valid days: {}",
193                    day,
194                    idx,
195                    valid_days.join(", ")
196                )));
197            }
198        }
199
200        Ok(())
201    }
202
203    pub fn database_path(&self) -> PathBuf {
204        return self
205            .database_path
206            .as_deref()
207            .unwrap_or(
208                ConfigLoader::get_default_database_path()
209                    .unwrap()
210                    .as_os_str()
211                    .to_str()
212                    .unwrap(),
213            )
214            .into();
215    }
216
217    fn is_valid_time_format(&self, time_str: &str) -> bool {
218        let parts: Vec<&str> = time_str.split(':').collect();
219        if parts.len() != 2 {
220            return false;
221        }
222
223        if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
224            hour < 24 && minute < 60
225        } else {
226            false
227        }
228    }
229}