1use 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#[derive(Debug, Clone, Copy)]
23pub struct DraftPlugin {
24 include_drafts: bool,
25}
26
27impl DraftPlugin {
28 #[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 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
93fn is_draft(path: &Path) -> Result<bool, SsgError> {
95 let content = fs::read_to_string(path).with_path(path)?;
96
97 if !content.starts_with("---") {
99 return Ok(false);
100 }
101
102 if let Some(end) = content[3..].find("---") {
104 let frontmatter = &content[3..3 + end];
105 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 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 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 #[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 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 #[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 "---\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 "---\ntitle: T\ndraft: false\n---\n",
221 "---\ntitle: T\ndraft: maybe\n---\n",
223 "---\ntitle: T\n---\n",
225 "---\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 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 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 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 #[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 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 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 #[test]
362 fn after_compile_with_include_drafts_short_circuits() {
363 let (_tmp, content, ctx) = make_content_layout();
366 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 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 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 #[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 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(¤t).unwrap();
483 fs::write(current.join("p.md.draft"), "").unwrap();
484 }
485 let result = collect_draft_files(dir.path()).unwrap();
486 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(¤t).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 #[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); 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 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}