Skip to main content

wipe_core/
config.rs

1//! User-global configuration (`<config>/wipe/config.json`).
2//!
3//! Distinct from a board's `settings.json` (which is per-project and git-tracked),
4//! this file holds the *defaults* a user picks once during onboarding - preferred
5//! port, exposure, whether to auto-serve, how much starter content a new board
6//! gets, where to install the agent skill, and UI styling - so later
7//! `wipe init` / `wipe serve` runs don't have to ask again.
8
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::model::{Exposure, Starter};
14
15/// Machine-wide user preferences. Every field is optional; an absent field means
16/// "use the built-in default".
17#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
18pub struct GlobalConfig {
19    /// Default daemon port for new boards.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub default_port: Option<u16>,
22    /// Default exposure for new boards.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub default_expose: Option<Exposure>,
25    /// Default: shut the daemon down when idle (no overhead when not viewed).
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub autoserve: Option<bool>,
28    /// Default idle timeout in seconds.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub idle_timeout_secs: Option<u64>,
31    /// Start the wipe UI daemon automatically at login (an always-on, lightweight
32    /// viewer). Backed by a per-OS login entry managed by the CLI.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub autostart: Option<bool>,
35    /// How much content a fresh board is seeded with.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub starter: Option<Starter>,
38    /// Preferred agent-skill install convention: `claude` or `agents`.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub skill_target: Option<String>,
41    /// Whether to install the skill user-globally (vs project-scoped) by default.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub skill_global: Option<bool>,
44    /// Preferred UI accent color (token or hex), surfaced to the board UI.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub ui_accent: Option<String>,
47    /// Preferred UI theme: `light`, `dark`, or `system`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub ui_theme: Option<String>,
50    /// Fallback identity used when a project's VCS reports no user (mandatory in
51    /// practice: onboarding sets it, defaulting to `human`).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub default_identity: Option<String>,
54    /// Always attribute actions to [`default_identity`] instead of the VCS-reported
55    /// user, even when the VCS does report one.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub prefer_default_identity: Option<bool>,
58    /// Directories scanned for `.wipe` boards (so serving surfaces every board you
59    /// have locally). Empty/absent means "the user's home directory".
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub scan_roots: Option<Vec<String>>,
62}
63
64impl GlobalConfig {
65    /// Path to `config.json`. Honors `$WIPE_CONFIG_DIR` (useful for isolating
66    /// tests and for pinning config in CI), else the user's platform config dir.
67    pub fn path() -> Option<PathBuf> {
68        if let Ok(dir) = std::env::var("WIPE_CONFIG_DIR") {
69            if !dir.trim().is_empty() {
70                return Some(PathBuf::from(dir).join("config.json"));
71            }
72        }
73        directories::ProjectDirs::from("dev", "wipe", "wipe")
74            .map(|d| d.config_dir().join("config.json"))
75    }
76
77    /// Load the config, returning defaults if the file is missing or unreadable.
78    pub fn load() -> Self {
79        Self::path()
80            .and_then(|p| std::fs::read(p).ok())
81            .and_then(|b| serde_json::from_slice(&b).ok())
82            .unwrap_or_default()
83    }
84
85    /// Persist the config (pretty JSON + trailing newline). Best-effort: creates
86    /// the config directory if needed.
87    pub fn save(&self) -> std::io::Result<()> {
88        if let Some(path) = Self::path() {
89            if let Some(dir) = path.parent() {
90                std::fs::create_dir_all(dir)?;
91            }
92            let mut s = serde_json::to_string_pretty(self).unwrap_or_default();
93            s.push('\n');
94            // Write-then-rename so a concurrent reader (or a racing writer) never
95            // sees a truncated file.
96            let tmp = path.with_extension("json.tmp");
97            std::fs::write(&tmp, s)?;
98            std::fs::rename(&tmp, &path)?;
99        }
100        Ok(())
101    }
102}