1use crate::check::{check_responses, CheckReport, REQUIRED_PREFIX};
4use crate::corpus::load_response;
5use crate::jira_import::slugify;
6use anyhow::{bail, Context, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub const TEAM_SIGN_OFF: &str = "Regards,\nSupport Team\n";
12
13#[derive(Debug, Clone)]
15pub struct ScaffoldOptions {
16 pub title: String,
18 pub id: Option<String>,
20 pub sop: String,
22 pub tags: Vec<String>,
24 pub body: Option<String>,
26}
27
28impl Default for ScaffoldOptions {
29 fn default() -> Self {
30 Self {
31 title: String::new(),
32 id: None,
33 sop: "none".into(),
34 tags: Vec::new(),
35 body: None,
36 }
37 }
38}
39
40pub fn resolve_response_id(opts: &ScaffoldOptions) -> Result<String> {
42 let raw = if let Some(ref id) = opts.id {
43 id.trim().to_string()
44 } else {
45 let slug = slugify(&opts.title);
46 format!("{REQUIRED_PREFIX}-{slug}")
47 };
48 if raw.is_empty() {
49 bail!("response id is empty");
50 }
51 if !raw.starts_with(&format!("{REQUIRED_PREFIX}-")) {
52 bail!(
53 "id must start with `{REQUIRED_PREFIX}-` (got `{raw}`); pass --id or a title that slugifies cleanly"
54 );
55 }
56 let rest = &raw[REQUIRED_PREFIX.len() + 1..];
58 if rest.is_empty() || !rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
59 bail!("id `{raw}` contains characters that are not safe for a markdown filename stem");
60 }
61 Ok(raw)
62}
63
64pub fn scaffold_markdown(opts: &ScaffoldOptions) -> Result<String> {
66 if opts.title.trim().is_empty() {
67 bail!("title must be non-empty");
68 }
69 let id = resolve_response_id(opts)?;
70 let sop = opts.sop.trim();
71 if sop.is_empty() {
72 bail!("sop must be non-empty (use the literal `none` if there is no SOP URL)");
73 }
74 let tags = if opts.tags.is_empty() {
75 "[]".to_string()
76 } else {
77 format!(
78 "[{}]",
79 opts.tags
80 .iter()
81 .map(|t| t.trim())
82 .filter(|t| !t.is_empty())
83 .collect::<Vec<_>>()
84 .join(", ")
85 )
86 };
87 let body = opts
88 .body
89 .as_deref()
90 .map(str::trim)
91 .filter(|s| !s.is_empty())
92 .unwrap_or(
93 "Replace this paragraph with the shared team answer. Keep the team sign-off below.",
94 );
95 let title = opts.title.trim();
96 Ok(format!(
97 "---\nid: {id}\ntitle: {title}\nprefix: {REQUIRED_PREFIX}\ntags: {tags}\nsop: {sop}\n---\n\n# {title}\n\nHello,\n\n{body}\n\n{TEAM_SIGN_OFF}"
98 ))
99}
100
101pub fn write_scaffold(dir: &Path, opts: &ScaffoldOptions, force: bool) -> Result<PathBuf> {
103 let id = resolve_response_id(opts)?;
104 let text = scaffold_markdown(opts)?;
105 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
106 let path = dir.join(format!("{id}.md"));
107 if path.exists() && !force {
108 bail!(
109 "refusing to overwrite {} (pass --force to replace)",
110 path.display()
111 );
112 }
113 fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
114 Ok(path)
115}
116
117pub fn check_response_path(path: &Path) -> Result<CheckReport> {
119 let doc = load_response(path)?;
120 Ok(check_responses(&[doc]))
121}
122
123pub fn promote_to_corpus(src: &Path, dest_dir: &Path, force: bool) -> Result<PathBuf> {
128 if !src.is_file() {
129 bail!("promote source is not a file: {}", src.display());
130 }
131 let report = check_response_path(src)?;
132 if !report.ok() {
133 let detail = report
134 .findings
135 .iter()
136 .map(|f| format!(" [{}] {}", f.code, f.message))
137 .collect::<Vec<_>>()
138 .join("\n");
139 bail!(
140 "refuse to promote {}: quality check failed ({} finding(s)):\n{detail}\nEdit the draft until `canonic check --corpus <dir>` is clean, then promote again.",
141 src.display(),
142 report.findings.len()
143 );
144 }
145 let doc = load_response(src)?;
146 let dest = dest_dir.join(format!("{}.md", doc.id));
147 if dest.exists() && !force {
148 bail!(
149 "refusing to overwrite published response {} (pass --force to replace)",
150 dest.display()
151 );
152 }
153 fs::create_dir_all(dest_dir).with_context(|| format!("create {}", dest_dir.display()))?;
154 fs::copy(src, &dest).with_context(|| {
155 format!(
156 "copy {} → {}",
157 src.display(),
158 dest.display()
159 )
160 })?;
161 let after = check_response_path(&dest)?;
163 if !after.ok() {
164 let _ = fs::remove_file(&dest);
165 bail!(
166 "promoted file failed quality check after copy: {:?}",
167 after.findings
168 );
169 }
170 Ok(dest)
171}
172
173#[cfg(test)]
175fn from_markdown_for_test(path: &Path, text: &str) -> Result<crate::corpus::CannedResponse> {
176 fs::write(path, text)?;
177 load_response(path)
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use crate::check::check_responses;
184 use tempfile::tempdir;
185
186 #[test]
187 fn scaffold_markdown_is_check_clean() {
188 let opts = ScaffoldOptions {
189 title: "Project space is not a backup".into(),
190 id: None,
191 sop: "none".into(),
192 tags: vec!["storage".into()],
193 body: Some(
194 "Shared project storage is for active working data, not long-term backup."
195 .into(),
196 ),
197 };
198 let md = scaffold_markdown(&opts).unwrap();
199 assert!(md.contains("prefix: snell"));
200 assert!(md.contains("id: snell-project-space-is-not-a-backup"));
201 assert!(md.contains("Support Team"));
202 let dir = tempdir().unwrap();
203 let path = dir.path().join("snell-project-space-is-not-a-backup.md");
204 let doc = from_markdown_for_test(&path, &md).unwrap();
205 let report = check_responses(&[doc]);
206 assert!(report.ok(), "{:?}", report.findings);
207 }
208
209 #[test]
210 fn resolve_id_rejects_non_snell_prefix() {
211 let opts = ScaffoldOptions {
212 title: "X".into(),
213 id: Some("personal-foo".into()),
214 ..Default::default()
215 };
216 assert!(resolve_response_id(&opts).is_err());
217 }
218
219 #[test]
220 fn write_scaffold_and_check_path() {
221 let dir = tempdir().unwrap();
222 let opts = ScaffoldOptions {
223 title: "Queue limits".into(),
224 id: Some("snell-queue-limits".into()),
225 sop: "none".into(),
226 tags: vec![],
227 body: Some("Use the documented partition limits.".into()),
228 };
229 let path = write_scaffold(dir.path(), &opts, false).unwrap();
230 assert!(path.ends_with("snell-queue-limits.md"));
231 let report = check_response_path(&path).unwrap();
232 assert!(report.ok(), "{:?}", report.findings);
233 }
234
235 #[test]
236 fn promote_rejects_dirty_draft() {
237 let dir = tempdir().unwrap();
238 let src = dir.path().join("password-reset.md");
239 fs::write(&src, "# no front matter\n\nRegards,\nAlice\n").unwrap();
240 let dest = dir.path().join("out");
241 let err = promote_to_corpus(&src, &dest, false).unwrap_err();
242 assert!(
243 err.to_string().contains("quality check failed")
244 || err.to_string().contains("refuse"),
245 "{err}"
246 );
247 }
248
249 #[test]
250 fn promote_copies_clean_draft() {
251 let dir = tempdir().unwrap();
252 let opts = ScaffoldOptions {
253 title: "Module environments".into(),
254 id: Some("snell-module-environments".into()),
255 sop: "none".into(),
256 tags: vec!["software".into()],
257 body: Some("Load the module system before running jobs.".into()),
258 };
259 let imports = dir.path().join("imports");
260 let responses = dir.path().join("responses");
261 let src = write_scaffold(&imports, &opts, false).unwrap();
262 let dest = promote_to_corpus(&src, &responses, false).unwrap();
263 assert!(dest.exists());
264 assert!(src.exists(), "source draft kept for human cleanup");
265 assert_eq!(
266 dest.file_name().unwrap().to_str().unwrap(),
267 "snell-module-environments.md"
268 );
269 assert!(check_response_path(&dest).unwrap().ok());
270 }
271}