sprawl-guard 0.1.0

Repository sprawl checker CLI.
mod advisory;

use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::json;
use sprawl_guard_lib::classification::discover_sources;
use sprawl_guard_lib::config::{built_in_config, supported_language_ids};
use sprawl_guard_lib::{LanguageId, SprawlError};

use crate::error::{CliError, Result};

use super::InitArgs;
use advisory::{InitAdvisory, advisory_json, run_advisory_scan, write_advisory_summary};

const PRIMARY_CONFIG_FILE: &str = "sprawl-guard.toml";
const ALIAS_CONFIG_FILE: &str = ".sprawlrc.toml";

pub(super) enum InitExecutionMode {
    DryRun { overwrite_warning: Option<PathBuf> },
    Written,
}

pub(super) struct InitExecution {
    advisory: InitAdvisory,
    config_text: String,
    mode: InitExecutionMode,
    selected_languages: Vec<LanguageId>,
    target: PathBuf,
}

pub(super) fn execute_init(
    root: Option<PathBuf>,
    config_path: Option<PathBuf>,
    args: InitArgs,
) -> Result<InitExecution> {
    let root = resolve_cli_root(root)?;
    let target = resolve_target_config_path(&root, config_path.as_deref());
    reject_existing_config_for_write(&root, &target, config_path.as_deref(), args.dry_run)?;
    let selected_languages = select_languages(&root, &args)?;
    let config_text = render_minimal_config(&selected_languages)?;
    let selected_config = selected_language_config(selected_languages.iter().copied())?;
    let advisory = run_advisory_scan(&root, &target, &selected_config)?;
    let overwrite_warning = existing_config_for_write(&root, &target, config_path.as_deref());
    let mode = if args.dry_run {
        InitExecutionMode::DryRun { overwrite_warning }
    } else {
        fs::write(&target, &config_text).map_err(|source| CliError::WriteConfig {
            path: target.clone(),
            source,
        })?;
        InitExecutionMode::Written
    };

    Ok(InitExecution {
        advisory,
        config_text,
        mode,
        selected_languages,
        target,
    })
}

pub(super) fn render_init_stdout(execution: &InitExecution, quiet: bool) -> String {
    if quiet {
        return String::new();
    }

    let mut output = String::new();
    match &execution.mode {
        InitExecutionMode::DryRun { overwrite_warning } => {
            writeln!(&mut output, "Would write {}", execution.target.display()).unwrap();
            if let Some(path) = overwrite_warning {
                writeln!(
                    &mut output,
                    "A real write would be refused because {} already exists.",
                    path.display()
                )
                .unwrap();
            }
            output.push('\n');
            write!(&mut output, "{}", execution.config_text).unwrap();
            output.push('\n');
        }
        InitExecutionMode::Written => {
            output.push_str("Selected languages:\n");
            for language in &execution.selected_languages {
                writeln!(&mut output, "  {}", init_language_key(*language)).unwrap();
            }
            output.push('\n');
            writeln!(&mut output, "Wrote {}", execution.target.display()).unwrap();
            output.push('\n');
        }
    }
    write_advisory_summary(&mut output, &execution.advisory);
    output
}

pub(super) fn init_payload(execution: &InitExecution) -> serde_json::Value {
    json!({
        "kind": "init",
        "target": execution.target,
        "selected_languages": execution.selected_languages,
        "config": toml::from_str::<toml::Value>(&execution.config_text)
            .expect("generated init config must stay valid TOML"),
        "mode": match &execution.mode {
            InitExecutionMode::DryRun { overwrite_warning } => json!({
                "kind": "dry_run",
                "overwrite_warning": overwrite_warning,
            }),
            InitExecutionMode::Written => json!({
                "kind": "written",
            }),
        },
        "advisory": advisory_json(&execution.advisory),
    })
}

