mps-rs 1.6.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
mod cli;
mod config;
mod constants;
mod date_parse;
mod elements;
mod error;
mod meta;
mod parser;
mod ref_resolver;
mod store;
mod time_parse;
mod commands;

use anyhow::{Context, Result};
use chrono::NaiveDate;
use clap::Parser as ClapParser;
use cli::{Cli, Commands};
use config::{Config, default_config_path};
use meta::MetaShared;
use std::collections::HashMap;
use std::path::PathBuf;

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {:#}", e);
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let raw_args: Vec<String> = std::env::args().collect();

    // Load command aliases before clap parsing so we can substitute them in args.
    // We do a best-effort early config load (silently falls back to empty map on failure).
    let early_config_path = extract_config_path_arg(&raw_args)
        .unwrap_or_else(default_config_path);

    // Best-effort early load: YAML aliases + meta aliases merged, for pre-clap substitution.
    let command_aliases: HashMap<String, String> = if early_config_path.exists() {
        let mut aliases = Config::load(&early_config_path)
            .map(|c| c.command_aliases)
            .unwrap_or_default();
        // Also pull command_aliases from .mps.meta if storage_dir is resolvable.
        if let Ok(cfg) = Config::load(&early_config_path) {
            let meta = MetaShared::load(&cfg.storage_dir);
            for (k, v) in meta.config.command_aliases {
                aliases.entry(k).or_insert(v);
            }
        }
        aliases
    } else {
        HashMap::new()
    };
    let cli_args = resolve_command_alias_in_args(raw_args, &command_aliases);

    let cli = Cli::parse_from(cli_args);

    let config_path = cli.config_path
        .as_deref()
        .map(PathBuf::from)
        .unwrap_or_else(default_config_path);

    if !config_path.exists() || cli.force {
        Config::init(&config_path)
            .with_context(|| format!("failed to init config at {}", config_path.display()))?;
    }

    let mut config = Config::load(&config_path)
        .with_context(|| format!("failed to load config from {}", config_path.display()))?;

    config.ensure_dirs()
        .context("failed to create MPS directories")?;

    // Load .mps.meta and merge machine-agnostic config overrides.
    let meta_shared = MetaShared::load(&config.storage_dir);
    config.merge_meta(&meta_shared.config);

    // Resolve the default command from config for bare invocations.
    let default_cmd = config.default_command.to_lowercase();

    let command = cli.command.unwrap_or_else(|| {
        match default_cmd.as_str() {
            "list" => Commands::List {
                datesign: None,
                r#type:   None,
                tag:      None,
                status:   None,
                since:    None,
                refs:     false,
                all:      false,
                name:     None,
            },
            _ => Commands::Open { datesign: None },
        }
    });

    match command {
        Commands::Version => {
            println!("mps (v{})", env!("CARGO_PKG_VERSION"));
        }

        Commands::Open { datesign } => {
            let date = resolve_date(datesign.as_deref().unwrap_or("today"))?;
            commands::open::run(&config, date)?;
        }

        Commands::List { datesign, r#type, tag, status, since, refs, all, name } => {
            let date  = resolve_date(datesign.as_deref().unwrap_or("today"))?;
            let dates = resolve_date_range(since.as_deref(), date)?;
            commands::list::run(&config, date, dates, r#type, tag, status, name, refs, all)?;
        }

        Commands::Append { kind, body, tags, status, at, start_time, end_time, name } => {
            let body_str  = body.join(" ");
            let kind_resolved = resolve_alias(&kind, &config);
            let tags_vec: Vec<String> = tags
                .as_deref()
                .unwrap_or("")
                .split(',')
                .map(|t| t.trim().to_string())
                .filter(|t| !t.is_empty())
                .collect();
            let date = chrono::Local::now().date_naive();
            commands::append::run(
                &config, &kind_resolved, &body_str, tags_vec,
                status, at, start_time, end_time, name, date,
            )?;
        }

        Commands::Edit { ref_path, date } => {
            let d = resolve_date(date.as_deref().unwrap_or("today"))?;
            commands::edit::run(&config, &ref_path, d)?;
        }

        Commands::Delete { ref_path, date, yes } => {
            let d = resolve_date(date.as_deref().unwrap_or("today"))?;
            commands::delete::run(&config, &ref_path, d, yes)?;
        }

        Commands::Update { ref_path, status, start_time, end_time, at, date } => {
            commands::update::run(&config, &ref_path, status, start_time, end_time, at, date)?;
        }

        Commands::Done { ref_path, date } => {
            commands::update::run_done(&config, &ref_path, date)?;
        }

        Commands::Search { query, r#type, tag, since, name } => {
            let since_date = since.as_deref().map(date_parse::parse_date).transpose()?;
            commands::search::run(&config, &query, r#type, tag, name, since_date)?;
        }

        Commands::Stats { datesign, since, all } => {
            let date  = resolve_date(datesign.as_deref().unwrap_or("today"))?;
            let dates = resolve_date_range(since.as_deref(), date)?;
            commands::stats::run(&config, dates, all)?;
        }

        Commands::Tags { datesign, r#type, status, since, all, name } => {
            let date  = resolve_date(datesign.as_deref().unwrap_or("today"))?;
            let dates = resolve_date_range(since.as_deref(), date)?;
            commands::tags::run(&config, dates, all, r#type, status, name)?;
        }

        Commands::Export { datesign, format, r#type, since } => {
            let date  = resolve_date(datesign.as_deref().unwrap_or("today"))?;
            let dates = resolve_date_range(since.as_deref(), date)?;
            commands::export::run(&config, dates, &format, r#type)?;
        }

        Commands::Config { subcommand } => {
            commands::config_cmd::run(&config, &config_path, subcommand.as_deref())?;
        }

        Commands::Git { args } => {
            commands::git::run_git(&config, &args)?;
        }

        Commands::Autogit => {
            commands::git::run_autogit(&config)?;
        }

        Commands::Cmd { args } => {
            commands::git::run_cmd(&config, &args)?;
        }

        Commands::Notify { dry_run, window, force } => {
            commands::notify::run(&config, dry_run, window, force)?;
        }

        Commands::Daemon { subcommand } => {
            commands::daemon::run(&config, &subcommand)?;
        }

        Commands::Meta { subcommand } => {
            commands::meta_cmd::run(&config, subcommand.as_deref())?;
        }
    }

    Ok(())
}

/// Resolve a type string through the config's type_aliases map.
/// e.g. "t" → "task" if type_aliases contains {"t": "task"}.
fn resolve_alias(kind: &str, config: &Config) -> String {
    let normalized = kind.to_lowercase();
    config.type_aliases
        .get(&normalized)
        .cloned()
        .unwrap_or(normalized)
}

/// Scan raw args for --config-path <value> or --config-path=<value>.
/// Used to load the config before clap parses it (for command alias resolution).
fn extract_config_path_arg(args: &[String]) -> Option<PathBuf> {
    let mut iter = args.iter().skip(1);
    while let Some(arg) = iter.next() {
        if arg == "--config-path" {
            return iter.next().map(PathBuf::from);
        }
        if let Some(val) = arg.strip_prefix("--config-path=") {
            return Some(PathBuf::from(val));
        }
    }
    None
}

/// Replace the subcommand token in args with its command alias, if any.
/// Only the first positional argument (the subcommand) is substituted.
/// Flag tokens (starting with `-`) and their values are skipped.
fn resolve_command_alias_in_args(
    mut args: Vec<String>,
    command_aliases: &HashMap<String, String>,
) -> Vec<String> {
    if command_aliases.is_empty() { return args; }
    let mut i = 1usize;
    while i < args.len() {
        let arg = &args[i];
        if arg == "--config-path" {
            i += 2; // skip flag and its value argument
            continue;
        }
        if arg.starts_with('-') {
            i += 1; // skip boolean flags
            continue;
        }
        // First positional token is the subcommand — substitute if aliased
        let lower = arg.to_lowercase();
        if let Some(resolved) = command_aliases.get(&lower) {
            args[i] = resolved.clone();
        }
        break;
    }
    args
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_extract_config_path_arg_present() {
        let args = vec!["mps".into(), "--config-path".into(), "/tmp/cfg.yaml".into(), "list".into()];
        assert_eq!(extract_config_path_arg(&args), Some(PathBuf::from("/tmp/cfg.yaml")));
    }

    #[test]
    fn test_extract_config_path_arg_equals_form() {
        let args = vec!["mps".into(), "--config-path=/tmp/cfg.yaml".into(), "list".into()];
        assert_eq!(extract_config_path_arg(&args), Some(PathBuf::from("/tmp/cfg.yaml")));
    }

    #[test]
    fn test_extract_config_path_arg_absent() {
        let args = vec!["mps".into(), "list".into()];
        assert_eq!(extract_config_path_arg(&args), None);
    }

    #[test]
    fn test_resolve_command_alias_basic() {
        let aliases = HashMap::from([("a".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "a".into(), "note".into(), "body".into()];
        let out = resolve_command_alias_in_args(args, &aliases);
        assert_eq!(out[1], "append");
        assert_eq!(out[2], "note"); // type alias untouched here
    }

    #[test]
    fn test_resolve_command_alias_skips_config_path_flag() {
        let aliases = HashMap::from([("a".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "--config-path".into(), "/tmp/x".into(), "a".into(), "n".into()];
        let out = resolve_command_alias_in_args(args, &aliases);
        assert_eq!(out[3], "append");
        assert_eq!(out[1], "--config-path"); // flag untouched
    }

    #[test]
    fn test_resolve_command_alias_skips_boolean_flag() {
        let aliases = HashMap::from([("a".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "--force".into(), "a".into(), "note".into()];
        let out = resolve_command_alias_in_args(args, &aliases);
        assert_eq!(out[2], "append");
    }

    #[test]
    fn test_resolve_command_alias_no_match_unchanged() {
        let aliases = HashMap::from([("a".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "list".into()];
        let out = resolve_command_alias_in_args(args.clone(), &aliases);
        assert_eq!(out, args);
    }

    #[test]
    fn test_resolve_command_alias_empty_map() {
        let aliases = HashMap::new();
        let args = vec!["mps".into(), "x".into()];
        let out = resolve_command_alias_in_args(args.clone(), &aliases);
        assert_eq!(out, args);
    }

    #[test]
    fn test_resolve_command_alias_symbol_like() {
        let aliases = HashMap::from([("+".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "+".into(), "note".into()];
        let out = resolve_command_alias_in_args(args, &aliases);
        assert_eq!(out[1], "append");
    }

    #[test]
    fn test_resolve_command_alias_case_insensitive() {
        let aliases = HashMap::from([("a".to_string(), "append".to_string())]);
        let args = vec!["mps".into(), "A".into(), "note".into()];
        let out = resolve_command_alias_in_args(args, &aliases);
        assert_eq!(out[1], "append");
    }
}

fn resolve_date(datesign: &str) -> Result<NaiveDate> {
    date_parse::parse_date(datesign)
        .with_context(|| format!("cannot parse date '{}'", datesign))
}

fn resolve_date_range(since: Option<&str>, to_date: NaiveDate) -> Result<Vec<NaiveDate>> {
    match since {
        None => Ok(vec![to_date]),
        Some(s) => {
            let since_date = resolve_date(s)?;
            let mut dates = Vec::new();
            let mut d = since_date;
            while d <= to_date {
                dates.push(d);
                d += chrono::Duration::days(1);
            }
            Ok(dates)
        }
    }
}