mmcif-cli 0.3.1

Check design specifications and write them out for design backends
use std::path::{Path, PathBuf};
use std::process::{Command, Output};

const TARGET: &str = "data_target
loop_
_atom_site.group_PDB
_atom_site.id
_atom_site.type_symbol
_atom_site.label_atom_id
_atom_site.label_comp_id
_atom_site.label_asym_id
_atom_site.label_entity_id
_atom_site.label_seq_id
_atom_site.Cartn_x
_atom_site.Cartn_y
_atom_site.Cartn_z
_atom_site.auth_seq_id
_atom_site.auth_asym_id
ATOM 1 N N  CYS A 1 1 0.000  0.000 0.000 1 A
ATOM 2 C CA CYS A 1 1 1.500  0.000 0.000 1 A
ATOM 3 S SG CYS A 1 1 2.000  1.000 0.000 1 A
ATOM 4 N N  GLY A 1 2 3.800  0.000 0.000 2 A
ATOM 5 C CA GLY A 1 2 5.000  0.000 0.000 2 A
ATOM 6 N N  CYS A 1 3 7.600  0.000 0.000 3 A
ATOM 7 C CA CYS A 1 3 8.000  0.000 0.000 3 A
ATOM 8 S SG CYS A 1 3 20.000 1.000 0.000 3 A
";

fn workspace(name: &str) -> PathBuf {
    let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join(name);
    std::fs::create_dir_all(&directory).expect("a writable test directory");
    std::fs::write(directory.join("target.cif"), TARGET).expect("the target is written");
    directory
}

fn spec(name: &str, yaml: &str) -> PathBuf {
    let path = workspace(name).join("design.yaml");
    std::fs::write(&path, yaml).expect("the specification is written");
    path
}

fn mmcif(arguments: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_mmcif"))
        .args(arguments)
        .output()
        .expect("the binary runs")
}

fn run(arguments: &[&str]) -> (i32, String, String) {
    let output = mmcif(arguments);
    (
        output.status.code().expect("the process exits normally"),
        String::from_utf8(output.stdout).expect("stdout is text"),
        String::from_utf8(output.stderr).expect("stderr is text"),
    )
}

const CLEAN: &str = "entities:
  - file:
      path: target.cif
      include:
        - chain:
            id: A
  - protein:
      id: S
      sequence: 10..14C6C3
constraints:
  - bond:
      atom1: [S, 11, SG]
      atom2: [S, 18, SG]
";

#[test]
fn a_clean_specification_passes_quietly() {
    let path = spec("clean", CLEAN);
    let (code, out, err) = run(&["check", path.to_str().unwrap()]);
    assert_eq!(code, 0);
    assert_eq!(out, "no problems found\n");
    assert!(err.is_empty(), "{err}");
}

#[test]
fn an_infeasible_bond_fails_with_its_diagnostic() {
    let path = spec(
        "infeasible",
        concat!(
            "entities:\n",
            "  - file:\n",
            "      path: target.cif\n",
            "      include:\n",
            "        - chain:\n",
            "            id: A\n",
            "constraints:\n",
            "  - bond:\n",
            "      atom1: [A, 1, SG]\n",
            "      atom2: [A, 3, SG]\n",
        ),
    );
    let (code, out, err) = run(&["check", path.to_str().unwrap()]);
    assert_eq!(code, 1);
    assert_eq!(out, "1 error found\n");
    assert!(err.contains("error[infeasible-geometry]"), "{err}");
    assert!(err.contains("--> design.yaml:8:5"), "{err}");
}

#[test]
fn warnings_alone_still_pass() {
    let path = spec(
        "warnings",
        concat!(
            "entities:\n",
            "  - file:\n",
            "      path: target.cif\n",
            "      structure_groups:\n",
            "        - group:\n",
            "            visibility: 1\n",
            "            id: A\n",
        ),
    );
    let (code, out, err) = run(&["check", path.to_str().unwrap()]);
    assert_eq!(code, 0);
    assert_eq!(out, "1 warning passed\n");
    assert!(err.contains("warning[unsupported-key]"), "{err}");
}

#[test]
fn show_prints_what_each_chain_resolves_to() {
    let path = spec("show", CLEAN);
    let (code, out, _) = run(&["show", path.to_str().unwrap()]);
    assert_eq!(code, 0);
    assert_eq!(
        out,
        concat!(
            "no problems found\n",
            "\n",
            "A: 3 slots (3 context)\n",
            "S: 21 slots (19 de novo, 2 partial)\n",
            "1 constraint over 24 slots, 0 groups\n",
        )
    );
}

