index-compat-lab 1.0.0

Offline compatibility artifact synthesis and validation tools for Index.
Documentation
//! CLI entrypoint for compatibility lab workflows.

use std::fs;
use std::io::{self, Write};
use std::process::ExitCode;

use index_compat_lab::{
    ingest_summary, lint_pack_json, merge_pack_overrides, scaffold_pack_json, synthesize_quality,
    synthesize_rules,
};

fn main() -> ExitCode {
    match run() {
        Ok(output) => {
            let _ = writeln!(io::stdout(), "{output}");
            ExitCode::SUCCESS
        }
        Err(error) => {
            let _ = writeln!(io::stderr(), "{error}");
            ExitCode::from(1)
        }
    }
}

fn run() -> Result<String, String> {
    let mut args = std::env::args().skip(1);
    let Some(command) = args.next() else {
        return Err(help());
    };
    match command.as_str() {
        "ingest" => run_ingest(args),
        "scaffold" => run_scaffold(args),
        "synthesize" => run_synthesize(args),
        "lint" => run_lint(args),
        "merge-overrides" => run_merge_overrides(args),
        "--help" | "-h" => Err(help()),
        other => Err(format!(
            "unsupported index-compat-lab command: {other}\n\n{}",
            help()
        )),
    }
}

fn run_ingest(mut args: impl Iterator<Item = String>) -> Result<String, String> {
    let mut top100_path: Option<String> = None;
    let mut forum_path: Option<String> = None;
    let mut captures = Vec::new();
    while let Some(argument) = args.next() {
        match argument.as_str() {
            "--top100" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                top100_path = Some(path);
            }
            "--forum" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                forum_path = Some(path);
            }
            "--capture" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                captures.push(path);
            }
            other => return Err(format!("unsupported ingest option: {other}")),
        }
    }
    let top100_path = top100_path.ok_or_else(help)?;
    let forum_path = forum_path.ok_or_else(help)?;

    let top100 = fs::read_to_string(&top100_path)
        .map_err(|error| format!("failed to read {top100_path}: {error}"))?;
    let forum = fs::read_to_string(&forum_path)
        .map_err(|error| format!("failed to read {forum_path}: {error}"))?;

    let capture_bodies = captures
        .iter()
        .map(|path| {
            fs::read_to_string(path).map_err(|error| format!("failed to read {path}: {error}"))
        })
        .collect::<Result<Vec<_>, _>>()?;
    let summary = ingest_summary(&top100, &forum, &capture_bodies)?;
    let mut output = vec![
        "index-compat-lab-ingest-v1".to_owned(),
        format!("top100_matrix: {top100_path}"),
        format!("forum_matrix: {forum_path}"),
        format!("rows_total: {}", summary.rows.len()),
        format!("captures_total: {}", summary.captures_total),
        "family_columns: family\trows".to_owned(),
    ];
    for (family, rows) in &summary.family_counts {
        output.push(format!("{family}\t{rows}"));
    }
    output.push("result: pass".to_owned());
    Ok(output.join("\n"))
}

fn run_scaffold(mut args: impl Iterator<Item = String>) -> Result<String, String> {
    let mut top100_path: Option<String> = None;
    let mut forum_path: Option<String> = None;
    let mut family: Option<String> = None;
    while let Some(argument) = args.next() {
        match argument.as_str() {
            "--top100" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                top100_path = Some(path);
            }
            "--forum" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                forum_path = Some(path);
            }
            "--family" => {
                let Some(value) = args.next() else {
                    return Err(help());
                };
                family = Some(value);
            }
            other => return Err(format!("unsupported scaffold option: {other}")),
        }
    }
    let top100_path = top100_path.ok_or_else(help)?;
    let forum_path = forum_path.ok_or_else(help)?;
    let family = family.ok_or_else(help)?;
    let top100 = fs::read_to_string(&top100_path)
        .map_err(|error| format!("failed to read {top100_path}: {error}"))?;
    let forum = fs::read_to_string(&forum_path)
        .map_err(|error| format!("failed to read {forum_path}: {error}"))?;
    let summary = ingest_summary(&top100, &forum, &[])?;
    scaffold_pack_json(&summary.rows, &family)
}

