mps-rs 1.8.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
//! Configuration — loads and writes `~/.mps_config.yaml`.
//!
//! Handles both Ruby-style symbol-key YAML (`:storage_dir:`) and
//! standard string-key YAML (`storage_dir:`).

use crate::error::MpsError;
use crate::meta::MetaConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};

// Re-export so callers can use `config::NotifyConfig` as before.
pub use crate::meta::NotifyConfig;

/// LLM chat settings. api_key and sessions_dir are local-only — never synced via .mps.meta.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatConfig {
    /// Base URL for the OpenAI-compatible endpoint. None = auto-detect.
    #[serde(default)]
    pub url: Option<String>,
    #[serde(default = "default_chat_model")]
    pub model: String,
    #[serde(default = "default_context_days")]
    pub context_days: u64,
    #[serde(default = "default_true")]
    pub stream: bool,
    /// Bearer token for keyed APIs (OpenAI, etc.). Empty = no auth. Never synced.
    #[serde(default)]
    pub api_key: String,
    /// Override sessions directory. None → ~/.mps/sessions/. Never synced.
    #[serde(default)]
    pub sessions_dir: Option<String>,
    /// TCP connect timeout in seconds. Controls how quickly an unreachable server is detected.
    #[serde(default = "default_connect_timeout_secs")]
    pub connect_timeout_secs: u64,
}

impl Default for ChatConfig {
    fn default() -> Self {
        Self {
            url: None,
            model: default_chat_model(),
            context_days: default_context_days(),
            stream: true,
            api_key: String::new(),
            sessions_dir: None,
            connect_timeout_secs: default_connect_timeout_secs(),
        }
    }
}

fn default_serve_port() -> u16 {
    3000
}
fn default_serve_host() -> String {
    "127.0.0.1".into()
}
fn default_git_remote() -> String {
    "origin".into()
}
fn default_git_branch() -> String {
    "master".into()
}
fn default_command() -> String {
    "open".into()
}
fn default_chat_model() -> String {
    "llama3.2".into()
}
fn default_context_days() -> u64 {
    7
}
fn default_connect_timeout_secs() -> u64 {
    10
}
fn default_true() -> bool {
    true
}
fn default_type_aliases() -> HashMap<String, String> {
    HashMap::new()
}
fn default_command_aliases() -> HashMap<String, String> {
    HashMap::new()
}

/// HTTP server settings — machine-specific, not synced via .mps.meta.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServeConfig {
    #[serde(default = "default_serve_port")]
    pub port: u16,
    #[serde(default = "default_serve_host")]
    pub host: String,
    /// Bearer token for API auth. Empty string = no auth (local-only use).
    #[serde(default)]
    pub token: String,
}

impl Default for ServeConfig {
    fn default() -> Self {
        Self {
            port: default_serve_port(),
            host: default_serve_host(),
            token: String::new(),
        }
    }
}

/// Mirrors ~/.mps_config.yaml written by the Ruby gem.
/// Ruby uses symbol keys (:storage_dir) but the load() normaliser strips them.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    pub mps_dir: PathBuf,
    pub storage_dir: PathBuf,
    pub log_file: PathBuf,
    #[serde(default = "default_git_remote")]
    pub git_remote: String,
    #[serde(default = "default_git_branch")]
    pub git_branch: String,
    /// Which command `mps` (bare invocation) runs. Default: "open". Ruby supports "list".
    #[serde(default = "default_command")]
    pub default_command: String,
    /// Short-hand element-type aliases: e.g. {"t": "task", "n": "note"}
    /// Accepts the legacy "aliases" key for backward compatibility with existing configs.
    #[serde(default = "default_type_aliases", alias = "aliases")]
    pub type_aliases: HashMap<String, String>,
    /// Short-hand command aliases: e.g. {"a": "append", "+": "append"}
    #[serde(default = "default_command_aliases")]
    pub command_aliases: HashMap<String, String>,
    /// Canonical tag list shared across devices via .mps.meta.
    #[serde(default)]
    pub custom_tags: Vec<String>,
    /// Notification settings.
    #[serde(default)]
    pub notify: NotifyConfig,
    /// HTTP API server settings.
    #[serde(default)]
    pub serve: ServeConfig,
    /// LLM chat settings.
    #[serde(default)]
    pub chat: ChatConfig,
}

impl Config {
    /// Default config values using the user home directory.
    pub fn default_config() -> Result<Self, MpsError> {
        let home = dirs::home_dir()
            .ok_or_else(|| MpsError::ConfigInvalid("cannot determine home directory".into()))?;
        let mps_dir = home.join(".mps");
        Ok(Config {
            storage_dir: mps_dir.join("mps"),
            log_file: mps_dir.join("mps.log"),
            mps_dir,
            git_remote: "origin".into(),
            git_branch: "master".into(),
            default_command: "open".into(),
            type_aliases: HashMap::new(),
            command_aliases: HashMap::new(),
            custom_tags: Vec::new(),
            notify: NotifyConfig::default(),
            serve: ServeConfig::default(),
            chat: ChatConfig::default(),
        })
    }

