Skip to main content

mps/
config.rs

1//! Configuration — loads and writes `~/.mps_config.yaml`.
2//!
3//! Handles both Ruby-style symbol-key YAML (`:storage_dir:`) and
4//! standard string-key YAML (`storage_dir:`).
5
6use crate::error::MpsError;
7use crate::meta::MetaConfig;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12// Re-export so callers can use `config::NotifyConfig` as before.
13pub use crate::meta::NotifyConfig;
14
15fn default_serve_port() -> u16 {
16    3000
17}
18fn default_serve_host() -> String {
19    "127.0.0.1".into()
20}
21fn default_git_remote() -> String {
22    "origin".into()
23}
24fn default_git_branch() -> String {
25    "master".into()
26}
27fn default_command() -> String {
28    "open".into()
29}
30fn default_type_aliases() -> HashMap<String, String> {
31    HashMap::new()
32}
33fn default_command_aliases() -> HashMap<String, String> {
34    HashMap::new()
35}
36
37/// HTTP server settings — machine-specific, not synced via .mps.meta.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ServeConfig {
40    #[serde(default = "default_serve_port")]
41    pub port: u16,
42    #[serde(default = "default_serve_host")]
43    pub host: String,
44    /// Bearer token for API auth. Empty string = no auth (local-only use).
45    #[serde(default)]
46    pub token: String,
47}
48
49impl Default for ServeConfig {
50    fn default() -> Self {
51        Self {
52            port: default_serve_port(),
53            host: default_serve_host(),
54            token: String::new(),
55        }
56    }
57}
58
59/// Mirrors ~/.mps_config.yaml written by the Ruby gem.
60/// Ruby uses symbol keys (:storage_dir) but the load() normaliser strips them.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Config {
63    pub mps_dir: PathBuf,
64    pub storage_dir: PathBuf,
65    pub log_file: PathBuf,
66    #[serde(default = "default_git_remote")]
67    pub git_remote: String,
68    #[serde(default = "default_git_branch")]
69    pub git_branch: String,
70    /// Which command `mps` (bare invocation) runs. Default: "open". Ruby supports "list".
71    #[serde(default = "default_command")]
72    pub default_command: String,
73    /// Short-hand element-type aliases: e.g. {"t": "task", "n": "note"}
74    /// Accepts the legacy "aliases" key for backward compatibility with existing configs.
75    #[serde(default = "default_type_aliases", alias = "aliases")]
76    pub type_aliases: HashMap<String, String>,
77    /// Short-hand command aliases: e.g. {"a": "append", "+": "append"}
78    #[serde(default = "default_command_aliases")]
79    pub command_aliases: HashMap<String, String>,
80    /// Canonical tag list shared across devices via .mps.meta.
81    #[serde(default)]
82    pub custom_tags: Vec<String>,
83    /// Notification settings.
84    #[serde(default)]
85    pub notify: NotifyConfig,
86    /// HTTP API server settings.
87    #[serde(default)]
88    pub serve: ServeConfig,
89}
90
91impl Config {
92    /// Default config values using the user home directory.
93    pub fn default_config() -> Result<Self, MpsError> {
94        let home = dirs::home_dir()
95            .ok_or_else(|| MpsError::ConfigInvalid("cannot determine home directory".into()))?;
96        let mps_dir = home.join(".mps");
97        Ok(Config {
98            storage_dir: mps_dir.join("mps"),
99            log_file: mps_dir.join("mps.log"),
100            mps_dir,
101            git_remote: "origin".into(),
102            git_branch: "master".into(),
103            default_command: "open".into(),
104            type_aliases: HashMap::new(),
105            command_aliases: HashMap::new(),
106            custom_tags: Vec::new(),
107            notify: NotifyConfig::default(),
108            serve: ServeConfig::default(),
109        })
110    }
111
112    /// Union-merge machine-agnostic settings from .mps.meta into this Config.
113    ///
114    /// Rules:
115    /// - type_aliases / command_aliases: union; YAML entry wins on key conflict
116    /// - default_command: meta wins if Some
117    /// - custom_tags: union, deduplicated
118    /// - notify: meta block wins when it contains non-default values
119    pub fn merge_meta(&mut self, meta: &MetaConfig) {
120        for (k, v) in &meta.type_aliases {
121            self.type_aliases
122                .entry(k.clone())
123                .or_insert_with(|| v.clone());
124        }
125        for (k, v) in &meta.command_aliases {
126            self.command_aliases
127                .entry(k.clone())
128                .or_insert_with(|| v.clone());
129        }
130        if let Some(ref dc) = meta.default_command {
131            self.default_command = dc.clone();
132        }
133        for t in &meta.custom_tags {
134            if !self.custom_tags.contains(t) {
135                self.custom_tags.push(t.clone());
136            }
137        }
138        // Notify: field-by-field merge. Each meta field only overrides the
139        // corresponding YAML field when the meta value differs from the default,
140        // so YAML settings like window_minutes are not silently clobbered.
141        let def = NotifyConfig::default();
142        let n = &meta.notify;
143        if !n.enabled {
144            self.notify.enabled = false;
145        }
146        if !n.notify_open_tasks {
147            self.notify.notify_open_tasks = false;
148        }
149        if n.task_notify_at.is_some() {
150            self.notify.task_notify_at = n.task_notify_at.clone();
151        }
152        if !n.open_task_tags.is_empty() {
153            self.notify.open_task_tags = n.open_task_tags.clone();
154        }
155        if n.window_minutes != def.window_minutes {
156            self.notify.window_minutes = n.window_minutes;
157        }
158        if n.task_cooldown_minutes != def.task_cooldown_minutes {
159            self.notify.task_cooldown_minutes = n.task_cooldown_minutes;
160        }
161        if n.overdue_days != def.overdue_days {
162            self.notify.overdue_days = n.overdue_days;
163        }
164    }
165
166    /// Load config from a YAML file. Handles both string and symbol-prefixed keys
167    /// (Ruby writes :storage_dir, Rust writes storage_dir).
168    pub fn load(path: &Path) -> Result<Self, MpsError> {
169        if !path.exists() {
170            return Err(MpsError::ConfigNotFound(path.to_path_buf()));
171        }
172        let content = std::fs::read_to_string(path)?;
173
174        // Normalise Ruby-style symbol keys (:key:) to plain keys (key:) before parsing.
175        let normalised = content
176            .lines()
177            .map(|line| {
178                if let Some(rest) = line.strip_prefix(':') {
179                    rest.to_string()
180                } else {
181                    line.to_string()
182                }
183            })
184            .collect::<Vec<_>>()
185            .join("\n");
186
187        let cfg: Config = serde_yaml::from_str(&normalised)
188            .map_err(|e| MpsError::ConfigInvalid(e.to_string()))?;
189        Ok(cfg)
190    }
191
192    /// Write default config to path. Does nothing if the file already exists.
193    pub fn init(path: &Path) -> Result<(), MpsError> {
194        if path.exists() {
195            return Ok(());
196        }
197        let cfg = Self::default_config()?;
198        let yaml = serde_yaml::to_string(&cfg)?;
199        std::fs::write(path, yaml)?;
200        Ok(())
201    }
202
203    /// Atomically write this config to a YAML file (tmp + rename).
204    pub fn save(&self, path: &Path) -> Result<(), MpsError> {
205        let yaml = serde_yaml::to_string(self)?;
206        let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
207        std::fs::write(&tmp, &yaml)?;
208        std::fs::rename(&tmp, path)?;
209        Ok(())
210    }
211
212    /// Ensure mps_dir, storage_dir exist and log_file is present.
213    pub fn ensure_dirs(&self) -> Result<(), MpsError> {
214        std::fs::create_dir_all(&self.mps_dir)?;
215        std::fs::create_dir_all(&self.storage_dir)?;
216        if !self.log_file.exists() {
217            std::fs::write(&self.log_file, "")?;
218        }
219        Ok(())
220    }
221}
222
223/// Resolve the config path: explicit arg > MPS_CONFIG env > default.
224pub fn default_config_path() -> PathBuf {
225    std::env::var("MPS_CONFIG")
226        .map(PathBuf::from)
227        .unwrap_or_else(|_| {
228            dirs::home_dir()
229                .unwrap_or_else(|| PathBuf::from("."))
230                .join(".mps_config.yaml")
231        })
232}