robin/src-tauri/src/config.rs
pyr0ball e4a682be2f feat(patterns): mobile-origin users + dual-boot supplement system
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.
2026-05-19 09:32:18 -07:00

207 lines
5.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>,
/// 05: 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
));
}
}