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
15/// LLM chat settings. api_key and sessions_dir are local-only — never synced via .mps.meta.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ChatConfig {
18    /// Base URL for the OpenAI-compatible endpoint. None = auto-detect.
19    #[serde(default)]
20    pub url: Option<String>,
21    #[serde(default = "default_chat_model")]
22    pub model: String,
23    #[serde(default = "default_context_days")]
24    pub context_days: u64,
25    #[serde(default = "default_true")]
26    pub stream: bool,
27    /// Bearer token for keyed APIs (OpenAI, etc.). Empty = no auth. Never synced.
28    #[serde(default)]
29    pub api_key: String,
30    /// Override sessions directory. None → ~/.mps/sessions/. Never synced.
31    #[serde(default)]
32    pub sessions_dir: Option<String>,
33    /// TCP connect timeout in seconds. Controls how quickly an unreachable server is detected.
34    #[serde(default = "default_connect_timeout_secs")]
35    pub connect_timeout_secs: u64,
36}
37
38impl Default for ChatConfig {
39    fn default() -> Self {
40        Self {
41            url: None,
42            model: default_chat_model(),
43            context_days: default_context_days(),
44            stream: true,
45            api_key: String::new(),
46            sessions_dir: None,
47            connect_timeout_secs: default_connect_timeout_secs(),
48        }
49    }
50}
51
52fn default_serve_port() -> u16 {
53    3000
54}
55fn default_serve_host() -> String {
56    "127.0.0.1".into()
57}
58fn default_git_remote() -> String {
59    "origin".into()
60}
61fn default_git_branch() -> String {
62    "master".into()
63}
64fn default_command() -> String {
65    "open".into()
66}
67fn default_chat_model() -> String {
68    "llama3.2".into()
69}
70fn default_context_days() -> u64 {
71    7
72}
73fn default_connect_timeout_secs() -> u64 {
74    10
75}
76fn default_true() -> bool {
77    true
78}
79fn default_type_aliases() -> HashMap<String, String> {
80    HashMap::new()
81}
82fn default_command_aliases() -> HashMap<String, String> {
83    HashMap::new()
84}
85
86/// HTTP server settings — machine-specific, not synced via .mps.meta.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ServeConfig {
89    #[serde(default = "default_serve_port")]
90    pub port: u16,
91    #[serde(default = "default_serve_host")]
92    pub host: String,
93    /// Bearer token for API auth. Empty string = no auth (local-only use).
94    #[serde(default)]
95    pub token: String,
96}
97
98impl Default for ServeConfig {
99    fn default() -> Self {
100        Self {
101            port: default_serve_port(),
102            host: default_serve_host(),
103            token: String::new(),
104        }
105    }
106}
107
108/// Mirrors ~/.mps_config.yaml written by the Ruby gem.
109/// Ruby uses symbol keys (:storage_dir) but the load() normaliser strips them.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Config {
112    pub mps_dir: PathBuf,
113    pub storage_dir: PathBuf,
114    pub log_file: PathBuf,
115    #[serde(default = "default_git_remote")]
116    pub git_remote: String,
117    #[serde(default = "default_git_branch")]
118    pub git_branch: String,
119    /// Which command `mps` (bare invocation) runs. Default: "open". Ruby supports "list".
120    #[serde(default = "default_command")]
121    pub default_command: String,
122    /// Short-hand element-type aliases: e.g. {"t": "task", "n": "note"}
123    /// Accepts the legacy "aliases" key for backward compatibility with existing configs.
124    #[serde(default = "default_type_aliases", alias = "aliases")]
125    pub type_aliases: HashMap<String, String>,
126    /// Short-hand command aliases: e.g. {"a": "append", "+": "append"}
127    #[serde(default = "default_command_aliases")]
128    pub command_aliases: HashMap<String, String>,
129    /// Canonical tag list shared across devices via .mps.meta.
130    #[serde(default)]
131    pub custom_tags: Vec<String>,
132    /// Notification settings.
133    #[serde(default)]
134    pub notify: NotifyConfig,
135    /// HTTP API server settings.
136    #[serde(default)]
137    pub serve: ServeConfig,
138    /// LLM chat settings.
139    #[serde(default)]
140    pub chat: ChatConfig,
141}
142
143impl Config {
144    /// Default config values using the user home directory.
145    pub fn default_config() -> Result<Self, MpsError> {
146        let home = dirs::home_dir()
147            .ok_or_else(|| MpsError::ConfigInvalid("cannot determine home directory".into()))?;
148        let mps_dir = home.join(".mps");
149        Ok(Config {
150            storage_dir: mps_dir.join("mps"),
151            log_file: mps_dir.join("mps.log"),
152            mps_dir,
153            git_remote: "origin".into(),
154            git_branch: "master".into(),
155            default_command: "open".into(),
156            type_aliases: HashMap::new(),
157            command_aliases: HashMap::new(),
158            custom_tags: Vec::new(),
159            notify: NotifyConfig::default(),
160            serve: ServeConfig::default(),
161            chat: ChatConfig::default(),
162        })
163    }
164
165    /// Union-merge machine-agnostic settings from .mps.meta into this Config.
166    ///
167    /// Rules:
168    /// - type_aliases / command_aliases: union; YAML entry wins on key conflict
169    /// - default_command: meta wins if Some
170    /// - custom_tags: union, deduplicated
171    /// - notify: meta block wins when it contains non-default values
172    pub fn merge_meta(&mut self, meta: &MetaConfig) {
173        for (k, v) in &meta.type_aliases {
174            self.type_aliases
175                .entry(k.clone())
176                .or_insert_with(|| v.clone());
177        }
178        for (k, v) in &meta.command_aliases {
179            self.command_aliases
180                .entry(k.clone())
181                .or_insert_with(|| v.clone());
182        }
183        if let Some(ref dc) = meta.default_command {
184            self.default_command = dc.clone();
185        }
186        for t in &meta.custom_tags {
187            if !self.custom_tags.contains(t) {
188                self.custom_tags.push(t.clone());
189            }
190        }
191        // Notify: field-by-field merge. Each meta field only overrides the
192        // corresponding YAML field when the meta value differs from the default,
193        // so YAML settings like window_minutes are not silently clobbered.
194        let def = NotifyConfig::default();
195        let n = &meta.notify;
196        if !n.enabled {
197            self.notify.enabled = false;
198        }
199        if !n.notify_open_tasks {
200            self.notify.notify_open_tasks = false;
201        }
202        if n.task_notify_at.is_some() {
203            self.notify.task_notify_at = n.task_notify_at.clone();
204        }
205        if !n.open_task_tags.is_empty() {
206            self.notify.open_task_tags = n.open_task_tags.clone();
207        }
208        if n.window_minutes != def.window_minutes {
209            self.notify.window_minutes = n.window_minutes;
210        }
211        if n.task_cooldown_minutes != def.task_cooldown_minutes {
212            self.notify.task_cooldown_minutes = n.task_cooldown_minutes;
213        }
214        if n.overdue_days != def.overdue_days {
215            self.notify.overdue_days = n.overdue_days;
216        }
217        // Chat: merge non-sensitive fields only. api_key and sessions_dir are never touched.
218        let c = &meta.chat;
219        if let Some(ref url) = c.url {
220            self.chat.url = Some(url.clone());
221        }
222        if let Some(ref model) = c.model {
223            self.chat.model = model.clone();
224        }
225        if let Some(days) = c.context_days {
226            self.chat.context_days = days;
227        }
228        if let Some(stream) = c.stream {
229            self.chat.stream = stream;
230        }
231        if let Some(secs) = c.connect_timeout_secs {
232            self.chat.connect_timeout_secs = secs;
233        }
234    }
235
236    /// Load config from a YAML file. Handles both string and symbol-prefixed keys
237    /// (Ruby writes :storage_dir, Rust writes storage_dir).
238    pub fn load(path: &Path) -> Result<Self, MpsError> {
239        if !path.exists() {
240            return Err(MpsError::ConfigNotFound(path.to_path_buf()));
241        }
242        let content = std::fs::read_to_string(path)?;
243
244        // Normalise Ruby-style symbol keys (:key:) to plain keys (key:) before parsing.
245        let normalised = content
246            .lines()
247            .map(|line| {
248                if let Some(rest) = line.strip_prefix(':') {
249                    rest.to_string()
250                } else {
251                    line.to_string()
252                }
253            })
254            .collect::<Vec<_>>()
255            .join("\n");
256
257        let cfg: Config = serde_yaml::from_str(&normalised)
258            .map_err(|e| MpsError::ConfigInvalid(e.to_string()))?;
259        Ok(cfg)
260    }
261
262    /// Write default config to path. Does nothing if the file already exists.
263    pub fn init(path: &Path) -> Result<(), MpsError> {
264        if path.exists() {
265            return Ok(());
266        }
267        let cfg = Self::default_config()?;
268        let yaml = serde_yaml::to_string(&cfg)?;
269        std::fs::write(path, yaml)?;
270        Ok(())
271    }
272
273    /// Atomically write this config to a YAML file (tmp + rename).
274    pub fn save(&self, path: &Path) -> Result<(), MpsError> {
275        let yaml = serde_yaml::to_string(self)?;
276        let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
277        std::fs::write(&tmp, &yaml)?;
278        std::fs::rename(&tmp, path)?;
279        Ok(())
280    }
281
282    /// Ensure mps_dir, storage_dir exist and log_file is present.
283    pub fn ensure_dirs(&self) -> Result<(), MpsError> {
284        std::fs::create_dir_all(&self.mps_dir)?;
285        std::fs::create_dir_all(&self.storage_dir)?;
286        if !self.log_file.exists() {
287            std::fs::write(&self.log_file, "")?;
288        }
289        Ok(())
290    }
291}
292
293/// Resolve the config path: explicit arg > MPS_CONFIG env > default.
294pub fn default_config_path() -> PathBuf {
295    std::env::var("MPS_CONFIG")
296        .map(PathBuf::from)
297        .unwrap_or_else(|_| {
298            dirs::home_dir()
299                .unwrap_or_else(|| PathBuf::from("."))
300                .join(".mps_config.yaml")
301        })
302}