use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum SourceOs { Macos, Windows, Linux, Android, IpadOs, Unknown, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum Tier { #[default] Free, Paid, Premium, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum NotificationLevel { Off, BadgeOnly, #[default] BadgeAndToast, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MigrationConfig { pub source_os: SourceOs, /// Detected distro string, e.g. "cachyos", "linuxmint" pub distro: String, /// Source distro family for Linux-to-Linux migrations, e.g. "debian", "fedora", "arch", "opensuse" /// Used to load a more specific pattern file (e.g. debian-to-arch) before the generic linux-to-arch fallback. #[serde(default)] pub source_distro_family: Option, /// Co-installed OS for dual-boot setups, e.g. "windows", "macos". /// When set, a supplementary pattern file (dualboot-windows.toml) is loaded on top of /// the primary migration patterns to surface coexistence-specific issues. #[serde(default)] pub dual_boot_with: Option, /// 0–5: grows as user dismisses suggestions they already know pub fluency_level: u8, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OllamaConfig { pub base_url: String, pub model: String, } impl Default for OllamaConfig { fn default() -> Self { Self { base_url: "http://localhost:11434".into(), model: "llama3.2".into(), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisplayConfig { #[serde(default)] pub notification_level: NotificationLevel, pub quiet_mode: bool, } impl Default for DisplayConfig { fn default() -> Self { Self { notification_level: NotificationLevel::BadgeAndToast, quiet_mode: false, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RobinConfig { pub migration: Option, pub ollama: OllamaConfig, pub display: DisplayConfig, #[serde(default)] pub tier: Tier, } impl Default for RobinConfig { fn default() -> Self { Self { migration: None, ollama: OllamaConfig::default(), display: DisplayConfig::default(), tier: Tier::Free, } } } impl RobinConfig { pub fn config_path() -> Result { let base = dirs::config_dir().context("could not locate config directory")?; Ok(base.join("robin").join("config.toml")) } pub fn load() -> Result { let path = Self::config_path()?; if !path.exists() { return Ok(Self::default()); } let content = std::fs::read_to_string(&path) .with_context(|| format!("failed to read {}", path.display()))?; toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display())) } pub fn save(&self) -> Result<()> { let path = Self::config_path()?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let content = toml::to_string_pretty(self).context("failed to serialize config")?; std::fs::write(&path, content) .with_context(|| format!("failed to write {}", path.display())) } /// True if first-run setup is still needed. pub fn needs_onboarding(&self) -> bool { self.migration.is_none() } } #[cfg(test)] mod tests { use super::*; #[test] fn default_config_needs_onboarding() { let config = RobinConfig::default(); assert!(config.needs_onboarding()); } #[test] fn config_with_migration_does_not_need_onboarding() { let mut config = RobinConfig::default(); config.migration = Some(MigrationConfig { source_os: SourceOs::Macos, distro: "cachyos".into(), source_distro_family: None, dual_boot_with: None, fluency_level: 0, }); assert!(!config.needs_onboarding()); } #[test] fn roundtrip_serialization() { let config = RobinConfig { migration: Some(MigrationConfig { source_os: SourceOs::Windows, distro: "linuxmint".into(), source_distro_family: None, dual_boot_with: None, fluency_level: 2, }), ..Default::default() }; let serialized = toml::to_string_pretty(&config).unwrap(); let deserialized: RobinConfig = toml::from_str(&serialized).unwrap(); assert!(!deserialized.needs_onboarding()); } #[test] fn notification_level_default_is_badge_and_toast() { let config = RobinConfig::default(); assert!(matches!( config.display.notification_level, NotificationLevel::BadgeAndToast )); } #[test] fn tier_default_is_free() { let config = RobinConfig::default(); assert!(matches!(config.tier, Tier::Free)); } #[test] fn notification_level_roundtrips_toml() { let config = RobinConfig { display: DisplayConfig { notification_level: NotificationLevel::BadgeOnly, quiet_mode: false, }, ..Default::default() }; let toml = toml::to_string_pretty(&config).unwrap(); let back: RobinConfig = toml::from_str(&toml).unwrap(); assert!(matches!( back.display.notification_level, NotificationLevel::BadgeOnly )); } }