1use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Path, PathBuf};
7use walkdir::WalkDir;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct CannedResponse {
12 pub id: String,
14 pub title: String,
16 pub prefix: Option<String>,
18 pub sop: Option<String>,
20 pub body: String,
22 pub content: String,
24 pub path: PathBuf,
26 pub tags: Vec<String>,
28}
29
30pub fn default_corpus_dir() -> PathBuf {
32 PathBuf::from("corpus/responses")
33}
34
35pub 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
59pub 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
95fn 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}