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        let def = NotifyConfig::default();
93        let n   = &meta.notify;
94        let meta_notify_is_non_default =
95            n.task_notify_at.is_some()
96            || !n.open_task_tags.is_empty()
97            || n.window_minutes        != def.window_minutes
98            || n.overdue_days          != def.overdue_days
99            || n.task_cooldown_minutes != def.task_cooldown_minutes
100            || !n.enabled
101            || !n.notify_open_tasks;
102        if meta_notify_is_non_default {
103            self.notify = n.clone();
104        }
105    }
106
107    /// Load config from a YAML file. Handles both string and symbol-prefixed keys
108    /// (Ruby writes :storage_dir, Rust writes storage_dir).
109    pub fn load(path: &Path) -> Result<Self, MpsError> {
110        if !path.exists() {
111            return Err(MpsError::ConfigNotFound(path.to_path_buf()));
112        }
113        let content = std::fs::read_to_string(path)?;
114
115        // Normalise Ruby-style symbol keys (:key:) to plain keys (key:) before parsing.
116        let normalised = content
117            .lines()
118            .map(|line| {
119                if let Some(rest) = line.strip_prefix(':') {
120                    rest.to_string()
121                } else {
122                    line.to_string()
123                }
124            })
125            .collect::<Vec<_>>()
126            .join("\n");
127
128        let cfg: Config = serde_yaml::from_str(&normalised)
129            .map_err(|e| MpsError::ConfigInvalid(e.to_string()))?;
130        Ok(cfg)
131    }
132
133    /// Write default config to path. Does nothing if the file already exists.
134    pub fn init(path: &Path) -> Result<(), MpsError> {
135        if path.exists() {
136            return Ok(());
137        }
138        let cfg = Self::default_config()?;
139        let yaml = serde_yaml::to_string(&cfg)?;
140        std::fs::write(path, yaml)?;
141        Ok(())
142    }
143
144    /// Ensure mps_dir, storage_dir exist and log_file is present.
145    pub fn ensure_dirs(&self) -> Result<(), MpsError> {
146        std::fs::create_dir_all(&self.mps_dir)?;
147        std::fs::create_dir_all(&self.storage_dir)?;
148        if !self.log_file.exists() {
149            std::fs::write(&self.log_file, "")?;
150        }
151        Ok(())
152    }
153}
154
155/// Resolve the config path: explicit arg > MPS_CONFIG env > default.
156pub fn default_config_path() -> PathBuf {
157    std::env::var("MPS_CONFIG")
158        .map(PathBuf::from)
159        .unwrap_or_else(|_| {
160            dirs::home_dir()
161                .unwrap_or_else(|| PathBuf::from("."))
162                .join(".mps_config.yaml")
163        })
164}