mps-rs 1.8.2

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

const VALID_TYPES: &[&str] = &["task", "note", "log", "reminder", "character"];
const VALID_COMMANDS: &[&str] = &[
    "open", "list", "append", "update", "edit", "delete", "done", "search", "stats", "tags",
    "export", "config", "git", "autogit", "cmd", "notify", "daemon", "meta", "chat", "serve",
    "version", "init",
];

pub fn run(
    config: &Config,
    config_path: &Path,
    subcommand: Option<&str>,
    no_network: bool,
) -> Result<()> {
    match subcommand.unwrap_or("show").to_lowercase().as_str() {
        "show" => {
            println!("{}", "MPS configuration".white());
            println!("  config file : {}", config_path.display());
            println!("  mps_dir     : {}", config.mps_dir.display());
            println!("  storage_dir : {}", config.storage_dir.display());
            println!("  log_file    : {}", config.log_file.display());
            println!("  git_remote  : {}", config.git_remote);
            println!("  git_branch  : {}", config.git_branch);
            println!("  default_cmd : {}", config.default_command);
            if !config.type_aliases.is_empty() {
                let mut pairs: Vec<String> = config
                    .type_aliases
                    .iter()
                    .map(|(k, v)| format!("{}{}", k, v))
                    .collect();
                pairs.sort();
                println!("  type_aliases    : {}", pairs.join(", "));
            }
            if !config.command_aliases.is_empty() {
                let mut pairs: Vec<String> = config
                    .command_aliases
                    .iter()
                    .map(|(k, v)| format!("{}{}", k, v))
                    .collect();
                pairs.sort();
                println!("  command_aliases : {}", pairs.join(", "));
            }
            if !config.custom_tags.is_empty() {
                println!("  custom_tags     : {}", config.custom_tags.join(", "));
            }
            let n = &config.notify;
            println!("  notify.enabled          : {}", n.enabled);
            if let Some(ref t) = n.task_notify_at {
                println!("  notify.task_notify_at   : {}", t);
            }
            println!("  notify.window_minutes   : {}", n.window_minutes);
            println!("  notify.overdue_days     : {}", n.overdue_days);
            let c = &config.chat;
            println!();
            println!(
                "{}",
                "  ── chat ─────────────────────────────────────".white()
            );
            println!(
                "  chat.url           : {}",
                c.url
                    .as_deref()
                    .unwrap_or("(auto-detect :11434 then :8080)")
            );
            println!("  chat.model         : {}", c.model);
            println!("  chat.context_days        : {}", c.context_days);
            println!("  chat.stream              : {}", c.stream);
            println!("  chat.connect_timeout_secs: {}", c.connect_timeout_secs);
            println!(
                "  chat.api_key       : {}",
                if c.api_key.is_empty() {
                    "(not set)".to_string()
                } else {
                    "***".to_string()
                }
            );
            println!(
                "  chat.sessions_dir  : {}",
                c.sessions_dir
                    .as_deref()
                    .unwrap_or("(default: ~/.mps/sessions/)")
            );
        }
        "edit" => {
            let editor = std::env::var("EDITOR")
                .or_else(|_| std::env::var("VISUAL"))
                .unwrap_or_else(|_| "vim".to_string());
            println!(
                "{}",
                format!("Opening {} in editor", config_path.display()).white()
            );
            std::process::Command::new(&editor)
                .arg(config_path)
                .status()
                .with_context(|| format!("failed to launch editor '{}'", editor))?;
        }

        "init" => {
            // Write every config key to the YAML file, idempotently.
            // Loads raw YAML (before meta-merge) so meta values aren't baked in.
            let raw = if config_path.exists() {
                Config::load(config_path)?
            } else {
                Config::default_config()?
            };
            raw.save(config_path)?;
            println!("{} {}", "written:".green(), config_path.display());
            println!("  All config keys are now explicitly present in the YAML file.");
            println!("  chat.api_key and sessions_dir are local-only (never synced).");
            println!(
                "  {} run {} to open in your editor",
                "tip:".cyan(),
                "mps config edit".bold()
            );
        }

        "check" => {
            run_check(config, config_path, no_network)?;
        }

        other => {
            println!(
                "{}",
                format!(
                    "Usage: mps config [show|edit|init|check]  (got '{}')",
                    other
                )
                .yellow()
            );
        }
    }
    Ok(())
}

