typst4ei 2.0.1

The Typst4EI building and serving tool/cli
mod config;
mod fetch_documents;
mod generate_html_pages;
mod inventory;
mod server;

use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process;

use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use clap::Subcommand;
use clap::crate_version;
use flexi_logger::Cleanup;
use flexi_logger::Criterion;
use flexi_logger::Duplicate;
use flexi_logger::FileSpec;
use flexi_logger::Logger;
use flexi_logger::Naming;
use flexi_logger::WriteMode;
use flexi_logger::colored_default_format;
use flexi_logger::detailed_format;
use log::error;
use log::info;
use schemars::schema_for;

use crate::config::Config;
use crate::config::License;
use crate::config::Site;
use crate::config::Source;
use crate::fetch_documents::Document;
use crate::fetch_documents::fetch_documents;
use crate::generate_html_pages::generate_html_error_pages;
use crate::generate_html_pages::generate_html_index_pages;
use crate::inventory::Inventory;
use crate::inventory::Module;
use crate::inventory::UncheckedInventory;
use crate::server::serve;

#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Generate Documentation (markdown and jsonschema) for Inventory and Config file
    GenerateDocs {
        /// Path where doc files are written
        #[arg(long, default_value = "./docs", value_name = "PATH")]
        output: PathBuf,
    },

    /// Validate TOML config file and exit immediately
    ValidateConfig {
        /// Path to TOML config file
        #[arg(long, value_name = "PATH")]
        file: PathBuf,
    },

    /// Fetch documents and generate HTML on demand
    Serve {
        /// Address and port to listen on
        #[arg(long, default_value = "0.0.0.0:8080")]
        address: String,

        /// Path to TOML config file
        #[arg(long, default_value = "config.toml", value_name = "PATH")]
        config: PathBuf,

        /// Path for Typst4EI to output HTML and PDFs
        #[arg(long, default_value = ".", value_name = "PATH")]
        output: PathBuf,
    },
}

#[tokio::main]
async fn main() {
    let cli = Cli::parse();

    let _logger = Logger::try_with_str("info")
        .unwrap()
        .log_to_file(
            FileSpec::default()
                .directory("logs")
                .basename("typst4ei")
                .suffix("log"),
        )
        .rotate(
            Criterion::Size(30 * 1024 * 1024), // ~ 20 MB
            Naming::Timestamps,
            Cleanup::KeepLogFiles(15),
        )
        .duplicate_to_stderr(Duplicate::Info)
        .format_for_files(detailed_format)
        .format_for_stderr(colored_default_format)
        .write_mode(WriteMode::Async)
        .print_message()
        .start()
        .unwrap();

    info!("starting Typst4EI v{}", crate_version!());

    match run(cli).await {
        Ok(_) => process::exit(0),
        Err(err) => {
            error!("{:?}", err);
            process::exit(1);
        }
    }
}

async fn run(cli: Cli) -> Result<()> {
    match cli.command {
        Command::ValidateConfig { file } => {
            Config::load(&file)?;
            info!("config `{}` is valid", file.display());
            Ok(())
        }
        Command::GenerateDocs { output } => {
            let schema_config = schema_for!(Config);
            let schema_inventory = schema_for!(UncheckedInventory);

            fs::create_dir_all(&output)
                .with_context(|| format!("unable to create directory `{}`", output.display()))?;

            fs::write(
                output.join(Path::new("inventory.schema.json")),
                serde_json::to_string_pretty(&schema_inventory)?,
            )?;
            fs::write(
                output.join(Path::new("config.schema.json")),
                serde_json::to_string_pretty(&schema_config)?,
            )?;

            Ok(())
        }
        Command::Serve {
            address,
            config,
            output,
        } => {
            let config = {
                let mut config = Config::load(&config)?;
                config.site.output_dir = output;
                config
            };

            fs::create_dir_all(config.site.full_static_dir()).with_context(|| {
                format!(
                    "unable to create directory `{}`",
                    config.site.full_static_dir().display()
                )
            })?;
            fs::create_dir_all(config.site.full_files_dir()).with_context(|| {
                format!(
                    "unable to create directory `{}`",
                    config.site.full_files_dir().display()
                )
            })?;

            let css_path = config.site.full_static_dir().join("bootstrap.min.css");
            fs::write(&css_path, include_str!("../static/bootstrap.min.css"))
                .with_context(|| format!("unable to create file `{}`", css_path.display()))?;
            let js_path = config.site.full_static_dir().join("auto-theme.js");
            fs::write(&js_path, include_bytes!("../static/auto-theme.js"))
                .with_context(|| format!("unable to create file `{}`", js_path.display()))?;
            let logo_light_path = config.site.full_static_dir().join("logo-light.png");
            fs::write(&logo_light_path, include_bytes!("../static/logo-light.png")).with_context(
                || format!("unable to create file `{}`", logo_light_path.display()),
            )?;
            let logo_dark_path = config.site.full_static_dir().join("logo-dark.png");
            fs::write(&logo_dark_path, include_bytes!("../static/logo-dark.png"))
                .with_context(|| format!("unable to create file `{}`", logo_dark_path.display()))?;

            let logo_path = config.site.full_static_dir().join("logo.svg");
            fs::write(&logo_path, include_bytes!("../static/logo.svg"))
                .with_context(|| format!("unable to create file `{}`", logo_path.display()))?;
            let favicon_path = config.site.full_static_dir().join("favicon.svg");
            fs::write(&favicon_path, include_bytes!("../static/favicon.svg"))
                .with_context(|| format!("unable to create file `{}`", favicon_path.display()))?;

            serve(config, address).await
        }
    }
}

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

    #[test]
    fn verify_cli() {
        use clap::CommandFactory;
        Cli::command().debug_assert();
    }
}