Skip to main content

dkp_core/okf/
parser.rs

1use std::path::Path;
2
3use crate::error::DkpResult;
4
5/// A parsed OKF concept file: YAML frontmatter + Markdown body.
6#[derive(Debug, Clone)]
7pub struct OkfConcept {
8    pub path: std::path::PathBuf,
9    pub frontmatter: serde_yaml::Value,
10    pub body: String,
11}
12
13/// Parse a single OKF concept file (Markdown with YAML frontmatter).
14pub fn parse_concept(path: &Path) -> DkpResult<OkfConcept> {
15    let content = std::fs::read_to_string(path)?;
16
17    if let Some(stripped) = content.strip_prefix("---\n")
18        && let Some(end) = stripped.find("\n---\n")
19    {
20        let yaml_str = &stripped[..end];
21        let body = stripped[end + 5..].to_string();
22        let frontmatter: serde_yaml::Value =
23            serde_yaml::from_str(yaml_str).map_err(|e| crate::error::DkpError::OkfFrontmatter {
24                file: path.display().to_string(),
25                reason: e.to_string(),
26            })?;
27        return Ok(OkfConcept {
28            path: path.to_path_buf(),
29            frontmatter,
30            body,
31        });
32    }
33
34    Err(crate::error::DkpError::OkfFrontmatter {
35        file: path.display().to_string(),
36        reason: "missing YAML frontmatter delimiters".to_string(),
37    })
38}
39
40/// Walk an OKF layer directory and parse all concept files.
41pub fn parse_okf_dir(okf_dir: &Path) -> DkpResult<Vec<OkfConcept>> {
42    let mut concepts = Vec::new();
43    fn walk(dir: &Path, out: &mut Vec<OkfConcept>) -> crate::error::DkpResult<()> {
44        for entry in std::fs::read_dir(dir)? {
45            let path = entry?.path();
46            if path.is_dir() {
47                walk(&path, out)?;
48            } else if path.extension().is_some_and(|e| e == "md") {
49                out.push(parse_concept(&path)?);
50            }
51        }
52        Ok(())
53    }
54    walk(okf_dir, &mut concepts)?;
55    Ok(concepts)
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61    use tempfile::TempDir;
62
63    #[test]
64    fn parse_concept_valid_frontmatter() {
65        let tmp = TempDir::new().unwrap();
66        let path = tmp.path().join("concept.md");
67        std::fs::write(
68            &path,
69            "---\ntype: concept\nname: Test\n---\nBody content.\n",
70        )
71        .unwrap();
72
73        let concept = parse_concept(&path).unwrap();
74        assert_eq!(
75            concept.frontmatter.get("type").and_then(|v| v.as_str()),
76            Some("concept")
77        );
78        assert_eq!(concept.body, "Body content.\n");
79    }
80
81    #[test]
82    fn parse_concept_missing_delimiters_errors() {
83        let tmp = TempDir::new().unwrap();
84        let path = tmp.path().join("concept.md");
85        std::fs::write(&path, "No frontmatter here.\n").unwrap();
86
87        let err = parse_concept(&path).unwrap_err();
88        assert!(matches!(err, crate::error::DkpError::OkfFrontmatter { .. }));
89    }
90
91    #[test]
92    fn parse_concept_invalid_yaml_errors() {
93        let tmp = TempDir::new().unwrap();
94        let path = tmp.path().join("concept.md");
95        std::fs::write(&path, "---\n[unclosed\n---\nBody.\n").unwrap();
96
97        let err = parse_concept(&path).unwrap_err();
98        assert!(matches!(err, crate::error::DkpError::OkfFrontmatter { .. }));
99    }
100
101    #[test]
102    fn parse_okf_dir_walks_nested_directories() {
103        let tmp = TempDir::new().unwrap();
104        let sub = tmp.path().join("nested");
105        std::fs::create_dir_all(&sub).unwrap();
106        std::fs::write(tmp.path().join("top.md"), "---\ntype: concept\n---\nTop.\n").unwrap();
107        std::fs::write(sub.join("child.md"), "---\ntype: concept\n---\nChild.\n").unwrap();
108        std::fs::write(tmp.path().join("not-markdown.txt"), "ignored").unwrap();
109
110        let concepts = parse_okf_dir(tmp.path()).unwrap();
111        assert_eq!(concepts.len(), 2);
112    }
113
114    #[test]
115    fn parse_okf_dir_empty_returns_empty_vec() {
116        let tmp = TempDir::new().unwrap();
117        let concepts = parse_okf_dir(tmp.path()).unwrap();
118        assert!(concepts.is_empty());
119    }
120}