    /// Union-merge machine-agnostic settings from .mps.meta into this Config.
    ///
    /// Rules:
    /// - type_aliases / command_aliases: union; YAML entry wins on key conflict
    /// - default_command: meta wins if Some
    /// - custom_tags: union, deduplicated
    /// - notify: meta block wins when it contains non-default values
    pub fn merge_meta(&mut self, meta: &MetaConfig) {
        for (k, v) in &meta.type_aliases {
            self.type_aliases
                .entry(k.clone())
                .or_insert_with(|| v.clone());
        }
        for (k, v) in &meta.command_aliases {
            self.command_aliases
                .entry(k.clone())
                .or_insert_with(|| v.clone());
        }
        if let Some(ref dc) = meta.default_command {
            self.default_command = dc.clone();
        }
        for t in &meta.custom_tags {
            if !self.custom_tags.contains(t) {
                self.custom_tags.push(t.clone());
            }
        }
        // Notify: field-by-field merge. Each meta field only overrides the
        // corresponding YAML field when the meta value differs from the default,
        // so YAML settings like window_minutes are not silently clobbered.
        let def = NotifyConfig::default();
        let n = &meta.notify;
        if !n.enabled {
            self.notify.enabled = false;
        }
        if !n.notify_open_tasks {
            self.notify.notify_open_tasks = false;
        }
        if n.task_notify_at.is_some() {
            self.notify.task_notify_at = n.task_notify_at.clone();
        }
        if !n.open_task_tags.is_empty() {
            self.notify.open_task_tags = n.open_task_tags.clone();
        }
        if n.window_minutes != def.window_minutes {
            self.notify.window_minutes = n.window_minutes;
        }
        if n.task_cooldown_minutes != def.task_cooldown_minutes {
            self.notify.task_cooldown_minutes = n.task_cooldown_minutes;
        }
        if n.overdue_days != def.overdue_days {
            self.notify.overdue_days = n.overdue_days;
        }
        // Chat: merge non-sensitive fields only. api_key and sessions_dir are never touched.
        let c = &meta.chat;
        if let Some(ref url) = c.url {
            self.chat.url = Some(url.clone());
        }
        if let Some(ref model) = c.model {
            self.chat.model = model.clone();
        }
        if let Some(days) = c.context_days {
            self.chat.context_days = days;
        }
        if let Some(stream) = c.stream {
            self.chat.stream = stream;
        }
        if let Some(secs) = c.connect_timeout_secs {
            self.chat.connect_timeout_secs = secs;
        }
    }

    /// Load config from a YAML file. Handles both string and symbol-prefixed keys
    /// (Ruby writes :storage_dir, Rust writes storage_dir).
    pub fn load(path: &Path) -> Result<Self, MpsError> {
        if !path.exists() {
            return Err(MpsError::ConfigNotFound(path.to_path_buf()));
        }
        let content = std::fs::read_to_string(path)?;

        // Normalise Ruby-style symbol keys (:key:) to plain keys (key:) before parsing.
        let normalised = content
            .lines()
            .map(|line| {
                if let Some(rest) = line.strip_prefix(':') {
                    rest.to_string()
                } else {
                    line.to_string()
                }
            })
            .collect::<Vec<_>>()
            .join("\n");

        let cfg: Config = serde_yaml::from_str(&normalised)
            .map_err(|e| MpsError::ConfigInvalid(e.to_string()))?;
        Ok(cfg)
    }

    /// Write default config to path. Does nothing if the file already exists.
    pub fn init(path: &Path) -> Result<(), MpsError> {
        if path.exists() {
            return Ok(());
        }
        let cfg = Self::default_config()?;
        let yaml = serde_yaml::to_string(&cfg)?;
        std::fs::write(path, yaml)?;
        Ok(())
    }

    /// Atomically write this config to a YAML file (tmp + rename).
    pub fn save(&self, path: &Path) -> Result<(), MpsError> {
        let yaml = serde_yaml::to_string(self)?;
        let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
        std::fs::write(&tmp, &yaml)?;
        std::fs::rename(&tmp, path)?;
        Ok(())
    }

    /// Ensure mps_dir, storage_dir exist and log_file is present.
    pub fn ensure_dirs(&self) -> Result<(), MpsError> {
        std::fs::create_dir_all(&self.mps_dir)?;
        std::fs::create_dir_all(&self.storage_dir)?;
        if !self.log_file.exists() {
            std::fs::write(&self.log_file, "")?;
        }
        Ok(())
    }
}

/// Resolve the config path: explicit arg > MPS_CONFIG env > default.
pub fn default_config_path() -> PathBuf {
    std::env::var("MPS_CONFIG")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join(".mps_config.yaml")
        })
}