New SourceOs variants: Android, IpadOs — routed to android-to-* and
ipad-to-* pattern files respectively. Pattern bodies assume zero terminal
experience; every command explained from first principles with App Store /
iOS analogies.
Dual-boot supplement system: PatternFile::extend() + load_supplement()
in patterns.rs; lib.rs loads dualboot-{windows,macos}.toml on top of the
primary pattern file when migration.dual_boot_with is set. Supplement
covers NTFS dirty flag from Fast Startup, clock skew (RTC local vs UTC),
GRUB overwrite by Windows Update, BitLocker, APFS/HFS+ access, T2 Secure
Boot.
complete_onboarding() now accepts dual_boot_with: Option<String> and
normalises it to "windows"/"macos". Onboarding.vue becomes a 3-step flow:
source OS -> (Linux distro if linux) -> (dual-boot if windows/macos).
Mobile users skip the dual-boot step entirely.
10 new pattern files (8 mobile + 2 supplements), config.rs tests updated.
207 lines
5.9 KiB
Rust
207 lines
5.9 KiB
Rust
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<String>,
|
||
/// 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<String>,
|
||
/// 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<MigrationConfig>,
|
||
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<PathBuf> {
|
||
let base = dirs::config_dir().context("could not locate config directory")?;
|
||
Ok(base.join("robin").join("config.toml"))
|
||
}
|
||
|
||
pub fn load() -> Result<Self> {
|
||
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
|
||
));
|
||
}
|
||
}
|