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