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}