Abbaye

at e1dfdc3

//! # Abbaye
//!
//! ![logo](logo-wordmark.svg)
//!
//! Abbaye is a Static Site Generator (SSG) for your software. As GitHub,
//! Gitea, Forgejo and consorts offer, Abbaye can be used to generate a
//! website with your software's presentation, documentation, and distribution, per version.
//!
//! Here's an example file structure:
//!
//! ```text
//! .
//! ├── index.html # the main page of the website, enabling choosing a version, defaults to "latest" (contains a list of available versions and a iframe to the selected version?)
//! ├── releases.feed # the RSS feed of the releases
//! ├── repository/ # the directory containing the repository UI (if enabled in config)
//! ├── repository.git # Clonable git repository
//! ├── latest -> v2.0.0 # symlink to the latest version (biggest version number)
//! ├── v1.0.0/ # the directory containing the version 1.0.0 of the software
//! │   ├── index.html # the main page of the version 1.0.0, from the README.md file.
//! │   │   # Contains a sidebar with links to the documentation and distribution.
//! │   │   # After the readme content, A changelog is displayed.
//! │   ├── docs/ # the directory containing the documentation of the version 1.0.0
//! │   │   ├── index.html # the main page of the documentation of the version 1.0.0
//! │   │   └── …
//! │   ├── docs.tar.gz # the tarball containing the documentation of the version 1.0.0
//! │   └── dist/ # the directory containing the distribution of the version 1.0.0
//! │       ├── source.tgz # the source code of the version 1.0.0
//! │       ├── mybin-v1.0.0-x86_64-unknown-linux-gnu
//! │       └── mybin-v1.0.0-x86_64-unknown-linux-musl
//! └── v2.0.0/ # the directory containing the version 2.0.0 of the software
//!     ├── index.html # the main page of the version 2.0.0
//!     ├── …
//!     └── …
//! ```
//!
//! ## Why ?
//!
//! This piece of software is for people that can't or won't use a full-featured forge such as GitHub, GitLab, ForgeJo & others.
//! These forges provide "release pages" that allow you to upload and distribute your software, as well as get a changelog.
//!
//! Abbaye is made to be a simple, lightweight alternative to these forges, for the release/documentation parts.
//!
//! ### Why "Abbaye" ?
//!
//! [Abbaye](https://en.wikipedia.org/wiki/Abbaye) is a French word for Abbey. An Abbey is a type of monastery, on the big-ish side, but still a small, quiet place.
//!
//! Anyway, that's where you store and display your relics (your software releases).
//!
//! ## Installation
//!
//! ### Pre-built binaries
//!
//! You can grab a pre-built binary from the [releases page](http://vit.am/~ololduck/abbaye/latest).
//!
//! The `-musl` binaries are statically linked and should run everywhere, while the `-gnu` binaries are dynamically linked and require a compatible system library(which is probably available if you're not using an exotic distribution).
//!
//! ### From source
//!
//! To build from source, you need to have Rust installed. You can install Rust using [rustup](https://rustup.rs/).
//!
//! You can clone the [repository](https://git.sr.ht/~ololduck/abbaye) and build the project using `cargo build --release`. The built binary will be located in `target/release/abbaye`. You can also install it directly using `cargo install --path .`
//!
//! ## Usage
//!
//! Run `abbaye init` in your project's directory to create a `abbaye.toml` configuration file. You can then customize the configuration to your liking.
//! Here's an example configuration file to get you started:
//!
//! ```toml
//! [site]
//! name = "Abbaye"
//! # required for Atom feed generation (canonical URLs are used for feed items)
//! base_url = "http://vit.am/~ololduck/abbaye/"
//!
//! [version_extractor]
//! type = "git" # extract version from git tags
//! tag_prefix = "v"
//!
//! [changelog] # use the default changelog parser (Keepachangelog format in CHANGELOG.md)
//!
//! [[builders]]  # builds the project using cargo build --release
//! type = "cargo"
//! targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"]
//!
//! [[builders]]  # generates documentation using cargo doc
//! type = "cargo_doc"
//! no_deps = true  # Don't include dependencies in the documentation
//!
//! [[builders]]
//! # creates a compressed tarball of the source code (can be of anything, really)
//! type = "archive"
//!
//! [[builders]]
//! # Just an example dumb script to showcase the `script` builder type
//! type = "script"
//! script = [
//!  "echo $ABBAYE_BUILDING_VERSION > .version",
//! ]
//! outputs = [".version"]
//! ```
//!
//! Then run `abbaye build` to build the site. The site will be generated in the `public/` directory by default.
//! Now you can copy the contents of `public/` to your web server to deploy the site. For instance, with rsync:
//! `rsync --progress -avz --links --perms --update public/ ololduck@vit.am:public_html/abbaye/`
//!
//! To have a look at all the available configuration options, please refer to the documentation of [`config::AbbayeConfig`].
//!
//! ### A note on the repository UI
//!
//! The repository UI is **NOT** enabled by default. It must be enabled explicitly in the configuration with the following:
//!
//! ```toml
//! [git_ui] # only this section is needed to enable the repository UI
//! ```
//! It has multiple options (each presented with their defaults, if any):
//!
//! ```toml
//! [git_ui]
//! default_branch = "main"
//! max_commits = 200
//! repo_path = "."
//! clone_url = "{{ site.base_url }}/repository.git"
//! ```
//!
//! When the git UI is enabled, pages permitting browsing the tree of the repository are available, _but only for the branches tips and the tags_, as to not generate too much content. It already is quite a lot of content to generate for a simple repository UI (about 10MBs for Abbaye's repository at the time of writing).
//!
//! ### ✨ Customization ✨
//!
//! You can dump the default theme/templates to your local filesystem with `abbaye dump-theme`.
//!
//! This will create a `.abbaye/theme/` directory in your current directory with the default templates, which you can then ✨customize✨.
//!
//! If you don't want to customize every template, simply delete the ones you don't want to change.
//!
//! In `.abbaye/theme/static/`, you will find the CSS sheet used by the default theme, across all templates. Editing this file will change the look and feel of all your site's pages.
//!
//! ## Future plans
//!
//! - [x] Add support for theming
//! - [ ] Add support for more site variables, such as the site title, description, and author, or even a custom footer and stuff.
//!   - I added OpenGraph support, does that count?
//! - [x] Add support for a `self-update`-like command to update the abbaye binary to the latest version. The mechanisms put in place for this goal should be usable to any user of `abbaye`.
//!
//! ## Contributing
//!
//! Contributions are welcome! As i am mainly a rust developer, i am open to any contributions that improve the project, especially to support more artifacts builders/types.
//!
//! Just clone the repository and {send me an email,contact me on {IRC (ololduck@irc.libera.chat),the Fediverse (@ololduck@fosstodon.org)}} with {a link to your fork,a git patch,compliments and adoration}.