#[test]
fn show_on_a_broken_specification_reports_and_stops() {
    let path = spec(
        "broken",
        "entities:\n  - protein:\n      id: S\n      sequence: 0\n",
    );
    let (code, out, err) = run(&["show", path.to_str().unwrap()]);
    assert_eq!(code, 1);
    assert_eq!(out, "1 error found\n");
    assert!(err.contains("error[bad-sequence]"), "{err}");
}

#[test]
fn usage_problems_are_told_apart_from_specification_problems() {
    let path = spec("usage", CLEAN);
    let one = path.to_str().unwrap();
    assert_eq!(run(&[]).0, 2);
    assert_eq!(run(&["check"]).0, 2);
    assert_eq!(run(&["lower", one]).0, 2);
    assert_eq!(run(&["check", one, one]).0, 2);
    assert_eq!(run(&["check", "nowhere.yaml"]).0, 2);
    assert!(run(&["nonsense"]).2.contains("unknown command nonsense"));
    assert!(run(&["check", "nowhere.yaml"]).2.contains("nowhere.yaml"));
}

#[test]
fn help_and_version_are_available() {
    let (code, out, _) = run(&["--help"]);
    assert_eq!(code, 0);
    assert!(out.contains("usage: mmcif"));
    assert!(out.contains("check <spec.yaml>"));
    let (code, out, _) = run(&["-V"]);
    assert_eq!(code, 0);
    assert!(out.starts_with("mmcif "));
}

#[test]
fn lower_writes_the_backend_specification_to_stdout() {
    let path = spec("lower", CLEAN);
    let (code, out, err) = run(&["lower", path.to_str().unwrap(), "--to", "boltzgen"]);
    assert_eq!(code, 0);
    assert!(err.is_empty(), "{err}");
    assert!(out.starts_with("entities:\n"), "{out}");
    assert!(out.contains("path: target.cif"), "{out}");
    assert!(out.contains("sequence: 10..14C6C3"), "{out}");
    assert!(out.contains("atom1: [S, 11, SG]"), "{out}");
}

#[test]
fn lower_refuses_what_a_backend_cannot_express() {
    let path = spec("lower-refused", CLEAN);
    let (code, out, err) = run(&["lower", path.to_str().unwrap(), "--to", "rfd3"]);
    assert_eq!(code, 1);
    assert!(out.is_empty(), "{out}");
    assert!(err.contains("error[unsupported-by-backend]"), "{err}");
    assert!(
        err.contains("rfd3 cannot express a bond constraint"),
        "{err}"
    );
    assert!(
        err.contains("rfd3 cannot express this specification"),
        "{err}"
    );
}

#[test]
fn lower_emits_rfd3_json_for_a_specification_it_can_express() {
    let path = spec(
        "lower-rfd3",
        "entities:\n  - file:\n      path: target.cif\n      include:\n        - chain:\n            id: A\n  - protein:\n      id: S\n      sequence: 50..80\n",
    );
    let (code, out, err) = run(&["lower", path.to_str().unwrap(), "--to", "rfd3"]);
    assert_eq!(code, 0, "{err}");
    assert_eq!(
        out,
        concat!(
            "{\n",
            "  \"design\": {\n",
            "    \"contig\": \"A1-3,/0,50-80\",\n",
            "    \"input\": \"target.cif\"\n",
            "  }\n",
            "}\n",
        )
    );
}

#[test]
fn lower_needs_a_backend_it_knows() {
    let path = spec("lower-usage", CLEAN);
    let one = path.to_str().unwrap();
    assert_eq!(run(&["lower", one, "--to", "nonsense"]).0, 2);
    assert_eq!(run(&["lower", one]).0, 2);
    assert_eq!(run(&["lower", one, "--to"]).0, 2);
    assert_eq!(run(&["lower"]).0, 2);
    assert!(
        run(&["lower", one, "--to", "nonsense"])
            .2
            .contains("expected boltzgen or rfd3")
    );
}

#[test]
fn lower_refuses_a_specification_that_does_not_check() {
    let path = spec(
        "lower-broken",
        "entities:\n  - protein:\n      id: S\n      sequence: 0\n",
    );
    let (code, out, err) = run(&["lower", path.to_str().unwrap(), "--to", "boltzgen"]);
    assert_eq!(code, 1);
    assert!(out.is_empty());
    assert!(err.contains("error[bad-sequence]"), "{err}");
    assert!(err.contains("nothing was written"), "{err}");
}