ic-host-tools 0.1.3

Shared Internet Computer host artifact tooling
Documentation
#![expect(
    clippy::significant_drop_tightening,
    reason = "fixture ownership and serialization must cover evidence assertions"
)]

use super::*;
use crate::{
    test_support::{Fixture, LIMITS, VERSION, admit},
    tool::{ExecutionFailure, OutputStream},
};
use std::{fs, path::Path};

const OPTIONS: StatusOptions = StatusOptions {
    untracked: UntrackedFiles::All,
    ignore_submodules: IgnoreSubmodules::None,
};

fn fixture_tool() -> std::path::PathBuf {
    Path::new(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/tests/fixtures/git.sh"
    ))
    .canonicalize()
    .unwrap()
}

#[test]
fn captures_raw_evidence_and_explicit_status_scope() {
    let fixture = Fixture::new();
    let marker = fixture.root.join("queries");
    let environment = [("FIXTURE_MARKER".into(), marker.as_os_str().to_owned())];
    let context = fixture.context(&environment);
    let git = admit(&fixture_tool(), &context, VERSION).unwrap();
    let result = capture_git(&git, &context, OPTIONS, LIMITS).unwrap();
    assert_eq!(result.revision, "0".repeat(40));
    assert_eq!(result.tree, "0".repeat(40));
    assert!(result.is_dirty());
    assert_eq!(result.options, OPTIONS);
    assert_eq!(result.tool_identity, git.identity());
    assert_eq!(result.status_evidence.stdout, b"?? space \xffpath\0");
    assert_eq!(
        result.status_identity.sha256,
        Sha256Digest::compute(&result.status_evidence.stdout)
    );
    assert_eq!(
        result.status_identity.bytes,
        result.status_evidence.stdout.len() as u64
    );
    let queries = fs::read_to_string(marker).unwrap();
    assert_eq!(queries.lines().count(), 3);
    assert!(queries.contains("rev-parse --verify HEAD^{tree}"));
    assert!(
        queries.contains("status --porcelain=v1 -z --untracked-files=all --ignore-submodules=none")
    );
    assert!(!format!("{result:?}").contains("space"));
}

#[test]
fn accepts_empty_status_and_both_git_object_formats() {
    let fixture = Fixture::new();
    for (mode, width) in [("clean", 40), ("sha256", 64)] {
        let environment = [("FIXTURE_MODE".into(), mode.into())];
        let context = fixture.context(&environment);
        let git = admit(&fixture_tool(), &context, VERSION).unwrap();
        for (untracked, ignore_submodules) in [
            (UntrackedFiles::No, IgnoreSubmodules::All),
            (UntrackedFiles::Normal, IgnoreSubmodules::Dirty),
            (UntrackedFiles::All, IgnoreSubmodules::Untracked),
        ] {
            let options = StatusOptions {
                untracked,
                ignore_submodules,
            };
            let result = capture_git(&git, &context, options, LIMITS).unwrap();
            assert_eq!(result.revision.len(), width);
            assert_eq!(result.tree.len(), width);
            assert_eq!(result.options, options);
            assert!(!result.is_dirty());
            assert_eq!(result.status_identity.sha256, Sha256Digest::compute(b""));
        }
    }
}

#[test]
fn rejects_malformed_successful_output_without_losing_evidence() {
    let fixture = Fixture::new();
    for mode in ["invalid-id", "uppercase-id", "bad-status"] {
        let environment = [("FIXTURE_MODE".into(), mode.into())];
        let context = fixture.context(&environment);
        let git = admit(&fixture_tool(), &context, VERSION).unwrap();
        let error = capture_git(&git, &context, OPTIONS, LIMITS).unwrap_err();
        if mode == "bad-status" {
            assert_eq!(error.query, GitQuery::Status);
            assert!(matches!(error.failure, GitFailure::UnterminatedStatus));
            assert!(error.completed.iter().all(Option::is_some));
            assert_eq!(
                error.completed[2].as_ref().unwrap().stdout,
                b"?? unterminated"
            );
        } else {
            assert_eq!(error.query, GitQuery::Revision);
            assert!(matches!(error.failure, GitFailure::InvalidObjectId));
            assert!(error.completed[0].is_some());
            assert!(error.completed[1..].iter().all(Option::is_none));
        }
    }
}

#[test]
fn retains_prior_captures_on_process_failure_and_bounds_output() {
    let fixture = Fixture::new();
    let marker = fixture.root.join("queries");
    let environment = [
        ("FIXTURE_MODE".into(), "fail-tree".into()),
        ("FIXTURE_MARKER".into(), marker.as_os_str().to_owned()),
    ];
    let context = fixture.context(&environment);
    let git = admit(&fixture_tool(), &context, VERSION).unwrap();
    let error = capture_git(&git, &context, OPTIONS, LIMITS).unwrap_err();
    assert_eq!(error.query, GitQuery::Tree);
    assert_eq!(fs::read_to_string(marker).unwrap().lines().count(), 2);
    assert_eq!(error.completed[0].as_ref().unwrap().stdout.len(), 41);
    assert!(error.completed[1..].iter().all(Option::is_none));
    let GitFailure::Tool(source) = error.failure else {
        panic!("expected tool failure")
    };
    let ToolError::Execution(source) = *source else {
        panic!("expected process failure")
    };
    assert!(matches!(source.failure, ExecutionFailure::ExitStatus));
    assert_eq!(source.evidence.status.unwrap().code(), Some(23));
    assert_eq!(source.evidence.stderr, b"retained tree diagnostic\n");

    let error = capture_git(
        &git,
        &context,
        OPTIONS,
        OutputLimits {
            stdout_bytes: 10,
            ..LIMITS
        },
    )
    .unwrap_err();
    assert_eq!(error.query, GitQuery::Revision);
    let GitFailure::Tool(source) = error.failure else {
        panic!("expected tool failure")
    };
    let ToolError::Execution(source) = *source else {
        panic!("expected process failure")
    };
    assert!(matches!(
        source.failure,
        ExecutionFailure::OutputLimit {
            stream: OutputStream::Stdout
        }
    ));
    assert_eq!(source.evidence.stdout.len(), 10);
    assert!(source.evidence.stdout_truncated);
}