intr-parser 0.1.0

Parser for the .prompt file format (Dotprompt-compatible)
Documentation
//! `intr-parser` - Parser for the `.prompt` file format.
//!
//! Parses Dotprompt-compatible `.prompt` files into a structured [`ParseResult`].
//! Supports all three tiers:
//!
//! - **Tier 1**: Plain Handlebars template with no frontmatter.
//! - **Tier 2**: YAML frontmatter with `id`, `version`, model hints, typed inputs.
//! - **Tier 3**: Tier 2 + `evals`, chains, reputation metadata.
//!
//! # Example
//!
//! ```rust
//! use intr_parser::parse;
//!
//! let src = r#"---
//! id: greet
//! version: 1.0.0
//! description: Greet a user by name
//! model:
//!   preferred: [claude-sonnet-4-6]
//!   temperature: 0.3
//! input:
//!   schema:
//!     name: string
//! ---
//! Hello, {{name}}!
//! "#;
//!
//! let result = parse(src.as_bytes()).unwrap();
//! assert_eq!(result.tier, 2);
//! assert!(result.variables.contains(&"name".to_string()));
//! ```

mod parse;
pub mod types;

pub use parse::parse;
pub use types::{
    Eval, EvalExpectation, Frontmatter, IntrEntryMeta, ModelHints, ParseError, ParseResult,
    ParseWarning, Picoschema,
};

/// Extract description and tags from a `.prompt` file without strict schema validation.
///
/// Unlike [`parse`], this function parses the frontmatter as a generic YAML value,
/// so it tolerates unknown top-level fields (e.g. `author:`, `license:`) that are
/// not declared in the strict [`Frontmatter`] struct.
///
/// Tags are looked up at the top level (`tags:`) and under `intentry.tags`.
/// Returns `(description, tags)` — both may be empty on parse failure.
pub fn extract_metadata(bytes: &[u8]) -> (Option<String>, Vec<String>) {
    let src = match std::str::from_utf8(bytes) {
        Ok(s) => s,
        Err(_) => return (None, vec![]),
    };

    // Find the closing `---` fence after the opening one.
    let src = src.trim_start();
    if !src.starts_with("---") {
        return (None, vec![]);
    }
    let after_open = src[3..].trim_start_matches([' ', '\t', '\r', '\n']);

    // Find closing fence.
    let yaml_block = {
        let mut close_pos = None;
        for (i, _) in after_open.char_indices() {
            let rest = &after_open[i..];
            if (i == 0 || after_open.as_bytes().get(i - 1) == Some(&b'\n'))
                && rest.starts_with("---")
            {
                close_pos = Some(i);
                break;
            }
        }
        match close_pos {
            Some(pos) => &after_open[..pos],
            None => return (None, vec![]),
        }
    };

    // Parse as generic YAML Value — tolerates any unknown fields.
    let value: serde_yaml::Value = match serde_yaml::from_str(yaml_block) {
        Ok(v) => v,
        Err(_) => return (None, vec![]),
    };

    let map = match value.as_mapping() {
        Some(m) => m,
        None => return (None, vec![]),
    };

    // Extract description.
    let description = map
        .get("description")
        .and_then(|v| v.as_str())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty());

    // Extract tags: check top-level `tags:` first, then `intentry.tags`.
    let tags: Vec<String> = map
        .get("tags")
        .and_then(|v| v.as_sequence())
        .map(|seq| {
            seq.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .or_else(|| {
            map.get("intentry")
                .and_then(|v| v.as_mapping())
                .and_then(|m| m.get("tags"))
                .and_then(|v| v.as_sequence())
                .map(|seq| {
                    seq.iter()
                        .filter_map(|v| v.as_str().map(str::to_string))
                        .collect()
                })
        })
        .unwrap_or_default();

    (description, tags)
}