package-bootstrap 0.4.0

An embeddable solution for installing build artifacts
Documentation
#![warn(clippy::all, clippy::pedantic)]
#![allow(clippy::missing_errors_doc)]
#![doc = include_str!("../README.md")]

use std::{
    error::Error,
    ffi::OsStr,
    fs, io,
    os::unix::fs::DirBuilderExt,
    path::{Path, PathBuf},
    process,
};

#[cfg(feature = "mangen")]
use clap_mangen::Man;

#[cfg(feature = "complete")]
use {
    clap_complete::{generate_to, shells},
    clap_complete_nushell::Nushell,
};

#[cfg(feature = "icons")]
use {
    resvg::tiny_skia::Transform,
    resvg::usvg::{Options, Tree, TreeParsing},
};

/// a command installer
pub struct Bootstrap {
    name: String,
    #[cfg(feature = "complete")]
    cli: clap::Command,
    outdir: PathBuf,
}

impl Bootstrap {
    #[must_use]
    pub fn new<P: AsRef<OsStr>>(
        name: &str,
        #[cfg(feature = "complete")] cli: clap::Command,
        outdir: P,
    ) -> Self {
        Self {
            name: name.to_string(),
            #[cfg(feature = "complete")]
            cli,
            outdir: Path::new(&outdir).to_path_buf(),
        }
    }

    #[cfg(feature = "complete")]
    fn gencomp(&self, gen: &str, outdir: &Path) -> Result<(), Box<dyn Error>> {
        let mut cmd = self.cli.clone();
        let path = match gen {
            "bash" => generate_to(shells::Bash, &mut cmd, &self.name, outdir)?,
            "fish" => generate_to(shells::Fish, &mut cmd, &self.name, outdir)?,
            "nu" => generate_to(Nushell, &mut cmd, &self.name, outdir)?,
            "pwsh" => generate_to(shells::PowerShell, &mut cmd, &self.name, outdir)?,
            "zsh" => generate_to(shells::Zsh, &mut cmd, &self.name, outdir)?,
            "elvish" => generate_to(shells::Elvish, &mut cmd, &self.name, outdir)?,
            _ => unimplemented!(),
        };
        println!("    {}", path.display());
        Ok(())
    }

