Skip to main content

canonic/
corpus.rs

1//! Load and walk version-controlled markdown canned responses.
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9/// One canned response derived from a markdown file under the corpus.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct CannedResponse {
12    /// Stable id (front matter `id` or file stem).
13    pub id: String,
14    /// Display title (front matter `title` or first heading / id).
15    pub title: String,
16    /// Shared prefix (front matter `prefix`, e.g. `snell`).
17    pub prefix: Option<String>,
18    /// Optional SOP link or the literal `none`.
19    pub sop: Option<String>,
20    /// Full markdown body (including optional front matter as stored on disk).
21    pub body: String,
22    /// Body with YAML front matter stripped, used for search/indexing.
23    pub content: String,
24    /// Absolute or relative path to the source markdown file.
25    pub path: PathBuf,
26    /// Optional tags from front matter.
27    pub tags: Vec<String>,
28}
29
30/// Default corpus directory relative to the current working directory / repo root.
31pub fn default_corpus_dir() -> PathBuf {
32    PathBuf::from("corpus/responses")
33}
34
35/// Walk `root` for `*.md` files and load each as a [`CannedResponse`].
36pub fn walk_responses(root: &Path) -> Result<Vec<CannedResponse>> {
37    if !root.exists() {
38        bail!("corpus directory does not exist: {}", root.display());
39    }
40    let mut out = Vec::new();
41    for entry in WalkDir::new(root)
42        .follow_links(false)
43        .into_iter()
44        .filter_map(|e| e.ok())
45    {
46        let path = entry.path();
47        if !path.is_file() {
48            continue;
49        }
50        if path.extension().and_then(|e| e.to_str()) != Some("md") {
51            continue;
52        }
53        out.push(load_response(path)?);
54    }
55    out.sort_by(|a, b| a.id.cmp(&b.id));
56    Ok(out)
57}
58
59/// Load a single markdown file into a [`CannedResponse`].
60pub fn load_response(path: &Path) -> Result<CannedResponse> {
61    let raw = fs::read_to_string(path)
62        .with_context(|| format!("read canned response {}", path.display()))?;
63    let stem = path
64        .file_stem()
65        .and_then(|s| s.to_str())
66        .unwrap_or("unknown")
67        .to_string();
68
69    let (fm, content) = split_front_matter(&raw);
70    let id = fm.get("id").cloned().unwrap_or_else(|| stem.clone());
71    let title = fm
72        .get("title")
73        .cloned()
74        .or_else(|| first_heading(&content))
75        .unwrap_or_else(|| id.clone());
76    let tags = fm
77        .get("tags")
78        .map(|s| parse_tags(s))
79        .unwrap_or_default();
80    let prefix = fm.get("prefix").cloned().filter(|s| !s.is_empty());
81    let sop = fm.get("sop").cloned().filter(|s| !s.is_empty());
82
83    Ok(CannedResponse {
84        id,
85        title,
86        prefix,
87        sop,
88        body: raw,
89        content,
90        path: path.to_path_buf(),
91        tags,
92    })
93}
94
95/// Minimal YAML front-matter parser for `key: value` and simple list tags.
96fn split_front_matter(raw: &str) -> (std::collections::HashMap<String, String>, String) {
97    let mut map = std::collections::HashMap::new();
98    let trimmed = raw.trim_start_matches('\u{feff}');
99    if !trimmed.starts_with("---") {
100        return (map, raw.to_string());
101    }
102    let rest = &trimmed[3..];
103    let rest = rest.trim_start_matches(['\r', '\n']);
104    if let Some(end) = rest.find("\n---") {
105        let block = &rest[..end];
106        let body = rest[end + 4..].trim_start_matches(['\r', '\n']).to_string();
107        for line in block.lines() {
108            let line = line.trim();
109            if line.is_empty() || line.starts_with('#') {
110                continue;
111            }
112            if let Some((k, v)) = line.split_once(':') {
113                let key = k.trim().to_string();
114                let val = v.trim().to_string();
115                map.insert(key, val);
116            }
117        }
118        return (map, body);
119    }
120    (map, raw.to_string())
121}
122
123fn parse_tags(s: &str) -> Vec<String> {
124    let s = s.trim();
125    if s.starts_with('[') && s.ends_with(']') {
126        s[1..s.len() - 1]
127            .split(',')
128            .map(|t| t.trim().trim_matches('"').trim_matches('\'').to_string())
129            .filter(|t| !t.is_empty())
130            .collect()
131    } else if s.is_empty() {
132        vec![]
133    } else {
134        vec![s.to_string()]
135    }
136}
137
138fn first_heading(content: &str) -> Option<String> {
139    for line in content.lines() {
140        let t = line.trim();
141        if let Some(rest) = t.strip_prefix('#') {
142            let title = rest.trim_start_matches('#').trim();
143            if !title.is_empty() {
144                return Some(title.to_string());
145            }
146        }
147    }
148    None
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::io::Write;
155
156    #[test]
157    fn load_parses_front_matter_and_body() {
158        let dir = tempfile::tempdir().unwrap();
159        let p = dir.path().join("sample.md");
160        let mut f = fs::File::create(&p).unwrap();
161        writeln!(
162            f,
163            "---\nid: snell-sample\ntitle: Sample Title\nprefix: snell\nsop: none\ntags: [a, b]\n---\n\n# Heading\n\nBody text.\n"
164        )
165        .unwrap();
166        let r = load_response(&p).unwrap();
167        assert_eq!(r.id, "snell-sample");
168        assert_eq!(r.title, "Sample Title");
169        assert_eq!(r.prefix.as_deref(), Some("snell"));
170        assert_eq!(r.sop.as_deref(), Some("none"));
171        assert_eq!(r.tags, vec!["a", "b"]);
172        assert!(r.content.contains("Body text"));
173        assert!(!r.content.contains("snell-sample"));
174    }
175
176    #[test]
177    fn walk_finds_markdown_files() {
178        let dir = tempfile::tempdir().unwrap();
179        fs::write(dir.path().join("one.md"), "# One\n\nalpha\n").unwrap();
180        fs::write(dir.path().join("two.md"), "# Two\n\nbeta\n").unwrap();
181        fs::write(dir.path().join("skip.txt"), "nope").unwrap();
182        let docs = walk_responses(dir.path()).unwrap();
183        assert_eq!(docs.len(), 2);
184        assert_eq!(docs[0].id, "one");
185        assert_eq!(docs[1].id, "two");
186    }
187}