roma-core 0.1.0

Core types, session, errors, and utilities for Roma Agent
Documentation
//! Stream rules: user-declared regex guardrails matched against streamed
//! LLM output. See docs/superpowers/specs/2026-08-12-stream-rules-design.md.

use std::path::Path;

use serde::{Deserialize, Serialize};
use tracing::warn;

/// Sentinel prefix marking rule injections in chat history. The history
/// compressor never stubs or drops content containing this marker, so rule
/// injections survive long-session compression (spec C5/D6).
pub const RULE_SENTINEL: &str = "[stream rule ";

/// Which stream content a rule matches against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleScope {
    /// Streamed assistant text content.
    Text,
    /// Streamed thinking/reasoning content.
    Thinking,
    /// Serialized tool-call arguments.
    ToolArg,
}

/// What happens when a rule matches.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuleMode {
    /// Abort the generation, inject the rule body, and retry.
    Interrupt,
    /// Fold the rule body into the conversation as a note; never aborts.
    Remind,
}

/// A compiled stream rule loaded from `~/.roma/rules/<name>.md`.
#[derive(Debug)]
pub struct StreamRule {
    /// File stem of the rule file.
    pub name: String,
    /// Frontmatter `description`.
    pub description: String,
    /// Compiled frontmatter `match` pattern.
    pub matcher: regex::Regex,
    /// Which stream content this rule matches against.
    pub scope: RuleScope,
    /// What happens when this rule matches.
    pub mode: RuleMode,
    /// Markdown body — the text injected when the rule fires.
    pub body: String,
}

impl StreamRule {
    /// Construct a rule from a pattern string, compiling the regex.
    pub fn new(
        name: impl Into<String>,
        description: impl Into<String>,
        pattern: &str,
        scope: RuleScope,
        mode: RuleMode,
        body: impl Into<String>,
    ) -> Result<Self, String> {
        let matcher =
            regex::Regex::new(pattern).map_err(|e| format!("invalid regex `{pattern}`: {e}"))?;
        Ok(Self {
            name: name.into(),
            description: description.into(),
            matcher,
            scope,
            mode,
            body: body.into(),
        })
    }
}

#[derive(Deserialize)]
struct RuleFrontmatter {
    description: String,
    #[serde(rename = "match")]
    pattern: String,
    scope: RuleScope,
    mode: RuleMode,
}

/// Parse one rule file: YAML frontmatter + markdown body (the injected text).
/// Frontmatter parsing mirrors `roma_skills::Skill::parse`.
pub fn parse_rule(name: &str, content: &str) -> Result<StreamRule, String> {
    let trimmed = content.trim_start_matches('\u{feff}').trim_start();
    let rest = trimmed
        .strip_prefix("---")
        .ok_or_else(|| "missing YAML frontmatter (expected `---` delimiters)".to_string())?;
    let rest = rest.trim_start_matches(['\r', '\n']);
    let end_pos = rest
        .find("\n---")
        .ok_or_else(|| "unterminated YAML frontmatter".to_string())?;
    let yaml_str = rest[..end_pos].trim_end_matches('\r');
    let fm: RuleFrontmatter =
        serde_yaml::from_str(yaml_str).map_err(|e| format!("YAML parse error: {e}"))?;
    let body = rest[end_pos + "\n---".len()..]
        .trim_start_matches(['\r', '\n'])
        .trim()
        .to_string();
    StreamRule::new(name, fm.description, &fm.pattern, fm.scope, fm.mode, body)
}

/// Load all rules from `dir` (`<name>.md` files), sorted by name so
/// first-match-wins is deterministic. Invalid files are skipped with a
/// warning — a bad rule must never fail startup. A missing or unreadable
/// directory yields no rules.
#[must_use]
pub fn load_rules(dir: &Path) -> Vec<StreamRule> {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut rules = Vec::new();
    for entry in entries {
        let entry = match entry {
            Ok(entry) => entry,
            Err(e) => {
                warn!(dir = %dir.display(), error = %e, "skipping unreadable dir entry");
                continue;
            }
        };
        let path = entry.path();
        if path.extension().and_then(|e| e.to_str()) != Some("md") {
            continue;
        }
        let name = path
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or_default()
            .to_string();
        match std::fs::read_to_string(&path)
            .map_err(|e| e.to_string())
            .and_then(|content| parse_rule(&name, &content))
        {
            Ok(rule) => rules.push(rule),
            Err(e) => warn!(path = %path.display(), error = %e, "skipping invalid stream rule"),
        }
    }
    rules.sort_by(|a, b| a.name.cmp(&b.name));
    rules
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;

    const VALID: &str = r#"---
description: Never use Box::leak in Rust code
match: "Box::leak"
scope: text
mode: interrupt
---

Box::leak permanently leaks memory. Return owned values instead.
"#;

    #[test]
    fn parses_valid_rule_file() {
        let rule = parse_rule("no-box-leak", VALID).unwrap();
        assert_eq!(rule.name, "no-box-leak");
        assert_eq!(rule.description, "Never use Box::leak in Rust code");
        assert_eq!(rule.scope, RuleScope::Text);
        assert_eq!(rule.mode, RuleMode::Interrupt);
        assert!(rule.matcher.is_match("let x = Box::leak(b);"));
        assert!(!rule.matcher.is_match("let x = Box::new(b);"));
        assert!(rule.body.contains("permanently leaks memory"));
    }

    #[test]
    fn rejects_missing_frontmatter() {
        assert!(parse_rule("bad", "no frontmatter here").is_err());
    }

    #[test]
    fn rejects_invalid_regex() {
        let content =
            "---\ndescription: d\nmatch: \"[unclosed\"\nscope: text\nmode: remind\n---\nbody\n";
        let err = parse_rule("bad-regex", content).unwrap_err();
        assert!(err.contains("invalid regex"));
    }

    #[test]
    fn rejects_unknown_scope() {
        let content =
            "---\ndescription: d\nmatch: \"x\"\nscope: everything\nmode: remind\n---\nbody\n";
        assert!(parse_rule("bad-scope", content).is_err());
    }

    #[test]
    fn load_rules_skips_invalid_and_sorts_by_name() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("b-rule.md"), VALID).unwrap();
        std::fs::write(
            tmp.path().join("a-rule.md"),
            "---\ndescription: d\nmatch: \"secret\"\nscope: tool_arg\nmode: remind\n---\nnote body\n",
        )
        .unwrap();
        std::fs::write(tmp.path().join("broken.md"), "no frontmatter").unwrap();
        let rules = load_rules(tmp.path());
        assert_eq!(rules.len(), 2);
        assert_eq!(rules[0].name, "a-rule");
        assert_eq!(rules[1].name, "b-rule");
        assert_eq!(rules[0].scope, RuleScope::ToolArg);
        assert_eq!(rules[0].mode, RuleMode::Remind);
    }

    #[test]
    fn load_rules_missing_dir_is_empty() {
        let rules = load_rules(std::path::Path::new("/nonexistent/rules/dir"));
        assert!(rules.is_empty());
    }

    #[test]
    fn load_rules_ignores_non_markdown_files() {
        let tmp = tempfile::TempDir::new().unwrap();
        std::fs::write(tmp.path().join("notes.txt"), VALID).unwrap();
        assert!(load_rules(tmp.path()).is_empty());
    }
}