use serde::{Deserialize, Serialize};
use std::{collections::HashMap, path::PathBuf};
use super::ConfigLoader;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub motion_api_key: String,
pub sync_sources: Vec<SyncSource>,
pub global_sync_rules: SyncRules,
pub database_path: Option<String>,
pub polling_interval_seconds: u64,
pub schedule_overrides: Option<Vec<ScheduleOverride>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncSource {
pub name: String,
pub linear_api_key: String,
pub projects: Option<Vec<String>>,
pub webhook_base_url: Option<String>,
pub sync_rules: Option<SyncRules>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncRules {
pub default_task_duration_mins: u32,
pub completed_linear_tag: String,
pub time_estimate_strategy: TimeEstimateStrategy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeEstimateStrategy {
pub fibonacci: Option<HashMap<String, u32>>,
pub tshirt: Option<HashMap<String, u32>>,
pub linear: Option<HashMap<String, u32>>,
pub points: Option<HashMap<String, u32>>,
pub default_duration_mins: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleOverride {
pub name: String,
pub interval_seconds: u64,
pub start_time: String, pub end_time: String, pub days: Vec<String>, }
impl SyncSource {
pub fn effective_sync_rules(&self, global_rules: &SyncRules) -> SyncRules {
match &self.sync_rules {
Some(source_rules) => source_rules.clone(),
None => global_rules.clone(),
}
}
}
impl TimeEstimateStrategy {
pub fn convert_estimate(&self, estimate: f64, estimate_type: &str) -> Option<u32> {
let estimate_key = estimate.to_string();
match estimate_type.to_lowercase().as_str() {
"fibonacci" => self.fibonacci.as_ref()?.get(&estimate_key).copied(),
"tshirt" | "t-shirt" => self.tshirt.as_ref()?.get(&estimate_key).copied(),
"linear" => self.linear.as_ref()?.get(&estimate_key).copied(),
"points" => self.points.as_ref()?.get(&estimate_key).copied(),
_ => None,
}
}
pub fn convert_estimate_by_value(&self, estimate: f64) -> Option<u32> {
let estimate_key = estimate.to_string();
if let Some(mappings) = &self.fibonacci {
if let Some(duration) = mappings.get(&estimate_key) {
return Some(*duration);
}
}
if let Some(mappings) = &self.tshirt {
if let Some(duration) = mappings.get(&estimate_key) {
return Some(*duration);
}
}
if let Some(mappings) = &self.linear {
if let Some(duration) = mappings.get(&estimate_key) {
return Some(*duration);
}
}
if let Some(mappings) = &self.points {
if let Some(duration) = mappings.get(&estimate_key) {
return Some(*duration);
}
}
self.default_duration_mins
}
}
impl AppConfig {
pub fn validate(&self) -> crate::Result<()> {
use crate::Error;
if self.motion_api_key.trim().is_empty()
|| self.motion_api_key == "your_motion_api_key_here"
{
return Err(Error::Validation("Motion API key is required".to_string()));
}
if self.sync_sources.is_empty() {
return Err(Error::Validation(
"At least one sync source is required".to_string(),
));
}
for (idx, source) in self.sync_sources.iter().enumerate() {
if source.linear_api_key.trim().is_empty()
|| source.linear_api_key == "your_linear_api_key_here"
{
return Err(Error::Validation(format!(
"Linear API key is required for sync source {} ({})",
idx, source.name
)));
}
if let Some(projects) = &source.projects {
if projects.is_empty() {
return Err(Error::Validation(format!(
"At least one project is required for source projects filter {} ({})",
idx, source.name
)));
}
}
if source.name.trim().is_empty() {
return Err(Error::Validation(format!(
"Name is required for sync source {}",
idx
)));
}
}
if let Some(true) = self.database_path.as_ref().map(|p| p.trim().is_empty()) {
return Err(Error::Validation("Database path is required".to_string()));
}
if let Some(overrides) = &self.schedule_overrides {
for (idx, override_config) in overrides.iter().enumerate() {
self.validate_schedule_override(idx, override_config)?;
}
}
Ok(())
}
fn validate_schedule_override(
&self,
idx: usize,
override_config: &ScheduleOverride,
) -> crate::Result<()> {
use crate::Error;
if !self.is_valid_time_format(&override_config.start_time) {
return Err(Error::Validation(format!(
"Invalid start_time format for schedule override {}: expected HH:MM",
idx
)));
}
if !self.is_valid_time_format(&override_config.end_time) {
return Err(Error::Validation(format!(
"Invalid end_time format for schedule override {}: expected HH:MM",
idx
)));
}
let valid_days = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
for day in &override_config.days {
if !valid_days.contains(&day.as_str()) {
return Err(Error::Validation(format!(
"Invalid day '{}' in schedule override {}. Valid days: {}",
day,
idx,
valid_days.join(", ")
)));
}
}
Ok(())
}
pub fn database_path(&self) -> PathBuf {
return self
.database_path
.as_deref()
.unwrap_or(
ConfigLoader::get_default_database_path()
.unwrap()
.as_os_str()
.to_str()
.unwrap(),
)
.into();
}
fn is_valid_time_format(&self, time_str: &str) -> bool {
let parts: Vec<&str> = time_str.split(':').collect();
if parts.len() != 2 {
return false;
}
if let (Ok(hour), Ok(minute)) = (parts[0].parse::<u32>(), parts[1].parse::<u32>()) {
hour < 24 && minute < 60
} else {
false
}
}
}