Skip to main content

canonic/
scaffold.rs

1//! Scaffold new `snell-` responses and promote import drafts into the published corpus.
2
3use 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
10/// Default team closing used by scaffolds (must pass personal-signature checks).
11pub const TEAM_SIGN_OFF: &str = "Regards,\nSupport Team\n";
12
13/// Options for rendering a new canned-response markdown file.
14#[derive(Debug, Clone)]
15pub struct ScaffoldOptions {
16    /// Human title (also used for the H1 and default id slug).
17    pub title: String,
18    /// Explicit id (`snell-…`); when `None`, derived as `snell-<slugify(title)>`.
19    pub id: Option<String>,
20    /// Front-matter `sop` (Confluence URL or `none`).
21    pub sop: String,
22    /// Optional tags (without brackets).
23    pub tags: Vec<String>,
24    /// Optional body paragraphs (between greeting and sign-off). Empty → placeholder.
25    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
40/// Resolve a stable `snell-` id from options (or title).
41pub 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    // Filename-safe: only slug characters after the prefix.
57    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
64/// Render check-clean markdown for a new response (pure; no IO).
65pub 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
101/// Write a scaffolded response under `dir` as `{id}.md`. Refuses to overwrite unless `force`.
102pub 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
117/// Quality-check a single on-disk markdown response (real check rules).
118pub fn check_response_path(path: &Path) -> Result<CheckReport> {
119    let doc = load_response(path)?;
120    Ok(check_responses(&[doc]))
121}
122
123/// Promote an import draft (or any review markdown) into the published corpus after `check`.
124///
125/// Copies `src` → `dest_dir/{stem}.md`. Never overwrites unless `force`. Source is left in place
126/// (human deletes import drafts after review).
127pub 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    // Re-check destination (filename/id alignment, duplicate path semantics).
162    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/// Write markdown and load as a response (path stem must match id).
174#[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}