1use std::path::Path;
2
3use crate::error::DkpResult;
4
5#[derive(Debug, Clone)]
7pub struct OkfConcept {
8 pub path: std::path::PathBuf,
9 pub frontmatter: serde_yaml::Value,
10 pub body: String,
11}
12
13pub 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
40pub 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}