bckt 0.6.3

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

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

use crate::cli::{ThemeDownloadArgs, ThemesArgs, ThemesSubcommand};
use crate::config::Config;
use crate::theme::{GithubReference, ThemeSource, download_theme};
use crate::utils::resolve_root;

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

    match args.command {
        ThemesSubcommand::List => list_themes(&root),
        ThemesSubcommand::Use { name, force } => use_theme(&root, &name, force),
        ThemesSubcommand::Download(download_args) => download_theme_into(&root, download_args),
    }
}

fn list_themes(root: &Path) -> Result<()> {
    let themes_dir = root.join("themes");
    if !themes_dir.exists() {
        println!("No themes installed.");
        return Ok(());
    }

    let entries = fs::read_dir(&themes_dir)
        .with_context(|| format!("failed to read themes directory {}", themes_dir.display()))?;

    let mut names: Vec<String> = entries
        .filter_map(|entry| {
            let entry = entry.ok()?;
            if entry.file_type().ok()?.is_dir() {
                Some(entry.file_name().to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();

    if names.is_empty() {
        println!("No themes installed.");
        return Ok(());
    }

    names.sort_unstable();

    let config_path = root.join("bckt.yaml");
    let active = Config::load(&config_path)
        .ok()
        .and_then(|config| config.theme);

    for name in names {
        if Some(&name) == active.as_ref() {
            println!("* {}", name);
        } else {
            println!("  {}", name);
        }
    }

    Ok(())
}

fn use_theme(root: &Path, name: &str, force: bool) -> Result<()> {
    let theme_root = root.join("themes").join(name);
    if !theme_root.exists() {
        bail!("theme '{}' is not installed", name);
    }

    confirm_overwrite(root, force)?;
    apply_theme(&theme_root, root)?;

    let config_path = root.join("bckt.yaml");
    let mut config = Config::load(&config_path)?;
    config.theme = Some(name.to_string());
    config.save(&config_path)?;

    println!("Applied theme '{}'.", name);
    Ok(())
}

fn download_theme_into(root: &Path, args: ThemeDownloadArgs) -> Result<()> {
    let themes_dir = root.join("themes");
    fs::create_dir_all(&themes_dir).context("failed to create themes directory")?;

    let destination = themes_dir.join(&args.name);
    if destination.exists() {
        if args.force {
            fs::remove_dir_all(&destination).with_context(|| {
                format!("failed to remove existing theme {}", destination.display())
            })?;
        } else {
            bail!(
                "theme '{}' already exists. Use --force to overwrite",
                args.name
            );
        }
    }

    let repo_spec_info = args
        .github
        .as_ref()
        .map(|spec| parse_github_spec(spec))
        .transpose()?;

    let source = if let Some(url) = &args.url {
        ThemeSource::Url {
            url: url.clone(),
            subdir: args.subdir.clone(),
            strip_components: args.strip_components,
        }
    } else if let Some((owner, repo, repo_path)) = repo_spec_info {
        let reference = select_github_reference(args.tag.clone(), args.branch.clone());
        ThemeSource::Github {
            owner,
            repo,
            reference,
            subdir: derive_subdir(args.subdir.clone(), repo_path, &args.name),
            strip_components: Some(args.strip_components.unwrap_or(1)),
        }
    } else {
        bail!("either --url or --github must be provided");
    };

    download_theme(&destination, source)?;
    println!("Downloaded theme '{}'", args.name);
    Ok(())
}

fn confirm_overwrite(project_root: &Path, force: bool) -> Result<()> {
    if force {
        return Ok(());
    }

    let conflicts: Vec<&str> = ["templates", "skel"]
        .into_iter()
        .filter(|&name| {
            let path = project_root.join(name);
            directory_has_contents(&path).unwrap_or(false)
        })
        .collect();

    if conflicts.is_empty() {
        return Ok(());
    }

    println!(
        "The following directories will be overwritten: {}",
        conflicts.join(", ")
    );
    print!("Proceed? [y/N]: ");
    io::stdout().flush().context("failed to flush stdout")?;

    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .context("failed to read confirmation input")?;

    let answer = input.trim().to_lowercase();
    if matches!(answer.as_str(), "y" | "yes") {
        Ok(())
    } else {
        bail!("theme installation aborted by user");
    }
}

fn directory_has_contents(path: &Path) -> Result<bool> {
    if !path.exists() || !path.is_dir() {
        return Ok(false);
    }
    let mut entries = fs::read_dir(path)
        .with_context(|| format!("failed to read directory {}", path.display()))?;
    Ok(entries.next().is_some())
}

fn parse_github_spec(spec: &str) -> Result<(String, String, Option<String>)> {
    let mut segments = spec.split('/').collect::<Vec<_>>();
    if segments.len() < 2 {
        bail!("invalid GitHub specification '{spec}' (expected owner/repo[/path])");
    }

    let owner = segments.remove(0);
    let repo = segments.remove(0);

    if owner.is_empty() || repo.is_empty() {
        bail!("invalid GitHub specification '{spec}'");
    }

    let subdir = if segments.is_empty() {
        None
    } else {
        Some(segments.join("/"))
    };

    Ok((owner.to_string(), repo.to_string(), subdir))
}

fn select_github_reference(tag: Option<String>, branch: Option<String>) -> GithubReference {
    match (tag, branch) {
        (Some(tag), _) => GithubReference::Tag(tag),
        (None, Some(branch)) => GithubReference::Branch(branch),
        (None, None) => GithubReference::Branch("main".to_string()),
    }
}

fn apply_theme(theme_root: &Path, project_root: &Path) -> Result<()> {
    for name in ["templates", "skel", "pages"] {
        let source_path = theme_root.join(name);
        if source_path.exists() {
            let destination_path = project_root.join(name);
            copy_dir(&source_path, &destination_path)?;
        }
    }
    Ok(())
}

fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
    if dest.exists() {
        fs::remove_dir_all(dest).with_context(|| format!("failed to remove {}", dest.display()))?;
    }
    fs::create_dir_all(dest).with_context(|| format!("failed to create {}", dest.display()))?;

    for entry in WalkDir::new(src) {
        let entry = entry?;
        if entry.file_type().is_dir() {
            continue;
        }

        let relative = entry
            .path()
            .strip_prefix(src)
            .with_context(|| format!("failed to strip prefix for {}", entry.path().display()))?;
        let target = dest.join(relative);

        if let Some(parent) = target.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }

        fs::copy(entry.path(), &target).with_context(|| {
            format!(
                "failed to copy {} to {}",
                entry.path().display(),
                target.display()
            )
        })?;
    }

    Ok(())
}

fn derive_subdir(
    explicit: Option<String>,
    repo_path: Option<String>,
    theme_name: &str,
) -> Option<String> {
    if let Some(explicit) = explicit {
        return Some(explicit);
    }

    let repo_path = repo_path?;
    let mut components: Vec<String> = repo_path
        .split('/')
        .filter(|segment| !segment.is_empty())
        .map(|segment| segment.to_string())
        .collect();

    match components.last() {
        Some(last) if last == theme_name => {}
        _ => components.push(theme_name.to_string()),
    }

    Some(components.join("/"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_github_spec_handles_owner_repo() {
        let (owner, repo, path) = parse_github_spec("vrypan/bckt").unwrap();
        assert_eq!(owner, "vrypan");
        assert_eq!(repo, "bckt");
        assert!(path.is_none());
    }

    #[test]
    fn parse_github_spec_handles_nested_path() {
        let (owner, repo, path) = parse_github_spec("vrypan/bckt/themes").unwrap();
        assert_eq!(owner, "vrypan");
        assert_eq!(repo, "bckt");
        assert_eq!(path.as_deref(), Some("themes"));
    }

    #[test]
    fn parse_github_spec_requires_owner_repo() {
        assert!(parse_github_spec("vrypan").is_err());
    }

    #[test]
    fn derive_subdir_prefers_explicit_value() {
        let result = derive_subdir(
            Some("custom/path".to_string()),
            Some("themes".to_string()),
            "bckt3",
        );
        assert_eq!(result.as_deref(), Some("custom/path"));
    }

    #[test]
    fn derive_subdir_appends_theme_name_when_missing() {
        let result = derive_subdir(None, Some("themes".to_string()), "bckt3");
        assert_eq!(result.as_deref(), Some("themes/bckt3"));
    }

    #[test]
    fn derive_subdir_respects_existing_theme_name() {
        let result = derive_subdir(None, Some("themes/bckt3".to_string()), "bckt3");
        assert_eq!(result.as_deref(), Some("themes/bckt3"));
    }

    #[test]
    fn derive_subdir_returns_none_without_repo_path() {
        assert!(derive_subdir(None, None, "bckt3").is_none());
    }
}