Skip to main content

ssg/plugins/
drafts.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Draft filtering plugin.
5//!
6//! Removes content files with `draft: true` in their frontmatter
7//! before compilation, unless the `--drafts` flag is set.
8
9use crate::error::{PathErrorExt, SsgError};
10use crate::plugin::{Plugin, PluginContext};
11use crate::MAX_DIR_DEPTH;
12use std::{
13    fs,
14    path::{Path, PathBuf},
15};
16
17/// Plugin that filters draft content before compilation.
18///
19/// In `before_compile`, scans content files for `draft: true` in
20/// frontmatter and renames them to `.md.draft` so staticdatagen
21/// skips them. In `after_compile`, restores the originals.
22#[derive(Debug, Clone, Copy)]
23pub struct DraftPlugin {
24    include_drafts: bool,
25}
26
27impl DraftPlugin {
28    /// Creates a new `DraftPlugin`.
29    ///
30    /// If `include_drafts` is true, draft files are left in place.
31    ///
32    /// # Examples
33    ///
34    /// ```rust
35    /// use ssg::drafts::DraftPlugin;
36    /// use ssg::plugin::Plugin;
37    ///
38    /// let p = DraftPlugin::new(false);
39    /// assert_eq!(p.name(), "drafts");
40    /// ```
41    #[must_use]
42    pub const fn new(include_drafts: bool) -> Self {
43        Self { include_drafts }
44    }
45}
46
47impl Plugin for DraftPlugin {
48    fn name(&self) -> &'static str {
49        "drafts"
50    }
51
52    fn before_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
53        if self.include_drafts || !ctx.content_dir.exists() {
54            return Ok(());
55        }
56
57        let md_files = collect_md_files(&ctx.content_dir)?;
58        let mut hidden = 0usize;
59
60        for path in &md_files {
61            if is_draft(path)? {
62                let draft_path = path.with_extension("md.draft");
63                fs::rename(path, &draft_path).with_path(path)?;
64                hidden += 1;
65            }
66        }
67
68        if hidden > 0 {
69            log::info!(
70                "[drafts] Hidden {hidden} draft file(s) (use --drafts to include)"
71            );
72        }
73        Ok(())
74    }
75
76    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
77        if self.include_drafts || !ctx.content_dir.exists() {
78            return Ok(());
79        }
80
81        // Restore hidden drafts
82        let draft_files = collect_draft_files(&ctx.content_dir)?;
83        for draft_path in &draft_files {
84            let original = draft_path.with_extension("");
85            if !original.exists() {
86                fs::rename(draft_path, &original).with_path(draft_path)?;
87            }
88        }
89        Ok(())
90    }
91}
92
93/// Checks if a Markdown file has `draft: true` in its frontmatter.
94fn is_draft(path: &Path) -> Result<bool, SsgError> {
95    let content = fs::read_to_string(path).with_path(path)?;
96
97    // Quick check: look for draft field in YAML frontmatter
98    if !content.starts_with("---") {
99        return Ok(false);
100    }
101
102    // Find the closing ---
103    if let Some(end) = content[3..].find("---") {
104        let frontmatter = &content[3..3 + end];
105        // Check for draft: true (handles various YAML formats)
106        for line in frontmatter.lines() {
107            let trimmed = line.trim();
108            if trimmed == "draft: true"
109                || trimmed == "draft: True"
110                || trimmed == "draft: TRUE"
111                || trimmed == "draft: yes"
112            {
113                return Ok(true);
114            }
115        }
116    }
117
118    Ok(false)
119}
120
121fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
122    crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
123}
124
125fn collect_draft_files(dir: &Path) -> Result<Vec<PathBuf>, SsgError> {
126    crate::walk::walk_files_bounded_depth(dir, "draft", MAX_DIR_DEPTH)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::test_support::init_logger;
133    use std::path::PathBuf;
134    use tempfile::{tempdir, TempDir};
135
136    // -------------------------------------------------------------------
137    // Test fixtures
138    // -------------------------------------------------------------------
139
140    /// Builds `<root>/content` and returns the temp dir guard, the
141    /// content path, and a `PluginContext` rooted at it.
142    fn make_content_layout() -> (TempDir, PathBuf, PluginContext) {
143        init_logger();
144        let dir = tempdir().expect("create tempdir");
145        let content = dir.path().join("content");
146        fs::create_dir_all(&content).expect("mkdir content");
147        let ctx =
148            PluginContext::new(&content, dir.path(), dir.path(), dir.path());
149        (dir, content, ctx)
150    }
151
152    /// Writes a Markdown file with the given frontmatter draft flag.
153    fn write_md(dir: &Path, name: &str, draft_value: Option<&str>) {
154        let body = match draft_value {
155            Some(v) => format!("---\ntitle: T\ndraft: {v}\n---\nbody"),
156            None => "---\ntitle: T\n---\nbody".to_string(),
157        };
158        fs::write(dir.join(name), body).expect("write md");
159    }
160
161    // -------------------------------------------------------------------
162    // Constructor + derive surface
163    // -------------------------------------------------------------------
164
165    #[test]
166    fn new_table_driven_constructs_plugin_with_supplied_flag() {
167        let cases = [(true, true), (false, false)];
168        for (input, expected) in cases {
169            let plugin = DraftPlugin::new(input);
170            assert_eq!(
171                plugin.include_drafts, expected,
172                "include_drafts({input}) should be {expected}"
173            );
174        }
175    }
176
177    #[test]
178    fn draft_plugin_is_copy_after_move() {
179        // Guards the `Copy` derive added in v0.0.34.
180        let plugin = DraftPlugin::new(false);
181        let _copy = plugin;
182        assert_eq!(plugin.name(), "drafts");
183    }
184
185    #[test]
186    fn name_returns_static_drafts_identifier() {
187        assert_eq!(DraftPlugin::new(false).name(), "drafts");
188        assert_eq!(DraftPlugin::new(true).name(), "drafts");
189    }
190
191    // -------------------------------------------------------------------
192    // is_draft — table-driven over every YAML truthy spelling
193    // -------------------------------------------------------------------
194
195    #[test]
196    fn is_draft_table_driven_truthy_values_return_true() {
197        let cases: &[&str] = &[
198            "---\ntitle: T\ndraft: true\n---\n",
199            "---\ntitle: T\ndraft: True\n---\n",
200            "---\ntitle: T\ndraft: TRUE\n---\n",
201            "---\ntitle: T\ndraft: yes\n---\n",
202            // leading/trailing whitespace on the line is trimmed
203            "---\ntitle: T\n  draft: true  \n---\n",
204        ];
205        let dir = tempdir().expect("tempdir");
206        for (i, body) in cases.iter().enumerate() {
207            let path = dir.path().join(format!("d{i}.md"));
208            fs::write(&path, body).unwrap();
209            assert!(
210                is_draft(&path).unwrap(),
211                "case {i} {body:?} should be detected as draft"
212            );
213        }
214    }
215
216    #[test]
217    fn is_draft_table_driven_falsy_values_return_false() {
218        let cases: &[&str] = &[
219            // explicit false
220            "---\ntitle: T\ndraft: false\n---\n",
221            // unrecognised value
222            "---\ntitle: T\ndraft: maybe\n---\n",
223            // missing field entirely
224            "---\ntitle: T\n---\n",
225            // YAML 1.2 strict — `True` is not the same as `true`
226            // in many parsers; we accept it (covered above) but
227            // `tRue` and `Yes` are NOT in the accepted list.
228            "---\ntitle: T\ndraft: tRue\n---\n",
229            "---\ntitle: T\ndraft: Yes\n---\n",
230        ];
231        let dir = tempdir().expect("tempdir");
232        for (i, body) in cases.iter().enumerate() {
233            let path = dir.path().join(format!("p{i}.md"));
234            fs::write(&path, body).unwrap();
235            assert!(
236                !is_draft(&path).unwrap(),
237                "case {i} {body:?} should NOT be detected as draft"
238            );
239        }
240    }
241
242    #[test]
243    fn is_draft_no_frontmatter_returns_false() {
244        // The `!content.starts_with("---")` early return at line 88.
245        let dir = tempdir().expect("tempdir");
246        let path = dir.path().join("plain.md");
247        fs::write(&path, "# No frontmatter\nJust prose.\n").unwrap();
248        assert!(!is_draft(&path).unwrap());
249    }
250
251    #[test]
252    fn is_draft_unterminated_frontmatter_returns_false() {
253        // The `if let Some(end) = ...find("---")` branch at line 93
254        // must take the implicit `None` path when the closing `---`
255        // is missing — function should return Ok(false), not error.
256        let dir = tempdir().expect("tempdir");
257        let path = dir.path().join("unterminated.md");
258        fs::write(&path, "---\ntitle: T\ndraft: true\nno closing fence here\n")
259            .unwrap();
260        assert!(!is_draft(&path).unwrap());
261    }
262
263    #[test]
264    fn is_draft_empty_file_returns_false() {
265        let dir = tempdir().expect("tempdir");
266        let path = dir.path().join("empty.md");
267        fs::write(&path, "").unwrap();
268        assert!(!is_draft(&path).unwrap());
269    }
270
271    #[test]
272    fn is_draft_missing_file_returns_err() {
273        let dir = tempdir().expect("tempdir");
274        let missing = dir.path().join("does-not-exist.md");
275        let result = is_draft(&missing);
276        assert!(result.is_err());
277        // Debug formatting carries both the variant name and the path,
278        // so this asserts the same facts as a `matches!` + field check
279        // without leaving an uncoverable non-Io match arm behind.
280        let msg = format!("{:?}", result.unwrap_err());
281        assert!(msg.contains("Io"), "expected Io variant, got: {msg}");
282        assert!(
283            msg.contains("does-not-exist.md"),
284            "error should carry the missing path: {msg}"
285        );
286    }
287
288    // -------------------------------------------------------------------
289    // before_compile — short-circuit paths
290    // -------------------------------------------------------------------
291
292    #[test]
293    fn before_compile_with_include_drafts_does_not_rename_anything() {
294        let (_tmp, content, ctx) = make_content_layout();
295        write_md(&content, "draft.md", Some("true"));
296        write_md(&content, "published.md", None);
297
298        DraftPlugin::new(true).before_compile(&ctx).unwrap();
299        assert!(content.join("draft.md").exists());
300        assert!(!content.join("draft.md.draft").exists());
301        assert!(content.join("published.md").exists());
302    }
303
304    #[test]
305    fn before_compile_missing_content_dir_returns_ok() {
306        // The `!ctx.content_dir.exists()` short-circuit at line 43.
307        let dir = tempdir().expect("tempdir");
308        let missing = dir.path().join("missing-content");
309        let ctx =
310            PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
311
312        DraftPlugin::new(false)
313            .before_compile(&ctx)
314            .expect("missing content dir is not an error");
315        assert!(!missing.exists());
316    }
317
318    #[test]
319    fn before_compile_renames_only_drafts_leaves_published_intact() {
320        let (_tmp, content, ctx) = make_content_layout();
321        write_md(&content, "draft.md", Some("true"));
322        write_md(&content, "published.md", None);
323
324        DraftPlugin::new(false).before_compile(&ctx).unwrap();
325        assert!(!content.join("draft.md").exists());
326        assert!(content.join("draft.md.draft").exists());
327        assert!(content.join("published.md").exists());
328    }
329
330    #[test]
331    fn before_compile_recurses_into_subdirectories() {
332        // collect_md_files walks the tree — drafts in nested dirs
333        // must also be hidden.
334        let (_tmp, content, ctx) = make_content_layout();
335        let nested = content.join("blog").join("2026");
336        fs::create_dir_all(&nested).unwrap();
337        write_md(&nested, "secret.md", Some("true"));
338        write_md(&content, "live.md", None);
339
340        DraftPlugin::new(false).before_compile(&ctx).unwrap();
341        assert!(nested.join("secret.md.draft").exists());
342        assert!(!nested.join("secret.md").exists());
343        assert!(content.join("live.md").exists());
344    }
345
346    #[test]
347    fn before_compile_no_drafts_yields_no_renames() {
348        let (_tmp, content, ctx) = make_content_layout();
349        write_md(&content, "a.md", None);
350        write_md(&content, "b.md", Some("false"));
351
352        DraftPlugin::new(false).before_compile(&ctx).unwrap();
353        assert!(content.join("a.md").exists());
354        assert!(content.join("b.md").exists());
355    }
356
357    // -------------------------------------------------------------------
358    // after_compile — restoration paths
359    // -------------------------------------------------------------------
360
361    #[test]
362    fn after_compile_with_include_drafts_short_circuits() {
363        // The `self.include_drafts` short-circuit at line 67 must not
364        // attempt to restore anything.
365        let (_tmp, content, ctx) = make_content_layout();
366        // Pre-place a .draft file to prove it is NOT touched.
367        fs::write(content.join("ghost.md.draft"), "---\n---\n").unwrap();
368
369        DraftPlugin::new(true).after_compile(&ctx).unwrap();
370        assert!(content.join("ghost.md.draft").exists());
371        assert!(!content.join("ghost.md").exists());
372    }
373
374    #[test]
375    fn after_compile_missing_content_dir_returns_ok() {
376        let dir = tempdir().expect("tempdir");
377        let missing = dir.path().join("missing");
378        let ctx =
379            PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
380        DraftPlugin::new(false).after_compile(&ctx).unwrap();
381    }
382
383    #[test]
384    fn after_compile_restores_draft_extension_to_md() {
385        let (_tmp, content, ctx) = make_content_layout();
386        fs::write(content.join("post.md.draft"), "---\n---\n").unwrap();
387
388        DraftPlugin::new(false).after_compile(&ctx).unwrap();
389        assert!(content.join("post.md").exists());
390        assert!(!content.join("post.md.draft").exists());
391    }
392
393    #[test]
394    fn after_compile_does_not_overwrite_existing_original() {
395        // The `if !original.exists()` guard at line 75 must skip the
396        // rename when an original-named file is already present.
397        // Otherwise we'd silently clobber user content.
398        let (_tmp, content, ctx) = make_content_layout();
399        fs::write(content.join("post.md"), "USER WROTE THIS").unwrap();
400        fs::write(content.join("post.md.draft"), "STALE DRAFT").unwrap();
401
402        DraftPlugin::new(false).after_compile(&ctx).unwrap();
403        let body = fs::read_to_string(content.join("post.md")).unwrap();
404        assert_eq!(
405            body, "USER WROTE THIS",
406            "existing original must not be clobbered"
407        );
408        assert!(
409            content.join("post.md.draft").exists(),
410            "stale draft is left in place when original exists"
411        );
412    }
413
414    #[test]
415    fn before_and_after_round_trip_restores_original_content() {
416        // End-to-end: hide a draft, then restore it, and prove the
417        // file is byte-identical to before.
418        let (_tmp, content, ctx) = make_content_layout();
419        let payload = "---\ntitle: T\ndraft: true\n---\nDRAFT BODY";
420        fs::write(content.join("d.md"), payload).unwrap();
421
422        let plugin = DraftPlugin::new(false);
423        plugin.before_compile(&ctx).unwrap();
424        plugin.after_compile(&ctx).unwrap();
425
426        let restored = fs::read_to_string(content.join("d.md")).unwrap();
427        assert_eq!(restored, payload);
428    }
429
430    // -------------------------------------------------------------------
431    // collect_md_files / collect_draft_files — recursion + filtering
432    // -------------------------------------------------------------------
433
434    #[test]
435    fn collect_md_files_returns_empty_for_missing_directory() {
436        let dir = tempdir().expect("tempdir");
437        let result = collect_md_files(&dir.path().join("missing")).unwrap();
438        assert!(result.is_empty());
439    }
440
441    #[test]
442    fn collect_md_files_filters_non_md_extensions() {
443        let dir = tempdir().expect("tempdir");
444        fs::write(dir.path().join("a.md"), "").unwrap();
445        fs::write(dir.path().join("b.txt"), "").unwrap();
446        fs::write(dir.path().join("c.html"), "").unwrap();
447
448        let result = collect_md_files(dir.path()).unwrap();
449        assert_eq!(result.len(), 1);
450    }
451
452    #[test]
453    fn collect_md_files_recurses_into_nested_subdirectories() {
454        let dir = tempdir().expect("tempdir");
455        let nested = dir.path().join("a").join("b");
456        fs::create_dir_all(&nested).unwrap();
457        fs::write(dir.path().join("top.md"), "").unwrap();
458        fs::write(nested.join("deep.md"), "").unwrap();
459
460        let result = collect_md_files(dir.path()).unwrap();
461        assert_eq!(result.len(), 2);
462    }
463
464    #[test]
465    fn collect_draft_files_filters_non_draft_extensions() {
466        let dir = tempdir().expect("tempdir");
467        fs::write(dir.path().join("a.md.draft"), "").unwrap();
468        fs::write(dir.path().join("b.md"), "").unwrap();
469
470        let result = collect_draft_files(dir.path()).unwrap();
471        assert_eq!(result.len(), 1);
472    }
473
474    #[test]
475    fn collect_draft_files_respects_max_dir_depth_guard() {
476        // The `depth > MAX_DIR_DEPTH` continue at line 135 is only
477        // reached with a tree deeper than the limit. Closes line 136.
478        let dir = tempdir().expect("tempdir");
479        let mut current = dir.path().to_path_buf();
480        for i in 0..MAX_DIR_DEPTH + 2 {
481            current = current.join(format!("d{i}"));
482            fs::create_dir_all(&current).unwrap();
483            fs::write(current.join("p.md.draft"), "").unwrap();
484        }
485        let result = collect_draft_files(dir.path()).unwrap();
486        // At most MAX_DIR_DEPTH+1 files (depths 0..=MAX) survive.
487        assert!(result.len() <= MAX_DIR_DEPTH + 1);
488    }
489
490    #[test]
491    fn collect_md_files_respects_max_dir_depth_guard() {
492        let dir = tempdir().expect("tempdir");
493        let mut current = dir.path().to_path_buf();
494        for i in 0..MAX_DIR_DEPTH + 2 {
495            current = current.join(format!("d{i}"));
496            fs::create_dir_all(&current).unwrap();
497            fs::write(current.join("p.md"), "").unwrap();
498        }
499        let result = collect_md_files(dir.path()).unwrap();
500        assert!(result.len() <= MAX_DIR_DEPTH + 1);
501    }
502
503    #[test]
504    fn collect_draft_files_recurses_into_nested_subdirectories() {
505        let dir = tempdir().expect("tempdir");
506        let nested = dir.path().join("a");
507        fs::create_dir_all(&nested).unwrap();
508        fs::write(dir.path().join("top.md.draft"), "").unwrap();
509        fs::write(nested.join("nested.md.draft"), "").unwrap();
510
511        let result = collect_draft_files(dir.path()).unwrap();
512        assert_eq!(result.len(), 2);
513    }
514
515    // -------------------------------------------------------------------
516    // I/O error propagation (unix permission fixtures)
517    // -------------------------------------------------------------------
518
519    /// Sets unix permission bits on `path` (test-only helper).
520    #[cfg(unix)]
521    fn chmod(path: &Path, mode: u32) {
522        use std::os::unix::fs::PermissionsExt;
523        fs::set_permissions(path, fs::Permissions::from_mode(mode))
524            .expect("set permissions");
525    }
526
527    #[test]
528    #[cfg(unix)]
529    fn before_compile_propagates_walk_error_from_unreadable_subdir() {
530        let (_tmp, content, ctx) = make_content_layout();
531        let locked = content.join("locked");
532        fs::create_dir_all(&locked).unwrap();
533        chmod(&locked, 0o000);
534
535        let result = DraftPlugin::new(false).before_compile(&ctx);
536        chmod(&locked, 0o755); // restore so tempdir cleanup succeeds
537        assert!(result.is_err(), "unreadable subdir must surface as Err");
538    }
539
540    #[test]
541    #[cfg(unix)]
542    fn before_compile_propagates_is_draft_read_error() {
543        let (_tmp, content, ctx) = make_content_layout();
544        write_md(&content, "secret.md", Some("true"));
545        chmod(&content.join("secret.md"), 0o000);
546
547        let result = DraftPlugin::new(false).before_compile(&ctx);
548        chmod(&content.join("secret.md"), 0o644);
549        assert!(result.is_err(), "unreadable md file must surface as Err");
550    }
551
552    #[test]
553    #[cfg(unix)]
554    fn before_compile_rename_failure_in_readonly_dir_is_propagated() {
555        let (_tmp, content, ctx) = make_content_layout();
556        write_md(&content, "draft.md", Some("true"));
557        // r-x: walking and reading still work, the rename does not.
558        chmod(&content, 0o555);
559
560        let result = DraftPlugin::new(false).before_compile(&ctx);
561        chmod(&content, 0o755);
562        assert!(result.is_err(), "rename in read-only dir must be an Err");
563        assert!(content.join("draft.md").exists());
564    }
565
566    #[test]
567    #[cfg(unix)]
568    fn after_compile_propagates_walk_error_from_unreadable_subdir() {
569        let (_tmp, content, ctx) = make_content_layout();
570        let locked = content.join("locked");
571        fs::create_dir_all(&locked).unwrap();
572        chmod(&locked, 0o000);
573
574        let result = DraftPlugin::new(false).after_compile(&ctx);
575        chmod(&locked, 0o755);
576        assert!(result.is_err(), "unreadable subdir must surface as Err");
577    }
578
579    #[test]
580    #[cfg(unix)]
581    fn after_compile_restore_rename_failure_is_propagated() {
582        let (_tmp, content, ctx) = make_content_layout();
583        fs::write(content.join("post.md.draft"), "---\n---\n").unwrap();
584        chmod(&content, 0o555);
585
586        let result = DraftPlugin::new(false).after_compile(&ctx);
587        chmod(&content, 0o755);
588        assert!(result.is_err(), "restore rename must surface as Err");
589        assert!(content.join("post.md.draft").exists());
590    }
591}