sbol-cli 0.2.1

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

use sbol::{
    Document, MapsToSide, NamespaceSource, RdfFormat, UpgradeCounts, UpgradeOptions, UpgradeReport,
    UpgradeWarning,
};
use serde_json::{Value, json};

use crate::cli::{UpgradeArgs, UpgradeReportFormat};
use crate::output::{format_issue, infer_conversion_rdf_format};
use crate::style::Styles;

pub(crate) fn upgrade(args: UpgradeArgs, styles: Styles) -> ExitCode {
    let writing_to_stdout = args.output == "-";
    let target_format = match args.to {
        Some(format) => RdfFormat::from(format),
        None => {
            if writing_to_stdout {
                eprintln!(
                    "{}: --to is required when writing to stdout; \
                     pass --to <FORMAT> or --output <PATH>",
                    styles.err_label()
                );
                return ExitCode::from(2);
            }
            match infer_conversion_rdf_format(Path::new(&args.output)) {
                Some(format) => format,
                None => {
                    eprintln!(
                        "{}: cannot infer target format from `{}` — pass --to <FORMAT> \
                         (one of: turtle, rdfxml, jsonld, ntriples)",
                        styles.err_label(),
                        args.output
                    );
                    return ExitCode::from(2);
                }
            }
        }
    };

    let mut options = UpgradeOptions::default();
    if let Some(ns) = args.namespace.as_deref() {
        match sbol::Iri::new(ns) {
            Ok(iri) => options.default_namespace = Some(iri),
            Err(err) => {
                eprintln!("{}: invalid --namespace `{ns}`: {err}", styles.err_label());
                return ExitCode::from(2);
            }
        }
    }

    let format = match args.from {
        Some(format) => RdfFormat::from(format),
        None => match infer_conversion_rdf_format(&args.path) {
            Some(format) => format,
            None => {
                let ext = args
                    .path
                    .extension()
                    .and_then(|ext| ext.to_str())
                    .unwrap_or("<none>");
                eprintln!(
                    "{}: unsupported extension `{ext}` for {} — pass --from <FORMAT> \
                     (one of: turtle, rdfxml, jsonld, ntriples)",
                    styles.err_label(),
                    args.path.display()
                );
                return ExitCode::from(2);
            }
        },
    };
    let input = match fs::read_to_string(&args.path) {
        Ok(text) => text,
        Err(err) => {
            eprintln!(
                "{}: failed to read {}: {err}",
                styles.err_label(),
                args.path.display()
            );
            return ExitCode::from(2);
        }
    };

    let (document, report) = match Document::upgrade_from_sbol2_with(&input, format, options) {
        Ok(pair) => pair,
        Err(err) => {
            eprintln!(
                "{}: failed to upgrade {}: {err}",
                styles.err_label(),
                args.path.display()
            );
            return ExitCode::from(2);
        }
    };

    let payload = match document.write(target_format) {
        Ok(payload) => payload,
        Err(err) => {
            eprintln!(
                "{}: failed to serialize as {target_format}: {err}",
                styles.err_label()
            );
            return ExitCode::from(2);
        }
    };

    if writing_to_stdout {
        let mut stdout = io::stdout().lock();
        if let Err(err) = stdout.write_all(payload.as_bytes()) {
            eprintln!("{}: failed to write output: {err}", styles.err_label());
            return ExitCode::from(2);
        }
        if !payload.ends_with('\n') && stdout.write_all(b"\n").is_err() {
            return ExitCode::from(2);
        }
    } else if let Err(err) = fs::write(&args.output, payload) {
        eprintln!(
            "{}: failed to write {}: {err}",
            styles.err_label(),
            args.output
        );
        return ExitCode::from(2);
    }

    emit_upgrade_report(&report, args.report, styles);

    if args.strict && !report.is_clean() {
        return ExitCode::from(1);
    }

    if args.validate {
        let validation = document.validate();
        if validation.has_errors() {
            for issue in validation.issues() {
                eprintln!("{}", format_issue(issue, &args.path, styles.stderr));
            }
            return ExitCode::from(1);
        }
    }
    ExitCode::SUCCESS
}

fn emit_upgrade_report(report: &UpgradeReport, format: UpgradeReportFormat, _styles: Styles) {
    match format {
        UpgradeReportFormat::None => {}
        UpgradeReportFormat::Text => {
            let counts = report.counts();
            eprintln!("{}", format_upgrade_counts(counts));
            for warning in report.warnings() {
                eprintln!("warning: {}", format_upgrade_warning(warning));
            }
        }
        UpgradeReportFormat::Json => {
            let payload = format_upgrade_report_json(report);
            eprintln!("{payload}");
        }
    }
}

fn format_upgrade_counts(counts: &UpgradeCounts) -> String {
    format!(
        "upgrade summary: {} CD→Component, {} MD→Component, {} SubComponent, \
         {} SequenceFeature, {} SA collapsed onto SubComponent, \
         {} MapsTo decomposed, {} Interface synthesized, \
         {} Location.hasSequence inferred",
        counts.component_definitions,
        counts.module_definitions,
        counts.sub_components,
        counts.sequence_features,
        counts.sequence_annotations_collapsed,
        counts.mapstos_decomposed,
        counts.interfaces_synthesized,
        counts.locations_with_inferred_sequence,
    )
}

