dots-bin 0.2.1

A cozy, simple-to-use dotfiles manager
Documentation
//! See [`World`] for more info

use eyre::ContextCompat as _;
use std::{
    fs,
    path::{self, Path, PathBuf},
};

use itertools::Itertools as _;
use tap::Pipe as _;
use walkdir::WalkDir;

use crate::{
    config::Config,
    output_path::OutputPath,
    stdx::{self, PathExt as _},
};

use std::collections::BTreeMap;

use crate::analysis::Analysis;
use crate::config::GITHUB;
use crate::config::Marker;

use eyre::{Context as _, Error, Result, bail, eyre};
use handlebars::Handlebars;
use simply_colored::*;

/// This structure represents inputs to the application, with all
/// paths resolved so the core of `dots` does not need to do any IO.
#[derive(Debug)]
pub struct World {
    /// Path which contains the config file
    pub root: PathBuf,
    /// Contents of the config file
    pub links: Vec<Link>,
    /// Files to create
    pub files: Vec<File>,
}

/// Represents a URL
#[derive(Debug)]
pub struct Link {
    /// Url to the contents of the link
    pub url: String,
    /// Content at the URL
    pub contents: String,
    /// Path where to write the file to in the `config` directory,
    /// e.g. `nushell/catppuccin.nu` writes to `config/nushell/catppuccin.nu` if `config` in `Config` is `"config"`
    pub path: PathBuf,
    /// Expected hash of the file. This can be supplied for security purposes
    pub sha256: Option<String>,
    /// A marker to add, like `"--path '{config}/gitui/theme.ron'"`
    /// This marker is not interpreted. Instead, the marker is written to the
    /// file as-is
    pub marker: Option<String>,
}

/// A single file to be mapped from the input (`old_location`) to the output (`new_location`)
#[derive(Debug)]
pub struct File {
    /// Old location of the file
    pub old_location: PathBuf,
    /// Contents of the file
    pub contents: String,
    /// Output path
    pub output: OutputPath,
    /// Input directory
    pub input: PathBuf,
}

impl World {
    /// This function is the "core" of `dots`, it is pure and does no IO (except for logging)
    ///
    /// We want to keep it like this as it makes it easier to reason about and test.
    pub fn process(self) -> Result<Analysis, Vec<Error>> {
        let mut errors = vec![];

        let links = self
            .links
            .into_iter()
            .map(
                |Link {
                     contents,
                     path,
                     sha256,
                     marker,
                     url,
                 }| {
                    let actual_sha256 = sha256::digest(&contents);

                    if let Some(expected_sha256) = sha256
                        && actual_sha256 != *expected_sha256
                    {
                        let mismatch = format!("link       {BLUE}{url}{RESET}");
                        let actual = format!("actual     {CYAN}{actual_sha256}{RESET}");
                        let expected = format!("expected   {CYAN}{expected_sha256}{RESET}");
                        bail!("hash mismatch\n  {mismatch}\n  {actual}\n  {expected}");
                    }

                    // download the link's contents to *this* path
                    let path = self.root.join(path);

                    // add the marker if necessary
                    let marker = marker.as_ref().map_or(String::new(), |marker_args| {
                        format!("{}{marker_args}", Marker::MARKER)
                            .pipe(|marker| commented::comment(marker, &path))
                            + "\n"
                    });

                    let file_contents = format!("{marker}{contents}");

                    let marker = file_contents
                        .lines()
                        .next()
                        .filter(|line| line.contains(Marker::MARKER))
                        .map(|line| format!("{line}\n"))
                        .unwrap_or_default();

                    let contents = if let Some(first_line) = file_contents.lines().next()
                        && first_line.contains(Marker::MARKER)
                    {
                        file_contents.lines().skip(1).collect::<Vec<_>>().join("\n")
                    } else {
                        file_contents.to_string()
                    };

                    let generated_notice = [
                        format!("@generated by `{}` <{GITHUB}>", Marker::MARKER),
                        "Do not edit by hand.".to_string(),
                        String::new(),
                        format!("downloaded from: {url}"),
                    ]
                    .into_iter()
                    .fold(String::new(), |previous_lines, line| {
                        format!("{previous_lines}{}\n", commented::comment(line, &path))
                    });

                    let contents = format!("{marker}{generated_notice}{contents}");

                    Ok(crate::analysis::WritePath { path, contents })
                },
            )
            .partition_result::<Vec<_>, Vec<_>, _, _>()
            .pipe(|(oks, errs)| {
                errors.extend(errs);
                oks
            });

        let files = self
            .files
            .into_iter()
            .map(
                |File {
                     old_location,
                     contents,
                     output,
                     input,
                 }| {
                    let relative_location = old_location
                        .strip_prefix(&self.root)?
                        .strip_prefix(&input)?;

                    let (file_contents, new_location) = if let Some(first_line) =
                        contents.lines().next()
                        && let Some(marker_start_pos) = first_line.find(Marker::MARKER)
                        && let Some(marker_args) =
                            first_line.get(marker_start_pos + Marker::MARKER.len()..)
                        && let Ok(args) = marker_args.parse::<Marker>()
                        && let Some(path) = args.path
                    {
                        (
                            // remove the first line which contains the `@dotfilers`
                            contents.lines().skip(1).collect_vec().join(","),
                            path,
                        )
                    } else {
                        (
                            contents,
                            output
                                .as_ref()
                                .join(relative_location)
                                .pipe(OutputPath::new),
                        )
                    };

                    let mut handlebars = Handlebars::new();
                    handlebars
                        .register_template_string("t1", file_contents)
                        .with_context(|| eyre!("failed to parse template for {new_location}"))?;

                    let contents = handlebars
                        .render("t1", &BTreeMap::<u8, u8>::new())
                        .with_context(|| eyre!("failed to render template for {new_location}"))?;

                    Ok::<_, Error>(crate::analysis::WritePath {
                        path: new_location.into_inner(),
                        contents,
                    })
                },
            )
            .partition_result::<Vec<_>, Vec<_>, _, _>()
            .pipe(|(oks, errs)| {
                errors.extend(errs);
                oks
            });

        if !errors.is_empty() {
            return Err(errors);
        }

        Ok(Analysis {
            writes: links.into_iter().chain(files).collect(),
        })
    }

