Skip to main content

tsx_forge/
metadata.rs

1//! Frontmatter parser for forge knowledge files.
2//!
3//! Parses the `---\nkey: value\n---\nbody` format used in framework knowledge/*.md files.
4
5use serde::{Deserialize, Serialize};
6
7/// Metadata extracted from a knowledge file's frontmatter.
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct FrontMatter {
10    #[serde(default)]
11    pub id: Option<String>,
12    #[serde(default)]
13    pub question: Option<String>,
14    /// Rough token count for the file body. Used for token-budget responses.
15    #[serde(default)]
16    pub token_estimate: Option<u32>,
17    #[serde(default)]
18    pub tags: Vec<String>,
19    #[serde(default)]
20    pub requires: Vec<String>,
21    #[serde(default)]
22    pub related: Vec<String>,
23}
24
25/// Parse `---\nkey: value\n---\nbody...` into `(FrontMatter, body)`.
26/// If no frontmatter delimiters are found, returns default metadata and the full content as body.
27pub fn parse(content: &str) -> (FrontMatter, String) {
28    let content = content.trim_start();
29    if !content.starts_with("---") {
30        return (FrontMatter::default(), content.to_string());
31    }
32
33    let after_open = &content[3..];
34    // Find the closing ---
35    let Some(end_pos) = after_open.find("\n---") else {
36        return (FrontMatter::default(), content.to_string());
37    };
38
39    let yaml = after_open[..end_pos].trim();
40    let body = after_open[end_pos + 4..]
41        .trim_start_matches('\n')
42        .to_string();
43
44    let fm = parse_yaml(yaml);
45    (fm, body)
46}
47
48fn parse_yaml(yaml: &str) -> FrontMatter {
49    let mut fm = FrontMatter::default();
50    for line in yaml.lines() {
51        let line = line.trim();
52        if line.is_empty() || line.starts_with('#') {
53            continue;
54        }
55        let Some(colon) = line.find(':') else {
56            continue;
57        };
58        let key = line[..colon].trim();
59        let value = line[colon + 1..].trim();
60        match key {
61            "id" => fm.id = Some(value.to_string()),
62            "question" => fm.question = Some(value.to_string()),
63            "token_estimate" => fm.token_estimate = value.parse().ok(),
64            "tags" => fm.tags = parse_list(value),
65            "requires" => fm.requires = parse_list(value),
66            "related" => fm.related = parse_list(value),
67            _ => {}
68        }
69    }
70    fm
71}
72
73fn parse_list(value: &str) -> Vec<String> {
74    let value = value.trim();
75    if value.starts_with('[') && value.ends_with(']') {
76        let inner = &value[1..value.len() - 1];
77        inner
78            .split(',')
79            .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
80            .filter(|s| !s.is_empty())
81            .collect()
82    } else if !value.is_empty() {
83        vec![value.to_string()]
84    } else {
85        vec![]
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn parses_full_frontmatter() {
95        let src = "---\nid: add-auth\ntoken_estimate: 120\ntags: [auth, security]\nrelated: [add-migration]\n---\n\n## Body text";
96        let (fm, body) = parse(src);
97        assert_eq!(fm.id.as_deref(), Some("add-auth"));
98        assert_eq!(fm.token_estimate, Some(120));
99        assert_eq!(fm.tags, vec!["auth", "security"]);
100        assert_eq!(fm.related, vec!["add-migration"]);
101        assert!(body.contains("Body text"));
102    }
103
104    #[test]
105    fn returns_defaults_when_no_frontmatter() {
106        let src = "## Just a body";
107        let (fm, body) = parse(src);
108        assert!(fm.id.is_none());
109        assert_eq!(body, "## Just a body");
110    }
111
112    #[test]
113    fn handles_question_field() {
114        let src = "---\nquestion: How do I add auth?\n---\nAnswer here.";
115        let (fm, body) = parse(src);
116        assert_eq!(fm.question.as_deref(), Some("How do I add auth?"));
117        assert_eq!(body, "Answer here.");
118    }
119}