linear_motion/config/
models.rs1use 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, pub end_time: String, pub days: Vec<String>, }
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 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 self.default_duration_mins
102 }
103}
104
105impl AppConfig {
106 pub fn validate(&self) -> crate::Result<()> {
107 use crate::Error;
108
109 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 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 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 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 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 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}