confget 5.0.1

Parse configuration files.
Documentation
/*
 * Copyright (c) 2021, 2022  Peter Pentchev <roam@ringlet.net>
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
 * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 */
//! confget - a command-line configuration file parser.
//!
//! Extract the specified variables from a configuration file and
//! format their values as requested.

use std::process::{ExitCode, Termination};

use anyhow::{bail, ensure, Context, Result};
use clap::Parser;

use confget::defs::{BackendKind, Config, FileData};
use confget::format;

/// The exit code from the program itself.
#[derive(Debug)]
enum MainResult {
    /// Everything is fine.
    OK,
    /// An error in the command-line arguments, or a non-match in "check only" mode.
    Failure,
}

impl Termination for MainResult {
    fn report(self) -> ExitCode {
        ExitCode::from(match self {
            Self::OK => 0,
            Self::Failure => 1,
        })
    }
}

/// The requested mode of operation for the confget tool.
#[derive(Debug)]
enum Mode {
    /// Either help/version/features were requested, or the supplied
    /// command-line options were inconsistent.
    Handled(MainResult),
    /// Query for the specified variables with the specified options.
    Query(Config),
    /// Only check whether a variable is defined in the file.
    Check(Config, String),
    /// Only display the section names parsed from the config file.
    Sections(Config),
}

/// The command-line option definitions.
#[derive(Debug, Parser)]
#[clap(disable_help_flag(true))]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
    /// check if the variable is defined in the file
    #[clap(short)]
    check_only: bool,

    /// the configuration file to read from
    #[clap(short)]
    filename: Option<String>,

    /// display usage information and exit
    #[clap(short = 'h', long = "help")]
    show_help: bool,

    /// specify which variables to display
    #[clap(short = 'L')]
    match_var_names: bool,

    /// list all variables in the specified section
    #[clap(short)]
    list_all: bool,

    /// only display values that match the specified pattern
    #[clap(short)]
    match_var_values: Option<String>,

    /// always display the variable name
    #[clap(short = 'N')]
    show_var_name: bool,

    /// never display the variable name
    #[clap(short = 'n')]
    never_show_var_name: bool,

    /// allow variables in the specified section to override those placed before any section definitions
    #[clap(short = 'O')]
    section_override: bool,

    /// display this string after the variable name
    #[clap(short = 'P')]
    name_suffix: Option<String>,

    /// display this string before the variable name
    #[clap(short = 'p')]
    name_prefix: Option<String>,

    /// query for a specific type of information
    #[clap(short)]
    query: Option<String>,

    /// quote the values suitably for the Bourne shell
    #[clap(short = 'S')]
    shell_escape: bool,

    /// the configuration section to read
    #[clap(short = 's')]
    section: Option<String>,

    /// the configuration file type
    #[clap(short = 't')]
    backend_name: Option<String>,

    /// display program version information and exit
    #[clap(short = 'V', long = "version")]
    show_version: bool,

    /// treat the match patterns as regular expressions
    #[clap(short = 'x')]
    match_regex: bool,

    /// the names of the variables to display
    varnames: Vec<String>,
}

/// The usage (help) message for the confget tool.
const USAGE: &str = "Usage:
confget [-cOSx] [-N | -n] [-f filename] [-m pattern] [-P postfix] [-p prefix]
        [-s section] [-t type] var...

confget [-OSx] [-N | -n] [-f filename] [-m pattern] [-P postfix] [-p prefix]
        [-s section] [-t type] -L pattern...

confget [-OSx] [-N | -n] [-f filename] [-m pattern] [-P postfix] [-p prefix]
        [-s section] [-t type] -l
confget [-f filename] -q sections [-t type]

confget -q features
confget -q feature NAME";

/// Figure out whether a backend type supports `-q sections`.
const fn supports_sections_query(backend: &BackendKind) -> bool {
    match *backend {
        #[cfg(feature = "ini-nom")]
        BackendKind::IniNom => true,

        #[cfg(feature = "ini-regex")]
        BackendKind::IniRE => true,

        _ => false,
    }
}

