Skip to main content

ijima_server/
doctrine.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Doctrine seed-pack format + ingest client.
5//!
6//! Doctrine entries are authored as markdown files in a Git repo (the
7//! "seed pack"), reviewed via PR, and mirrored into the service. The
8//! format is frontmatter + body — human-friendly for review:
9//!
10//! ```markdown
11//! ---
12//! id: doctrine-kai-vocabulary
13//! project: kai
14//! topic: vocabulary
15//! ---
16//!
17//! The canonical set of terms used across the Kai ecosystem...
18//! ```
19//!
20//! See `docs/DESIGN.md` D9 and `docs/discovery/memory-service-design.md`
21//! §1-2 for the doctrine tier's role.
22
23use std::path::{Path, PathBuf};
24
25use ijima_core::{IjimaError, Result};
26
27/// A parsed doctrine entry from a seed-pack markdown file.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct DoctrineEntry {
30    /// Stable identifier (becomes the memory id in `ns_doctrine`).
31    pub id: String,
32    /// Project namespace.
33    pub project: String,
34    /// Topic within the project.
35    pub topic: String,
36    /// The body content (markdown prose).
37    pub content: String,
38}
39
40/// Parses a doctrine markdown file: leading `---`-delimited frontmatter
41/// (flat `key: value` lines) + body.
42///
43/// # Errors
44///
45/// Returns [`IjimaError::InvalidInput`] if the frontmatter is missing,
46/// malformed, or lacks the required `id` field.
47pub 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    // Find the closing ---.
54    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
85/// Reads all `*.md` files from `dir` (non-recursive) and parses each.
86/// Returns `(path, entry)` pairs so callers can report which file failed.
87///
88/// # Errors
89///
90/// Returns [`IjimaError::Store`] on I/O failure or
91/// [`IjimaError::InvalidInput`] on a parse error.
92pub 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/// Ingests parsed doctrine entries into a running daemon via HTTP.
115/// Each entry is POSTed to `/doctrine` with the admin bearer token.
116/// Returns the count successfully ingested.
117///
118/// Requires the `cli` feature (reqwest).
119///
120/// # Errors
121///
122/// Returns [`IjimaError::Transport`] on any HTTP failure.
123#[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}