{"repo": "andrzejwitkowski/aiwattcoach", "files": {"src/adapters/rest/settings/mapping.rs": {"86a99535680c25fe179a7687fc41b7af1296a8b1": {"t": 1785745848.5441318, "n": 19761, "h": "8048a9abf3fd8a0b", "c": "use crate::domain::llm::{normalize_openai_compatible_base_url, LlmProvider};\nuse crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\nfn api_key_is_set(value: &Option) -> bool {\n value.as_ref().is_some_and(|key| !key.trim().is_empty())\n}\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: api_key_is_set(&settings.ai_agents.openai_api_key),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: api_key_is_set(&settings.ai_agents.gemini_api_key),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: api_key_is_set(&settings.ai_agents.openrouter_api_key),\n deepseek_api_key: mask_sensitive(&settings.ai_agents.deepseek_api_key),\n deepseek_api_key_set: api_key_is_set(&settings.ai_agents.deepseek_api_key),\n zai_api_key: mask_sensitive(&settings.ai_agents.zai_api_key),\n zai_api_key_set: api_key_is_set(&settings.ai_agents.zai_api_key),\n openai_compatible_api_key: mask_sensitive(\n &settings.ai_agents.openai_compatible_api_key,\n ),\n openai_compatible_api_key_set: api_key_is_set(\n &settings.ai_agents.openai_compatible_api_key,\n ),\n openai_compatible_base_url: settings.ai_agents.openai_compatible_base_url.clone(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n workout_chat_provider: settings\n .ai_agents\n .workout_chat_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n workout_chat_model: settings.ai_agents.workout_chat_model.clone(),\n workout_planning_provider: settings\n .ai_agents\n .workout_planning_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n workout_planning_model: settings.ai_agents.workout_planning_model.clone(),\n meso_cycle_provider: settings\n .ai_agents\n .meso_cycle_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n meso_cycle_model: settings.ai_agents.meso_cycle_model.clone(),\n include_power_image: settings.ai_agents.include_power_image,\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let workout_chat_provider_update = parse_provider_settings_input(body.workout_chat_provider)?;\n let workout_chat_model_update = normalize_string_input(body.workout_chat_model);\n let workout_planning_provider_update =\n parse_provider_settings_input(body.workout_planning_provider)?;\n let workout_planning_model_update = normalize_string_input(body.workout_planning_model);\n let meso_cycle_provider_update = parse_provider_settings_input(body.meso_cycle_provider)?;\n let meso_cycle_model_update = normalize_string_input(body.meso_cycle_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n let deepseek_api_key = normalize_string_input(body.deepseek_api_key);\n let zai_api_key = normalize_string_input(body.zai_api_key);\n let openai_compatible_api_key = normalize_string_input(body.openai_compatible_api_key);\n let openai_compatible_base_url = normalize_string_input(body.openai_compatible_base_url);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n let (workout_chat_provider, workout_chat_model) = map_optional_provider_override(\n workout_chat_provider_update,\n workout_chat_model_update,\n ¤t.ai_agents.workout_chat_provider,\n ¤t.ai_agents.workout_chat_model,\n \"workoutChatProvider\",\n \"workoutChatModel\",\n )?;\n let (workout_planning_provider, workout_planning_model) = map_optional_provider_override(\n workout_planning_provider_update,\n workout_planning_model_update,\n ¤t.ai_agents.workout_planning_provider,\n ¤t.ai_agents.workout_planning_model,\n \"workoutPlanningProvider\",\n \"workoutPlanningModel\",\n )?;\n let (meso_cycle_provider, meso_cycle_model) = map_optional_provider_override(\n meso_cycle_provider_update,\n meso_cycle_model_update,\n ¤t.ai_agents.meso_cycle_provider,\n ¤t.ai_agents.meso_cycle_model,\n \"mesoCycleProvider\",\n \"mesoCycleModel\",\n )?;\n\n let openai_compatible_base_url = apply_field_update(\n openai_compatible_base_url,\n current.ai_agents.openai_compatible_base_url.clone(),\n )\n .filter(|url| !url.trim().is_empty())\n .map(|url| normalize_openai_compatible_base_url(&url).map_err(SettingsError::Validation))\n .transpose()?;\n\n let uses_openai_compatible = [\n selected_provider.as_ref(),\n workout_chat_provider.as_ref(),\n workout_planning_provider.as_ref(),\n meso_cycle_provider.as_ref(),\n ]\n .into_iter()\n .flatten()\n .any(|provider| *provider == LlmProvider::OpenAiCompatible);\n if uses_openai_compatible && openai_compatible_base_url.is_none() {\n return Err(SettingsError::Validation(\n \"openaiCompatibleBaseUrl is required when OpenAI Compatible is selected\".to_string(),\n ));\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n deepseek_api_key: apply_field_update(\n deepseek_api_key,\n current.ai_agents.deepseek_api_key.clone(),\n ),\n zai_api_key: apply_field_update(zai_api_key, current.ai_agents.zai_api_key.clone()),\n openai_compatible_api_key: apply_field_update(\n openai_compatible_api_key,\n current.ai_agents.openai_compatible_api_key.clone(),\n ),\n openai_compatible_base_url,\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n workout_chat_provider: validation::validate_ai_provider(workout_chat_provider)?,\n workout_chat_model,\n workout_planning_provider: validation::validate_ai_provider(workout_planning_provider)?,\n workout_planning_model,\n meso_cycle_provider: validation::validate_ai_provider(meso_cycle_provider)?,\n meso_cycle_model,\n include_power_image: body\n .include_power_image\n .unwrap_or(current.ai_agents.include_power_image),\n })\n}\n\nfn map_optional_provider_override(\n provider_update: FieldUpdate,\n model_update: FieldUpdate,\n current_provider: &Option,\n current_model: &Option,\n provider_field_name: &str,\n model_field_name: &str,\n) -> Result<(Option, Option), SettingsError> {\n let provider_changed = match &provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current_provider.is_some(),\n FieldUpdate::Set(provider) => current_provider.as_ref() != Some(provider),\n };\n let provider = apply_field_update(provider_update, current_provider.clone());\n let model = validation::validate_ai_model(if provider_changed {\n apply_field_update(model_update, None)\n } else {\n apply_field_update(model_update, current_model.clone())\n })?;\n\n match (&provider, &model) {\n (Some(_), None) => Err(SettingsError::Validation(format!(\n \"{model_field_name} must not be empty\"\n ))),\n (None, Some(_)) => Err(SettingsError::Validation(format!(\n \"{provider_field_name} must not be empty\"\n ))),\n _ => Ok((validation::validate_ai_provider(provider)?, model)),\n }\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_settings_to_dto_masks_deepseek_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.deepseek_api_key = Some(\"sk-ds-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(\n dto.ai_agents.deepseek_api_key.as_deref(),\n Some(\"***...5678\")\n );\n assert!(dto.ai_agents.deepseek_api_key_set);\n }\n\n #[test]\n fn map_settings_to_dto_masks_zai_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.zai_api_key = Some(\"sk-zai-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.zai_api_key.as_deref(), Some(\"***...5678\"));\n assert!(dto.ai_agents.zai_api_key_set);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "21455d5ced27d1a361bff3d13b30acca9df65d11": {"t": 1785757213.3758998, "n": 17762, "h": "802bb354e83b8400", "c": "use crate::domain::llm::LlmProvider;\nuse crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n deepseek_api_key: mask_sensitive(&settings.ai_agents.deepseek_api_key),\n deepseek_api_key_set: settings.ai_agents.deepseek_api_key.is_some(),\n zai_api_key: mask_sensitive(&settings.ai_agents.zai_api_key),\n zai_api_key_set: settings.ai_agents.zai_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n workout_chat_provider: settings\n .ai_agents\n .workout_chat_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n workout_chat_model: settings.ai_agents.workout_chat_model.clone(),\n workout_planning_provider: settings\n .ai_agents\n .workout_planning_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n workout_planning_model: settings.ai_agents.workout_planning_model.clone(),\n meso_cycle_provider: settings\n .ai_agents\n .meso_cycle_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n meso_cycle_model: settings.ai_agents.meso_cycle_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let workout_chat_provider_update = parse_provider_settings_input(body.workout_chat_provider)?;\n let workout_chat_model_update = normalize_string_input(body.workout_chat_model);\n let workout_planning_provider_update =\n parse_provider_settings_input(body.workout_planning_provider)?;\n let workout_planning_model_update = normalize_string_input(body.workout_planning_model);\n let meso_cycle_provider_update = parse_provider_settings_input(body.meso_cycle_provider)?;\n let meso_cycle_model_update = normalize_string_input(body.meso_cycle_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n let deepseek_api_key = normalize_string_input(body.deepseek_api_key);\n let zai_api_key = normalize_string_input(body.zai_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n let (workout_chat_provider, workout_chat_model) = map_optional_provider_override(\n workout_chat_provider_update,\n workout_chat_model_update,\n ¤t.ai_agents.workout_chat_provider,\n ¤t.ai_agents.workout_chat_model,\n \"workoutChatProvider\",\n \"workoutChatModel\",\n )?;\n let (workout_planning_provider, workout_planning_model) = map_optional_provider_override(\n workout_planning_provider_update,\n workout_planning_model_update,\n ¤t.ai_agents.workout_planning_provider,\n ¤t.ai_agents.workout_planning_model,\n \"workoutPlanningProvider\",\n \"workoutPlanningModel\",\n )?;\n let (meso_cycle_provider, meso_cycle_model) = map_optional_provider_override(\n meso_cycle_provider_update,\n meso_cycle_model_update,\n ¤t.ai_agents.meso_cycle_provider,\n ¤t.ai_agents.meso_cycle_model,\n \"mesoCycleProvider\",\n \"mesoCycleModel\",\n )?;\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n deepseek_api_key: apply_field_update(\n deepseek_api_key,\n current.ai_agents.deepseek_api_key.clone(),\n ),\n zai_api_key: apply_field_update(zai_api_key, current.ai_agents.zai_api_key.clone()),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n workout_chat_provider: validation::validate_ai_provider(workout_chat_provider)?,\n workout_chat_model,\n workout_planning_provider: validation::validate_ai_provider(workout_planning_provider)?,\n workout_planning_model,\n meso_cycle_provider: validation::validate_ai_provider(meso_cycle_provider)?,\n meso_cycle_model,\n })\n}\n\nfn map_optional_provider_override(\n provider_update: FieldUpdate,\n model_update: FieldUpdate,\n current_provider: &Option,\n current_model: &Option,\n provider_field_name: &str,\n model_field_name: &str,\n) -> Result<(Option, Option), SettingsError> {\n let provider_changed = match &provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current_provider.is_some(),\n FieldUpdate::Set(provider) => current_provider.as_ref() != Some(provider),\n };\n let provider = apply_field_update(provider_update, current_provider.clone());\n let model = validation::validate_ai_model(if provider_changed {\n apply_field_update(model_update, None)\n } else {\n apply_field_update(model_update, current_model.clone())\n })?;\n\n match (&provider, &model) {\n (Some(_), None) => Err(SettingsError::Validation(format!(\n \"{model_field_name} must not be empty\"\n ))),\n (None, Some(_)) => Err(SettingsError::Validation(format!(\n \"{provider_field_name} must not be empty\"\n ))),\n _ => Ok((validation::validate_ai_provider(provider)?, model)),\n }\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_settings_to_dto_masks_deepseek_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.deepseek_api_key = Some(\"sk-ds-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(\n dto.ai_agents.deepseek_api_key.as_deref(),\n Some(\"***...5678\")\n );\n assert!(dto.ai_agents.deepseek_api_key_set);\n }\n\n #[test]\n fn map_settings_to_dto_masks_zai_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.zai_api_key = Some(\"sk-zai-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.zai_api_key.as_deref(), Some(\"***...5678\"));\n assert!(dto.ai_agents.zai_api_key_set);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "fcafbfae0eb0e049974c84d6bc453d83be918e39": {"t": 1785757215.0630915, "n": 15493, "h": "a99fe14213821079", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n deepseek_api_key: mask_sensitive(&settings.ai_agents.deepseek_api_key),\n deepseek_api_key_set: settings.ai_agents.deepseek_api_key.is_some(),\n zai_api_key: mask_sensitive(&settings.ai_agents.zai_api_key),\n zai_api_key_set: settings.ai_agents.zai_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n meso_cycle_provider: settings\n .ai_agents\n .meso_cycle_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n meso_cycle_model: settings.ai_agents.meso_cycle_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let meso_cycle_provider_update = parse_provider_settings_input(body.meso_cycle_provider)?;\n let meso_cycle_model_update = normalize_string_input(body.meso_cycle_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n let deepseek_api_key = normalize_string_input(body.deepseek_api_key);\n let zai_api_key = normalize_string_input(body.zai_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n let meso_cycle_provider_changed = match &meso_cycle_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.meso_cycle_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.meso_cycle_provider.as_ref() != Some(provider)\n }\n };\n let meso_cycle_provider = apply_field_update(\n meso_cycle_provider_update,\n current.ai_agents.meso_cycle_provider.clone(),\n );\n let meso_cycle_model = validation::validate_ai_model(if meso_cycle_provider_changed {\n apply_field_update(meso_cycle_model_update, None)\n } else {\n apply_field_update(\n meso_cycle_model_update,\n current.ai_agents.meso_cycle_model.clone(),\n )\n })?;\n\n match (&meso_cycle_provider, &meso_cycle_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"mesoCycleModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"mesoCycleProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n deepseek_api_key: apply_field_update(\n deepseek_api_key,\n current.ai_agents.deepseek_api_key.clone(),\n ),\n zai_api_key: apply_field_update(zai_api_key, current.ai_agents.zai_api_key.clone()),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n meso_cycle_provider: validation::validate_ai_provider(meso_cycle_provider)?,\n meso_cycle_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_settings_to_dto_masks_deepseek_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.deepseek_api_key = Some(\"sk-ds-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(\n dto.ai_agents.deepseek_api_key.as_deref(),\n Some(\"***...5678\")\n );\n assert!(dto.ai_agents.deepseek_api_key_set);\n }\n\n #[test]\n fn map_settings_to_dto_masks_zai_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.zai_api_key = Some(\"sk-zai-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.zai_api_key.as_deref(), Some(\"***...5678\"));\n assert!(dto.ai_agents.zai_api_key_set);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "c4d9f2899845ac3a8381ec60285a49812fcaf210": {"t": 1785757216.8934605, "n": 14763, "h": "da52b93c41041ac9", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n deepseek_api_key: mask_sensitive(&settings.ai_agents.deepseek_api_key),\n deepseek_api_key_set: settings.ai_agents.deepseek_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n meso_cycle_provider: settings\n .ai_agents\n .meso_cycle_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n meso_cycle_model: settings.ai_agents.meso_cycle_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let meso_cycle_provider_update = parse_provider_settings_input(body.meso_cycle_provider)?;\n let meso_cycle_model_update = normalize_string_input(body.meso_cycle_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n let deepseek_api_key = normalize_string_input(body.deepseek_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n let meso_cycle_provider_changed = match &meso_cycle_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.meso_cycle_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.meso_cycle_provider.as_ref() != Some(provider)\n }\n };\n let meso_cycle_provider = apply_field_update(\n meso_cycle_provider_update,\n current.ai_agents.meso_cycle_provider.clone(),\n );\n let meso_cycle_model = validation::validate_ai_model(if meso_cycle_provider_changed {\n apply_field_update(meso_cycle_model_update, None)\n } else {\n apply_field_update(\n meso_cycle_model_update,\n current.ai_agents.meso_cycle_model.clone(),\n )\n })?;\n\n match (&meso_cycle_provider, &meso_cycle_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"mesoCycleModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"mesoCycleProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n deepseek_api_key: apply_field_update(\n deepseek_api_key,\n current.ai_agents.deepseek_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n meso_cycle_provider: validation::validate_ai_provider(meso_cycle_provider)?,\n meso_cycle_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_settings_to_dto_masks_deepseek_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.deepseek_api_key = Some(\"sk-ds-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(\n dto.ai_agents.deepseek_api_key.as_deref(),\n Some(\"***...5678\")\n );\n assert!(dto.ai_agents.deepseek_api_key_set);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "e9191e0e38e800e97cfa5672adad06dbf9c443a9": {"t": 1785757218.6181872, "n": 13013, "h": "70d799951bc3d5d1", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n deepseek_api_key: mask_sensitive(&settings.ai_agents.deepseek_api_key),\n deepseek_api_key_set: settings.ai_agents.deepseek_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n let deepseek_api_key = normalize_string_input(body.deepseek_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n deepseek_api_key: apply_field_update(\n deepseek_api_key,\n current.ai_agents.deepseek_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_settings_to_dto_masks_deepseek_api_key() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.deepseek_api_key = Some(\"sk-ds-secretkey5678\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(\n dto.ai_agents.deepseek_api_key.as_deref(),\n Some(\"***...5678\")\n );\n assert!(dto.ai_agents.deepseek_api_key_set);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "05d93f95f6766e0e8b2b2eca9242a1c4840dff79": {"t": 1785757220.3373172, "n": 12150, "h": "bf1c70d589410211", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings, false);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "7869cf1e54d0298cc817c906222a7d73e9ef0d6c": {"t": 1785757221.7430546, "n": 12143, "h": "1c0f13366ca2601f", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "6bfe53be4898b60efcc6ed281c87a0b8a8929271": {"t": 1785757223.9592786, "n": 9783, "h": "4010b65d77bdf532", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto, WahooDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(\n settings: &UserSettings,\n wahoo_available: bool,\n) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n wahoo: WahooDto {\n available: wahoo_available,\n access_token: mask_sensitive(&settings.wahoo.access_token),\n access_token_set: settings.wahoo.access_token.is_some(),\n refresh_token_set: settings.wahoo.refresh_token.is_some(),\n expires_at_epoch_seconds: settings.wahoo.expires_at_epoch_seconds,\n connected: settings.wahoo.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "a73d4683c82e62d812b5fa3e9154b4beaf799abb": {"t": 1785757225.6385078, "n": 11682, "h": "08ec9a4a44d1e1f1", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::{map_intervals_update, map_settings_to_dto};\n use crate::adapters::rest::settings::dto::{OptionalStringInput, UpdateIntervalsRequest};\n use crate::domain::settings::UserSettings;\n\n #[test]\n fn map_settings_to_dto_masks_ai_agent_keys() {\n let mut settings = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n settings.ai_agents.openai_api_key = Some(\"sk-verysecretkey1234\".to_string());\n\n let dto = map_settings_to_dto(&settings);\n\n assert_eq!(dto.ai_agents.openai_api_key.as_deref(), Some(\"***...1234\"));\n assert!(dto.ai_agents.openai_api_key_set);\n assert_eq!(dto.ai_agents.gemini_api_key, None);\n }\n\n #[test]\n fn map_intervals_update_clears_credentials_when_blank_values_are_sent() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\" \".to_string()),\n athlete_id: OptionalStringInput::Value(\" \".to_string()),\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key, None);\n assert_eq!(intervals.athlete_id, None);\n assert!(!intervals.connected);\n }\n\n #[test]\n fn map_intervals_update_marks_connection_inactive_when_credentials_change() {\n let mut current = UserSettings::new_defaults(\"user-1\".to_string(), 1_700_000_000);\n current.intervals.api_key = Some(\"saved-api-key\".to_string());\n current.intervals.athlete_id = Some(\"saved-athlete-id\".to_string());\n current.intervals.connected = true;\n\n let intervals = map_intervals_update(\n UpdateIntervalsRequest {\n api_key: OptionalStringInput::Value(\"updated-api-key\".to_string()),\n athlete_id: OptionalStringInput::Missing,\n },\n ¤t,\n );\n\n assert_eq!(intervals.api_key.as_deref(), Some(\"updated-api-key\"));\n assert_eq!(intervals.athlete_id.as_deref(), Some(\"saved-athlete-id\"));\n assert!(!intervals.connected);\n }\n}\n"}, "1166916aca7a7b3cfcdc4f3ab48283c02d5c7335": {"t": 1785757227.5801382, "n": 9322, "h": "e2c140138d9e0a6b", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: current.intervals.connected && !credentials_changed && has_complete_credentials,\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "69a62886c3bc18810afc8bb7950677e797773804": {"t": 1785757229.0837402, "n": 9349, "h": "ef20c6aefe9967a9", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n let credentials_changed =\n api_key != current.intervals.api_key || athlete_id != current.intervals.athlete_id;\n let has_complete_credentials = api_key.is_some() && athlete_id.is_some();\n\n IntervalsConfig {\n connected: if credentials_changed {\n false\n } else {\n has_complete_credentials\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "f2f22df4ddbfabdcf7640cd9d6fb19e9d1f93b5e": {"t": 1785757230.4324112, "n": 9235, "h": "95272973d7d4e6ee", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n validation::validate_availability(AvailabilitySettings {\n configured: true,\n days,\n })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "789d8ac3cc7ab9aaf86a841bcc23dd85d7f3d3ee": {"t": 1785757231.6987915, "n": 9297, "h": "9330185cc0202ed1", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, AvailabilityDay,\n AvailabilitySettings, CyclingSettings, IntervalsConfig, SettingsError, UserSettings, Weekday,\n};\n\nuse super::dto::{\n AiAgentsDto, AvailabilityDayDto, AvailabilityDto, CyclingDto, IntervalsDto, OptionsDto,\n UpdateAiAgentsRequest, UpdateAvailabilityRequest, UpdateCyclingRequest, UpdateIntervalsRequest,\n UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n availability: AvailabilityDto {\n configured: settings.availability.configured,\n days: settings\n .availability\n .days\n .iter()\n .map(|day| AvailabilityDayDto {\n weekday: day.weekday.as_str().to_string(),\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n .collect(),\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_availability_update(\n body: UpdateAvailabilityRequest,\n _current: &UserSettings,\n) -> Result {\n let days: Vec = body\n .days\n .into_iter()\n .map(|day| {\n let weekday = day.weekday.trim().to_lowercase();\n let weekday = Weekday::parse(&weekday).ok_or_else(|| {\n SettingsError::Validation(format!(\n \"availability weekday '{}' is invalid\",\n day.weekday\n ))\n })?;\n\n Ok(AvailabilityDay {\n weekday,\n available: day.available,\n max_duration_minutes: day.max_duration_minutes,\n })\n })\n .collect::>()?;\n let configured = days.iter().any(|day| day.available);\n\n validation::validate_availability(AvailabilitySettings { configured, days })\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "696c52d00a51556a6b50c04f16d09ceb98cc1660": {"t": 1785757233.1674473, "n": 7764, "h": "f2cdd7b2b069a943", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n athlete_prompt: settings.cycling.athlete_prompt.clone(),\n medications: settings.cycling.medications.clone(),\n athlete_notes: settings.cycling.athlete_notes.clone(),\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key = apply_field_update(\n normalize_string_input(body.api_key),\n current.intervals.api_key.clone(),\n );\n let athlete_id = apply_field_update(\n normalize_string_input(body.athlete_id),\n current.intervals.athlete_id.clone(),\n );\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n let full_name_update = normalize_string_input(body.full_name);\n let athlete_prompt_update = normalize_string_input(body.athlete_prompt);\n let medications_update = normalize_string_input(body.medications);\n let athlete_notes_update = normalize_string_input(body.athlete_notes);\n let athlete_prompt = validation::validate_optional_profile_text(\n \"athletePrompt\",\n apply_field_update(\n athlete_prompt_update,\n current.cycling.athlete_prompt.clone(),\n ),\n 6000,\n )?;\n let medications = validation::validate_optional_profile_text(\n \"medications\",\n apply_field_update(medications_update, current.cycling.medications.clone()),\n 4000,\n )?;\n let athlete_notes = validation::validate_optional_profile_text(\n \"athleteNotes\",\n apply_field_update(athlete_notes_update, current.cycling.athlete_notes.clone()),\n 8000,\n )?;\n\n Ok(CyclingSettings {\n full_name: apply_field_update(full_name_update, current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n athlete_prompt,\n medications,\n athlete_notes,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "6ceecc9a2b7e1951837aac53a4db16a102b6a1c0": {"t": 1785757235.0969028, "n": 6765, "h": "af46765cfb746b48", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{\n apply_field_update, normalize_string_input, parse_provider_settings_input, FieldUpdate,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider_update = parse_provider_settings_input(body.selected_provider)?;\n let selected_model_update = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n let provider_changed = match &selected_provider_update {\n FieldUpdate::Missing => false,\n FieldUpdate::Clear => current.ai_agents.selected_provider.is_some(),\n FieldUpdate::Set(provider) => {\n current.ai_agents.selected_provider.as_ref() != Some(provider)\n }\n };\n\n let selected_provider = apply_field_update(\n selected_provider_update,\n current.ai_agents.selected_provider.clone(),\n );\n let selected_model = validation::validate_ai_model(if provider_changed {\n apply_field_update(selected_model_update, None)\n } else {\n apply_field_update(\n selected_model_update,\n current.ai_agents.selected_model.clone(),\n )\n })?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key =\n normalize_optional_patch_value(body.api_key).or(current.intervals.api_key.clone());\n let athlete_id =\n normalize_optional_patch_value(body.athlete_id).or(current.intervals.athlete_id.clone());\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\nfn normalize_optional_patch_value(value: Option) -> Option {\n value.and_then(|value| {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n None\n } else {\n Some(trimmed.to_string())\n }\n })\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "ca9e3241f8e9076b12d6e0ba3d3c6bd3509ebbf6": {"t": 1785757236.500931, "n": 5831, "h": "ab8b5cc6949e6153", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\nuse super::input::{apply_field_update, normalize_string_input, parse_provider_settings_input};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider = parse_provider_settings_input(body.selected_provider)?;\n let selected_model = normalize_string_input(body.selected_model);\n let openai_api_key = normalize_string_input(body.openai_api_key);\n let gemini_api_key = normalize_string_input(body.gemini_api_key);\n let openrouter_api_key = normalize_string_input(body.openrouter_api_key);\n\n Ok(AiAgentsConfig {\n openai_api_key: apply_field_update(\n openai_api_key,\n current.ai_agents.openai_api_key.clone(),\n ),\n gemini_api_key: apply_field_update(\n gemini_api_key,\n current.ai_agents.gemini_api_key.clone(),\n ),\n openrouter_api_key: apply_field_update(\n openrouter_api_key,\n current.ai_agents.openrouter_api_key.clone(),\n ),\n selected_provider: validation::validate_ai_provider(apply_field_update(\n selected_provider,\n current.ai_agents.selected_provider.clone(),\n ))?,\n selected_model: validation::validate_ai_model(apply_field_update(\n selected_model,\n current.ai_agents.selected_model.clone(),\n ))?,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key =\n normalize_optional_patch_value(body.api_key).or(current.intervals.api_key.clone());\n let athlete_id =\n normalize_optional_patch_value(body.athlete_id).or(current.intervals.athlete_id.clone());\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\nfn normalize_optional_patch_value(value: Option) -> Option {\n value.and_then(|value| {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n None\n } else {\n Some(trimmed.to_string())\n }\n })\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "d793b00c5ebc478ad1ea1b6af884ad53db7d3019": {"t": 1785757237.8038, "n": 6984, "h": "e6ecbf153ac0ca5a", "c": "use crate::domain::llm::LlmProvider;\nuse crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let provider_patch = match body.selected_provider {\n Some(value) => {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n Some(None)\n } else {\n Some(Some(LlmProvider::parse(trimmed).ok_or_else(|| {\n SettingsError::Validation(\n \"selectedProvider must be one of: openai, gemini, openrouter\".to_string(),\n )\n })?))\n }\n }\n None => None,\n };\n let model_patch = match body.selected_model {\n Some(value) => {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n Some(None)\n } else {\n Some(Some(trimmed.to_string()))\n }\n }\n None => None,\n };\n let selected_provider = provider_patch\n .clone()\n .unwrap_or_else(|| current.ai_agents.selected_provider.clone());\n let provider_changed = provider_patch\n .as_ref()\n .is_some_and(|patch| patch.as_ref() != current.ai_agents.selected_provider.as_ref());\n if provider_changed && model_patch.is_none() {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ));\n }\n let selected_model = validation::validate_ai_model(\n model_patch.unwrap_or_else(|| current.ai_agents.selected_model.clone()),\n )?;\n\n match (&selected_provider, &selected_model) {\n (Some(_), None) => {\n return Err(SettingsError::Validation(\n \"selectedModel must not be empty\".to_string(),\n ))\n }\n (None, Some(_)) => {\n return Err(SettingsError::Validation(\n \"selectedProvider must not be empty\".to_string(),\n ))\n }\n _ => {}\n }\n\n Ok(AiAgentsConfig {\n openai_api_key: normalize_optional_patch_value(body.openai_api_key)\n .or(current.ai_agents.openai_api_key.clone()),\n gemini_api_key: normalize_optional_patch_value(body.gemini_api_key)\n .or(current.ai_agents.gemini_api_key.clone()),\n openrouter_api_key: normalize_optional_patch_value(body.openrouter_api_key)\n .or(current.ai_agents.openrouter_api_key.clone()),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key =\n normalize_optional_patch_value(body.api_key).or(current.intervals.api_key.clone());\n let athlete_id =\n normalize_optional_patch_value(body.athlete_id).or(current.intervals.athlete_id.clone());\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\nfn normalize_optional_patch_value(value: Option) -> Option {\n value.and_then(|value| {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n None\n } else {\n Some(trimmed.to_string())\n }\n })\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "2dbaa8a3425e3d6b22eca9719451a9dedc4852bd": {"t": 1785757239.524978, "n": 5676, "h": "ddb67fb530c6079f", "c": "use crate::domain::llm::LlmProvider;\nuse crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n openrouter_api_key: mask_sensitive(&settings.ai_agents.openrouter_api_key),\n openrouter_api_key_set: settings.ai_agents.openrouter_api_key.is_some(),\n selected_provider: settings\n .ai_agents\n .selected_provider\n .as_ref()\n .map(|provider| provider.as_str().to_string()),\n selected_model: settings.ai_agents.selected_model.clone(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> Result {\n let selected_provider = match body.selected_provider {\n Some(value) => Some(LlmProvider::parse(&value).ok_or_else(|| {\n SettingsError::Validation(\n \"selectedProvider must be one of: openai, gemini, openrouter\".to_string(),\n )\n })?),\n None => current.ai_agents.selected_provider.clone(),\n };\n\n Ok(AiAgentsConfig {\n openai_api_key: normalize_optional_patch_value(body.openai_api_key)\n .or(current.ai_agents.openai_api_key.clone()),\n gemini_api_key: normalize_optional_patch_value(body.gemini_api_key)\n .or(current.ai_agents.gemini_api_key.clone()),\n openrouter_api_key: normalize_optional_patch_value(body.openrouter_api_key)\n .or(current.ai_agents.openrouter_api_key.clone()),\n selected_provider: validation::validate_ai_provider(selected_provider)?,\n selected_model: validation::validate_ai_model(\n normalize_optional_patch_value(body.selected_model)\n .or(current.ai_agents.selected_model.clone()),\n )?,\n })\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key =\n normalize_optional_patch_value(body.api_key).or(current.intervals.api_key.clone());\n let athlete_id =\n normalize_optional_patch_value(body.athlete_id).or(current.intervals.athlete_id.clone());\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\nfn normalize_optional_patch_value(value: Option) -> Option {\n value.and_then(|value| {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n None\n } else {\n Some(trimmed.to_string())\n }\n })\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "3f3001c9a806232463549b8fb6cb7432ef58250e": {"t": 1785757240.9140158, "n": 4397, "h": "2bcc022acd6793f9", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> AiAgentsConfig {\n AiAgentsConfig {\n openai_api_key: normalize_optional_patch_value(body.openai_api_key)\n .or(current.ai_agents.openai_api_key.clone()),\n gemini_api_key: normalize_optional_patch_value(body.gemini_api_key)\n .or(current.ai_agents.gemini_api_key.clone()),\n }\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n let api_key =\n normalize_optional_patch_value(body.api_key).or(current.intervals.api_key.clone());\n let athlete_id =\n normalize_optional_patch_value(body.athlete_id).or(current.intervals.athlete_id.clone());\n\n IntervalsConfig {\n connected: if api_key != current.intervals.api_key\n || athlete_id != current.intervals.athlete_id\n {\n false\n } else {\n current.intervals.connected\n },\n api_key,\n athlete_id,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\nfn normalize_optional_patch_value(value: Option) -> Option {\n value.and_then(|value| {\n let trimmed = value.trim();\n if trimmed.is_empty() {\n None\n } else {\n Some(trimmed.to_string())\n }\n })\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}, "9db06f2fa57228c26c6382b738a3fd1359b1975c": {"t": 1785757242.3362677, "n": 3808, "h": "b082e75834c7b688", "c": "use crate::domain::settings::{\n mask_sensitive, validation, AiAgentsConfig, AnalysisOptions, CyclingSettings, IntervalsConfig,\n SettingsError, UserSettings,\n};\n\nuse super::dto::{\n AiAgentsDto, CyclingDto, IntervalsDto, OptionsDto, UpdateAiAgentsRequest, UpdateCyclingRequest,\n UpdateIntervalsRequest, UpdateOptionsRequest, UserSettingsDto,\n};\n\npub(super) fn map_settings_to_dto(settings: &UserSettings) -> UserSettingsDto {\n UserSettingsDto {\n ai_agents: AiAgentsDto {\n openai_api_key: mask_sensitive(&settings.ai_agents.openai_api_key),\n openai_api_key_set: settings.ai_agents.openai_api_key.is_some(),\n gemini_api_key: mask_sensitive(&settings.ai_agents.gemini_api_key),\n gemini_api_key_set: settings.ai_agents.gemini_api_key.is_some(),\n },\n intervals: IntervalsDto {\n api_key: mask_sensitive(&settings.intervals.api_key),\n api_key_set: settings.intervals.api_key.is_some(),\n athlete_id: settings.intervals.athlete_id.clone(),\n connected: settings.intervals.connected,\n },\n options: OptionsDto {\n analyze_without_heart_rate: settings.options.analyze_without_heart_rate,\n },\n cycling: CyclingDto {\n full_name: settings.cycling.full_name.clone(),\n age: settings.cycling.age,\n height_cm: settings.cycling.height_cm,\n weight_kg: settings.cycling.weight_kg,\n ftp_watts: settings.cycling.ftp_watts,\n hr_max_bpm: settings.cycling.hr_max_bpm,\n vo2_max: settings.cycling.vo2_max,\n last_zone_update_epoch_seconds: settings.cycling.last_zone_update_epoch_seconds,\n },\n }\n}\n\npub(super) fn map_ai_agents_update(\n body: UpdateAiAgentsRequest,\n current: &UserSettings,\n) -> AiAgentsConfig {\n AiAgentsConfig {\n openai_api_key: body\n .openai_api_key\n .or(current.ai_agents.openai_api_key.clone()),\n gemini_api_key: body\n .gemini_api_key\n .or(current.ai_agents.gemini_api_key.clone()),\n }\n}\n\npub(super) fn map_intervals_update(\n body: UpdateIntervalsRequest,\n current: &UserSettings,\n) -> IntervalsConfig {\n IntervalsConfig {\n api_key: body.api_key.or(current.intervals.api_key.clone()),\n athlete_id: body.athlete_id.or(current.intervals.athlete_id.clone()),\n connected: current.intervals.connected,\n }\n}\n\npub(super) fn map_options_update(\n body: UpdateOptionsRequest,\n current: &UserSettings,\n) -> AnalysisOptions {\n AnalysisOptions {\n analyze_without_heart_rate: body\n .analyze_without_heart_rate\n .unwrap_or(current.options.analyze_without_heart_rate),\n }\n}\n\npub(super) fn map_cycling_update(\n body: UpdateCyclingRequest,\n current: &UserSettings,\n) -> Result {\n let age = validation::validate_cycling_age(body.age.or(current.cycling.age))?;\n let height_cm =\n validation::validate_cycling_height(body.height_cm.or(current.cycling.height_cm))?;\n let weight_kg =\n validation::validate_cycling_weight(body.weight_kg.or(current.cycling.weight_kg))?;\n let ftp_watts = validation::validate_cycling_ftp(body.ftp_watts.or(current.cycling.ftp_watts))?;\n let hr_max_bpm =\n validation::validate_cycling_hr(body.hr_max_bpm.or(current.cycling.hr_max_bpm))?;\n let vo2_max = validation::validate_cycling_vo2(body.vo2_max.or(current.cycling.vo2_max))?;\n\n Ok(CyclingSettings {\n full_name: body.full_name.or(current.cycling.full_name.clone()),\n age,\n height_cm,\n weight_kg,\n ftp_watts,\n hr_max_bpm,\n vo2_max,\n last_zone_update_epoch_seconds: current.cycling.last_zone_update_epoch_seconds,\n })\n}\n"}}, "src/domain/settings/model.rs": {"86a99535680c25fe179a7687fc41b7af1296a8b1": {"t": 1785745867.3292813, "n": 14211, "h": "e0c27ea709dde4a7", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub deepseek_api_key: Option,\n pub zai_api_key: Option,\n pub openai_compatible_api_key: Option,\n pub openai_compatible_base_url: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n pub workout_chat_provider: Option,\n pub workout_chat_model: Option,\n pub workout_planning_provider: Option,\n pub workout_planning_model: Option,\n pub meso_cycle_provider: Option,\n pub meso_cycle_model: Option,\n pub include_power_image: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "21455d5ced27d1a361bff3d13b30acca9df65d11": {"t": 1785757214.7454894, "n": 14073, "h": "29a814ac1423a6a2", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub deepseek_api_key: Option,\n pub zai_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n pub workout_chat_provider: Option,\n pub workout_chat_model: Option,\n pub workout_planning_provider: Option,\n pub workout_planning_model: Option,\n pub meso_cycle_provider: Option,\n pub meso_cycle_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "fcafbfae0eb0e049974c84d6bc453d83be918e39": {"t": 1785757216.5166743, "n": 13873, "h": "f24a67016753e346", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub deepseek_api_key: Option,\n pub zai_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n pub meso_cycle_provider: Option,\n pub meso_cycle_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "c4d9f2899845ac3a8381ec60285a49812fcaf210": {"t": 1785757218.4414952, "n": 13836, "h": "0b94b40f6ce384cd", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub deepseek_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n pub meso_cycle_provider: Option,\n pub meso_cycle_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "e9191e0e38e800e97cfa5672adad06dbf9c443a9": {"t": 1785757220.2622275, "n": 13744, "h": "1d55dc6af688c0b0", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub deepseek_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "5384712f8edea19f30316c57cfdad462ec6ea0df": {"t": 1785757223.1352088, "n": 13702, "h": "0a766b37858e8574", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\n \"user_id\",\n &RedactedOptionalText(&self.user_id.map(|value| value.to_string())),\n )\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "b7862c7af010fd9ef89ac1168c217722ad14717b": {"t": 1785757224.7162611, "n": 13601, "h": "c52fbf3acf2e82cd", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub user_id: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\"user_id\", &self.user_id)\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "8c558a0a803bcfaaad2752b7a9f687610d4f6fb0": {"t": 1785757226.0836415, "n": 13491, "h": "431168f4277193ad", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub connected: bool,\n pub updated_at_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\"connected\", &self.connected)\n .field(\"updated_at_epoch_seconds\", &self.updated_at_epoch_seconds)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "6bfe53be4898b60efcc6ed281c87a0b8a8929271": {"t": 1785757228.553918, "n": 13306, "h": "a39c57253ab23f16", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, PartialEq, Eq, Default)]\npub struct WahooConfig {\n pub access_token: Option,\n pub refresh_token: Option,\n pub expires_at_epoch_seconds: Option,\n pub connected: bool,\n}\n\nimpl std::fmt::Debug for WahooConfig {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"WahooConfig\")\n .field(\"access_token\", &RedactedOptionalText(&self.access_token))\n .field(\"refresh_token\", &RedactedOptionalText(&self.refresh_token))\n .field(\"expires_at_epoch_seconds\", &self.expires_at_epoch_seconds)\n .field(\"connected\", &self.connected)\n .finish()\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub wahoo: WahooConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n wahoo: WahooConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.wahoo, WahooConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "f2f22df4ddbfabdcf7640cd9d6fb19e9d1f93b5e": {"t": 1785757230.070287, "n": 11943, "h": "03e6d7764a908799", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]\npub enum Weekday {\n #[default]\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.len() == Weekday::ALL.len()\n && ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "789d8ac3cc7ab9aaf86a841bcc23dd85d7f3d3ee": {"t": 1785757231.4462593, "n": 11944, "h": "3d078b3ac57522b8", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\npub enum Weekday {\n Mon,\n Tue,\n Wed,\n Thu,\n Fri,\n Sat,\n Sun,\n}\n\nimpl Weekday {\n pub const ALL: [Self; 7] = [\n Self::Mon,\n Self::Tue,\n Self::Wed,\n Self::Thu,\n Self::Fri,\n Self::Sat,\n Self::Sun,\n ];\n\n pub const fn as_str(self) -> &'static str {\n match self {\n Self::Mon => \"mon\",\n Self::Tue => \"tue\",\n Self::Wed => \"wed\",\n Self::Thu => \"thu\",\n Self::Fri => \"fri\",\n Self::Sat => \"sat\",\n Self::Sun => \"sun\",\n }\n }\n\n pub fn parse(value: &str) -> Option {\n match value {\n \"mon\" => Some(Self::Mon),\n \"tue\" => Some(Self::Tue),\n \"wed\" => Some(Self::Wed),\n \"thu\" => Some(Self::Thu),\n \"fri\" => Some(Self::Fri),\n \"sat\" => Some(Self::Sat),\n \"sun\" => Some(Self::Sun),\n _ => None,\n }\n }\n}\n\nimpl std::fmt::Display for Weekday {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.write_str(self.as_str())\n }\n}\n\nimpl Default for Weekday {\n fn default() -> Self {\n Self::Mon\n }\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilityDay {\n pub weekday: Weekday,\n pub available: bool,\n pub max_duration_minutes: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq)]\npub struct AvailabilitySettings {\n pub configured: bool,\n pub days: Vec,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub availability: AvailabilitySettings,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n availability: AvailabilitySettings::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\nimpl AvailabilitySettings {\n pub fn is_configured(&self) -> bool {\n self.configured && self.days.len() == 7 && self.days.iter().any(|day| day.available)\n }\n\n pub fn from_days(days: Vec) -> Self {\n let ordered_days = order_availability_days(days);\n let configured = ordered_days.iter().any(|day| day.available);\n Self {\n configured,\n days: ordered_days,\n }\n }\n}\n\nimpl Default for AvailabilitySettings {\n fn default() -> Self {\n Self {\n configured: false,\n days: default_availability_days(),\n }\n }\n}\n\npub fn default_availability_days() -> Vec {\n Weekday::ALL\n .into_iter()\n .map(|weekday| AvailabilityDay {\n weekday,\n available: false,\n max_duration_minutes: None,\n })\n .collect()\n}\n\nfn order_availability_days(days: Vec) -> Vec {\n let mut by_weekday = days\n .into_iter()\n .map(|day| (day.weekday, day))\n .collect::>();\n\n Weekday::ALL\n .into_iter()\n .filter_map(|weekday| by_weekday.remove(&weekday))\n .collect()\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\">(),\n vec![\"mon\", \"tue\", \"wed\", \"thu\", \"fri\", \"sat\", \"sun\"]\n );\n }\n\n #[test]\n fn weekday_parse_round_trips_supported_values() {\n assert_eq!(Weekday::parse(\"mon\"), Some(Weekday::Mon));\n assert_eq!(Weekday::parse(\"sun\"), Some(Weekday::Sun));\n assert_eq!(Weekday::Mon.as_str(), \"mon\");\n assert_eq!(Weekday::Sun.to_string(), \"sun\");\n assert_eq!(Weekday::parse(\"MON\"), None);\n assert_eq!(Weekday::parse(\"monday\"), None);\n }\n}\n"}, "2cefebf14bb1ab4021e96b10f5e7ee278612ad62": {"t": 1785757232.8246021, "n": 6464, "h": "04f1a5029830ff83", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\nimpl std::fmt::Debug for CyclingSettings {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n f.debug_struct(\"CyclingSettings\")\n .field(\"full_name\", &self.full_name)\n .field(\"age\", &self.age)\n .field(\"height_cm\", &self.height_cm)\n .field(\"weight_kg\", &self.weight_kg)\n .field(\"ftp_watts\", &self.ftp_watts)\n .field(\"hr_max_bpm\", &self.hr_max_bpm)\n .field(\"vo2_max\", &self.vo2_max)\n .field(\n \"athlete_prompt\",\n &RedactedOptionalText(&self.athlete_prompt),\n )\n .field(\"medications\", &RedactedOptionalText(&self.medications))\n .field(\"athlete_notes\", &RedactedOptionalText(&self.athlete_notes))\n .field(\n \"last_zone_update_epoch_seconds\",\n &self.last_zone_update_epoch_seconds,\n )\n .finish()\n }\n}\n\nstruct RedactedOptionalText<'a>(&'a Option);\n\nimpl std::fmt::Debug for RedactedOptionalText<'_> {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self.0 {\n Some(value) => write!(f, \"Some()\", value.chars().count()),\n None => write!(f, \"None\"),\n }\n }\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n\n #[test]\n fn cycling_settings_debug_redacts_sensitive_profile_fields() {\n let settings = CyclingSettings {\n athlete_prompt: Some(\"prompt details\".to_string()),\n medications: Some(\"medication details\".to_string()),\n athlete_notes: Some(\"note details\".to_string()),\n ..CyclingSettings::default()\n };\n\n let debug_output = format!(\"{settings:?}\");\n\n assert!(!debug_output.contains(\"prompt details\"));\n assert!(!debug_output.contains(\"medication details\"));\n assert!(!debug_output.contains(\"note details\"));\n assert!(debug_output.contains(\") -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub athlete_prompt: Option,\n pub medications: Option,\n pub athlete_notes: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n}\n"}, "2dbaa8a3425e3d6b22eca9719451a9dedc4852bd": {"t": 1785757235.8575747, "n": 4408, "h": "7fc3afa5cf777380", "c": "use crate::domain::llm::LlmProvider;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n pub openrouter_api_key: Option,\n pub selected_provider: Option,\n pub selected_model: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n}\n"}, "db9f2e6a401eebf31ea46cc69bb76d48af51d3da": {"t": 1785757237.370509, "n": 4238, "h": "323d7523317d1149", "c": "#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{last_four}\")\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n}\n"}, "c3ef11f8a595bc6be8fca6ac5dcbffb7974c7d7f": {"t": 1785757239.2509384, "n": 4240, "h": "fc133239775f56ee", "c": "#[derive(Clone, Debug, PartialEq)]\npub enum SettingsError {\n Unauthenticated,\n Repository(String),\n Validation(String),\n}\n\nimpl std::fmt::Display for SettingsError {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Unauthenticated => write!(f, \"Authentication is required\"),\n Self::Repository(message) => write!(f, \"{message}\"),\n Self::Validation(message) => write!(f, \"{message}\"),\n }\n }\n}\n\nimpl std::error::Error for SettingsError {}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AiAgentsConfig {\n pub openai_api_key: Option,\n pub gemini_api_key: Option,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct IntervalsConfig {\n pub api_key: Option,\n pub athlete_id: Option,\n pub connected: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Eq, Default)]\npub struct AnalysisOptions {\n pub analyze_without_heart_rate: bool,\n}\n\n#[derive(Clone, Debug, PartialEq, Default)]\npub struct CyclingSettings {\n pub full_name: Option,\n pub age: Option,\n pub height_cm: Option,\n pub weight_kg: Option,\n pub ftp_watts: Option,\n pub hr_max_bpm: Option,\n pub vo2_max: Option,\n pub last_zone_update_epoch_seconds: Option,\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub struct UserSettings {\n pub user_id: String,\n pub ai_agents: AiAgentsConfig,\n pub intervals: IntervalsConfig,\n pub options: AnalysisOptions,\n pub cycling: CyclingSettings,\n pub created_at_epoch_seconds: i64,\n pub updated_at_epoch_seconds: i64,\n}\n\nimpl UserSettings {\n pub fn new_defaults(user_id: String, now_epoch_seconds: i64) -> Self {\n Self {\n user_id,\n ai_agents: AiAgentsConfig::default(),\n intervals: IntervalsConfig::default(),\n options: AnalysisOptions::default(),\n cycling: CyclingSettings::default(),\n created_at_epoch_seconds: now_epoch_seconds,\n updated_at_epoch_seconds: now_epoch_seconds,\n }\n }\n}\n\npub fn mask_sensitive(value: &Option) -> Option {\n value.as_ref().map(|v| {\n let char_count = v.chars().count();\n if char_count <= 4 {\n \"***\".to_string()\n } else {\n let last_four: String = v.chars().skip(char_count - 4).collect();\n format!(\"***...{}\", last_four)\n }\n })\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_mask_sensitive_none() {\n assert_eq!(mask_sensitive(&None), None);\n }\n\n #[test]\n fn test_mask_sensitive_short_string() {\n assert_eq!(\n mask_sensitive(&Some(\"abc\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_exact_four_chars() {\n assert_eq!(\n mask_sensitive(&Some(\"abcd\".to_string())),\n Some(\"***\".to_string())\n );\n }\n\n #[test]\n fn test_mask_sensitive_long_string() {\n assert_eq!(\n mask_sensitive(&Some(\"sk-abc123xyz789\".to_string())),\n Some(\"***...z789\".to_string())\n );\n }\n\n #[test]\n fn test_new_defaults() {\n let user_id = \"user123\".to_string();\n let now = 1700000000i64;\n let settings = UserSettings::new_defaults(user_id.clone(), now);\n\n assert_eq!(settings.user_id, user_id);\n assert_eq!(settings.ai_agents, AiAgentsConfig::default());\n assert_eq!(settings.intervals, IntervalsConfig::default());\n assert_eq!(settings.options, AnalysisOptions::default());\n assert_eq!(settings.cycling, CyclingSettings::default());\n assert_eq!(settings.created_at_epoch_seconds, now);\n assert_eq!(settings.updated_at_epoch_seconds, now);\n }\n\n #[test]\n fn test_user_settings_partial_eq() {\n let now = 1700000000i64;\n let settings1 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings2 = UserSettings::new_defaults(\"user1\".to_string(), now);\n let settings3 = UserSettings::new_defaults(\"user2\".to_string(), now);\n\n assert_eq!(settings1, settings2);\n assert_ne!(settings1, settings3);\n }\n}\n"}}, "src/adapters/rest/settings/dto.rs": {"86a99535680c25fe179a7687fc41b7af1296a8b1": {"t": 1786068333.6922975, "n": 10521, "h": "e5d56b28334a191c", "c": "use serde::{Deserialize, Serialize};\n\n#[derive(Clone, Debug, Default, PartialEq, Eq)]\npub(super) enum OptionalStringInput {\n #[default]\n Missing,\n Null,\n Value(String),\n}\n\nimpl<'de> Deserialize<'de> for OptionalStringInput {\n fn deserialize(deserializer: D) -> Result\n where\n D: serde::Deserializer<'de>,\n {\n Ok(match Option::::deserialize(deserializer)? {\n Some(value) => Self::Value(value),\n None => Self::Null,\n })\n }\n}\n\n#[derive(Serialize)]\npub(super) struct UserSettingsDto {\n #[serde(rename = \"aiAgents\")]\n pub(super) ai_agents: AiAgentsDto,\n pub(super) intervals: IntervalsDto,\n pub(super) wahoo: WahooDto,\n pub(super) options: OptionsDto,\n pub(super) availability: AvailabilityDto,\n pub(super) cycling: CyclingDto,\n}\n\n#[derive(Serialize)]\npub(super) struct AiAgentsDto {\n #[serde(rename = \"openaiApiKey\")]\n pub(super) openai_api_key: Option,\n #[serde(rename = \"openaiApiKeySet\")]\n pub(super) openai_api_key_set: bool,\n #[serde(rename = \"geminiApiKey\")]\n pub(super) gemini_api_key: Option,\n #[serde(rename = \"geminiApiKeySet\")]\n pub(super) gemini_api_key_set: bool,\n #[serde(rename = \"openrouterApiKey\")]\n pub(super) openrouter_api_key: Option,\n #[serde(rename = \"openrouterApiKeySet\")]\n pub(super) openrouter_api_key_set: bool,\n #[serde(rename = \"deepseekApiKey\")]\n pub(super) deepseek_api_key: Option,\n #[serde(rename = \"deepseekApiKeySet\")]\n pub(super) deepseek_api_key_set: bool,\n #[serde(rename = \"zaiApiKey\")]\n pub(super) zai_api_key: Option,\n #[serde(rename = \"zaiApiKeySet\")]\n pub(super) zai_api_key_set: bool,\n #[serde(rename = \"openaiCompatibleApiKey\")]\n pub(super) openai_compatible_api_key: Option,\n #[serde(rename = \"openaiCompatibleApiKeySet\")]\n pub(super) openai_compatible_api_key_set: bool,\n #[serde(rename = \"openaiCompatibleBaseUrl\")]\n pub(super) openai_compatible_base_url: Option,\n #[serde(rename = \"selectedProvider\")]\n pub(super) selected_provider: Option,\n #[serde(rename = \"selectedModel\")]\n pub(super) selected_model: Option,\n #[serde(rename = \"workoutChatProvider\")]\n pub(super) workout_chat_provider: Option,\n #[serde(rename = \"workoutChatModel\")]\n pub(super) workout_chat_model: Option,\n #[serde(rename = \"workoutPlanningProvider\")]\n pub(super) workout_planning_provider: Option,\n #[serde(rename = \"workoutPlanningModel\")]\n pub(super) workout_planning_model: Option,\n #[serde(rename = \"mesoCycleProvider\")]\n pub(super) meso_cycle_provider: Option,\n #[serde(rename = \"mesoCycleModel\")]\n pub(super) meso_cycle_model: Option,\n #[serde(rename = \"includePowerImage\")]\n pub(super) include_power_image: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct IntervalsDto {\n #[serde(rename = \"apiKey\")]\n pub(super) api_key: Option,\n #[serde(rename = \"apiKeySet\")]\n pub(super) api_key_set: bool,\n #[serde(rename = \"athleteId\")]\n pub(super) athlete_id: Option,\n pub(super) connected: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct WahooDto {\n pub(super) available: bool,\n #[serde(rename = \"accessToken\")]\n pub(super) access_token: Option,\n #[serde(rename = \"accessTokenSet\")]\n pub(super) access_token_set: bool,\n #[serde(rename = \"refreshTokenSet\")]\n pub(super) refresh_token_set: bool,\n #[serde(rename = \"expiresAtEpochSeconds\")]\n pub(super) expires_at_epoch_seconds: Option,\n pub(super) connected: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct OptionsDto {\n #[serde(rename = \"analyzeWithoutHeartRate\")]\n pub(super) analyze_without_heart_rate: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct AvailabilityDto {\n pub(super) configured: bool,\n pub(super) days: Vec,\n}\n\n#[derive(Serialize)]\npub(super) struct AvailabilityDayDto {\n pub(super) weekday: String,\n pub(super) available: bool,\n #[serde(rename = \"maxDurationMinutes\")]\n pub(super) max_duration_minutes: Option,\n}\n\n#[derive(Serialize)]\npub(super) struct CyclingDto {\n #[serde(rename = \"fullName\")]\n pub(super) full_name: Option,\n pub(super) age: Option,\n #[serde(rename = \"heightCm\")]\n pub(super) height_cm: Option,\n #[serde(rename = \"weightKg\")]\n pub(super) weight_kg: Option,\n #[serde(rename = \"ftpWatts\")]\n pub(super) ftp_watts: Option,\n #[serde(rename = \"hrMaxBpm\")]\n pub(super) hr_max_bpm: Option,\n #[serde(rename = \"vo2Max\")]\n pub(super) vo2_max: Option,\n #[serde(rename = \"athletePrompt\")]\n pub(super) athlete_prompt: Option,\n pub(super) medications: Option,\n #[serde(rename = \"athleteNotes\")]\n pub(super) athlete_notes: Option,\n #[serde(rename = \"lastZoneUpdateEpochSeconds\")]\n pub(super) last_zone_update_epoch_seconds: Option,\n}\n\n#[derive(Deserialize)]\npub(crate) struct UpdateAiAgentsRequest {\n #[serde(default, rename = \"openaiApiKey\")]\n pub(super) openai_api_key: OptionalStringInput,\n #[serde(default, rename = \"geminiApiKey\")]\n pub(super) gemini_api_key: OptionalStringInput,\n #[serde(default, rename = \"openrouterApiKey\")]\n pub(super) openrouter_api_key: OptionalStringInput,\n #[serde(default, rename = \"deepseekApiKey\")]\n pub(super) deepseek_api_key: OptionalStringInput,\n #[serde(default, rename = \"zaiApiKey\")]\n pub(super) zai_api_key: OptionalStringInput,\n #[serde(default, rename = \"openaiCompatibleApiKey\")]\n pub(super) openai_compatible_api_key: OptionalStringInput,\n #[serde(default, rename = \"openaiCompatibleBaseUrl\")]\n pub(super) openai_compatible_base_url: OptionalStringInput,\n #[serde(default, rename = \"selectedProvider\")]\n pub(super) selected_provider: OptionalStringInput,\n #[serde(default, rename = \"selectedModel\")]\n pub(super) selected_model: OptionalStringInput,\n #[serde(default, rename = \"workoutChatProvider\")]\n pub(super) workout_chat_provider: OptionalStringInput,\n #[serde(default, rename = \"workoutChatModel\")]\n pub(super) workout_chat_model: OptionalStringInput,\n #[serde(default, rename = \"workoutPlanningProvider\")]\n pub(super) workout_planning_provider: OptionalStringInput,\n #[serde(default, rename = \"workoutPlanningModel\")]\n pub(super) workout_planning_model: OptionalStringInput,\n #[serde(default, rename = \"mesoCycleProvider\")]\n pub(super) meso_cycle_provider: OptionalStringInput,\n #[serde(default, rename = \"mesoCycleModel\")]\n pub(super) meso_cycle_model: OptionalStringInput,\n #[serde(default, rename = \"includePowerImage\")]\n pub(super) include_power_image: Option,\n}\n\n#[derive(Deserialize)]\npub(crate) struct UpdateIntervalsRequest {\n #[serde(default, rename = \"apiKey\")]\n pub(super) api_key: OptionalStringInput,\n #[serde(default, rename = \"athleteId\")]\n pub(super) athlete_id: OptionalStringInput,\n}\n\n#[derive(Deserialize)]\npub(crate) struct UpdateOptionsRequest {\n #[serde(rename = \"analyzeWithoutHeartRate\")]\n pub(super) analyze_without_heart_rate: Option,\n}\n\n#[derive(Deserialize)]\npub(crate) struct UpdateAvailabilityRequest {\n pub(super) days: Vec,\n}\n\n#[derive(Deserialize)]\npub(crate) struct AvailabilityDayRequest {\n pub(super) weekday: String,\n pub(super) available: bool,\n #[serde(rename = \"maxDurationMinutes\")]\n pub(super) max_duration_minutes: Option,\n}\n\n#[derive(Deserialize)]\npub(crate) struct UpdateCyclingRequest {\n #[serde(default, rename = \"fullName\")]\n pub(super) full_name: OptionalStringInput,\n pub(super) age: Option,\n #[serde(rename = \"heightCm\")]\n pub(super) height_cm: Option,\n #[serde(rename = \"weightKg\")]\n pub(super) weight_kg: Option,\n #[serde(rename = \"ftpWatts\")]\n pub(super) ftp_watts: Option,\n #[serde(rename = \"hrMaxBpm\")]\n pub(super) hr_max_bpm: Option,\n #[serde(rename = \"vo2Max\")]\n pub(super) vo2_max: Option,\n #[serde(default, rename = \"athletePrompt\")]\n pub(super) athlete_prompt: OptionalStringInput,\n #[serde(default)]\n pub(super) medications: OptionalStringInput,\n #[serde(default, rename = \"athleteNotes\")]\n pub(super) athlete_notes: OptionalStringInput,\n}\n\n#[derive(Deserialize)]\npub(crate) struct TestIntervalsConnectionRequest {\n #[serde(rename = \"apiKey\")]\n pub(super) api_key: Option,\n #[serde(rename = \"athleteId\")]\n pub(super) athlete_id: Option,\n}\n\n#[derive(Serialize)]\npub(super) struct TestIntervalsConnectionResponse {\n pub(super) connected: bool,\n pub(super) message: String,\n #[serde(rename = \"usedSavedApiKey\")]\n pub(super) used_saved_api_key: bool,\n #[serde(rename = \"usedSavedAthleteId\")]\n pub(super) used_saved_athlete_id: bool,\n #[serde(rename = \"persistedStatusUpdated\")]\n pub(super) persisted_status_updated: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct TestAiAgentsConnectionResponse {\n pub(super) connected: bool,\n pub(super) message: String,\n #[serde(rename = \"usedSavedApiKey\")]\n pub(super) used_saved_api_key: bool,\n #[serde(rename = \"usedSavedProvider\")]\n pub(super) used_saved_provider: bool,\n #[serde(rename = \"usedSavedModel\")]\n pub(super) used_saved_model: bool,\n}\n\n#[derive(Serialize)]\npub(super) struct ValidationMessageResponse {\n pub(super) message: String,\n}\n\npub(super) fn test_connection_response(\n connected: bool,\n message: &str,\n used_saved_api_key: bool,\n used_saved_athlete_id: bool,\n persisted_status_updated: bool,\n) -> TestIntervalsConnectionResponse {\n TestIntervalsConnectionResponse {\n connected,\n message: message.to_string(),\n used_saved_api_key,\n used_saved_athlete_id,\n persisted_status_updated,\n }\n}\n\npub(super) fn test_ai_agents_connection_response(\n connected: bool,\n message: &str,\n used_saved_api_key: bool,\n used_saved_provider: bool,\n used_saved_model: bool,\n) -> TestAiAgentsConnectionResponse {\n TestAiAgentsConnectionResponse {\n connected,\n message: message.to_string(),\n used_saved_api_key,\n used_saved_provider,\n used_saved_model,\n }\n}\n\npub(super) fn validation_message_response(message: &str) -> ValidationMessageResponse {\n ValidationMessageResponse {\n message: message.to_string(),\n }\n}\n"}}}}