Skip to main content

intr_parser/
lib.rs

1//! `intr-parser` - Parser for the `.prompt` file format.
2//!
3//! Parses Dotprompt-compatible `.prompt` files into a structured [`ParseResult`].
4//! Supports all three tiers:
5//!
6//! - **Tier 1**: Plain Handlebars template with no frontmatter.
7//! - **Tier 2**: YAML frontmatter with `id`, `version`, model hints, typed inputs.
8//! - **Tier 3**: Tier 2 + `evals`, chains, reputation metadata.
9//!
10//! # Example
11//!
12//! ```rust
13//! use intr_parser::parse;
14//!
15//! let src = r#"---
16//! id: greet
17//! version: 1.0.0
18//! description: Greet a user by name
19//! model:
20//!   preferred: [claude-sonnet-4-6]
21//!   temperature: 0.3
22//! input:
23//!   schema:
24//!     name: string
25//! ---
26//! Hello, {{name}}!
27//! "#;
28//!
29//! let result = parse(src.as_bytes()).unwrap();
30//! assert_eq!(result.tier, 2);
31//! assert!(result.variables.contains(&"name".to_string()));
32//! ```
33
34mod parse;
35pub mod types;
36
37pub use parse::parse;
38pub use types::{
39    Eval, EvalExpectation, Frontmatter, IntrEntryMeta, ModelHints, ParseError, ParseResult,
40    ParseWarning, Picoschema,
41};
42
43/// Extract description and tags from a `.prompt` file without strict schema validation.
44///
45/// Unlike [`parse`], this function parses the frontmatter as a generic YAML value,
46/// so it tolerates unknown top-level fields (e.g. `author:`, `license:`) that are
47/// not declared in the strict [`Frontmatter`] struct.
48///
49/// Tags are looked up at the top level (`tags:`) and under `intentry.tags`.
50/// Returns `(description, tags)` — both may be empty on parse failure.
51pub fn extract_metadata(bytes: &[u8]) -> (Option<String>, Vec<String>) {
52    let src = match std::str::from_utf8(bytes) {
53        Ok(s) => s,
54        Err(_) => return (None, vec![]),
55    };
56
57    // Find the closing `---` fence after the opening one.
58    let src = src.trim_start();
59    if !src.starts_with("---") {
60        return (None, vec![]);
61    }
62    let after_open = src[3..].trim_start_matches([' ', '\t', '\r', '\n']);
63
64    // Find closing fence.
65    let yaml_block = {
66        let mut close_pos = None;
67        for (i, _) in after_open.char_indices() {
68            let rest = &after_open[i..];
69            if (i == 0 || after_open.as_bytes().get(i - 1) == Some(&b'\n'))
70                && rest.starts_with("---")
71            {
72                close_pos = Some(i);
73                break;
74            }
75        }
76        match close_pos {
77            Some(pos) => &after_open[..pos],
78            None => return (None, vec![]),
79        }
80    };
81
82    // Parse as generic YAML Value — tolerates any unknown fields.
83    let value: serde_yaml::Value = match serde_yaml::from_str(yaml_block) {
84        Ok(v) => v,
85        Err(_) => return (None, vec![]),
86    };
87
88    let map = match value.as_mapping() {
89        Some(m) => m,
90        None => return (None, vec![]),
91    };
92
93    // Extract description.
94    let description = map
95        .get("description")
96        .and_then(|v| v.as_str())
97        .map(|s| s.trim().to_string())
98        .filter(|s| !s.is_empty());
99
100    // Extract tags: check top-level `tags:` first, then `intentry.tags`.
101    let tags: Vec<String> = map
102        .get("tags")
103        .and_then(|v| v.as_sequence())
104        .map(|seq| {
105            seq.iter()
106                .filter_map(|v| v.as_str().map(str::to_string))
107                .collect()
108        })
109        .or_else(|| {
110            map.get("intentry")
111                .and_then(|v| v.as_mapping())
112                .and_then(|m| m.get("tags"))
113                .and_then(|v| v.as_sequence())
114                .map(|seq| {
115                    seq.iter()
116                        .filter_map(|v| v.as_str().map(str::to_string))
117                        .collect()
118                })
119        })
120        .unwrap_or_default();
121
122    (description, tags)
123}