mps-rs 1.1.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
//! Element type system.
//!
//! [`Element`] is an enum with one variant per known element type.
//! [`split_args`] parses `"work, status: done"` into tags + attrs,
//! mirroring Ruby's `Element.split_args`.

use std::collections::HashMap;

pub mod task;
pub mod note;
pub mod log_elem;
pub mod reminder;
pub mod mps_group;
pub mod character;

pub use task::TaskData;
pub use note::NoteData;
pub use log_elem::LogData;
pub use reminder::ReminderData;
pub use mps_group::MpsGroupData;
pub use character::CharacterData;

/// Parsed args from a raw args string like "work, release, status: done".
/// Parts with ':' become attrs; bare words become tags.
#[derive(Debug, Clone, Default)]
pub struct ParsedArgs {
    pub attrs: HashMap<String, String>,
    pub tags:  Vec<String>,
}

/// Split "work, release, status: done" into tags + attrs.
/// Mirrors Ruby's Element.split_args exactly.
pub fn split_args(raw: &str) -> ParsedArgs {
    let mut attrs = HashMap::new();
    let mut tags  = Vec::new();
    if raw.trim().is_empty() {
        return ParsedArgs::default();
    }
    for part in raw.split(',') {
        let part = part.trim();
        if part.is_empty() { continue; }
        if let Some(colon) = part.find(':') {
            let key = part[..colon].trim().to_string();
            let val = part[colon + 1..].trim().to_string();
            attrs.insert(key, val);
        } else {
            tags.push(part.to_string());
        }
    }
    ParsedArgs { attrs, tags }
}

/// Discriminant for filtering without matching the full variant.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ElementKind {
    Task,
    Note,
    Log,
    Reminder,
    MpsGroup,
    Character,
    Unknown,
}

impl std::fmt::Display for ElementKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ElementKind::Task      => write!(f, "task"),
            ElementKind::Note      => write!(f, "note"),
            ElementKind::Log       => write!(f, "log"),
            ElementKind::Reminder  => write!(f, "reminder"),
            ElementKind::MpsGroup  => write!(f, "mps"),
            ElementKind::Character => write!(f, "character"),
            ElementKind::Unknown   => write!(f, "unknown"),
        }
    }
}

impl ElementKind {
    pub fn from_sign(sign: &str) -> Self {
        match sign {
            "task"      => ElementKind::Task,
            "note"      => ElementKind::Note,
            "log"       => ElementKind::Log,
            "reminder"  => ElementKind::Reminder,
            "mps"       => ElementKind::MpsGroup,
            "character" => ElementKind::Character,
            _           => ElementKind::Unknown,
        }
    }
}

#[allow(dead_code)]
/// All element types the parser can produce.
/// Using an enum (not trait objects): exhaustive matching, no heap allocation per element,
/// and pattern matching is idiomatic for the display/filter/export branches.
#[derive(Debug, Clone)]
pub enum Element {
    Task {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     TaskData,
    },
    Note {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     NoteData,
    },
    Log {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     LogData,
    },
    Reminder {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     ReminderData,
    },
    MpsGroup {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     MpsGroupData,
    },
    Character {
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
        data:     CharacterData,
    },
    Unknown {
        sign:     String,
        raw_args: String,
        refs:     Vec<u64>,
        body_str: String,
    },
}

impl Element {
    pub fn kind(&self) -> ElementKind {
        match self {
            Element::Task      { .. } => ElementKind::Task,
            Element::Note      { .. } => ElementKind::Note,
            Element::Log       { .. } => ElementKind::Log,
            Element::Reminder  { .. } => ElementKind::Reminder,
            Element::MpsGroup  { .. } => ElementKind::MpsGroup,
            Element::Character { .. } => ElementKind::Character,
            Element::Unknown   { .. } => ElementKind::Unknown,
        }
    }

    pub fn is_mps_group(&self) -> bool { matches!(self, Element::MpsGroup { .. }) }

    pub fn tags(&self) -> &[String] {
        match self {
            Element::Task      { data, .. } => &data.tags,
            Element::Note      { data, .. } => &data.tags,
            Element::Log       { data, .. } => &data.tags,
            Element::Reminder  { data, .. } => &data.tags,
            Element::MpsGroup  { data, .. } => &data.tags,
            Element::Character { data, .. } => &data.tags,
            Element::Unknown   { .. }       => &[],
        }
    }

    pub fn body_str(&self) -> &str {
        match self {
            Element::Task      { body_str, .. } => body_str,
            Element::Note      { body_str, .. } => body_str,
            Element::Log       { body_str, .. } => body_str,
            Element::Reminder  { body_str, .. } => body_str,
            Element::MpsGroup  { body_str, .. } => body_str,
            Element::Character { body_str, .. } => body_str,
            Element::Unknown   { body_str, .. } => body_str,
        }
    }