fn run_check(config: &Config, config_path: &Path, no_network: bool) -> Result<()> {
    println!("{}", "Config health check".white().bold());
    println!("  {}", config_path.display());
    println!();

    let mut failures = 0usize;

    // ── Directory / file presence ─────────────────────────────────────────────
    failures += check_exists(
        "mps_dir    ",
        &config.mps_dir.display().to_string(),
        config.mps_dir.exists(),
    );
    failures += check_exists(
        "storage_dir",
        &config.storage_dir.display().to_string(),
        config.storage_dir.exists(),
    );
    failures += check_exists(
        "log_file   ",
        &config.log_file.display().to_string(),
        config.log_file.exists(),
    );

    // ── Alias validity ────────────────────────────────────────────────────────
    let mut type_alias_ok = true;
    for (k, v) in &config.type_aliases {
        if !VALID_TYPES.contains(&v.to_lowercase().as_str()) {
            println!(
                "  {}  type_aliases   : {}{} — '{}' is not a valid element type ({})",
                "".red().bold(),
                k,
                v,
                v,
                VALID_TYPES.join(", ")
            );
            type_alias_ok = false;
            failures += 1;
        }
    }
    if type_alias_ok {
        if config.type_aliases.is_empty() {
            println!("  {}  type_aliases   : (none)", "".green().bold());
        } else {
            let pairs: Vec<String> = config
                .type_aliases
                .iter()
                .map(|(k, v)| format!("{}{}", k, v))
                .collect();
            println!(
                "  {}  type_aliases   : {}",
                "".green().bold(),
                pairs.join(", ")
            );
        }
    }

    let mut cmd_alias_ok = true;
    for (k, v) in &config.command_aliases {
        if !VALID_COMMANDS.contains(&v.to_lowercase().as_str()) {
            println!(
                "  {}  command_aliases: {}{} — '{}' is not a known command",
                "".red().bold(),
                k,
                v,
                v
            );
            cmd_alias_ok = false;
            failures += 1;
        }
    }
    if cmd_alias_ok {
        if config.command_aliases.is_empty() {
            println!("  {}  command_aliases: (none)", "".green().bold());
        } else {
            let pairs: Vec<String> = config
                .command_aliases
                .iter()
                .map(|(k, v)| format!("{}{}", k, v))
                .collect();
            println!(
                "  {}  command_aliases: {}",
                "".green().bold(),
                pairs.join(", ")
            );
        }
    }

    // ── Chat URL reachability ────────────────────────────────────────────────
    if no_network {
        println!("  {}  chat.url       : skipped (--no-network)", "".white());
    } else {
        match &config.chat.url {
            None => {
                println!(
                    "  {}  chat.url       : (auto-detect — skipped)",
                    "".white()
                );
            }
            Some(url) => {
                if tcp_reachable(url) {
                    println!(
                        "  {}  chat.url       : {} (reachable)",
                        "".green().bold(),
                        url
                    );
                } else {
                    println!(
                        "  {}  chat.url       : {} — cannot connect",
                        "".red().bold(),
                        url
                    );
                    failures += 1;
                }
            }
        }
    }

    // ── Result ────────────────────────────────────────────────────────────────
    println!();
    if failures == 0 {
        println!("  {}  all checks passed", "".green().bold());
    } else {
        eprintln!(
            "  {}  {} check(s) failed — run {} to fix",
            "".red().bold(),
            failures,
            "mps config edit".bold()
        );
        std::process::exit(1);
    }

    Ok(())
}

fn check_exists(label: &str, value: &str, exists: bool) -> usize {
    if exists {
        println!("  {}  {}  : {}", "".green().bold(), label, value);
        0
    } else {
        println!(
            "  {}  {}  : {} — does not exist",
            "".red().bold(),
            label,
            value
        );
        1
    }
}

fn tcp_reachable(url: &str) -> bool {
    use std::net::{TcpStream, ToSocketAddrs};
    use std::time::Duration;

    let is_https = url.starts_with("https://");
    let stripped = url
        .trim_start_matches("https://")
        .trim_start_matches("http://");
    let host_port = stripped.split('/').next().unwrap_or(stripped);
    let default_port = if is_https { 443u16 } else { 80u16 };
    let addr_str = if host_port.contains(':') {
        host_port.to_string()
    } else {
        format!("{}:{}", host_port, default_port)
    };

    match addr_str.to_socket_addrs() {
        Ok(mut addrs) => addrs
            .next()
            .and_then(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).ok())
            .is_some(),
        Err(_) => false,
    }
}