    #[cfg(feature = "complete")]
    /// Generates and installs shell completions for all supported shells into
    /// `dir`.
    /// ## Examples
    /// To install shell completions under /usr:
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    ///
    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "/usr")
    ///         .completions()?;
    ///     Ok(())
    /// }
    /// ```
    /// To install into a staging directory for packaging purposes:
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    ///
    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "staging/usr")
    ///         .completions()?;
    ///     Ok(())
    /// }
    pub fn completions(&self) -> Result<(), Box<dyn Error>> {
        println!("Generating completions:");
        ["bash", "fish", "nu", "pwsh", "zsh", "elvish"]
            .iter()
            .try_for_each(|gen| {
                let mut outdir = self.outdir.clone();
                let base = match *gen {
                    "bash" => ["share", "bash-completion", "completions"],
                    "zsh" => ["share", "zsh", "site-functions"],
                    "nu" => ["share", "nu", "completions"],
                    "pwsh" => ["share", "pwsh", "completions"],
                    "fish" => ["share", "fish", "completions"],
                    "elvish" => ["share", "elvish", "completions"],
                    _ => unimplemented!(),
                };
                base.iter().for_each(|d| outdir.push(d));
                let mut dirbuilder = fs::DirBuilder::new();
                dirbuilder.recursive(true).mode(0o0755);
                if !outdir.exists() {
                    dirbuilder.create(&outdir)?;
                }
                self.gencomp(gen, &outdir)
            })?;
        Ok(())
    }

    fn copy_bin(&self, target_dir: Option<String>) -> Result<(), Box<dyn Error>> {
        println!("Copying binary:");
        let mut bindir = self.outdir.clone();
        bindir.push("bin");
        let mut dirbuilder = fs::DirBuilder::new();
        dirbuilder.recursive(true).mode(0o0755);
        if !bindir.exists() {
            dirbuilder.create(&bindir)?;
        }
        let mut outfile = bindir;
        outfile.push(&self.name);
        let infile: PathBuf = if let Some(target_dir) = target_dir {
            [&target_dir, &self.name].iter().collect()
        } else {
            ["target", "release", &self.name].iter().collect()
        };
        if !infile.exists() {
            eprintln!("Error: you must run \"cargo build --release\" first");
        }
        fs::copy(&infile, &outfile)?;
        println!("    {} -> {}", infile.display(), outfile.display());
        Ok(())
    }

    fn compile_translation(&self, potfile: &str, lang: &str) -> Result<(), io::Error> {
        let infile: PathBuf = ["po", potfile].iter().collect();
        let mut lcdir = self.outdir.clone();
        ["share", "locale", lang, "LC_MESSAGES"]
            .iter()
            .for_each(|d| lcdir.push(d));
        if !lcdir.exists() {
            fs::create_dir_all(&lcdir)?;
        }
        let mut outfile = lcdir.clone();
        outfile.push(&self.name);
        outfile.set_extension("mo");
        let output = process::Command::new("msgfmt")
            .args([
                infile.to_str().ok_or(io::Error::other("Bad path"))?,
                "-o",
                outfile.to_str().ok_or(io::Error::other("Bad path"))?,
            ])
            .output()?;
        if !output.status.success() {
            process::exit(output.status.code().unwrap_or(1));
        }
        println!("    {} -> {}", infile.display(), outfile.display());
        Ok(())
    }

    /// Compiles and installs translations into <prefix>/share/locale using the
    /// [msgfmt](https://www.gnu.org/software/gettext/manual/html_node/msgfmt-Invocation.html)
    /// external utility.
    pub fn translations<P: AsRef<Path>>(&self, podir: P) -> Result<(), Box<dyn Error>> {
        fs::read_dir(podir)?.try_for_each(|e| {
            match e {
                Err(e) => return Err(e),
                Ok(entry) => {
                    if entry
                        .path()
                        .extension()
                        .ok_or(io::Error::other("Bad extension"))?
                        == "po"
                    {
                        let Some(lang) = entry
                            .file_name()
                            .to_str()
                            .ok_or(io::Error::other("PathError"))?
                            .strip_suffix(".po")
                            .map(ToString::to_string)
                        else {
                            return Err(io::Error::other("File path error"));
                        };
                        let path = entry
                            .path()
                            .to_str()
                            .ok_or(io::Error::other("Path error"))
                            .map(ToString::to_string)?;
                        self.compile_translation(&path, &lang)?;
                    }
                }
            }
            Ok(())
        })?;
        Ok(())
    }

    #[cfg(feature = "mangen")]
    /// Install a Unix man page base on the given `clap::Command` struct.
    /// ## Example
    /// Install a manual page for this command into /usr, in the man1 section:
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    ///
    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "/usr")
    ///         .manpage(1)?;
    ///     Ok(())
    /// }
    /// ```
    /// Install a manual page into a staging directory in the man8 section:
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    ///
    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "pkg/usr")
    ///         .manpage(8)?;
    ///     Ok(())
    /// }
    pub fn manpage(&self, section: u8) -> Result<(), io::Error> {
        let fname = format!("{}.{section}", &self.name);
        println!("Generating manpage {fname}:");
        let command = self.cli.clone();
        let mut outdir = self.outdir.clone();
        ["share", "man", &format!("man{section}")]
            .iter()
            .for_each(|d| outdir.push(d));
        let mut dirbuilder = fs::DirBuilder::new();
        dirbuilder.recursive(true).mode(0o0755);
        if !outdir.exists() {
            dirbuilder.create(&outdir)?;
        }
        let mut outfile = outdir;
        outfile.push(fname);
        let man = Man::new(command);
        let mut buffer: Vec<u8> = vec![];
        man.render(&mut buffer)?;
        fs::write(&outfile, buffer)?;
        println!("    {}", outfile.display());
        Ok(())
    }

    #[cfg(feature = "icons")]
    fn png(&self, tree: &Tree, size: u32, source: &Path) -> Result<(), Box<dyn Error>> {
        let transform = Transform::from_scale(1.0, 1.0);
        let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(size, size) else {
            return Err(String::from("Error creating png").into());
        };
        let tree = resvg::Tree::from_usvg(tree);
        tree.render(transform, &mut pixmap.as_mut());
        let mut outdir = self.outdir.clone();
        let sizedir = format!("{size}x{size}");
        ["share", "icons", "hicolor", &sizedir, "apps"]
            .iter()
            .for_each(|d| outdir.push(d));
        let mut dirbuilder = fs::DirBuilder::new();
        dirbuilder.recursive(true).mode(0o0755);
        if !outdir.exists() {
            dirbuilder.create(&outdir)?;
        }
        let mut outfile = outdir;
        let fname = source
            .file_stem()
            .and_then(|x| x.to_str().map(|x| format!("{x}.png")))
            .unwrap();
        outfile.push(&fname);
        println!("    {} -> {}", source.display(), outfile.display());
        pixmap.save_png(outfile)?;
        Ok(())
    }

    #[cfg(feature = "icons")]
    /// Generate sized png icons from a master svg icon
    /// ## Example
    /// Generate and install icons into /usr/share/icons
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    /// use std::error::Error;
    ///
    /// fn main() -> Result<(), Box<dyn Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "/usr")
    ///         .icons(Some("data/foo.svg"))?;
    ///     Ok(())
    /// }
    /// ```
    /// Generate and install icons into a staging directory
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    /// use std::error::Error;
    ///
    /// fn main() -> Result<(), Box<dyn Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "pkg/usr")
    ///         .icons(Some("data/foo.svg"))?;
    ///     Ok(())
    /// }
    /// ```
    pub fn icons<P: AsRef<OsStr>>(&self, source: Option<P>) -> Result<(), Box<dyn Error>> {
        println!("Creating png icons from svg:");
        let infile = if let Some(s) = source {
            Path::new(&s).to_path_buf()
        } else {
            let mut p = ["data", &self.name].iter().collect::<PathBuf>();
            p.set_extension("svg");
            p
        };
        eprintln!("infile: {}", infile.display());
        let data = fs::read(&infile)?;
        let tree = Tree::from_data(&data, &Options::default())?;
        for size in [256, 128, 64, 48, 32, 24, 16] {
            self.png(&tree, size, &infile)?;
        }
        Ok(())
    }

    /// Copies a slice of files to a documentation subdirectory.
    /// # Example
    /// Copy this crate's README and LICENSE files into <PREFIX>/share/doc/foo
    /// ```no_run
    /// use package_bootstrap::Bootstrap;
    /// use std::error::Error;
    /// use std::path::Path;
    ///
    /// fn main() -> Result<(), Box<dyn Error>> {
    ///     Bootstrap::new("foo", clap::Command::new("foo"), "pkg/usr")
    ///         .docfiles(&["README.md", "LICENSE.md"], &Path::new("foo"))?;
    ///     Ok(())
    /// }
    pub fn docfiles<P: AsRef<Path> + AsRef<OsStr>>(
        &self,
        files: &[P],
        doc_subdir: &Path,
    ) -> Result<(), Box<dyn Error>> {
        println!("Copying documentation");
        let docdir: PathBuf = [&self.outdir, &"share".into(), &"doc".into(), doc_subdir]
            .iter()
            .collect();
        if !docdir.exists() {
            fs::create_dir_all(&docdir)?;
        }
        files.iter().try_for_each(|f| {
            let infile = PathBuf::from(f);
            let Some(filename) = infile.file_name().map(PathBuf::from) else {
                return Err(io::Error::other("Bad path").into());
            };
            let outfile: PathBuf = [&docdir, &filename].iter().collect();
            fs::copy(&infile, &outfile)?;
            println!("    {} -> {}", infile.display(), outfile.display());
            Ok::<(), Box<dyn Error>>(())
        })
    }

    /// Installs this crate's binary into <Prefix>/bin. If the "mangen" feature
    /// is enabled, also generates and installs the program's manpage.
    pub fn install(
        &self,
        target_dir: Option<String>,
        #[cfg(feature = "mangen")] section: u8,
    ) -> Result<(), Box<dyn Error>> {
        self.copy_bin(target_dir)?;
        #[cfg(feature = "complete")]
        self.completions()?;
        #[cfg(feature = "mangen")]
        self.manpage(section)?;
        #[cfg(feature = "icons")]
        {
            let infile: Option<&str> = None;
            self.icons(infile)?;
        }
        Ok(())
    }
}