    #[allow(dead_code)]
    pub fn refs(&self) -> &[u64] {
        match self {
            Element::Task      { refs, .. } => refs,
            Element::Note      { refs, .. } => refs,
            Element::Log       { refs, .. } => refs,
            Element::Reminder  { refs, .. } => refs,
            Element::MpsGroup  { refs, .. } => refs,
            Element::Character { refs, .. } => refs,
            Element::Unknown   { refs, .. } => refs,
        }
    }

    pub fn sign(&self) -> &str {
        match self {
            Element::Task      { .. }       => "task",
            Element::Note      { .. }       => "note",
            Element::Log       { .. }       => "log",
            Element::Reminder  { .. }       => "reminder",
            Element::MpsGroup  { .. }       => "mps",
            Element::Character { .. }       => "character",
            Element::Unknown   { sign, .. } => sign,
        }
    }

    /// The raw args string as it appeared in the source file (e.g. "work, status: open").
    pub fn raw_args(&self) -> &str {
        match self {
            Element::Task      { raw_args, .. } => raw_args,
            Element::Note      { raw_args, .. } => raw_args,
            Element::Log       { raw_args, .. } => raw_args,
            Element::Reminder  { raw_args, .. } => raw_args,
            Element::MpsGroup  { raw_args, .. } => raw_args,
            Element::Character { raw_args, .. } => raw_args,
            Element::Unknown   { raw_args, .. } => raw_args,
        }
    }

    /// Typed (named) attributes, excluding tags. Used by rewrite_element to merge new attrs.
    /// Only includes attributes that were actually set (no defaults for absent optional attrs).
    pub fn typed_attrs(&self) -> Vec<(String, String)> {
        match self {
            // Task always has a status (default "open") — include it always.
            Element::Task { data, .. } => vec![
                ("status".into(), data.status_str().into()),
            ],
            Element::Log { data, .. } => {
                let mut attrs = Vec::new();
                if let Some(ref s) = data.start { attrs.push(("start".into(), s.clone())); }
                if let Some(ref e) = data.end   { attrs.push(("end".into(),   e.clone())); }
                attrs
            }
            Element::Reminder { data, .. } => {
                if let Some(ref a) = data.at {
                    vec![("at".into(), a.clone())]
                } else {
                    Vec::new()
                }
            }
            Element::Character { data, .. } => {
                if let Some(ref n) = data.name {
                    vec![("name".into(), n.clone())]
                } else {
                    Vec::new()
                }
            }
            _ => Vec::new(),
        }
    }

    /// True if this element is an Unknown type (sign not recognised by the parser).
    pub fn is_unknown(&self) -> bool { matches!(self, Element::Unknown { .. }) }

    /// Build an Element from a parsed sign, raw_args, refs, and body_str.
    pub fn from_parts(sign: &str, raw_args: String, refs: Vec<u64>, body_str: String) -> Self {
        match sign {
            "task" => Element::Task {
                data: TaskData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            "note" => Element::Note {
                data: NoteData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            "log" => Element::Log {
                data: LogData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            "reminder" => Element::Reminder {
                data: ReminderData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            "mps" => Element::MpsGroup {
                data: MpsGroupData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            "character" => Element::Character {
                data: CharacterData::parse_args(&raw_args),
                raw_args, refs, body_str,
            },
            other => Element::Unknown {
                sign: other.to_string(),
                raw_args, refs, body_str,
            },
        }
    }
}

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

    #[test]
    fn test_split_args_empty() {
        let p = split_args("");
        assert!(p.tags.is_empty());
        assert!(p.attrs.is_empty());
    }

    #[test]
    fn test_split_args_tags_only() {
        let p = split_args("work, release");
        assert_eq!(p.tags, vec!["work", "release"]);
        assert!(p.attrs.is_empty());
    }

    #[test]
    fn test_split_args_attrs_only() {
        let p = split_args("status: done");
        assert!(p.tags.is_empty());
        assert_eq!(p.attrs.get("status").map(|s| s.as_str()), Some("done"));
    }

    #[test]
    fn test_split_args_mixed() {
        let p = split_args("work, release, status: done");
        assert_eq!(p.tags, vec!["work", "release"]);
        assert_eq!(p.attrs.get("status").map(|s| s.as_str()), Some("done"));
    }

    #[test]
    fn test_split_args_at_field() {
        let p = split_args("at: 5pm");
        assert_eq!(p.attrs.get("at").map(|s| s.as_str()), Some("5pm"));
    }

    #[test]
    fn test_element_kind_from_sign() {
        assert_eq!(ElementKind::from_sign("task"),      ElementKind::Task);
        assert_eq!(ElementKind::from_sign("note"),      ElementKind::Note);
        assert_eq!(ElementKind::from_sign("log"),       ElementKind::Log);
        assert_eq!(ElementKind::from_sign("reminder"),  ElementKind::Reminder);
        assert_eq!(ElementKind::from_sign("mps"),       ElementKind::MpsGroup);
        assert_eq!(ElementKind::from_sign("character"), ElementKind::Character);
        assert_eq!(ElementKind::from_sign("unknown"),   ElementKind::Unknown);
    }
}