fn run_synthesize(mut args: impl Iterator<Item = String>) -> Result<String, String> {
    let mut top100_path: Option<String> = None;
    let mut forum_path: Option<String> = None;
    let mut family: Option<String> = None;
    while let Some(argument) = args.next() {
        match argument.as_str() {
            "--top100" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                top100_path = Some(path);
            }
            "--forum" => {
                let Some(path) = args.next() else {
                    return Err(help());
                };
                forum_path = Some(path);
            }
            "--family" => {
                let Some(value) = args.next() else {
                    return Err(help());
                };
                family = Some(value);
            }
            other => return Err(format!("unsupported synthesize option: {other}")),
        }
    }
    let top100_path = top100_path.ok_or_else(help)?;
    let forum_path = forum_path.ok_or_else(help)?;
    let family = family.ok_or_else(help)?;
    let top100 = fs::read_to_string(&top100_path)
        .map_err(|error| format!("failed to read {top100_path}: {error}"))?;
    let forum = fs::read_to_string(&forum_path)
        .map_err(|error| format!("failed to read {forum_path}: {error}"))?;
    let summary = ingest_summary(&top100, &forum, &[])?;
    let rules = synthesize_rules(&summary.rows, &family);
    let quality = synthesize_quality(&summary.rows, &family, &rules);
    let mut output = vec![
        "index-compat-lab-synthesize-v1".to_owned(),
        format!("family: {family}"),
        format!("rules: {}", rules.len()),
        format!(
            "quality: score={:.4} covered={}/{}",
            quality.score, quality.covered_rows, quality.eligible_rows
        ),
        "columns: host\tpath_prefix".to_owned(),
    ];
    for rule in &rules {
        output.push(format!("{}\t{}", rule.host, rule.path_prefix));
    }
    output.push("quality_reasons:".to_owned());
    for reason in quality.reasons {
        output.push(format!("- {reason}"));
    }
    output.push("result: pass".to_owned());
    Ok(output.join("\n"))
}

fn run_lint(mut args: impl Iterator<Item = String>) -> Result<String, String> {
    let Some(path) = args.next() else {
        return Err(help());
    };
    if args.next().is_some() {
        return Err("too many arguments".to_owned());
    }
    let input =
        fs::read_to_string(&path).map_err(|error| format!("failed to read {path}: {error}"))?;
    let report = lint_pack_json(&input)?;
    let passed = report.passed();
    let mut output = vec![
        "index-compat-lab-lint-v1".to_owned(),
        format!("pack: {path}"),
    ];
    if report.errors.is_empty() {
        output.push("errors: none".to_owned());
    } else {
        output.push("errors:".to_owned());
        for error in report.errors {
            output.push(format!("- {error}"));
        }
    }
    if report.warnings.is_empty() {
        output.push("warnings: none".to_owned());
    } else {
        output.push("warnings:".to_owned());
        for warning in &report.warnings {
            output.push(format!("- {warning}"));
        }
    }
    output.push(format!("result: {}", if passed { "pass" } else { "fail" }));
    Ok(output.join("\n"))
}

fn run_merge_overrides(mut args: impl Iterator<Item = String>) -> Result<String, String> {
    let Some(generated_path) = args.next() else {
        return Err(help());
    };
    let Some(overrides_path) = args.next() else {
        return Err(help());
    };
    if args.next().is_some() {
        return Err("too many arguments".to_owned());
    }
    let generated = fs::read_to_string(&generated_path)
        .map_err(|error| format!("failed to read {generated_path}: {error}"))?;
    let overrides = fs::read_to_string(&overrides_path)
        .map_err(|error| format!("failed to read {overrides_path}: {error}"))?;
    merge_pack_overrides(&generated, &overrides)
}

fn help() -> String {
    "index-compat-lab\n\nUsage:\n  index-compat-lab ingest --top100 <matrix.tsv> --forum <matrix.tsv> [--capture <artifact> ...]\n  index-compat-lab scaffold --top100 <matrix.tsv> --forum <matrix.tsv> --family <family>\n  index-compat-lab synthesize --top100 <matrix.tsv> --forum <matrix.tsv> --family <family>\n  index-compat-lab lint <pack.json>\n  index-compat-lab merge-overrides <generated-pack.json> <override-pack.json>".to_owned()
}