pub(super) fn resolve_cli_root(root: Option<PathBuf>) -> Result<PathBuf> {
    let root = root.unwrap_or(
        std::env::current_dir().map_err(|source| CliError::CurrentDirectory { source })?,
    );
    root.canonicalize()
        .map_err(|source| SprawlError::ResolveRoot { path: root, source }.into())
}

fn resolve_target_config_path(root: &Path, config_path: Option<&Path>) -> PathBuf {
    match config_path {
        Some(path) if path.is_absolute() => path.to_path_buf(),
        Some(path) => root.join(path),
        None => root.join(PRIMARY_CONFIG_FILE),
    }
}

fn reject_existing_config_for_write(
    root: &Path,
    target: &Path,
    explicit_config_path: Option<&Path>,
    dry_run: bool,
) -> Result<()> {
    if dry_run {
        return Ok(());
    }
    if let Some(path) = existing_config_for_write(root, target, explicit_config_path) {
        return Err(CliError::ExistingConfig { path });
    }
    Ok(())
}

fn existing_config_for_write(
    root: &Path,
    target: &Path,
    explicit_config_path: Option<&Path>,
) -> Option<PathBuf> {
    if explicit_config_path.is_some() {
        return target.exists().then(|| target.to_path_buf());
    }
    [root.join(PRIMARY_CONFIG_FILE), root.join(ALIAS_CONFIG_FILE)]
        .into_iter()
        .find(|path| path.exists())
}

fn select_languages(root: &Path, args: &InitArgs) -> Result<Vec<LanguageId>> {
    if args.all_languages {
        return Ok(supported_language_ids());
    }
    if !args.languages.is_empty() {
        return Ok(sorted_unique_languages(args.languages.iter().copied()));
    }
    detect_languages(root)
}

fn detect_languages(root: &Path) -> Result<Vec<LanguageId>> {
    let mut config = built_in_config();
    config.enable_only_languages(supported_language_ids())?;
    let sources = discover_sources(root, &config)?;
    let languages = sorted_unique_languages(sources.into_iter().map(|source| source.language));
    if languages.is_empty() {
        return Err(CliError::NoInitLanguages);
    }
    Ok(languages)
}

fn sorted_unique_languages(languages: impl IntoIterator<Item = LanguageId>) -> Vec<LanguageId> {
    languages
        .into_iter()
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect()
}

fn selected_language_config(
    languages: impl IntoIterator<Item = LanguageId>,
) -> Result<sprawl_guard_lib::config::Config> {
    let mut config = built_in_config();
    config.enable_only_languages(languages)?;
    Ok(config)
}

fn render_minimal_config(languages: &[LanguageId]) -> Result<String> {
    let mut root = toml::map::Map::new();
    let mut language_tables = toml::map::Map::new();
    for language in languages {
        let mut language_table = toml::map::Map::new();
        language_table.insert("enabled".to_owned(), toml::Value::Boolean(true));
        language_tables.insert(
            init_language_key(*language),
            toml::Value::Table(language_table),
        );
    }
    root.insert("languages".to_owned(), toml::Value::Table(language_tables));
    let toml = toml::to_string_pretty(&toml::Value::Table(root))
        .map_err(|source| CliError::RenderConfig { source })?;
    Ok(format!("{header}{toml}", header = minimal_config_header()))
}

fn init_language_key(language: LanguageId) -> String {
    language.to_string().to_ascii_lowercase()
}

fn minimal_config_header() -> &'static str {
    "# sprawl-guard.toml\n\
     #\n\
     # View all resolved defaults:\n\
     #   sprawl-guard config resolved\n\
     #\n\
     # Debug why a path is included or excluded:\n\
     #   sprawl-guard explain path/to/file\n\n"
}

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

    mod init_language_key {
        use super::*;

        #[test]
        fn it_lowercases_the_canonical_tokei_name() {
            assert_eq!(
                super::init_language_key(LanguageId::new("typescript").unwrap()),
                "typescript"
            );
            assert_eq!(
                super::init_language_key(LanguageId::new("c++").unwrap()),
                "c++"
            );
        }
    }
}