mps-rs 1.8.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
use crate::config::Config;
use crate::constants::mps_file_name_regexp;
use anyhow::{Context, Result};
use chrono::NaiveDate;
use colored::Colorize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;

/// A legacy file discovered during scanning.
pub struct LegacyFile {
    pub path: PathBuf,
    pub stem: String,
    pub ext: String,
    pub date: NaiveDate,
    pub content: String,
}

/// Discover legacy files: `.ms` files in `mps_dir` and non-standard `.mps`
/// files in `storage_dir`. Skips files ending with `.imported`.
pub fn collect_legacy_files(mps_dir: &Path, storage_dir: &Path) -> Result<Vec<LegacyFile>> {
    let re = mps_file_name_regexp();
    let mut files: Vec<LegacyFile> = Vec::new();

    // Scan mps_dir for .ms files
    if let Ok(rd) = std::fs::read_dir(mps_dir) {
        for entry in rd.filter_map(|e| e.ok()) {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            let fname = match path.file_name().and_then(|n| n.to_str()) {
                Some(n) => n.to_string(),
                None => continue,
            };
            // Skip .imported markers
            if fname.ends_with(".imported") {
                continue;
            }
            if path.extension().and_then(|e| e.to_str()) == Some("ms") {
                if let Some(lf) = read_legacy(&path, &fname) {
                    files.push(lf);
                }
            }
        }
    }

    // Scan storage_dir for non-standard .mps files
    if let Ok(rd) = std::fs::read_dir(storage_dir) {
        for entry in rd.filter_map(|e| e.ok()) {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }
            let fname = match path.file_name().and_then(|n| n.to_str()) {
                Some(n) => n.to_string(),
                None => continue,
            };
            if fname.ends_with(".imported") {
                continue;
            }
            if path.extension().and_then(|e| e.to_str()) == Some("mps") && !re.is_match(&fname) {
                if let Some(lf) = read_legacy(&path, &fname) {
                    files.push(lf);
                }
            }
        }
    }

    files.sort_by(|a, b| a.date.cmp(&b.date).then(a.stem.cmp(&b.stem)));
    Ok(files)
}

fn read_legacy(path: &Path, fname: &str) -> Option<LegacyFile> {
    // Determine stem and extension robustly: stem = everything before the LAST dot
    let (stem, ext) = match fname.rsplit_once('.') {
        Some((s, e)) => (s.to_string(), e.to_string()),
        None => (fname.to_string(), String::new()),
    };

    let date = mtime_date(path).unwrap_or_else(|_| chrono::Local::now().date_naive());
    let content = std::fs::read_to_string(path).unwrap_or_default();

    Some(LegacyFile {
        path: path.to_path_buf(),
        stem,
        ext,
        date,
        content,
    })
}

fn mtime_date(path: &Path) -> Result<NaiveDate> {
    let meta = std::fs::metadata(path).with_context(|| format!("stat {}", path.display()))?;
    let mtime = meta.modified().context("mtime not available")?;
    let secs = mtime
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    let dt = chrono::DateTime::from_timestamp(secs as i64, 0).context("invalid timestamp")?;
    Ok(dt.date_naive())
}

/// Normalise a filename stem to a tag: lowercase, underscores → hyphens.
pub fn normalise_tag(stem: &str) -> String {
    stem.to_lowercase().replace('_', "-")
}

