mps-rs 1.6.2

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 crate::constants::{at_regexp, end_curly_regexp};
use crate::elements::Element;
use crate::error::MpsError;
use indexmap::IndexMap;
use std::path::Path;

/// 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.
#[allow(dead_code)]
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
    }
}