master
John Lauer 1.3.11: guided demo tour with a play/pause/next/prev/exit floaty, ab desktop captions, pre-baked adom-tts voiceover clips behind a checkbox, spotlighting, and a front-and-center Play-the-1-minute-demo button in the single-X newbie panel a79bc20 1mo ago
//! chip-fetcher settings: one JSON file, typed by the default's type, enum
//! values validated, read back literal for literal (settings-layout skill:
//! "never trust the 200"). Lives in `~/.config/adom-chip-fetcher/settings.json`.

use anyhow::{anyhow, Result};
use serde_json::{json, Map, Value};
use std::path::PathBuf;

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

pub fn path() -> PathBuf {
    config_dir().join("settings.json")
}

/// (key, default, allowed enum values or empty, human label)
pub const SCHEMA: &[(&str, &str, &[&str], &str)] = &[
    ("taskbar_progress", "indeterminate", &["indeterminate", "off"], "Taskbar progress during a lookup"),
    ("auto_zoom", "false", &[], "Auto-zoom the active card"),
    ("auto_select_active", "true", &[], "Follow the active lookup"),
    ("show_stock_badges", "true", &[], "Stock and lifecycle badges on cards"),
    ("show_welcome", "true", &[], "Newbie panel on the dashboard"),
    ("seed_samples", "true", &[], "Seed sample parts into an empty library"),
    ("demo_voiceover", "true", &[], "Voiceover during the guided demo"),
    ("activity_stale_seconds", "90", &[], "Heartbeat considered stale after (seconds)"),
];

fn parse_default(s: &str) -> Value {
    match s {
        "true" => Value::Bool(true),
        "false" => Value::Bool(false),
        _ => s.parse::<i64>().map(Value::from).unwrap_or_else(|_| Value::String(s.to_string())),
    }
}

pub fn defaults() -> Map<String, Value> {
    SCHEMA.iter().map(|(k, d, _, _)| (k.to_string(), parse_default(d))).collect()
}

/// Coerce + validate one value against its default's type.
fn coerce(key: &str, v: &Value) -> Result<Value> {
    let (_, d, allowed, _) = SCHEMA
        .iter()
        .find(|(k, _, _, _)| *k == key)
        .ok_or_else(|| anyhow!("unknown setting '{key}'"))?;
    let def = parse_default(d);
    let out = match def {
        Value::Bool(_) => match v {
            Value::Bool(b) => Value::Bool(*b),
            Value::String(s) => match s.as_str() {
                "true" | "on" | "1" | "yes" => Value::Bool(true),
                "false" | "off" | "0" | "no" => Value::Bool(false),
                _ => return Err(anyhow!("'{key}' wants true or false, got '{s}'")),
            },
            Value::Number(n) => Value::Bool(n.as_i64().unwrap_or(0) != 0),
            _ => return Err(anyhow!("'{key}' wants true or false")),
        },
        Value::Number(_) => match v {
            Value::Number(n) => Value::from(n.as_i64().ok_or_else(|| anyhow!("'{key}' wants an integer"))?),
            Value::String(s) => Value::from(s.trim().parse::<i64>().map_err(|_| anyhow!("'{key}' wants an integer, got '{s}'"))?),
            _ => return Err(anyhow!("'{key}' wants an integer")),
        },
        _ => {
            let s = match v {
                Value::String(s) => s.clone(),
                other => other.to_string().trim_matches('"').to_string(),
            };
            if !allowed.is_empty() && !allowed.contains(&s.as_str()) {
                return Err(anyhow!("'{key}' must be one of {}", allowed.join(", ")));
            }
            Value::String(s)
        }
    };
    Ok(out)
}

pub fn load() -> Map<String, Value> {
    let mut m = defaults();
    if let Ok(text) = std::fs::read_to_string(path()) {
        if let Ok(Value::Object(stored)) = serde_json::from_str::<Value>(&text) {
            for (k, v) in stored {
                if let Ok(c) = coerce(&k, &v) {
                    m.insert(k, c);
                }
            }
        }
    }
    m
}

pub fn get(key: &str) -> Value {
    load().get(key).cloned().unwrap_or(Value::Null)
}

pub fn get_str(key: &str) -> String {
    match get(key) {
        Value::String(s) => s,
        Value::Bool(b) => b.to_string(),
        Value::Number(n) => n.to_string(),
        _ => String::new(),
    }
}

pub fn get_bool(key: &str) -> bool {
    get(key).as_bool().unwrap_or(false)
}

fn save(m: &Map<String, Value>) -> Result<()> {
    std::fs::create_dir_all(config_dir())?;
    let text = serde_json::to_string_pretty(&Value::Object(m.clone()))?;
    std::fs::write(path(), text)?;
    Ok(())
}

/// Merge a JSON object of changes; every value is validated before anything
/// is written. Returns the full settings after the write.
pub fn merge(changes: &Value) -> Result<Map<String, Value>> {
    let obj = changes.as_object().ok_or_else(|| anyhow!("settings body must be a JSON object"))?;
    let mut m = load();
    for (k, v) in obj {
        let c = coerce(k, v)?;
        m.insert(k.clone(), c);
    }
    save(&m)?;
    Ok(load())
}

pub fn reset() -> Result<Map<String, Value>> {
    let d = defaults();
    save(&d)?;
    Ok(load())
}

pub fn describe_json() -> String {
    let labels: Map<String, Value> = SCHEMA.iter().map(|(k, _, _, l)| (k.to_string(), json!(l))).collect();
    let enums: Map<String, Value> = SCHEMA
        .iter()
        .filter(|(_, _, a, _)| !a.is_empty())
        .map(|(k, _, a, _)| (k.to_string(), json!(a)))
        .collect();
    json!({
        "ok": true,
        "settings": load(),
        "defaults": defaults(),
        "labels": labels,
        "enums": enums,
        "path": path().display().to_string(),
    })
    .to_string()
}