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