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}