mps-rs 1.1.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
//! Position-based stack parser for `.mps` files.
//!
//! Mirrors Ruby's `Engines::Parser.parse_mps_file_to_elements_hash` exactly.
//! Returns an [`IndexMap`] keyed by dotted ref-path (`"20260501.1.2"`).

use std::path::Path;
use indexmap::IndexMap;
use crate::constants::{at_regexp, end_curly_regexp};
use crate::elements::Element;
use crate::error::MpsError;

/// Parses a .mps file into a flat ordered map keyed by dotted ref-path (e.g. "1746000000.1.2").
///
/// Algorithm mirrors Ruby's Engines::Parser.parse_mps_file_to_elements_hash exactly:
///   1. Wrap file content in a synthetic @mps[]{\n...\n} root.
///   2. Single-pass: at each pos, find the nearest @element[args]{ open and } close.
///   3. Whichever starts earlier wins.
///   4. Open → push stack frame; Close → pop, emit element keyed by dotted ref path.
pub fn parse_file(path: &Path) -> Result<IndexMap<String, Element>, MpsError> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| MpsError::ParseError {
            file: path.display().to_string(),
            msg:  e.to_string(),
        })?;

    let basename = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("0");

    // Base ref = YYYYMMDD as integer (same as Ruby's MPS_FILE_NAME_CLIPPER.call(...).to_i).
    // Ruby's .to_i on "20260501.1746113538" stops at the dot → 20260501.
    // We extract just the first 8-digit date portion.
    let base_ref: u64 = if basename.len() >= 8 {
        basename[..8].parse().unwrap_or(0)
    } else {
        0
    };

    let wrapped = format!("@mps[]{{\n{}\n}}", content);
    parse_wrapped(&wrapped, base_ref)
}

/// Parse a raw `.mps` content string (without a surrounding `@mps{}` wrapper).
/// Uses base_ref 0 so ref-paths are `0.1`, `0.2`, etc. Intended for unit tests.
pub fn parse_str(content: &str) -> IndexMap<String, Element> {
    let wrapped = format!("@mps[]{{\n{}\n}}", content);
    parse_wrapped(&wrapped, 0).unwrap_or_default()
}

/// Parse a pre-wrapped string. Exported for tests that supply in-memory content.
pub fn parse_wrapped(wrapped: &str, base_ref: u64) -> Result<IndexMap<String, Element>, MpsError> {
    let open_re  = at_regexp();
    let close_re = end_curly_regexp();

    struct Frame {
        sign:          String,
        args:          String,
        body_start:    usize,
        child_counter: u64,
        ref_path:      Vec<u64>,
    }

    let mut elements: IndexMap<String, Element> = IndexMap::new();
    let mut stack: Vec<Frame> = Vec::new();
    let mut pos = 0usize;

    loop {
        if pos >= wrapped.len() { break; }

        let open_m  = open_re.find_at(wrapped, pos);
        let close_m = close_re.find_at(wrapped, pos);

        let use_open = match (open_m, close_m) {
            (None, None)       => break,
            (Some(_), None)    => true,
            (None, Some(_))    => false,
            (Some(o), Some(c)) => o.start() < c.start(),
        };

        if use_open {
            let om = open_m.unwrap();

            // Build ref_path for this new frame.
            let ref_path = if stack.is_empty() {
                vec![base_ref]
            } else {
                let parent = stack.last_mut().unwrap();
                parent.child_counter += 1;
                let mut p = parent.ref_path.clone();
                p.push(parent.child_counter);
                p
            };

            let caps = open_re.captures_at(wrapped, om.start()).unwrap();
            let sign = caps.name("element_sign")
                .map_or("", |m| m.as_str())
                .to_string();
            let args = caps.name("args")
                .map_or("", |m| m.as_str())
                .to_string();

            stack.push(Frame { sign, args, body_start: om.end(), child_counter: 0, ref_path });
            pos = om.end();
        } else {
            let cm = close_m.unwrap();
            if stack.is_empty() { break; }

            let frame    = stack.pop().unwrap();
            let body_str = wrapped[frame.body_start..cm.start()].to_string();
            let ref_key  = frame.ref_path.iter()
                .map(|n| n.to_string())
                .collect::<Vec<_>>()
                .join(".");

            let el = Element::from_parts(&frame.sign, frame.args, frame.ref_path, body_str);
            elements.insert(ref_key, el);
            pos = cm.end();
        }
    }

    Ok(elements)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::elements::ElementKind;
    use std::io::Write;

    fn parse_content(content: &str) -> IndexMap<String, Element> {
        let wrapped = format!("@mps[]{{\n{}\n}}", content);
        parse_wrapped(&wrapped, 20260101).unwrap()
    }

    #[test]
    fn test_empty_file() {
        let els = parse_content("");
        assert_eq!(els.len(), 1, "only root @mps wrapper");
        let (key, el) = els.iter().next().unwrap();
        assert_eq!(key, "20260101");
        assert!(el.is_mps_group());
    }

    #[test]
    fn test_single_task() {
        let els = parse_content("@task[work]{\n  Do the thing\n}");
        assert_eq!(els.len(), 2);
        let task = els.get("20260101.1").unwrap();
        assert_eq!(task.kind(), ElementKind::Task);
        assert!(task.tags().contains(&"work".to_string()));
    }

    #[test]
    fn test_multiple_elements_sequential_refs() {
        let els = parse_content("@task{ First }\n@note{ Second }");
        assert!(els.contains_key("20260101.1"), "first child");
        assert!(els.contains_key("20260101.2"), "second child");
    }

    #[test]
    fn test_nested_mps_block() {
        let els = parse_content("@mps{\n  @task{ Nested task }\n}");
        assert!(els.contains_key("20260101.1"),   "@mps child");
        assert!(els.contains_key("20260101.1.1"), "@task inside @mps");
    }

    #[test]
    fn test_args_captured() {
        let els = parse_content("@task[work, status: done]{ Done thing }");
        let el = els.get("20260101.1").unwrap();
        if let Element::Task { data, .. } = el {
            assert!(data.is_done());
            assert_eq!(data.tags, vec!["work"]);
        } else {
            panic!("expected Task variant");
        }
    }

    #[test]
    fn test_optional_brackets() {
        let els = parse_content("@task{ No brackets }");
        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Task);
    }

    #[test]
    fn test_unknown_element() {
        let els = parse_content("@widget{ Unknown type }");
        assert_eq!(els.get("20260101.1").unwrap().kind(), ElementKind::Unknown);
    }

    #[test]
    fn test_deeply_nested() {
        let content = "@mps{\n  @mps{\n    @task{ Deep }\n  }\n}";
        let els = parse_content(content);
        assert!(els.contains_key("20260101.1.1.1"), "deeply nested task ref");
    }

    #[test]
    fn test_parse_real_file() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("20260101.1700000000.mps");
        let mut f = std::fs::File::create(&path).unwrap();
        writeln!(f, "@task[work]{{ Do the thing }}").unwrap();
        writeln!(f, "@note{{ A note }}").unwrap();
        drop(f);

        let els = parse_file(&path).unwrap();
        assert_eq!(els.len(), 3); // root mps + task + note
    }
}