    /// Create the `World`
    pub fn new(cwd: &Path) -> Result<Self, Vec<Error>> {
        // Directory which contains the config file
        let root = cwd
            .pipe_ref(stdx::traverse_upwards)
            .find(|dir| dir.join(Config::FILE_NAME).exists())
            .with_context(|| {
                eyre!(
                    "failed to find directory that contains a `{}`. traversed upwards from {}",
                    Config::FILE_NAME,
                    cwd.show()
                )
            })
            .map_err(single_err)?;

        let config = root
            .join(Config::FILE_NAME)
            .pipe(fs::read_to_string)
            .with_context(|| eyre!("failed to read config file {}", Config::FILE_NAME))
            .map_err(single_err)?
            .pipe_deref(toml::de::from_str::<Config>)
            .context("failed to parse config file")
            .map_err(single_err)?
            .pipe(|mut conf| {
                conf.root = root;
                conf
            });

        let mut errors = vec![];

        let links = config
            .links
            .into_iter()
            .map(
                |crate::config::Link {
                     url,
                     path,
                     sha256,
                     marker,
                 }| {
                    Ok::<_, Error>(Link {
                        contents: ureq::get(&url).call()?.body_mut().read_to_string()?,
                        path,
                        sha256,
                        marker,
                        url,
                    })
                },
            )
            .partition_result::<Vec<_>, Vec<_>, _, _>()
            .pipe(|(oks, errs)| {
                errors.extend(errs);
                oks
            });

        let files = config
            .dirs
            .into_iter()
            .flat_map(|crate::config::Dir { input, output }| {
                WalkDir::new(config.root.join(&input))
                    .into_iter()
                    .flatten()
                    .filter(|dir_entry| dir_entry.file_type().is_file())
                    .map(move |file| {
                        // location of the `input` file
                        let old_location = path::absolute(file.path())?;

                        let contents = fs::read_to_string(&old_location).with_context(|| {
                            eyre!("failed to read path {}", old_location.show())
                        })?;

                        Ok::<_, Error>(File {
                            old_location,
                            contents,
                            output: output.clone(),
                            input: input.clone(),
                        })
                    })
            })
            .partition_result::<Vec<_>, Vec<_>, _, _>()
            .pipe(|(oks, errs)| {
                errors.extend(errs);
                oks
            });

        if !errors.is_empty() {
            return Err(errors);
        }

        Ok(Self {
            root: config.root,
            links,
            files,
        })
    }
}

/// Helper to return a single error from a function that returns a `Vec<Error>`
///
/// Useful for **unrecoverable** errors
fn single_err(err: impl Into<Error>) -> Vec<Error> {
    vec![err.into()]
}