1use std::path::{Path, PathBuf};
24
25use ijima_core::{IjimaError, Result};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DoctrineEntry {
30 pub id: String,
32 pub project: String,
34 pub topic: String,
36 pub content: String,
38}
39
40pub fn parse_doctrine_file(text: &str) -> Result<DoctrineEntry> {
48 let trimmed = text.trim_start();
49 let after_delim = trimmed.strip_prefix("---").ok_or_else(|| {
50 IjimaError::invalid_input("doctrine file must start with --- frontmatter")
51 })?;
52
53 let close = after_delim
55 .find("\n---")
56 .ok_or_else(|| IjimaError::invalid_input("doctrine frontmatter missing closing ---"))?;
57 let frontmatter = &after_delim[..close];
58 let body = after_delim[close + "\n---".len()..].trim();
59
60 let mut id = None;
61 let mut project = None;
62 let mut topic = None;
63 for line in frontmatter.lines() {
64 let line = line.trim();
65 if let Some((k, v)) = line.split_once(':') {
66 let key = k.trim();
67 let val = v.trim().trim_matches('"');
68 match key {
69 "id" => id = Some(val.to_string()),
70 "project" => project = Some(val.to_string()),
71 "topic" => topic = Some(val.to_string()),
72 _ => {}
73 }
74 }
75 }
76
77 Ok(DoctrineEntry {
78 id: id.ok_or_else(|| IjimaError::invalid_input("doctrine entry missing 'id'"))?,
79 project: project.unwrap_or_else(|| "doctrine".into()),
80 topic: topic.unwrap_or_else(|| "general".into()),
81 content: body.to_string(),
82 })
83}
84
85pub fn read_doctrine_dir(dir: &Path) -> Result<Vec<(PathBuf, DoctrineEntry)>> {
93 let mut entries = Vec::new();
94 let read = std::fs::read_dir(dir).map_err(|e| IjimaError::Store {
95 detail: format!("read doctrine dir {}: {e}", dir.display()),
96 })?;
97 for item in read {
98 let path = item.map_err(|e| IjimaError::Store {
99 detail: format!("readdir: {e}"),
100 })?;
101 let path = path.path();
102 if path.extension().and_then(|e| e.to_str()) == Some("md") {
103 let text = std::fs::read_to_string(&path).map_err(|e| IjimaError::Store {
104 detail: format!("read {}: {e}", path.display()),
105 })?;
106 let entry = parse_doctrine_file(&text)?;
107 entries.push((path, entry));
108 }
109 }
110 entries.sort_by(|a, b| a.1.id.cmp(&b.1.id));
111 Ok(entries)
112}
113
114#[cfg(feature = "cli")]
124pub async fn ingest_to_daemon(url: &str, token: &str, entries: &[DoctrineEntry]) -> Result<usize> {
125 let client = reqwest::Client::new();
126 let endpoint = format!("{}/doctrine", url.trim_end_matches('/'));
127 let mut count = 0;
128 for entry in entries {
129 let resp = client
130 .post(&endpoint)
131 .bearer_auth(token)
132 .json(&serde_json::json!({
133 "id": entry.id,
134 "content": entry.content,
135 "project": entry.project,
136 "topic": entry.topic,
137 }))
138 .send()
139 .await
140 .map_err(|e| IjimaError::Transport {
141 detail: format!("ingest {}: {e}", entry.id),
142 })?;
143 resp.error_for_status().map_err(|e| IjimaError::Transport {
144 detail: format!("ingest {}: {e}", entry.id),
145 })?;
146 count += 1;
147 }
148 Ok(count)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn parses_frontmatter_and_body() {
157 let text =
158 "---\nid: doctrine-vocab\nproject: kai\ntopic: vocabulary\n---\n\nThe canonical terms.";
159 let entry = parse_doctrine_file(text).expect("parse");
160 assert_eq!(entry.id, "doctrine-vocab");
161 assert_eq!(entry.project, "kai");
162 assert_eq!(entry.topic, "vocabulary");
163 assert_eq!(entry.content, "The canonical terms.");
164 }
165
166 #[test]
167 fn defaults_project_and_topic_when_absent() {
168 let text = "---\nid: minimal\n---\nBody only.";
169 let entry = parse_doctrine_file(text).expect("parse");
170 assert_eq!(entry.id, "minimal");
171 assert_eq!(entry.project, "doctrine");
172 assert_eq!(entry.topic, "general");
173 assert_eq!(entry.content, "Body only.");
174 }
175
176 #[test]
177 fn rejects_missing_frontmatter() {
178 assert!(parse_doctrine_file("no frontmatter here").is_err());
179 }
180
181 #[test]
182 fn rejects_missing_closing_delimiter() {
183 assert!(parse_doctrine_file("---\nid: x\nbody without close").is_err());
184 }
185
186 #[test]
187 fn rejects_missing_id() {
188 assert!(parse_doctrine_file("---\nproject: x\n---\nbody").is_err());
189 }
190
191 #[test]
192 fn preserves_multiline_body() {
193 let text = "---\nid: multi\n---\nLine one.\n\nLine two.\n- bullet";
194 let entry = parse_doctrine_file(text).expect("parse");
195 assert_eq!(entry.content, "Line one.\n\nLine two.\n- bullet");
196 }
197
198 #[test]
199 fn strips_quoted_values() {
200 let text = "---\nid: \"quoted-id\"\nproject: \"quoted project\"\ntopic: x\n---\nbody";
201 let entry = parse_doctrine_file(text).expect("parse");
202 assert_eq!(entry.id, "quoted-id");
203 assert_eq!(entry.project, "quoted project");
204 }
205}