use std::path::PathBuf;

use clap::CommandFactory;
use clap::Parser;
use human_panic::setup_panic;
use miette::{IntoDiagnostic, Result};
use tokio::fs::create_dir_all;
use tracing::{info, warn};

use crate::{
    builders::{AnyBuilder, BuilderEntry, archive::ArchiveBuilderConfig},
    changelog::ChangelogConfig,
    config::{AbbayeConfig, SiteConfig},
    version_extractors::{AnyVersionExtractor, git::GitVersionConfig},
};

/// All builders for the site (ex: cargo build, cargo doc, etc.).
pub mod builders;
/// Parses the changelog file and generates a changelog page for the site.
pub mod changelog;
/// Stuff related to the CLI interface. Also contains colour escape codes for terminal output.
pub mod cli;
/// Handles the `abbaye.toml` configuration file.
pub mod config;
/// Generates a static git web UI and clonable bare repository.
pub mod git_ui;
/// Generates the site from the configuration and builds it.
pub mod site;
/// Self-update logic: fetches the release feed and replaces the binary when a newer version exists.
mod updater;
/// Extracts current version information from different sources (ex: git tags, cargo metadata, etc.).
pub mod version_extractors;

pub mod utils;

/// Build the full website for every git tag, sorted from the lowest semver
/// version to the highest.
///
/// For each tag the function:
/// 1. Runs `git checkout <tag>` to switch the working tree.
/// 2. Loads `abbaye.toml` from the checked-out revision (falling back to the
///    config that was active before the loop if the file is absent).
/// 3. Calls [`site::build_site`] to produce the version page and update the
///    root index and Atom feed.
///
/// The original HEAD (branch or commit) is always restored after the loop,
/// even when an error occurs.
async fn build_all() -> Result<()> {
    // Load the current config to discover the version extractor settings.
    let base_config = config::load_config()?;

    // `git for-each-ref --sort=version:refname` returns tags in semver order,
    // lowest first, which is exactly the order we want.
    let all_versions = base_config.version_extractor.extract_all().await?;
    if all_versions.is_empty() {
        info!("No tagged versions found – nothing to build.");
        return Ok(());
    }

    // Remember where we are so we can restore it when we're done.
    // Prefer the branch name (symbolic ref) so that checking it out
    // afterwards leaves the user on their branch rather than in a
    // detached-HEAD state.  Fall back to the raw commit SHA when HEAD
    // is already detached.
    let symref_out = tokio::process::Command::new("git")
        .args(["symbolic-ref", "--short", "HEAD"])
        .output()
        .await
        .into_diagnostic()?;
    let original_head = if symref_out.status.success() {
        // On a branch.
        String::from_utf8(symref_out.stdout)
            .into_diagnostic()?
            .trim()
            .to_owned()
    } else {
        // Detached HEAD - fall back to the commit SHA.
        let sha_out = tokio::process::Command::new("git")
            .args(["rev-parse", "HEAD"])
            .output()
            .await
            .into_diagnostic()?;
        if !sha_out.status.success() {
            return Err(miette::miette!("Could not determine current HEAD"));
        }
        String::from_utf8(sha_out.stdout)
            .into_diagnostic()?
            .trim()
            .to_owned()
    };

    let total = all_versions.len();
    info!("Building {} version(s) …", total);

    // Run the build loop; capture the result so we can restore HEAD first.
    let loop_result = async {
        for (i, version_info) in all_versions.iter().enumerate() {
            let tag = base_config
                .version_extractor
                .tag_name(&version_info.version);

            info!("[{}/{}] Checking out {} …", i + 1, total, tag);

            let checkout = tokio::process::Command::new("git")
                .args(["checkout", &tag])
                .output()
                .await
                .into_diagnostic()?;
            if !checkout.status.success() {
                let stderr = String::from_utf8_lossy(&checkout.stderr);
                return Err(miette::miette!("git checkout {tag} failed:\n{stderr}"));
            }

            // Reload `abbaye.toml` from the checked-out revision so the build
            // uses that version's own configuration (builders, readme path,
            // etc.).  If the file does not exist in this revision, fall back
            // to the config we loaded before the loop.
            let version_config = config::load_config().unwrap_or_else(|_| base_config.clone());

            info!(
                "[{}/{}] Building version {} …",
                i + 1,
                total,
                version_info.version
            );

            site::build_site(version_config).await?;
        }
        Ok(())
    }
    .await;

    // Always restore HEAD, regardless of whether the loop succeeded.
    let restore = tokio::process::Command::new("git")
        .args(["checkout", &original_head])
        .output()
        .await
        .into_diagnostic()?;
    if !restore.status.success() {
        let stderr = String::from_utf8_lossy(&restore.stderr);
        warn!("Could not restore HEAD to {original_head}:\n{stderr}");
    }

    loop_result?;

    // Build the git repository UI once, now that HEAD is restored.
    if let Some(git_ui_cfg) = &base_config.git_ui {
        git_ui::build_git_repository_ui(&base_config, git_ui_cfg).await?;
    }

    info!("Done. Built {total} version(s).");
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    setup_panic!();
    let cli_args = cli::CliArgs::parse();

    tracing_subscriber::fmt()
        .with_timer(tracing_subscriber::fmt::time::SystemTime)
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    match cli_args.command {
        cli::CliCommand::Init { path } => {
            let base_path = if let Some(path) = path {
                path
            } else {
                std::env::current_dir().into_diagnostic()?
            };
            if base_path.join("abbaye.toml").exists() {
                return Err(miette::miette!(
                    "abbaye.toml already exists in this directory"
                ));
            }
            let abbaye_config = AbbayeConfig {
                site: SiteConfig {
                    name: "MyProject Release Page".to_string(),
                    ..Default::default()
                },
                version_extractor: AnyVersionExtractor::Git(GitVersionConfig {
                    tag_prefix: Some("v".to_string()),
                    dirty_suffix: "-dirty".to_string(),
                }),
                builders: vec![BuilderEntry {
                    builder: AnyBuilder::Archive(ArchiveBuilderConfig {
                        source_dir: None,
                        output: None,
                        prefix: None,
                        ignore_patterns: vec![".git/".to_string(), "*.tar.gz".to_string()],
                    }),
                    id: None,
                    depends_on: vec![],
                }],
                changelog: ChangelogConfig {
                    ..Default::default()
                },
                git_ui: None,
            };
            let config_path = base_path.join("abbaye.toml");
            let toml = toml::to_string_pretty(&abbaye_config).into_diagnostic()?;
            tokio::fs::write(&config_path, toml)
                .await
                .into_diagnostic()?;
        }
        cli::CliCommand::Build { repository_only } => {
            let config = config::load_config()?;
            if repository_only {
                match &config.git_ui {
                    Some(git_ui_cfg) => {
                        git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
                    }
                    None => {
                        return Err(miette::miette!(
                            "--repository-only requires [git_ui] to be configured in abbaye.toml"
                        ));
                    }
                }
            } else {
                site::build_site(config.clone()).await?;
                if let Some(git_ui_cfg) = &config.git_ui {
                    git_ui::build_git_repository_ui(&config, git_ui_cfg).await?;
                }
            }
        }
        cli::CliCommand::BuildAll => {
            build_all().await?;
        }
        cli::CliCommand::DumpSchema => {
            // Emit a JSON Schema draft-07 document rather than the 2020-12
            // default.  Taplo (and most TOML LSP tooling) validates against
            // draft-07, which treats `$ref` as exclusive - it ignores any
            // sibling keywords.  Draft-07 output from schemars wraps `$ref`
            // in `allOf` instead, keeping the `const` type-discriminators
            // visible to the validator and resolving `oneOf` ambiguity.
            let generator = schemars::generate::SchemaSettings::draft07().into_generator();
            let schema = generator.into_root_schema_for::<config::AbbayeConfig>();
            println!(
                "{}",
                serde_json::to_string_pretty(&schema).into_diagnostic()?
            );
        }
        cli::CliCommand::SelfUpdate { check } => {
            updater::self_update(check).await?;
        }
        cli::CliCommand::UsageSpec => {
            let mut cmd = cli::CliArgs::command();
            clap_usage::generate(&mut cmd, "abbaye", &mut std::io::stdout());
        }
        cli::CliCommand::DumpTheme => {
            let theme_path = PathBuf::from(".abbaye").join("theme");
            create_dir_all(&theme_path).await.into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("root_index.html.j2"),
                site::TEMPLATE_ROOT_INDEX,
            )
            .await
            .into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("version_index.html.j2"),
                site::TEMPLATE_VERSION_INDEX,
            )
            .await
            .into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("markdown.html.j2"),
                builders::markdown::TEMPLATE_MARKDOWN,
            )
            .await
            .into_diagnostic()?;
            tokio::fs::write(theme_path.join("git_log.html.j2"), git_ui::TEMPLATE_GIT_LOG)
                .await
                .into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("git_commit.html.j2"),
                git_ui::TEMPLATE_GIT_COMMIT,
            )
            .await
            .into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("git_refs.html.j2"),
                git_ui::TEMPLATE_GIT_REFS,
            )
            .await
            .into_diagnostic()?;
            {
                let static_dir = theme_path.join("static");
                create_dir_all(&static_dir).await.into_diagnostic()?;
                tokio::fs::write(static_dir.join("site.css"), site::SITE_CSS)
                    .await
                    .into_diagnostic()?;
            }
            tokio::fs::write(
                theme_path.join("git_tree.html.j2"),
                git_ui::TEMPLATE_GIT_TREE,
            )
            .await
            .into_diagnostic()?;
            tokio::fs::write(
                theme_path.join("git_blob.html.j2"),
                git_ui::TEMPLATE_GIT_BLOB,
            )
            .await
            .into_diagnostic()?;
        }
    }
    Ok(())
}