/// Process the command-line options, check for conflicts or inconsistencies.
/// If the `--version`, `--help`, or `-q features/feature` options were specified,
/// display the requested information and exit.
#[allow(clippy::print_stdout)]
fn validate_options(opts: &Cli, config: Config) -> Result<Mode> {
    ensure!(
        [
            opts.query.is_some(),
            opts.match_var_names,
            opts.list_all,
            !(config.varnames.is_empty()
                || opts.match_var_names
                || opts
                    .query
                    .as_ref()
                    .map_or(false, |value| value == "feature")),
        ]
        .into_iter()
        .filter(|value| *value)
        .count()
            < 2,
        "Only a single query at a time, please!"
    );

    if opts.show_version {
        let (_, version) = confget::features()
            .into_iter()
            .find(|&(name, _)| name == "BASE")
            .context("Internal error: no 'BASE' in the features list")?;
        println!("confget {}", version);
    }
    if opts.show_help {
        println!("{}", USAGE);
    }
    if opts.show_help || opts.show_version {
        return Ok(Mode::Handled(MainResult::OK));
    }

    if let Some(ref query) = opts.query {
        return Ok(match query.as_str() {
            "sections" => {
                ensure!(supports_sections_query(&config.backend),
                "The query for sections is only supported for the 'ini' backend for the present",
            );
                Mode::Sections(config)
            }
            "features" => {
                ensure!(config.varnames.is_empty(), "No arguments to -q features");
                let features: Vec<String> = confget::features()
                    .into_iter()
                    .map(|(name, version)| format!("{}={}", name, version))
                    .collect();
                println!("{}", features.join(" "));
                Mode::Handled(MainResult::OK)
            }
            "feature" => {
                let (feature_name, rest) = config
                    .varnames
                    .split_first()
                    .context("Expected a single feature name")?;
                ensure!(rest.is_empty(), "Only a single feature name expected");
                match confget::features()
                    .into_iter()
                    .find(|&(name, _)| name == feature_name)
                {
                    Some((_, value)) => {
                        println!("{}", value);
                        Mode::Handled(MainResult::OK)
                    }
                    None => Mode::Handled(MainResult::Failure),
                }
            }
            other => bail!(format!("Unrecognized query '{}'", other)),
        });
    }

    if opts.list_all {
        ensure!(
            config.varnames.is_empty(),
            "Only a single query at a time, please!"
        );
    } else if opts.match_var_names {
        ensure!(!config.varnames.is_empty(), "No patterns to match against");
    } else {
        ensure!(
            !config.varnames.is_empty(),
            "No variables specified to query"
        );
    }

    if opts.check_only {
        let (varname, rest) = opts
            .varnames
            .split_first()
            .context("Internal error: check_only with no config.varnames")?;
        ensure!(rest.is_empty(), "Only a single query at a time, please!");
        Ok(Mode::Check(config, varname.clone()))
    } else {
        Ok(Mode::Query(config))
    }
}

/// Parse the command-line arguments, process informational queries.
fn parse_args() -> Result<Mode> {
    let opts = Cli::parse();

    let clone_or_empty =
        |opt: Option<&String>| opt.map_or_else(String::new, |value| (*value).clone());
    let config = Config {
        backend: if let Some(value) = opts.backend_name.as_ref() {
            value.parse().context("Invalid backend specified")?
        } else {
            BackendKind::get_preferred_ini_backend()
        },
        encoding: String::new(),
        filename: opts.filename.clone(),
        list_all: opts.list_all,
        match_regex: opts.match_regex,
        match_var_names: opts.match_var_names,
        match_var_values: opts.match_var_values.clone(),
        name_prefix: clone_or_empty(opts.name_prefix.as_ref()),
        name_suffix: clone_or_empty(opts.name_suffix.as_ref()),
        section: clone_or_empty(opts.section.as_ref()),
        section_override: opts.section_override,
        section_specified: opts.section.is_some(),
        shell_escape: opts.shell_escape,
        show_var_name: opts.show_var_name
            || ((opts.match_var_names || opts.list_all || opts.varnames.len() > 1)
                && !opts.never_show_var_name),
        varnames: opts.varnames.clone(),
    };
    validate_options(&opts, config)
}

/// Display the processed variable names and/or values.
#[allow(clippy::print_stdout)]
fn output_vars(config: &Config, data: &FileData, section: &str) -> Result<MainResult> {
    for var in format::filter_vars(config, data, section)
        .context("Could not select the variables to output")?
    {
        println!("{}", var.output_full);
    }
    Ok(MainResult::OK)
}

/// Exit with status 0 if the specified variable is present in the config file.
fn output_check_only(
    _config: &Config,
    data: &FileData,
    section: &str,
    varname: &str,
) -> MainResult {
    data.get(section).map_or(MainResult::Failure, |sect_data| {
        if sect_data.contains_key(varname) {
            MainResult::OK
        } else {
            MainResult::Failure
        }
    })
}

/// Output the names of the sections parsed from the config file.
#[allow(clippy::print_stdout)]
fn output_sections(data: &FileData) -> MainResult {
    let mut sections: Vec<&String> = data.keys().filter(|value| !value.is_empty()).collect();
    sections.sort();
    for name in sections {
        println!("{}", name);
    }
    MainResult::OK
}

/// Parse the INI-style file, return the data and the queried section name.
fn parse_config(config: &Config) -> Result<(FileData, String)> {
    let (data, first_section) =
        confget::read_ini_file(config).context("Could not parse the INI-style file")?;
    let section = if config.section.is_empty() {
        first_section
    } else {
        config.section.clone()
    };
    Ok((data, section))
}

fn main() -> Result<MainResult> {
    Ok(match parse_args()? {
        Mode::Handled(res) => res,
        Mode::Check(config, varname) => {
            let (data, section) = parse_config(&config)?;
            output_check_only(&config, &data, &section, &varname)
        }
        Mode::Sections(config) => {
            let (data, _) = parse_config(&config)?;
            output_sections(&data)
        }
        Mode::Query(config) => {
            let (data, section) = parse_config(&config)?;
            output_vars(&config, &data, &section)?
        }
    })
}