skif-cli 0.2.0

Command-line interface for skif, a dependency and package-manager linter for JS/TS monorepos.
Documentation
//! The `rules`, `schema` and `init` subcommands.

use std::fmt::Write as _;
use std::fs::OpenOptions;
use std::io::{ErrorKind, Write};

use serde::Serialize;
use serde_json::json;
use skif::{
    CONFIG_FILE_NAMES, Ceiling, Config, ConfigCache, ConfigOverrides, Linter, OsFs, RuleMeta, resolve_config, slash,
};

use crate::args::{Format, InitArgs, RulesArgs, RulesFormat};
use crate::config::relative;
use crate::error::{CliError, Kind};
use crate::output::{emit_error, write_stdout};

const CLEAN: u8 = 0;
const COULD_NOT_RUN: u8 = 2;

const STARTER_CONFIG: &str = "\
#:schema https://raw.githubusercontent.com/Goldziher/skif/main/schema/skif.schema.json

# skif configuration. Every key is optional; the values below are the defaults.

[lint]
# Exit 1 when only warnings were reported (the same as `--max-warnings 0`).
fail-on-warnings = false

[policy]
# The version that alignment fixes target: \"highest\" or \"lowest\".
prefer = \"highest\"

# [workspace]
# ignore-packages = [\"@scope/legacy-*\"]

# [rules]
# unsorted-dependencies = \"off\"
";

fn finish(result: Result<(), CliError>, format: Format) -> u8 {
    match result {
        Ok(()) => CLEAN,
        Err(error) => {
            emit_error(&error, format);
            COULD_NOT_RUN
        }
    }
}

fn label<T: Serialize>(value: &T) -> String {
    serde_json::to_value(value)
        .ok()
        .and_then(|value| value.as_str().map(str::to_owned))
        .unwrap_or_default()
}

fn rules_table(metas: &[&'static RuleMeta]) -> String {
    let width = metas
        .iter()
        .map(|meta| meta.id.len())
        .max()
        .unwrap_or(0)
        .max("RULE".len());
    let mut table = format!(
        "{:<width$}  {:<9}  {:<7}  {:<9}  SUMMARY\n",
        "RULE", "CATEGORY", "DEFAULT", "FIXABLE"
    );
    for meta in metas {
        let default = meta
            .default
            .map_or_else(|| "off".to_owned(), |severity| label(&severity));
        let _ = writeln!(
            table,
            "{:<width$}  {:<9}  {:<7}  {:<9}  {}",
            meta.id,
            label(&meta.category),
            default,
            label(&meta.fixable),
            meta.summary
        );
    }
    table
}

fn list_rules(format: RulesFormat) -> Result<(), CliError> {
    let linter = Linter::new(&Config::default())?;
    let metas: Vec<&'static RuleMeta> = linter.rules().collect();
    let text = match format {
        RulesFormat::Human => rules_table(&metas),
        RulesFormat::Json => {
            let mut text = serde_json::to_string_pretty(&rules_json(&metas))
                .map_err(|error| CliError::new(Kind::Io, error.to_string()))?;
            text.push('\n');
            text
        }
    };
    write_stdout(text.as_bytes())
}

fn rules_json(metas: &[&'static RuleMeta]) -> serde_json::Value {
    json!({
        "version": 1,
        "skif": env!("CARGO_PKG_VERSION"),
        "rules": metas,
    })
}

/// The document `skif rules --format json` prints.
pub fn rules_document() -> Result<serde_json::Value, CliError> {
    let linter = Linter::new(&Config::default())?;
    let metas: Vec<&'static RuleMeta> = linter.rules().collect();
    Ok(rules_json(&metas))
}

pub fn rules(args: &RulesArgs) -> u8 {
    finish(list_rules(args.format), args.format.into())
}

pub fn schema() -> u8 {
    finish(write_stdout(skif::config_schema().as_bytes()), Format::Human)
}

/// The configuration files that already apply to `dir`, relative to it: what a file written there will inherit from.
fn inherited_files(dir: &std::path::Path) -> Vec<String> {
    resolve_config(
        dir,
        &Ceiling::Vcs,
        &OsFs,
        &ConfigOverrides::default(),
        &ConfigCache::new(),
    )
    .map(|resolved| {
        resolved
            .sources
            .iter()
            .map(|source| relative(dir, &source.path))
            .collect()
    })
    .unwrap_or_default()
}

fn write_starter(args: &InitArgs) -> Result<(), CliError> {
    let dir = std::path::absolute(&args.path).map_err(|error| CliError::io("cannot resolve", &args.path, &error))?;
    if !dir.is_dir() {
        return Err(CliError::new(Kind::Io, format!("{} is not a directory", dir.display())));
    }
    if let Some(existing) = CONFIG_FILE_NAMES
        .iter()
        .map(|name| dir.join(name))
        .find(|path| path.exists())
    {
        return Err(CliError {
            path: Some(slash(&existing)),
            ..CliError::new(
                Kind::Config,
                format!("{} already exists; refusing to overwrite it", existing.display()),
            )
        });
    }
    let inherited = inherited_files(&dir);
    let target = dir.join(CONFIG_FILE_NAMES[0]);
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&target)
        .map_err(|error| match error.kind() {
            ErrorKind::AlreadyExists => CliError::new(
                Kind::Config,
                format!("{} already exists; refusing to overwrite it", target.display()),
            ),
            _ => CliError::io("cannot create", &target, &error),
        })?;
    file.write_all(STARTER_CONFIG.as_bytes())
        .map_err(|error| CliError::io("cannot write", &target, &error))?;
    let mut message = format!("Created {}\n", slash(&target));
    if !inherited.is_empty() {
        let _ = writeln!(message, "Note: it will inherit from {}", inherited.join(", "));
    }
    write_stdout(message.as_bytes())
}

pub fn init(args: &InitArgs) -> u8 {
    finish(write_starter(args), Format::Human)
}

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

    #[test]
    fn starter_config_is_valid_configuration() {
        let config = Config::from_toml_str(STARTER_CONFIG).unwrap();
        assert_eq!(config, Config::default());
    }
}