1use crate::plugin::{Plugin, PluginContext};
10use crate::MAX_DIR_DEPTH;
11use anyhow::Result;
12use std::{
13 fs,
14 path::{Path, PathBuf},
15};
16
17#[derive(Debug, Clone, Copy)]
23pub struct DraftPlugin {
24 include_drafts: bool,
25}
26
27impl DraftPlugin {
28 #[must_use]
32 pub const fn new(include_drafts: bool) -> Self {
33 Self { include_drafts }
34 }
35}
36
37impl Plugin for DraftPlugin {
38 fn name(&self) -> &'static str {
39 "drafts"
40 }
41
42 fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
43 if self.include_drafts || !ctx.content_dir.exists() {
44 return Ok(());
45 }
46
47 let md_files = collect_md_files(&ctx.content_dir)?;
48 let mut hidden = 0usize;
49
50 for path in &md_files {
51 if is_draft(path)? {
52 let draft_path = path.with_extension("md.draft");
53 fs::rename(path, &draft_path)?;
54 hidden += 1;
55 }
56 }
57
58 if hidden > 0 {
59 log::info!(
60 "[drafts] Hidden {hidden} draft file(s) (use --drafts to include)"
61 );
62 }
63 Ok(())
64 }
65
66 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
67 if self.include_drafts || !ctx.content_dir.exists() {
68 return Ok(());
69 }
70
71 let draft_files = collect_draft_files(&ctx.content_dir)?;
73 for draft_path in &draft_files {
74 let original = draft_path.with_extension("");
75 if !original.exists() {
76 fs::rename(draft_path, &original)?;
77 }
78 }
79 Ok(())
80 }
81}
82
83fn is_draft(path: &Path) -> Result<bool> {
85 let content = fs::read_to_string(path)?;
86
87 if !content.starts_with("---") {
89 return Ok(false);
90 }
91
92 if let Some(end) = content[3..].find("---") {
94 let frontmatter = &content[3..3 + end];
95 for line in frontmatter.lines() {
97 let trimmed = line.trim();
98 if trimmed == "draft: true"
99 || trimmed == "draft: True"
100 || trimmed == "draft: TRUE"
101 || trimmed == "draft: yes"
102 {
103 return Ok(true);
104 }
105 }
106 }
107
108 Ok(false)
109}
110
111fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>> {
112 crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
113}
114
115fn collect_draft_files(dir: &Path) -> Result<Vec<PathBuf>> {
116 crate::walk::walk_files_bounded_depth(dir, "draft", MAX_DIR_DEPTH)
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used, clippy::expect_used)]
121mod tests {
122 use super::*;
123 use crate::test_support::init_logger;
124 use std::path::PathBuf;
125 use tempfile::{tempdir, TempDir};
126
127 fn make_content_layout() -> (TempDir, PathBuf, PluginContext) {
134 init_logger();
135 let dir = tempdir().expect("create tempdir");
136 let content = dir.path().join("content");
137 fs::create_dir_all(&content).expect("mkdir content");
138 let ctx =
139 PluginContext::new(&content, dir.path(), dir.path(), dir.path());
140 (dir, content, ctx)
141 }
142
143 fn write_md(dir: &Path, name: &str, draft_value: Option<&str>) {
145 let body = match draft_value {
146 Some(v) => format!("---\ntitle: T\ndraft: {v}\n---\nbody"),
147 None => "---\ntitle: T\n---\nbody".to_string(),
148 };
149 fs::write(dir.join(name), body).expect("write md");
150 }
151
152 #[test]
157 fn new_table_driven_constructs_plugin_with_supplied_flag() {
158 let cases = [(true, true), (false, false)];
159 for (input, expected) in cases {
160 let plugin = DraftPlugin::new(input);
161 assert_eq!(
162 plugin.include_drafts, expected,
163 "include_drafts({input}) should be {expected}"
164 );
165 }
166 }
167
168 #[test]
169 fn draft_plugin_is_copy_after_move() {
170 let plugin = DraftPlugin::new(false);
172 let _copy = plugin;
173 assert_eq!(plugin.name(), "drafts");
174 }
175
176 #[test]
177 fn name_returns_static_drafts_identifier() {
178 assert_eq!(DraftPlugin::new(false).name(), "drafts");
179 assert_eq!(DraftPlugin::new(true).name(), "drafts");
180 }
181
182 #[test]
187 fn is_draft_table_driven_truthy_values_return_true() {
188 let cases: &[&str] = &[
189 "---\ntitle: T\ndraft: true\n---\n",
190 "---\ntitle: T\ndraft: True\n---\n",
191 "---\ntitle: T\ndraft: TRUE\n---\n",
192 "---\ntitle: T\ndraft: yes\n---\n",
193 "---\ntitle: T\n draft: true \n---\n",
195 ];
196 let dir = tempdir().expect("tempdir");
197 for (i, body) in cases.iter().enumerate() {
198 let path = dir.path().join(format!("d{i}.md"));
199 fs::write(&path, body).unwrap();
200 assert!(
201 is_draft(&path).unwrap(),
202 "case {i} {body:?} should be detected as draft"
203 );
204 }
205 }
206
207 #[test]
208 fn is_draft_table_driven_falsy_values_return_false() {
209 let cases: &[&str] = &[
210 "---\ntitle: T\ndraft: false\n---\n",
212 "---\ntitle: T\ndraft: maybe\n---\n",
214 "---\ntitle: T\n---\n",
216 "---\ntitle: T\ndraft: tRue\n---\n",
220 "---\ntitle: T\ndraft: Yes\n---\n",
221 ];
222 let dir = tempdir().expect("tempdir");
223 for (i, body) in cases.iter().enumerate() {
224 let path = dir.path().join(format!("p{i}.md"));
225 fs::write(&path, body).unwrap();
226 assert!(
227 !is_draft(&path).unwrap(),
228 "case {i} {body:?} should NOT be detected as draft"
229 );
230 }
231 }
232
233 #[test]
234 fn is_draft_no_frontmatter_returns_false() {
235 let dir = tempdir().expect("tempdir");
237 let path = dir.path().join("plain.md");
238 fs::write(&path, "# No frontmatter\nJust prose.\n").unwrap();
239 assert!(!is_draft(&path).unwrap());
240 }
241
242 #[test]
243 fn is_draft_unterminated_frontmatter_returns_false() {
244 let dir = tempdir().expect("tempdir");
248 let path = dir.path().join("unterminated.md");
249 fs::write(&path, "---\ntitle: T\ndraft: true\nno closing fence here\n")
250 .unwrap();
251 assert!(!is_draft(&path).unwrap());
252 }
253
254 #[test]
255 fn is_draft_empty_file_returns_false() {
256 let dir = tempdir().expect("tempdir");
257 let path = dir.path().join("empty.md");
258 fs::write(&path, "").unwrap();
259 assert!(!is_draft(&path).unwrap());
260 }
261
262 #[test]
263 fn is_draft_missing_file_returns_err() {
264 let dir = tempdir().expect("tempdir");
265 let result = is_draft(&dir.path().join("does-not-exist.md"));
266 assert!(result.is_err());
267 }
268
269 #[test]
274 fn before_compile_with_include_drafts_does_not_rename_anything() {
275 let (_tmp, content, ctx) = make_content_layout();
276 write_md(&content, "draft.md", Some("true"));
277 write_md(&content, "published.md", None);
278
279 DraftPlugin::new(true).before_compile(&ctx).unwrap();
280 assert!(content.join("draft.md").exists());
281 assert!(!content.join("draft.md.draft").exists());
282 assert!(content.join("published.md").exists());
283 }
284
285 #[test]
286 fn before_compile_missing_content_dir_returns_ok() {
287 let dir = tempdir().expect("tempdir");
289 let missing = dir.path().join("missing-content");
290 let ctx =
291 PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
292
293 DraftPlugin::new(false)
294 .before_compile(&ctx)
295 .expect("missing content dir is not an error");
296 assert!(!missing.exists());
297 }
298
299 #[test]
300 fn before_compile_renames_only_drafts_leaves_published_intact() {
301 let (_tmp, content, ctx) = make_content_layout();
302 write_md(&content, "draft.md", Some("true"));
303 write_md(&content, "published.md", None);
304
305 DraftPlugin::new(false).before_compile(&ctx).unwrap();
306 assert!(!content.join("draft.md").exists());
307 assert!(content.join("draft.md.draft").exists());
308 assert!(content.join("published.md").exists());
309 }
310
311 #[test]
312 fn before_compile_recurses_into_subdirectories() {
313 let (_tmp, content, ctx) = make_content_layout();
316 let nested = content.join("blog").join("2026");
317 fs::create_dir_all(&nested).unwrap();
318 write_md(&nested, "secret.md", Some("true"));
319 write_md(&content, "live.md", None);
320
321 DraftPlugin::new(false).before_compile(&ctx).unwrap();
322 assert!(nested.join("secret.md.draft").exists());
323 assert!(!nested.join("secret.md").exists());
324 assert!(content.join("live.md").exists());
325 }
326
327 #[test]
328 fn before_compile_no_drafts_yields_no_renames() {
329 let (_tmp, content, ctx) = make_content_layout();
330 write_md(&content, "a.md", None);
331 write_md(&content, "b.md", Some("false"));
332
333 DraftPlugin::new(false).before_compile(&ctx).unwrap();
334 assert!(content.join("a.md").exists());
335 assert!(content.join("b.md").exists());
336 }
337
338 #[test]
343 fn after_compile_with_include_drafts_short_circuits() {
344 let (_tmp, content, ctx) = make_content_layout();
347 fs::write(content.join("ghost.md.draft"), "---\n---\n").unwrap();
349
350 DraftPlugin::new(true).after_compile(&ctx).unwrap();
351 assert!(content.join("ghost.md.draft").exists());
352 assert!(!content.join("ghost.md").exists());
353 }
354
355 #[test]
356 fn after_compile_missing_content_dir_returns_ok() {
357 let dir = tempdir().expect("tempdir");
358 let missing = dir.path().join("missing");
359 let ctx =
360 PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
361 DraftPlugin::new(false).after_compile(&ctx).unwrap();
362 }
363
364 #[test]
365 fn after_compile_restores_draft_extension_to_md() {
366 let (_tmp, content, ctx) = make_content_layout();
367 fs::write(content.join("post.md.draft"), "---\n---\n").unwrap();
368
369 DraftPlugin::new(false).after_compile(&ctx).unwrap();
370 assert!(content.join("post.md").exists());
371 assert!(!content.join("post.md.draft").exists());
372 }
373
374 #[test]
375 fn after_compile_does_not_overwrite_existing_original() {
376 let (_tmp, content, ctx) = make_content_layout();
380 fs::write(content.join("post.md"), "USER WROTE THIS").unwrap();
381 fs::write(content.join("post.md.draft"), "STALE DRAFT").unwrap();
382
383 DraftPlugin::new(false).after_compile(&ctx).unwrap();
384 let body = fs::read_to_string(content.join("post.md")).unwrap();
385 assert_eq!(
386 body, "USER WROTE THIS",
387 "existing original must not be clobbered"
388 );
389 assert!(
390 content.join("post.md.draft").exists(),
391 "stale draft is left in place when original exists"
392 );
393 }
394
395 #[test]
396 fn before_and_after_round_trip_restores_original_content() {
397 let (_tmp, content, ctx) = make_content_layout();
400 let payload = "---\ntitle: T\ndraft: true\n---\nDRAFT BODY";
401 fs::write(content.join("d.md"), payload).unwrap();
402
403 let plugin = DraftPlugin::new(false);
404 plugin.before_compile(&ctx).unwrap();
405 plugin.after_compile(&ctx).unwrap();
406
407 let restored = fs::read_to_string(content.join("d.md")).unwrap();
408 assert_eq!(restored, payload);
409 }
410
411 #[test]
416 fn collect_md_files_returns_empty_for_missing_directory() {
417 let dir = tempdir().expect("tempdir");
418 let result = collect_md_files(&dir.path().join("missing")).unwrap();
419 assert!(result.is_empty());
420 }
421
422 #[test]
423 fn collect_md_files_filters_non_md_extensions() {
424 let dir = tempdir().expect("tempdir");
425 fs::write(dir.path().join("a.md"), "").unwrap();
426 fs::write(dir.path().join("b.txt"), "").unwrap();
427 fs::write(dir.path().join("c.html"), "").unwrap();
428
429 let result = collect_md_files(dir.path()).unwrap();
430 assert_eq!(result.len(), 1);
431 }
432
433 #[test]
434 fn collect_md_files_recurses_into_nested_subdirectories() {
435 let dir = tempdir().expect("tempdir");
436 let nested = dir.path().join("a").join("b");
437 fs::create_dir_all(&nested).unwrap();
438 fs::write(dir.path().join("top.md"), "").unwrap();
439 fs::write(nested.join("deep.md"), "").unwrap();
440
441 let result = collect_md_files(dir.path()).unwrap();
442 assert_eq!(result.len(), 2);
443 }
444
445 #[test]
446 fn collect_draft_files_filters_non_draft_extensions() {
447 let dir = tempdir().expect("tempdir");
448 fs::write(dir.path().join("a.md.draft"), "").unwrap();
449 fs::write(dir.path().join("b.md"), "").unwrap();
450
451 let result = collect_draft_files(dir.path()).unwrap();
452 assert_eq!(result.len(), 1);
453 }
454
455 #[test]
456 fn collect_draft_files_respects_max_dir_depth_guard() {
457 let dir = tempdir().expect("tempdir");
460 let mut current = dir.path().to_path_buf();
461 for i in 0..MAX_DIR_DEPTH + 2 {
462 current = current.join(format!("d{i}"));
463 fs::create_dir_all(¤t).unwrap();
464 fs::write(current.join("p.md.draft"), "").unwrap();
465 }
466 let result = collect_draft_files(dir.path()).unwrap();
467 assert!(result.len() <= MAX_DIR_DEPTH + 1);
469 }
470
471 #[test]
472 fn collect_md_files_respects_max_dir_depth_guard() {
473 let dir = tempdir().expect("tempdir");
474 let mut current = dir.path().to_path_buf();
475 for i in 0..MAX_DIR_DEPTH + 2 {
476 current = current.join(format!("d{i}"));
477 fs::create_dir_all(¤t).unwrap();
478 fs::write(current.join("p.md"), "").unwrap();
479 }
480 let result = collect_md_files(dir.path()).unwrap();
481 assert!(result.len() <= MAX_DIR_DEPTH + 1);
482 }
483
484 #[test]
485 fn collect_draft_files_recurses_into_nested_subdirectories() {
486 let dir = tempdir().expect("tempdir");
487 let nested = dir.path().join("a");
488 fs::create_dir_all(&nested).unwrap();
489 fs::write(dir.path().join("top.md.draft"), "").unwrap();
490 fs::write(nested.join("nested.md.draft"), "").unwrap();
491
492 let result = collect_draft_files(dir.path()).unwrap();
493 assert_eq!(result.len(), 2);
494 }
495}