configlation 0.1.1

Generate configuration from inputs and template
Documentation
#![doc = include_str!("../README.md")]

use anyhow::{Context, Result};
use clap::{ArgAction, Parser};
use configlation::{Config, Contents, Input, InputKind, Output};
use dialoguer::theme::ColorfulTheme;
use indicatif::{ProgressBar, ProgressStyle};
use std::{
    collections::HashMap, fmt::Write, fs, path::PathBuf, thread::sleep,
    time::Duration,
};
use tinytemplate::TinyTemplate;
use tracing::{debug, instrument, trace, warn};

#[derive(Debug, Parser)]
struct Cli {
    #[clap(short, long, action = ArgAction::Count, global = true )]
    verbose: u8,

    #[clap(short, long, default_value = "./configlation.toml")]
    config: PathBuf,

    #[clap(short, long, default_value = ".")]
    destination: PathBuf,

    /// Prompt for values that have already been configured.
    #[clap(long)]
    reconfigure: bool,
}

#[instrument(ret, err)]
fn main() -> Result<()> {
    let cli = Cli::parse();
    jacklog::from_level!(2 + cli.verbose)?;
    debug!(?cli);

    // Read the config.
    let cfg: Config = configlation::read_toml(&cli.config)?;
    debug!(?cfg);

    // Read any saved config from previous runs.
    let values_dest = cli.destination.join("saved.configlation.toml");
    let mut values: HashMap<String, String> =
        configlation::read_toml(&values_dest).unwrap_or_default();
    debug!(?values);

    eprintln!("{}", console::style(cfg.title).bold());

    // Go through and collect all the inputs.
    let inputs_len = cfg.inputs.len();
    for (
        n,
        Input {
            name,
            kind,
            default,
            description,
        },
    ) in cfg.inputs.into_iter().enumerate()
    {
        // Set up an input dialogue.
        let theme = ColorfulTheme::default();
        let mut dia = dialoguer::Input::<String>::with_theme(&theme)
            .with_prompt(format!(
                "{} {}: {name}{}",
                console::style(format!("{}/{inputs_len}", n + 1)).dim(),
                description.unwrap_or_default(),
                console::style(if matches!(kind, InputKind::Secret) {
                    " <redacted>"
                } else {
                    ""
                })
                .dim(),
            ));

        // Check to see if we already have a value for this input.
        if let Some(v) = values.get(&name) {
            // If --no-update was passed, don't do anything if the value's
            // already present.
            if !cli.reconfigure {
                continue;
            }

            // If we have a previously-used saved config, default to it.
            dia = dia.default(v.into());
        } else if let Some(default) = default {
            // If the config has a default value, set it.
            dia = dia.default(default);
        }

        // If this is a secret, don't show it after input.
        if matches!(kind, InputKind::Secret) {
            trace!(?name, ?kind, "redacting post completion text");
            dia = dia
                .with_post_completion_text("<redacted>")
                .show_default(false);
        }
        let val = dia.interact_text()?;

        if let Some(v) = values.insert(name.clone(), val.clone()) {
            // Warn if we overwrote an existing value with a different value.
            if v != val {
                warn!("overwrote {v} with {val} for input {name}");
            }
        }
    }
    debug!(?values);

    // Go through the outputs and process them.
    let bar = ProgressBar::new(cfg.outputs.len() as u64)
        .with_style(
            ProgressStyle::default_bar()
                .template("{wide_bar} {pos}/{len} {msg:21}")?,
        )
        .with_message("writing outputs");
    for output in cfg.outputs {
        trace!(?output);

        match output {
            Output::File {
                path,
                contents,
            } => {
                // Build output template.
                let mut tt = TinyTemplate::new();
                tt.add_formatter("quoted", |v, o| {
                    match v {
                        serde_json::Value::Null => {},
                        serde_json::Value::Bool(b) => {
                            write!(o, "{b}")?;
                        },
                        serde_json::Value::Number(number) => {
                            write!(o, "{number:?}")?;
                        },
                        serde_json::Value::String(s) => {
                            write!(o, "{s:?}")?;
                        },
                        serde_json::Value::Array(values) => todo!(),
                        serde_json::Value::Object(map) => todo!(),
                    }

                    Ok(())
                });
                let template = match contents {
                    Contents::String(s) => s,
                    Contents::List(v) => v.join("\n"),
                };
                tt.add_template("default", &template)?;

                // Figure out where we're writing the output.
                let dest = if path.is_absolute() {
                    path
                } else {
                    cli.destination.join(path)
                };

                // Write the rendered template.
                fs::write(dest, tt.render("default", &values)?)?;
            },
        }

        bar.inc(1);
    }
    bar.finish_with_message("all outputs complete!");

    // Save the inputs.
    configlation::write_toml(&values_dest, &values, 400)?;

    eprintln!("all configuration updated");

    Ok(())
}