mps-rs 1.6.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
use crate::config::{Config, NotifyConfig};
use crate::meta::{MetaLocal, MetaShared};
use anyhow::{bail, Result};
use colored::Colorize;
use std::path::Path;

pub fn run(config: &Config, config_path: &Path, args: &[String]) -> Result<()> {
    let sub = args.first().map(|s| s.as_str()).unwrap_or("show");
    match sub.to_lowercase().as_str() {
        "show" => show(&config.storage_dir),
        "clear" => clear(&config.storage_dir),
        "edit" => edit(&config.storage_dir),
        "sync" => {
            let direction = args.get(1).map(|s| s.as_str()).unwrap_or("");
            match direction.to_lowercase().as_str() {
                "up" => sync_up(config_path, &config.storage_dir),
                "down" => sync_down(config_path, &config.storage_dir),
                other => bail!(
                    "Unknown sync direction '{}'. Use: sync up, sync down",
                    other
                ),
            }
        }
        other => bail!(
            "Unknown meta subcommand '{}'. Use: show, clear, edit, sync up, sync down",
            other
        ),
    }
}

fn show(storage_dir: &Path) -> Result<()> {
    let shared = MetaShared::load(storage_dir);
    let local = MetaLocal::load(storage_dir);

    println!(
        "{}",
        "── .mps.meta (git-tracked config layer) ─────────────".white()
    );
    println!("  path    : {}", MetaShared::path(storage_dir).display());
    println!("  version : {}", shared.version);

    if !shared.config.type_aliases.is_empty() {
        let mut pairs: Vec<_> = shared
            .config
            .type_aliases
            .iter()
            .map(|(k, v)| format!("{}{}", k, v))
            .collect();
        pairs.sort();
        println!("  type_aliases    : {}", pairs.join(", "));
    }
    if !shared.config.command_aliases.is_empty() {
        let mut pairs: Vec<_> = shared
            .config
            .command_aliases
            .iter()
            .map(|(k, v)| format!("{}{}", k, v))
            .collect();
        pairs.sort();
        println!("  command_aliases : {}", pairs.join(", "));
    }
    if let Some(ref dc) = shared.config.default_command {
        println!("  default_command : {}", dc);
    }
    if !shared.config.custom_tags.is_empty() {
        println!(
            "  custom_tags     : {}",
            shared.config.custom_tags.join(", ")
        );
    }
    let n = &shared.config.notify;
    println!("  notify.enabled          : {}", n.enabled);
    println!("  notify.window_minutes   : {}", n.window_minutes);
    println!("  notify.notify_open_tasks: {}", n.notify_open_tasks);
    if let Some(ref t) = n.task_notify_at {
        println!("  notify.task_notify_at   : {}", t);
    }
    if !n.open_task_tags.is_empty() {
        println!(
            "  notify.open_task_tags   : {}",
            n.open_task_tags.join(", ")
        );
    }
    println!("  notify.overdue_days     : {}", n.overdue_days);

    println!();
    println!(
        "{}",
        "── .mps.local (gitignored local state) ───────────────".white()
    );
    println!(
        "  path             : {}",
        MetaLocal::path(storage_dir).display()
    );
    println!("  notified entries : {}", local.notified.len());
    if let Some(ref d) = local.last_task_date {
        println!("  last_task_date   : {}", d);
    }
    if !local.cache.tag_counts.is_empty() {
        println!("  cached tag counts: {} tags", local.cache.tag_counts.len());
    }

    Ok(())
}

fn clear(storage_dir: &Path) -> Result<()> {
    let path = MetaLocal::path(storage_dir);
    if path.exists() {
        std::fs::remove_file(&path)?;
        println!("  {} {}", "cleared".green(), path.display());
    } else {
        println!("  (nothing to clear — .mps.local does not exist)");
    }
    Ok(())
}