fn namespace_source_label(source: &NamespaceSource) -> &'static str {
    match source {
        NamespaceSource::UrlOrigin => "derived from URL scheme+host",
        NamespaceSource::DefaultOption => "fell back to --namespace value",
        NamespaceSource::None => "no namespace assigned",
        _ => "unknown source",
    }
}

fn namespace_source_token(source: &NamespaceSource) -> &'static str {
    match source {
        NamespaceSource::UrlOrigin => "url_origin",
        NamespaceSource::DefaultOption => "default_option",
        NamespaceSource::None => "none",
        _ => "unknown",
    }
}

fn mapsto_side_token(side: &MapsToSide) -> &'static str {
    match side {
        MapsToSide::Local => "local",
        MapsToSide::Remote => "remote",
        MapsToSide::Carrier => "carrier",
        _ => "unknown",
    }
}

fn format_upgrade_warning(warning: &UpgradeWarning) -> String {
    match warning {
        UpgradeWarning::NamespaceFallback { subject, source } => format!(
            "namespace fallback for <{subject}>: {}",
            namespace_source_label(source)
        ),
        UpgradeWarning::UnresolvedMapsTo { mapsto, side } => format!(
            "unresolved MapsTo <{mapsto}>: {} side did not resolve",
            mapsto_side_token(side)
        ),
        UpgradeWarning::UnsupportedRefinement { mapsto, refinement } => {
            format!("MapsTo <{mapsto}> uses refinement <{refinement}> with no SBOL 3 equivalent")
        }
        UpgradeWarning::SequenceAnnotationWithComponent { annotation } => format!(
            "SequenceAnnotation <{annotation}> references a Component; \
             upgrade collapsed it onto the referenced SubComponent"
        ),
        UpgradeWarning::UnknownSbol2Type {
            subject,
            sbol2_type,
        } => format!("subject <{subject}> has unrecognized SBOL 2 type <{sbol2_type}>"),
        UpgradeWarning::LocationWithoutSequence {
            location,
            component,
            sequence_count,
        } => format!(
            "location <{location}> on component <{component}> has no inferable sbol3:hasSequence \
             (component owns {sequence_count} sequences — need exactly 1)"
        ),
        UpgradeWarning::IdentityCollision { canonical, sources } => format!(
            "{} distinct SBOL 2 subjects canonicalize to <{canonical}>; the SBOL 3 output \
             merges their triples into a single subject. Sources: {}",
            sources.len(),
            sources
                .iter()
                .map(|s| format!("<{s}>"))
                .collect::<Vec<_>>()
                .join(", ")
        ),
        _ => "unrecognized upgrade warning".to_string(),
    }
}

fn format_upgrade_report_json(report: &UpgradeReport) -> String {
    let counts = report.counts();
    let counts_json = json!({
        "component_definitions": counts.component_definitions,
        "module_definitions": counts.module_definitions,
        "sub_components": counts.sub_components,
        "sequence_features": counts.sequence_features,
        "sequence_annotations_collapsed": counts.sequence_annotations_collapsed,
        "mapstos_decomposed": counts.mapstos_decomposed,
        "interfaces_synthesized": counts.interfaces_synthesized,
        "locations_with_inferred_sequence": counts.locations_with_inferred_sequence,
    });
    let warnings: Vec<Value> = report
        .warnings()
        .iter()
        .map(|w| match w {
            UpgradeWarning::NamespaceFallback { subject, source } => json!({
                "kind": "namespace_fallback",
                "subject": subject,
                "source": namespace_source_token(source),
            }),
            UpgradeWarning::UnresolvedMapsTo { mapsto, side } => json!({
                "kind": "unresolved_mapsto",
                "mapsto": mapsto,
                "side": mapsto_side_token(side),
            }),
            UpgradeWarning::UnsupportedRefinement { mapsto, refinement } => json!({
                "kind": "unsupported_refinement",
                "mapsto": mapsto,
                "refinement": refinement,
            }),
            UpgradeWarning::SequenceAnnotationWithComponent { annotation } => json!({
                "kind": "sequence_annotation_with_component",
                "annotation": annotation,
            }),
            UpgradeWarning::UnknownSbol2Type {
                subject,
                sbol2_type,
            } => json!({
                "kind": "unknown_sbol2_type",
                "subject": subject,
                "sbol2_type": sbol2_type,
            }),
            UpgradeWarning::LocationWithoutSequence {
                location,
                component,
                sequence_count,
            } => json!({
                "kind": "location_without_sequence",
                "location": location,
                "component": component,
                "sequence_count": sequence_count,
            }),
            UpgradeWarning::IdentityCollision { canonical, sources } => json!({
                "kind": "identity_collision",
                "canonical": canonical,
                "sources": sources,
            }),
            _ => json!({ "kind": "unknown" }),
        })
        .collect();
    let payload = json!({ "counts": counts_json, "warnings": warnings });
    serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string())
}