/// Format a legacy file's content as a `@note` element.
pub fn format_as_note(stem: &str, content: &str) -> String {
    let indented: String = content
        .lines()
        .map(|l| {
            if l.trim().is_empty() {
                String::new()
            } else {
                format!("  {l}")
            }
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "@note[{}]{{\n  {stem}\n\n{indented}\n}}\n",
        normalise_tag(stem)
    )
}

pub fn run(config: &Config, dry_run: bool, yes: bool, move_: bool) -> Result<()> {
    let legacy = collect_legacy_files(&config.mps_dir, &config.storage_dir)?;

    if legacy.is_empty() {
        println!("{}", "mps import — no legacy files found.".white());
        println!("  Looked for:");
        println!("    .ms  files in  {}", config.mps_dir.display());
        println!(
            "    non-standard .mps files in  {}",
            config.storage_dir.display()
        );
        return Ok(());
    }

    // Group by date
    let mut groups: BTreeMap<NaiveDate, Vec<&LegacyFile>> = BTreeMap::new();
    for lf in &legacy {
        groups.entry(lf.date).or_default().push(lf);
    }

    // Pre-compute output paths (one per date group, same epoch for whole run)
    let epoch = std::time::SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();

    let output_paths: BTreeMap<NaiveDate, PathBuf> = groups
        .keys()
        .enumerate()
        .map(|(i, &date)| {
            let fname = format!("{}.{}.mps", date.format("%Y%m%d"), epoch + i as u64);
            (date, config.storage_dir.join(fname))
        })
        .collect();

    // Print preview table
    let mode_label = if dry_run || (!yes && !move_) {
        "dry run (pass --yes to execute)"
    } else if move_ {
        "execute — originals will be deleted"
    } else {
        "execute — originals will be renamed to .imported"
    };
    println!(
        "\n{}  {}\n",
        "mps import —".white().bold(),
        mode_label.white()
    );

    let col_w = legacy
        .iter()
        .map(|f| {
            f.path
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("")
                .len()
        })
        .max()
        .unwrap_or(20)
        .max(20);

    println!(
        "  {:<col_w$}  {:<10}  \u{2192} output file",
        "source file", "date"
    );
    println!("  {}", "".repeat(col_w + 28));

    let mut prev_date: Option<NaiveDate> = None;
    for lf in &legacy {
        let fname = lf.path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let out = output_paths[&lf.date]
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("");
        let same_note = if prev_date == Some(lf.date) {
            " (same)"
        } else {
            ""
        };
        println!(
            "  {:<col_w$}  {:<10}  → {}{}",
            fname,
            lf.date.format("%Y-%m-%d"),
            out,
            same_note
        );
        prev_date = Some(lf.date);
    }

    let file_count = legacy.len();
    let group_count = groups.len();
    println!(
        "\n  {} {}{} output {}",
        file_count,
        if file_count == 1 { "file" } else { "files" },
        group_count,
        if group_count == 1 { "file" } else { "files" }
    );

    if dry_run || (!yes && !move_) {
        println!("  {}", "run with --yes to execute".cyan());
        println!();
        return Ok(());
    }

    println!();

    // Execute
    for (date, files) in &groups {
        let out_path = &output_paths[date];
        let content: String = files
            .iter()
            .map(|lf| format_as_note(&lf.stem, &lf.content))
            .collect::<Vec<_>>()
            .join("\n");

        let tmp = out_path.with_extension("tmp");
        std::fs::write(&tmp, &content).with_context(|| format!("write {}", tmp.display()))?;
        std::fs::rename(&tmp, out_path)
            .with_context(|| format!("rename to {}", out_path.display()))?;

        println!(
            "  {}  written {}  ({} element{})",
            "".green().bold(),
            out_path.file_name().and_then(|n| n.to_str()).unwrap_or(""),
            files.len(),
            if files.len() == 1 { "" } else { "s" }
        );
    }

    // Post-import: rename or delete sources
    for lf in &legacy {
        if move_ {
            std::fs::remove_file(&lf.path)
                .with_context(|| format!("delete {}", lf.path.display()))?;
        } else {
            // rename to STEM.EXT.imported
            let new_name = format!("{}.{}.imported", lf.stem, lf.ext);
            let new_path = lf.path.parent().unwrap_or(Path::new(".")).join(&new_name);
            std::fs::rename(&lf.path, &new_path)
                .with_context(|| format!("rename {}", lf.path.display()))?;
        }
    }

    println!();
    println!("  {}  {} imported", "".green().bold(), file_count);
    if !move_ {
        println!("  Originals renamed to *.imported — they will be skipped on re-run.");
    }
    println!();

    Ok(())
}