bckt 0.7.3

bckt is an opinionated but flexible static site generator for blogs
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use walkdir::WalkDir;

use crate::cli::InitArgs;
use crate::theme::{install_theme_source, resolve_demo, resolve_theme};
use crate::utils::resolve_root;

const DIRECTORIES: &[&str] = &["html", "posts", "templates", "skel", "themes", "pages"];
const CONFIG_FILE: &str = "bckt.yaml";
const DEFAULT_THEME_NAME: &str = "bckt3";

const DEFAULT_CONFIG_TEMPLATE: &str = r#"title: "My bckt Site"
description: "A site generated by bckt."
open_graph_image: "/open-graph.png"
base_url: "https://example.com"
homepage_posts: 10
date_format: "[year]-[month]-[day]"
default_timezone: "+00:00"
theme: {theme}
"#;

const SAMPLE_POST: &str = r#"---
title: "Hello From bckt"
slug: "hello-from-bckt"
date: "2024-01-01T00:00:00Z"
tags:
  - welcome
abstract: "Kick the tires on the generator."
attached: []
images: []
---

This is the starter post. Edit it or drop in your own content to get going.
"#;

pub fn run_init_command(args: InitArgs) -> Result<()> {
    let root = resolve_root(args.root.as_deref())?;

    establish_directories(&root)?;

    let theme_spec = args.theme.as_deref().unwrap_or(DEFAULT_THEME_NAME);
    let theme_name = theme_name_from_spec(theme_spec);
    let theme_dir = root.join("themes").join(&theme_name);

    let theme_available = ensure_theme(&theme_dir, theme_spec)?;

    seed_configuration(&root, &theme_name)?;
    if theme_available {
        seed_templates(&root, &theme_dir)?;
        seed_static_assets(&root, &theme_dir)?;
    }

    if let Some(demo_name) = args.demo.as_deref() {
        match resolve_demo(demo_name) {
            Ok(demo_path) => {
                apply_demo(&demo_path, &root)?;
                println!("Populated project with demo '{demo_name}'");
            }
            Err(err) => eprintln!("Warning: {err}"),
        }
    } else {
        seed_sample_post(&root)?;
    }

    if theme_available {
        println!("Initialized project with theme '{theme_name}'");
    } else {
        println!(
            "Initialized project. Theme '{theme_name}' was not found; install one with: bckt themes install <path-to-zip>"
        );
    }
    Ok(())
}

/// Derive the directory name for a theme from its spec: the file stem of a
/// `.zip` path, or the spec itself when it is a bare name.
fn theme_name_from_spec(spec: &str) -> String {
    Path::new(spec)
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or(spec)
        .to_string()
}

fn establish_directories(root: &Path) -> Result<()> {
    for entry in DIRECTORIES {
        let path = root.join(entry);
        if path.exists() {
            continue;
        }
        fs::create_dir_all(&path)
            .with_context(|| format!("failed to create directory {}", path.display()))?;
    }
    Ok(())
}

/// Install the default theme into `theme_dir` from a local `.zip` archive.
/// Returns whether a theme is available afterwards. Missing archives are a
/// warning rather than a hard error so `init` always scaffolds the project.
fn ensure_theme(theme_dir: &Path, spec: &str) -> Result<bool> {
    if theme_dir.exists() {
        return Ok(true);
    }

    match resolve_theme(spec) {
        Ok(source) => {
            install_theme_source(&source, theme_dir)?;
            Ok(true)
        }
        Err(err) => {
            eprintln!("Warning: {err}");
            Ok(false)
        }
    }
}

fn seed_configuration(root: &Path, theme_name: &str) -> Result<()> {
    let destination = root.join(CONFIG_FILE);
    if destination.exists() {
        return Ok(());
    }
    let contents = DEFAULT_CONFIG_TEMPLATE.replace("{theme}", theme_name);
    write_if_missing(&destination, &contents)
        .with_context(|| format!("failed to write {}", CONFIG_FILE))
}

fn seed_templates(root: &Path, theme_root: &Path) -> Result<()> {
    let source = theme_root.join("templates");
    copy_if_missing(&source, &root.join("templates"))?;

    let pages = theme_root.join("pages");
    copy_if_missing(&pages, &root.join("pages"))
}

fn seed_static_assets(root: &Path, theme_root: &Path) -> Result<()> {
    let source = theme_root.join("skel");
    copy_if_missing(&source, &root.join("skel"))
}

fn seed_sample_post(root: &Path) -> Result<()> {
    let sample_dir = root.join(
        ["posts", "hello-from-bckt"]
            .into_iter()
            .collect::<PathBuf>(),
    );
    if !sample_dir.exists() {
        fs::create_dir_all(&sample_dir)
            .with_context(|| format!("failed to create {}", sample_dir.display()))?;
    }
    write_if_missing(&sample_dir.join("post.md"), SAMPLE_POST)
        .context("failed to write sample post")
}

fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
    write_bytes_if_missing(path, contents.as_bytes())
}

fn write_bytes_if_missing(path: &Path, contents: &[u8]) -> Result<()> {
    if path.exists() {
        return Ok(());
    }
    if let Some(parent) = path.parent()
        && !parent.exists()
    {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let mut file =
        fs::File::create(path).with_context(|| format!("failed to create {}", path.display()))?;
    file.write_all(contents)
        .with_context(|| format!("failed to write {}", path.display()))?;
    file.flush()
        .with_context(|| format!("failed to flush {}", path.display()))?;
    Ok(())
}

/// Copy demo content (posts/, pages/, bckt.yaml) into the project root.
/// The demo's bckt.yaml replaces the default one written by seed_configuration.
fn apply_demo(demo_path: &Path, project_root: &Path) -> Result<()> {
    for component in ["posts", "pages"] {
        let src = demo_path.join(component);
        if src.exists() {
            copy_tree(&src, &project_root.join(component))?;
        }
    }
    let demo_config = demo_path.join("bckt.yaml");
    if demo_config.exists() {
        fs::copy(&demo_config, project_root.join(CONFIG_FILE)).with_context(|| {
            format!("failed to copy demo config from {}", demo_config.display())
        })?;
    }
    Ok(())
}

/// Copy all files from `source_root` into `destination_root`, creating
/// directories as needed. Existing files are overwritten.
fn copy_tree(source_root: &Path, destination_root: &Path) -> Result<()> {
    for entry in WalkDir::new(source_root).into_iter().filter_map(|e| e.ok()) {
        let path = entry.path();
        if entry.file_type().is_dir() {
            continue;
        }
        let relative = path
            .strip_prefix(source_root)
            .with_context(|| format!("failed to strip prefix for {}", path.display()))?;
        let destination = destination_root.join(relative);
        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        fs::copy(path, &destination).with_context(|| {
            format!(
                "failed to copy {} to {}",
                path.display(),
                destination.display()
            )
        })?;
    }
    Ok(())
}

fn copy_if_missing(source_root: &Path, destination_root: &Path) -> Result<()> {
    if !source_root.exists() {
        return Ok(());
    }
    for entry in WalkDir::new(source_root).into_iter().filter_map(|e| e.ok()) {
        let path = entry.path();
        if entry.file_type().is_dir() {
            continue;
        }
        let relative = path
            .strip_prefix(source_root)
            .with_context(|| format!("failed to strip prefix for {}", path.display()))?;
        let destination = destination_root.join(relative);
        if destination.exists() {
            continue;
        }
        if let Some(parent) = destination.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }
        fs::copy(path, &destination).with_context(|| {
            format!(
                "failed to copy {} to {}",
                path.display(),
                destination.display()
            )
        })?;
    }
    Ok(())
}