mempal 0.5.4

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use std::collections::BTreeSet;

use super::model::{AaakDocument, AaakHeader, AaakLine, ArcLine, ParseError, Tunnel, Zettel};

pub(crate) const ALLOWED_FLAGS: &[&str] = &[
    "DECISION",
    "ORIGIN",
    "CORE",
    "PIVOT",
    "TECHNICAL",
    "SENSITIVE",
];

pub fn parse_document(input: &str) -> Result<AaakDocument, ParseError> {
    let mut lines = input.lines().filter(|line| !line.trim().is_empty());
    let header = parse_header(lines.next().ok_or(ParseError::MissingHeader)?)?;
    let mut body = Vec::new();
    let mut zettels = Vec::new();
    let mut zettel_ids = BTreeSet::new();

    for line in lines {
        if line.starts_with("T:") {
            body.push(AaakLine::Tunnel(parse_tunnel(line)?));
            continue;
        }
        if line.starts_with("ARC:") {
            body.push(AaakLine::Arc(parse_arc(line)?));
            continue;
        }
        let zettel = parse_zettel(line)?;
        if !zettel_ids.insert(zettel.id) {
            return Err(ParseError::InvalidZettel(line.to_string()));
        }
        body.push(AaakLine::Zettel(zettel.clone()));
        zettels.push(zettel);
    }

    for line in &body {
        if let AaakLine::Tunnel(tunnel) = line
            && (!zettel_ids.contains(&tunnel.left) || !zettel_ids.contains(&tunnel.right))
        {
            return Err(ParseError::InvalidZettel(format!(
                "T:{}<->{}|{}",
                tunnel.left, tunnel.right, tunnel.label
            )));
        }
    }

    Ok(AaakDocument {
        header,
        body,
        zettels,
    })
}

fn parse_header(line: &str) -> Result<AaakHeader, ParseError> {
    let mut parts = line.split('|');
    let version = parts.next().ok_or(ParseError::InvalidHeader)?;
    let wing = parts.next().ok_or(ParseError::InvalidHeader)?;
    let room = parts.next().ok_or(ParseError::InvalidHeader)?;
    let date = parts.next().ok_or(ParseError::InvalidHeader)?;
    let source = parts.next().ok_or(ParseError::InvalidHeader)?;
    if parts.next().is_some() {
        return Err(ParseError::InvalidHeader);
    }

    let version = version
        .strip_prefix('V')
        .ok_or(ParseError::InvalidVersion)?
        .parse::<u8>()
        .map_err(|_| ParseError::InvalidVersion)?;

    Ok(AaakHeader {
        version,
        wing: wing.to_string(),
        room: room.to_string(),
        date: date.to_string(),
        source: source.to_string(),
    })
}

fn parse_zettel(line: &str) -> Result<Zettel, ParseError> {
    let (id, rest) = line
        .split_once(':')
        .ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    let id = id
        .parse::<usize>()
        .map_err(|_| ParseError::InvalidZettel(line.to_string()))?;
    let parts = rest.split('|').collect::<Vec<_>>();
    if parts.len() != 6 {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let entities = split_field(parts[0], '+');
    if entities.is_empty() || entities.iter().any(|entity| !is_entity_code(entity)) {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let topics = split_field(parts[1], '_');
    if topics.is_empty() {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let quote = parse_quote(parts[2]).ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    let weight =
        parse_weight(parts[3]).ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;

    let emotions = split_field(parts[4], '+');
    if emotions.is_empty() || emotions.iter().any(|emotion| !is_emotion_code(emotion)) {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let flags = split_field(parts[5], '+');
    if flags.is_empty()
        || flags
            .iter()
            .any(|flag| !ALLOWED_FLAGS.contains(&flag.as_str()))
    {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    Ok(Zettel {
        id,
        entities,
        topics,
        quote,
        weight,
        emotions,
        flags,
    })
}

fn parse_tunnel(line: &str) -> Result<Tunnel, ParseError> {
    let rest = line
        .strip_prefix("T:")
        .ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    let (pair, label) = rest
        .split_once('|')
        .ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    if label.trim().is_empty() {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let (left, right) = pair
        .split_once("<->")
        .ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    let left = left
        .parse::<usize>()
        .map_err(|_| ParseError::InvalidZettel(line.to_string()))?;
    let right = right
        .parse::<usize>()
        .map_err(|_| ParseError::InvalidZettel(line.to_string()))?;

    Ok(Tunnel {
        left,
        right,
        label: label.to_string(),
    })
}

fn parse_arc(line: &str) -> Result<ArcLine, ParseError> {
    let rest = line
        .strip_prefix("ARC:")
        .ok_or_else(|| ParseError::InvalidZettel(line.to_string()))?;
    if rest.is_empty() {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    let emotions = rest.split("->").collect::<Vec<_>>();
    if emotions.is_empty() || emotions.iter().any(|emotion| !is_emotion_code(emotion)) {
        return Err(ParseError::InvalidZettel(line.to_string()));
    }

    Ok(ArcLine {
        emotions: emotions.into_iter().map(ToOwned::to_owned).collect(),
    })
}

fn split_field(raw: &str, separator: char) -> Vec<String> {
    raw.split(separator)
        .filter(|item| !item.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn parse_quote(raw: &str) -> Option<String> {
    if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
        return None;
    }

    Some(raw[1..raw.len() - 1].to_string())
}

fn parse_weight(raw: &str) -> Option<u8> {
    if raw.is_empty() || !raw.chars().all(|ch| ch == '') {
        return None;
    }

    let count = raw.chars().count();
    (1..=5)
        .contains(&count)
        .then(|| u8::try_from(count).ok())
        .flatten()
}

fn is_entity_code(raw: &str) -> bool {
    raw.len() == 3 && raw.chars().all(|ch| ch.is_ascii_uppercase())
}

fn is_emotion_code(raw: &str) -> bool {
    (3..=7).contains(&raw.len()) && raw.chars().all(|ch| ch.is_ascii_lowercase())
}