sbol-cli 0.2.1

Command-line tool for working with SBOL 3 documents.
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write};
use std::process::ExitCode;

use sbol::{FileResolver, Severity, ValidationContext, ValidationOptions, ValidationReport};
use sbol_ontology::OntologyCache;

#[cfg(feature = "http-resolver")]
use sbol::CachingHttpResolver;

use crate::cli::{ExternalModeArg, OutputFormat, ValidateArgs};
use crate::commands::ontology::known_ontology_by_name;
use crate::output::{format_issue, plural, read_document};
use crate::style::{Styles, paint};

pub(crate) fn validate(args: ValidateArgs, styles: Styles) -> ExitCode {
    let options = match build_options(&args) {
        Ok(options) => options,
        Err(message) => {
            eprintln!("{}: {message}", styles.err_label());
            return ExitCode::from(2);
        }
    };

    if args.external_mode == ExternalModeArg::Allowed && !cfg!(feature = "http-resolver") {
        eprintln!(
            "{}: --external-mode allowed requires the `http-resolver` feature \
             (rebuild sbol-cli with --features http-resolver)",
            styles.err_label()
        );
        return ExitCode::from(2);
    }
    if args.external_mode == ExternalModeArg::Allowed && args.cache_dir.is_none() {
        eprintln!(
            "{}: --external-mode allowed requires --cache-dir (so HTTP fetches stay deterministic)",
            styles.err_label()
        );
        return ExitCode::from(2);
    }

    let document = match read_document(&args.path, styles) {
        Ok(document) => document,
        Err(code) => return code,
    };

    let document_resolver = build_document_resolver(&args);
    let content_resolver = build_content_resolver(&args);
    #[cfg(feature = "http-resolver")]
    let caching_http = args
        .cache_dir
        .as_ref()
        .filter(|_| args.external_mode == ExternalModeArg::Allowed)
        .map(|dir| CachingHttpResolver::new(dir.clone()));

    let mut context =
        ValidationContext::with_options(options).with_external_mode(args.external_mode.into());
    if let Some(resolver) = &document_resolver {
        context = context.with_document_resolver(resolver);
    }
    if let Some(resolver) = &content_resolver {
        context = context.with_content_resolver(resolver);
    }
    #[cfg(feature = "http-resolver")]
    if let Some(resolver) = &caching_http {
        let doc_ref: &dyn sbol::DocumentResolver = resolver;
        let content_ref: &dyn sbol::ContentResolver = resolver;
        context = context
            .with_content_resolver(content_ref)
            .with_document_resolver(doc_ref);
    }

    let report = document.validate_with_context(context);

    if let Err(message) = render_output(&args, &report, styles) {
        eprintln!("{}: failed to write output: {message}", styles.err_label());
        return ExitCode::from(2);
    }

    let has_errors = report.has_errors();
    let has_partial = !report.coverage().partially_applied.is_empty();
    if has_errors {
        ExitCode::from(1)
    } else if args.treat_partial_as_errors && has_partial {
        ExitCode::from(3)
    } else {
        ExitCode::SUCCESS
    }
}

fn build_document_resolver(args: &ValidateArgs) -> Option<FileResolver> {
    if args.resolve_documents.is_empty() {
        return None;
    }
    let mut resolver = FileResolver::new();
    for root in &args.resolve_documents {
        resolver.add_root(root.clone());
    }
    Some(resolver)
}

fn build_content_resolver(args: &ValidateArgs) -> Option<FileResolver> {
    if args.resolve_content.is_empty() {
        return None;
    }
    let mut resolver = FileResolver::new();
    for root in &args.resolve_content {
        resolver.add_root(root.clone());
    }
    Some(resolver)
}

fn build_options(args: &ValidateArgs) -> Result<ValidationOptions, String> {
    let mut options = ValidationOptions::default();
    let mut configured: BTreeSet<&str> = BTreeSet::new();

    for rule in &args.allow {
        check_first_use(&mut configured, rule)?;
        options = options.allow(rule).map_err(|err| err.to_string())?;
    }
    for rule in &args.deny {
        check_first_use(&mut configured, rule)?;
        options = options.deny(rule).map_err(|err| err.to_string())?;
    }
    for rule in &args.warn {
        check_first_use(&mut configured, rule)?;
        options = options.warn(rule).map_err(|err| err.to_string())?;
    }

    if let Some(floor) = args.severity_floor {
        options = options.with_severity_floor(floor.into());
    }
    if let Some(ceiling) = args.severity_ceiling {
        options = options.with_severity_ceiling(ceiling.into());
    }
    if args.treat_warnings_as_errors {
        options = options.with_severity_floor(Severity::Error);
    }

    if !args.ontology.is_empty() {
        let cache = OntologyCache::from_default_path();
        for name in &args.ontology {
            let extension = cache.load(name).map_err(|error| {
                if known_ontology_by_name(name).is_some() {
                    format!(
                        "failed to load ontology extension `{name}` from {}: {error}. \
                         Install it first with `sbol ontology install {name}`.",
                        cache.path().display(),
                    )
                } else {
                    format!(
                        "unknown ontology extension `{name}` — try one of: ncit \
                         (run `sbol ontology install <name>` to install it)"
                    )
                }
            })?;
            options = options.with_ontology_extension(extension);
        }
    }
    Ok(options)
}

fn check_first_use<'a>(configured: &mut BTreeSet<&'a str>, rule: &'a str) -> Result<(), String> {
    if !configured.insert(rule) {
        return Err(format!(
            "rule `{rule}` is given more than one override on the command line"
        ));
    }
    Ok(())
}

fn render_output(args: &ValidateArgs, report: &ValidationReport, styles: Styles) -> io::Result<()> {
    let writing_to_stdout = args.output == "-";
    let payload = match args.format {
        OutputFormat::Text => {
            let color = styles.stdout && writing_to_stdout;
            format_text(args, report, color)
        }
        OutputFormat::Json => sbol::to_json(report),
        #[cfg(feature = "sarif")]
        OutputFormat::Sarif => crate::sarif::to_sarif(report, &args.path),
    };

    if writing_to_stdout {
        let mut stdout = io::stdout().lock();
        stdout.write_all(payload.as_bytes())?;
        if !payload.ends_with('\n') {
            stdout.write_all(b"\n")?;
        }
        Ok(())
    } else {
        fs::write(&args.output, payload)
    }
}

fn format_text(args: &ValidateArgs, report: &ValidationReport, color: bool) -> String {
    let mut out = String::new();
    for issue in report.issues() {
        out.push_str(&format_issue(issue, &args.path, color));
        out.push('\n');
    }

    let errors = report.errors().count();
    let warnings = report.warnings().count();
    out.push_str(&format!(
        "{}: {errors} error{}, {warnings} warning{}",
        args.path.display(),
        plural(errors),
        plural(warnings),
    ));
    if errors == 0 && warnings == 0 {
        out.push_str(&format!(" {}", paint(color, "1;32", "— OK")));
    }
    out.push('\n');

    if args.show_coverage {
        let coverage = report.coverage();
        let line = format!(
            "coverage: {} fully applied, {} partially applied, {} not applied\n",
            coverage.fully_applied.len(),
            coverage.partially_applied.len(),
            coverage.not_applied.len(),
        );
        out.push_str(&paint(color, "2", &line));
    }

    out
}