master
John Lauer v1.1.1: Quota guard, copy-only Say it phrases, neutral best-source language 8aa8b86 1mo ago
//! Persistent app settings: ~/.adom/parts-search-settings.json.
//!
//! Local-first truth (settings-layout skill): the file on this machine is
//! the store, the UI mirrors it optimistically, and every key is typed by
//! its default so an enum can never persist as a bool.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
    /// Hits fetched per vendor per search (3, 5, or 10).
    pub default_limit: u32,
    /// Drop stock=0 hits before rendering.
    pub in_stock_only: bool,
    pub vendor_mouser: bool,
    pub vendor_digikey: bool,
    pub vendor_jlcpcb: bool,
    /// "mouser" (Mouser-preferred) or
    /// "cheapest" (lowest in-stock unit price wins across all vendors).
    pub policy: String,
    /// Render vendor product photos (fetched from the vendors' CDNs).
    pub show_images: bool,
    /// Paint a progress bar on the Hydrogen taskbar button while a search runs.
    pub taskbar_progress: bool,
    /// Which Adom Desktop box carries the taskbar button ("" = first connected).
    pub taskbar_box: String,
    /// Show the "Say it to your AI" strip at the top of the page. The X on
    /// the strip turns this off; Settings turns it back on.
    pub show_say_strip: bool,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            default_limit: 5,
            in_stock_only: false,
            vendor_mouser: true,
            vendor_digikey: true,
            vendor_jlcpcb: true,
            policy: "mouser".into(),
            show_images: true,
            taskbar_progress: true,
            taskbar_box: String::new(),
            show_say_strip: true,
        }
    }
}

pub const KEYS: &[&str] = &[
    "defaultLimit", "inStockOnly", "vendorMouser", "vendorDigikey", "vendorJlcpcb",
    "policy", "showImages", "taskbarProgress", "taskbarBox", "showSayStrip",
];

pub fn adom_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
    PathBuf::from(home).join(".adom")
}

pub fn path() -> PathBuf { adom_dir().join("parts-search-settings.json") }

pub fn load() -> Settings {
    let defaults = Settings::default();
    let Ok(raw) = std::fs::read_to_string(path()) else { return defaults };
    let Ok(v) = serde_json::from_str::<Value>(&raw) else { return defaults };
    let mut s = defaults;
    let _ = apply(&mut s, &v);
    s
}

pub fn save(s: &Settings) -> Result<(), String> {
    let dir = adom_dir();
    std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?;
    let tmp = path().with_extension("json.tmp");
    std::fs::write(&tmp, serde_json::to_string_pretty(s).unwrap_or_default())
        .map_err(|e| format!("write {}: {}", tmp.display(), e))?;
    std::fs::rename(&tmp, path()).map_err(|e| format!("rename: {}", e))
}

/// Apply a partial JSON patch, typing every key by its default. Unknown
/// keys and wrongly-typed values are rejected by name so the caller can
/// report them instead of silently coercing (the kicad "flash persisted
/// as True" bug).
pub fn apply(s: &mut Settings, patch: &Value) -> Result<Vec<String>, String> {
    let Some(obj) = patch.as_object() else { return Err("settings patch must be a JSON object".into()) };
    let mut changed = vec![];
    for (k, v) in obj {
        match k.as_str() {
            "defaultLimit" => {
                let n = v.as_u64().ok_or("defaultLimit must be a number")?;
                if ![3, 5, 10].contains(&n) { return Err("defaultLimit must be 3, 5, or 10".into()); }
                s.default_limit = n as u32;
            }
            "inStockOnly" => s.in_stock_only = v.as_bool().ok_or("inStockOnly must be true or false")?,
            "vendorMouser" => s.vendor_mouser = v.as_bool().ok_or("vendorMouser must be true or false")?,
            "vendorDigikey" => s.vendor_digikey = v.as_bool().ok_or("vendorDigikey must be true or false")?,
            "vendorJlcpcb" => s.vendor_jlcpcb = v.as_bool().ok_or("vendorJlcpcb must be true or false")?,
            "policy" => {
                let p = v.as_str().ok_or("policy must be a string")?;
                if p != "mouser" && p != "cheapest" { return Err("policy must be \"mouser\" or \"cheapest\"".into()); }
                s.policy = p.to_string();
            }
            "showImages" => s.show_images = v.as_bool().ok_or("showImages must be true or false")?,
            "taskbarProgress" => s.taskbar_progress = v.as_bool().ok_or("taskbarProgress must be true or false")?,
            "taskbarBox" => s.taskbar_box = v.as_str().ok_or("taskbarBox must be a string")?.to_string(),
            "showSayStrip" => s.show_say_strip = v.as_bool().ok_or("showSayStrip must be true or false")?,
            other => return Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),
        }
        changed.push(k.clone());
    }
    Ok(changed)
}

/// Parse a CLI string value into the JSON type the key expects.
pub fn parse_cli_value(key: &str, raw: &str) -> Result<Value, String> {
    match key {
        "defaultLimit" => raw.parse::<u64>().map(Value::from).map_err(|_| "expected a number (3, 5, or 10)".to_string()),
        "policy" | "taskbarBox" => Ok(Value::String(raw.to_string())),
        k if KEYS.contains(&k) => match raw.to_ascii_lowercase().as_str() {
            "on" | "true" | "1" | "yes" => Ok(json!(true)),
            "off" | "false" | "0" | "no" => Ok(json!(false)),
            _ => Err("expected on or off".into()),
        },
        other => Err(format!("unknown setting \"{}\" (known: {})", other, KEYS.join(", "))),
    }
}

pub fn as_json(s: &Settings) -> Value { serde_json::to_value(s).unwrap_or(json!({})) }
pub fn defaults_json() -> Value { as_json(&Settings::default()) }