/// Push machine-agnostic fields from the YAML config into .mps.meta.
/// Re-reads the raw YAML (before meta-merge) so what you see in the file
/// is exactly what gets written to meta.
fn sync_up(config_path: &Path, storage_dir: &Path) -> Result<()> {
    let raw = Config::load(config_path)?;
    let mut meta = MetaShared::load(storage_dir);
    meta.version = meta.version.max(1);

    meta.config.type_aliases = raw.type_aliases.clone();
    meta.config.command_aliases = raw.command_aliases.clone();
    meta.config.default_command = Some(raw.default_command.clone());
    meta.config.custom_tags = raw.custom_tags.clone();
    meta.config.notify = raw.notify.clone();

    meta.save(storage_dir)?;

    println!("  {} YAML config → .mps.meta", "synced:".green());
    if !meta.config.type_aliases.is_empty() {
        let mut pairs: Vec<_> = meta
            .config
            .type_aliases
            .iter()
            .map(|(k, v)| format!("{}{}", k, v))
            .collect();
        pairs.sort();
        println!("  type_aliases    : {}", pairs.join(", "));
    }
    if !meta.config.command_aliases.is_empty() {
        let mut pairs: Vec<_> = meta
            .config
            .command_aliases
            .iter()
            .map(|(k, v)| format!("{}{}", k, v))
            .collect();
        pairs.sort();
        println!("  command_aliases : {}", pairs.join(", "));
    }
    if let Some(ref dc) = meta.config.default_command {
        println!("  default_command : {}", dc);
    }
    if !meta.config.custom_tags.is_empty() {
        println!("  custom_tags     : {}", meta.config.custom_tags.join(", "));
    }
    let n = &meta.config.notify;
    println!(
        "  notify.task_notify_at   : {}",
        n.task_notify_at.as_deref().unwrap_or("(not set)")
    );
    println!("  notify.window_minutes   : {}", n.window_minutes);
    println!("  notify.overdue_days     : {}", n.overdue_days);
    println!(
        "  {} run {} to push to other devices",
        "hint:".cyan(),
        "mps autogit".bold()
    );
    Ok(())
}

/// Pull machine-agnostic fields from .mps.meta into the YAML config file.
/// Machine-specific paths (storage_dir, mps_dir, log_file) are never touched.
fn sync_down(config_path: &Path, storage_dir: &Path) -> Result<()> {
    let meta = MetaShared::load(storage_dir);
    let mut cfg = Config::load(config_path)?;

    // Aliases: meta wins (union, meta entries added where key absent in YAML)
    for (k, v) in &meta.config.type_aliases {
        cfg.type_aliases.insert(k.clone(), v.clone());
    }
    for (k, v) in &meta.config.command_aliases {
        cfg.command_aliases.insert(k.clone(), v.clone());
    }
    if let Some(ref dc) = meta.config.default_command {
        cfg.default_command = dc.clone();
    }
    // custom_tags: union
    for t in &meta.config.custom_tags {
        if !cfg.custom_tags.contains(t) {
            cfg.custom_tags.push(t.clone());
        }
    }
    // notify: apply all meta values unconditionally (sync down = explicit intent)
    let n = &meta.config.notify;
    let def = NotifyConfig::default();
    if !n.enabled {
        cfg.notify.enabled = false;
    }
    if !n.notify_open_tasks {
        cfg.notify.notify_open_tasks = false;
    }
    if n.task_notify_at.is_some() {
        cfg.notify.task_notify_at = n.task_notify_at.clone();
    }
    if !n.open_task_tags.is_empty() {
        cfg.notify.open_task_tags = n.open_task_tags.clone();
    }
    if n.window_minutes != def.window_minutes {
        cfg.notify.window_minutes = n.window_minutes;
    }
    if n.task_cooldown_minutes != def.task_cooldown_minutes {
        cfg.notify.task_cooldown_minutes = n.task_cooldown_minutes;
    }
    if n.overdue_days != def.overdue_days {
        cfg.notify.overdue_days = n.overdue_days;
    }

    cfg.save(config_path)?;

    println!(
        "  {} .mps.meta → {}",
        "synced:".green(),
        config_path.display()
    );
    println!(
        "  {} machine-specific paths (storage_dir, mps_dir, log_file) were not changed",
        "note:".cyan()
    );
    Ok(())
}

fn edit(storage_dir: &Path) -> Result<()> {
    let path = MetaShared::path(storage_dir);
    // Ensure the file exists so the editor doesn't open to a blank state.
    if !path.exists() {
        let empty = MetaShared::default();
        empty.save(storage_dir)?;
    }
    let editor = std::env::var("EDITOR")
        .or_else(|_| std::env::var("VISUAL"))
        .unwrap_or_else(|_| "vim".to_string());
    println!(
        "{}",
        format!("Opening {} in editor", path.display()).white()
    );
    std::process::Command::new(&editor)
        .arg(&path)
        .status()
        .map_err(|e| anyhow::anyhow!("failed to launch editor '{}': {}", editor, e))?;

    // Validate the saved JSON so the user knows immediately if it's broken.
    match std::fs::read_to_string(&path)
        .ok()
        .and_then(|s| serde_json::from_str::<MetaShared>(&s).ok())
    {
        Some(_) => println!("  {} .mps.meta is valid JSON", "ok:".green()),
        None => eprintln!(
            "  {} .mps.meta contains invalid JSON — it will be ignored until fixed",
            "warn:".yellow()
        ),
    }

    // Remind the user to commit so other devices see the change.
    println!(
        "  {} run {} to sync this config to other devices",
        "hint:".cyan(),
        "mps autogit".bold()
    );
    Ok(())
}