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