use crate::plugin::{Plugin, PluginContext};
use crate::MAX_DIR_DEPTH;
use anyhow::Result;
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy)]
pub struct DraftPlugin {
include_drafts: bool,
}
impl DraftPlugin {
#[must_use]
pub const fn new(include_drafts: bool) -> Self {
Self { include_drafts }
}
}
impl Plugin for DraftPlugin {
fn name(&self) -> &'static str {
"drafts"
}
fn before_compile(&self, ctx: &PluginContext) -> Result<()> {
if self.include_drafts || !ctx.content_dir.exists() {
return Ok(());
}
let md_files = collect_md_files(&ctx.content_dir)?;
let mut hidden = 0usize;
for path in &md_files {
if is_draft(path)? {
let draft_path = path.with_extension("md.draft");
fs::rename(path, &draft_path)?;
hidden += 1;
}
}
if hidden > 0 {
log::info!(
"[drafts] Hidden {hidden} draft file(s) (use --drafts to include)"
);
}
Ok(())
}
fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
if self.include_drafts || !ctx.content_dir.exists() {
return Ok(());
}
let draft_files = collect_draft_files(&ctx.content_dir)?;
for draft_path in &draft_files {
let original = draft_path.with_extension("");
if !original.exists() {
fs::rename(draft_path, &original)?;
}
}
Ok(())
}
}
fn is_draft(path: &Path) -> Result<bool> {
let content = fs::read_to_string(path)?;
if !content.starts_with("---") {
return Ok(false);
}
if let Some(end) = content[3..].find("---") {
let frontmatter = &content[3..3 + end];
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed == "draft: true"
|| trimmed == "draft: True"
|| trimmed == "draft: TRUE"
|| trimmed == "draft: yes"
{
return Ok(true);
}
}
}
Ok(false)
}
fn collect_md_files(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files_bounded_depth(dir, "md", MAX_DIR_DEPTH)
}
fn collect_draft_files(dir: &Path) -> Result<Vec<PathBuf>> {
crate::walk::walk_files_bounded_depth(dir, "draft", MAX_DIR_DEPTH)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::init_logger;
use std::path::PathBuf;
use tempfile::{tempdir, TempDir};
fn make_content_layout() -> (TempDir, PathBuf, PluginContext) {
init_logger();
let dir = tempdir().expect("create tempdir");
let content = dir.path().join("content");
fs::create_dir_all(&content).expect("mkdir content");
let ctx =
PluginContext::new(&content, dir.path(), dir.path(), dir.path());
(dir, content, ctx)
}
fn write_md(dir: &Path, name: &str, draft_value: Option<&str>) {
let body = match draft_value {
Some(v) => format!("---\ntitle: T\ndraft: {v}\n---\nbody"),
None => "---\ntitle: T\n---\nbody".to_string(),
};
fs::write(dir.join(name), body).expect("write md");
}
#[test]
fn new_table_driven_constructs_plugin_with_supplied_flag() {
let cases = [(true, true), (false, false)];
for (input, expected) in cases {
let plugin = DraftPlugin::new(input);
assert_eq!(
plugin.include_drafts, expected,
"include_drafts({input}) should be {expected}"
);
}
}
#[test]
fn draft_plugin_is_copy_after_move() {
let plugin = DraftPlugin::new(false);
let _copy = plugin;
assert_eq!(plugin.name(), "drafts");
}
#[test]
fn name_returns_static_drafts_identifier() {
assert_eq!(DraftPlugin::new(false).name(), "drafts");
assert_eq!(DraftPlugin::new(true).name(), "drafts");
}
#[test]
fn is_draft_table_driven_truthy_values_return_true() {
let cases: &[&str] = &[
"---\ntitle: T\ndraft: true\n---\n",
"---\ntitle: T\ndraft: True\n---\n",
"---\ntitle: T\ndraft: TRUE\n---\n",
"---\ntitle: T\ndraft: yes\n---\n",
"---\ntitle: T\n draft: true \n---\n",
];
let dir = tempdir().expect("tempdir");
for (i, body) in cases.iter().enumerate() {
let path = dir.path().join(format!("d{i}.md"));
fs::write(&path, body).unwrap();
assert!(
is_draft(&path).unwrap(),
"case {i} {body:?} should be detected as draft"
);
}
}
#[test]
fn is_draft_table_driven_falsy_values_return_false() {
let cases: &[&str] = &[
"---\ntitle: T\ndraft: false\n---\n",
"---\ntitle: T\ndraft: maybe\n---\n",
"---\ntitle: T\n---\n",
"---\ntitle: T\ndraft: tRue\n---\n",
"---\ntitle: T\ndraft: Yes\n---\n",
];
let dir = tempdir().expect("tempdir");
for (i, body) in cases.iter().enumerate() {
let path = dir.path().join(format!("p{i}.md"));
fs::write(&path, body).unwrap();
assert!(
!is_draft(&path).unwrap(),
"case {i} {body:?} should NOT be detected as draft"
);
}
}
#[test]
fn is_draft_no_frontmatter_returns_false() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("plain.md");
fs::write(&path, "# No frontmatter\nJust prose.\n").unwrap();
assert!(!is_draft(&path).unwrap());
}
#[test]
fn is_draft_unterminated_frontmatter_returns_false() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("unterminated.md");
fs::write(&path, "---\ntitle: T\ndraft: true\nno closing fence here\n")
.unwrap();
assert!(!is_draft(&path).unwrap());
}
#[test]
fn is_draft_empty_file_returns_false() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("empty.md");
fs::write(&path, "").unwrap();
assert!(!is_draft(&path).unwrap());
}
#[test]
fn is_draft_missing_file_returns_err() {
let dir = tempdir().expect("tempdir");
let result = is_draft(&dir.path().join("does-not-exist.md"));
assert!(result.is_err());
}
#[test]
fn before_compile_with_include_drafts_does_not_rename_anything() {
let (_tmp, content, ctx) = make_content_layout();
write_md(&content, "draft.md", Some("true"));
write_md(&content, "published.md", None);
DraftPlugin::new(true).before_compile(&ctx).unwrap();
assert!(content.join("draft.md").exists());
assert!(!content.join("draft.md.draft").exists());
assert!(content.join("published.md").exists());
}
#[test]
fn before_compile_missing_content_dir_returns_ok() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("missing-content");
let ctx =
PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
DraftPlugin::new(false)
.before_compile(&ctx)
.expect("missing content dir is not an error");
assert!(!missing.exists());
}
#[test]
fn before_compile_renames_only_drafts_leaves_published_intact() {
let (_tmp, content, ctx) = make_content_layout();
write_md(&content, "draft.md", Some("true"));
write_md(&content, "published.md", None);
DraftPlugin::new(false).before_compile(&ctx).unwrap();
assert!(!content.join("draft.md").exists());
assert!(content.join("draft.md.draft").exists());
assert!(content.join("published.md").exists());
}
#[test]
fn before_compile_recurses_into_subdirectories() {
let (_tmp, content, ctx) = make_content_layout();
let nested = content.join("blog").join("2026");
fs::create_dir_all(&nested).unwrap();
write_md(&nested, "secret.md", Some("true"));
write_md(&content, "live.md", None);
DraftPlugin::new(false).before_compile(&ctx).unwrap();
assert!(nested.join("secret.md.draft").exists());
assert!(!nested.join("secret.md").exists());
assert!(content.join("live.md").exists());
}
#[test]
fn before_compile_no_drafts_yields_no_renames() {
let (_tmp, content, ctx) = make_content_layout();
write_md(&content, "a.md", None);
write_md(&content, "b.md", Some("false"));
DraftPlugin::new(false).before_compile(&ctx).unwrap();
assert!(content.join("a.md").exists());
assert!(content.join("b.md").exists());
}
#[test]
fn after_compile_with_include_drafts_short_circuits() {
let (_tmp, content, ctx) = make_content_layout();
fs::write(content.join("ghost.md.draft"), "---\n---\n").unwrap();
DraftPlugin::new(true).after_compile(&ctx).unwrap();
assert!(content.join("ghost.md.draft").exists());
assert!(!content.join("ghost.md").exists());
}
#[test]
fn after_compile_missing_content_dir_returns_ok() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("missing");
let ctx =
PluginContext::new(&missing, dir.path(), dir.path(), dir.path());
DraftPlugin::new(false).after_compile(&ctx).unwrap();
}
#[test]
fn after_compile_restores_draft_extension_to_md() {
let (_tmp, content, ctx) = make_content_layout();
fs::write(content.join("post.md.draft"), "---\n---\n").unwrap();
DraftPlugin::new(false).after_compile(&ctx).unwrap();
assert!(content.join("post.md").exists());
assert!(!content.join("post.md.draft").exists());
}
#[test]
fn after_compile_does_not_overwrite_existing_original() {
let (_tmp, content, ctx) = make_content_layout();
fs::write(content.join("post.md"), "USER WROTE THIS").unwrap();
fs::write(content.join("post.md.draft"), "STALE DRAFT").unwrap();
DraftPlugin::new(false).after_compile(&ctx).unwrap();
let body = fs::read_to_string(content.join("post.md")).unwrap();
assert_eq!(
body, "USER WROTE THIS",
"existing original must not be clobbered"
);
assert!(
content.join("post.md.draft").exists(),
"stale draft is left in place when original exists"
);
}
#[test]
fn before_and_after_round_trip_restores_original_content() {
let (_tmp, content, ctx) = make_content_layout();
let payload = "---\ntitle: T\ndraft: true\n---\nDRAFT BODY";
fs::write(content.join("d.md"), payload).unwrap();
let plugin = DraftPlugin::new(false);
plugin.before_compile(&ctx).unwrap();
plugin.after_compile(&ctx).unwrap();
let restored = fs::read_to_string(content.join("d.md")).unwrap();
assert_eq!(restored, payload);
}
#[test]
fn collect_md_files_returns_empty_for_missing_directory() {
let dir = tempdir().expect("tempdir");
let result = collect_md_files(&dir.path().join("missing")).unwrap();
assert!(result.is_empty());
}
#[test]
fn collect_md_files_filters_non_md_extensions() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.md"), "").unwrap();
fs::write(dir.path().join("b.txt"), "").unwrap();
fs::write(dir.path().join("c.html"), "").unwrap();
let result = collect_md_files(dir.path()).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn collect_md_files_recurses_into_nested_subdirectories() {
let dir = tempdir().expect("tempdir");
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("top.md"), "").unwrap();
fs::write(nested.join("deep.md"), "").unwrap();
let result = collect_md_files(dir.path()).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn collect_draft_files_filters_non_draft_extensions() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.md.draft"), "").unwrap();
fs::write(dir.path().join("b.md"), "").unwrap();
let result = collect_draft_files(dir.path()).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn collect_draft_files_respects_max_dir_depth_guard() {
let dir = tempdir().expect("tempdir");
let mut current = dir.path().to_path_buf();
for i in 0..MAX_DIR_DEPTH + 2 {
current = current.join(format!("d{i}"));
fs::create_dir_all(¤t).unwrap();
fs::write(current.join("p.md.draft"), "").unwrap();
}
let result = collect_draft_files(dir.path()).unwrap();
assert!(result.len() <= MAX_DIR_DEPTH + 1);
}
#[test]
fn collect_md_files_respects_max_dir_depth_guard() {
let dir = tempdir().expect("tempdir");
let mut current = dir.path().to_path_buf();
for i in 0..MAX_DIR_DEPTH + 2 {
current = current.join(format!("d{i}"));
fs::create_dir_all(¤t).unwrap();
fs::write(current.join("p.md"), "").unwrap();
}
let result = collect_md_files(dir.path()).unwrap();
assert!(result.len() <= MAX_DIR_DEPTH + 1);
}
#[test]
fn collect_draft_files_recurses_into_nested_subdirectories() {
let dir = tempdir().expect("tempdir");
let nested = dir.path().join("a");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("top.md.draft"), "").unwrap();
fs::write(nested.join("nested.md.draft"), "").unwrap();
let result = collect_draft_files(dir.path()).unwrap();
assert_eq!(result.len(), 2);
}
}