canic-host 0.92.6

Host-side build, install, deployment, and fleet-template library for Canic workspaces
Documentation
use super::*;
use canic_core::bootstrap::ConfigError;
use std::time::{SystemTime, UNIX_EPOCH};

const CONFIG: &str = r#"
controllers = []
app_index = []

[fleet]
name = "demo"

[roles.root]
kind = "root"
package = "root"

[roles.store]
kind = "canister"
package = "store"

[subnets.prime.canisters.root]
kind = "root"
"#;

#[test]
fn failed_package_manifest_write_restores_original_config() {
    let root = temp_root("rename-rollback");
    fs::create_dir_all(&root).expect("create temp root");
    let config_path = root.join("canic.toml");
    let invalid_package_target = root.join("Cargo.toml");
    fs::write(&config_path, "original config").expect("write original config");
    fs::create_dir(&invalid_package_target).expect("create invalid package target directory");

    let error = commit_role_rename_sources(
        &config_path,
        "original config",
        "updated config",
        Some((&invalid_package_target, "updated package")),
    )
    .expect_err("package write must fail");

    assert_io_error(
        &error,
        FleetConfigIoOperation::WritePackageManifest,
        &invalid_package_target,
        io::ErrorKind::IsADirectory,
    );

    assert_eq!(
        fs::read_to_string(&config_path).expect("read rolled back config"),
        "original config"
    );

    fs::remove_dir_all(root).expect("remove temp root");
}

#[test]
fn public_projection_preserves_config_path_and_core_parse_source() {
    let root = temp_root("typed-core-source");
    fs::create_dir_all(&root).expect("create temp root");
    let config_path = root.join("canic.toml");
    fs::write(&config_path, "controllers = [").expect("write invalid config");

    let error = configured_controllers(&config_path).expect_err("invalid config must fail");
    match error {
        FleetConfigError::ConfigInvalid { path, source } => {
            assert_eq!(path, config_path);
            assert!(matches!(
                *source,
                FleetConfigError::CoreConfig {
                    operation: FleetConfigOperation::Project,
                    source: ConfigError::CannotParseToml(_),
                }
            ));
        }
        other => panic!("expected typed config parse error, got {other:?}"),
    }

    fs::remove_dir_all(root).expect("remove temp root");
}

#[test]
fn public_projection_preserves_read_operation_path_and_io_source() {
    let config_path = temp_root("missing-config").join("canic.toml");

    let error = configured_fleet_name(&config_path).expect_err("missing config must fail");

    assert_io_error(
        &error,
        FleetConfigIoOperation::ReadConfig,
        &config_path,
        io::ErrorKind::NotFound,
    );
}

#[test]
fn fleet_mutation_failures_are_classified_without_rendered_text() {
    assert!(matches!(
        declare_fleet_role_source(CONFIG, "demo", "bad role", "store")
            .expect_err("invalid role must fail"),
        FleetConfigError::InvalidName {
            field: FleetConfigNameField::Role,
            issue: FleetConfigNameIssue::InvalidSnakeCase,
            ..
        }
    ));
    assert!(matches!(
        attach_fleet_role_source(CONFIG, "demo", "store", "prime", "worker")
            .expect_err("invalid kind must fail"),
        FleetConfigError::InvalidKind { .. }
    ));
    assert!(matches!(
        declare_fleet_role_source(CONFIG, "production", "new_role", "new_role")
            .expect_err("fleet mismatch must fail"),
        FleetConfigError::FleetMismatch { .. }
    ));
    assert!(matches!(
        attach_fleet_role_source(CONFIG, "demo", "missing", "prime", "service")
            .expect_err("missing role must fail"),
        FleetConfigError::DeclarationMissing {
            declaration: FleetConfigDeclaration::Role { .. }
        }
    ));
    assert!(matches!(
        declare_fleet_role_source(CONFIG, "demo", "store", "store")
            .expect_err("duplicate role must fail"),
        FleetConfigError::MutationConflict {
            conflict: FleetConfigMutationConflict::RoleAlreadyDeclared { .. }
        }
    ));
}

#[test]
fn fleet_mutations_use_canonical_canister_role_admission() {
    let declare_error = declare_fleet_role_source(CONFIG, "demo", "user-hub", "store")
        .expect_err("kebab-case declaration must fail");
    let attach_error = attach_fleet_role_source(CONFIG, "demo", "Store", "prime", "service")
        .expect_err("mixed-case attachment must fail");
    let rename_error =
        rename_fleet_role_source(CONFIG, Path::new("canic.toml"), "demo", "store", "store_")
            .expect_err("trailing-underscore rename must fail");

    for error in [declare_error, attach_error, rename_error] {
        assert!(matches!(
            error,
            FleetConfigError::InvalidName {
                field: FleetConfigNameField::Role,
                issue: FleetConfigNameIssue::InvalidSnakeCase,
                ..
            }
        ));
    }

    let long_role = "a".repeat(canic_core::bootstrap::compiled::NAME_MAX_BYTES + 1);
    assert!(matches!(
        declare_fleet_role_source(CONFIG, "demo", &long_role, "store")
            .expect_err("overlong declaration must fail"),
        FleetConfigError::InvalidName {
            field: FleetConfigNameField::Role,
            issue: FleetConfigNameIssue::TooLong { max_bytes },
            ..
        } if max_bytes == canic_core::bootstrap::compiled::NAME_MAX_BYTES
    ));

    declare_fleet_role_source(CONFIG, "demo", "new_role", "store")
        .expect("canonical role should be admitted");
}

#[test]
fn rollback_failure_preserves_mutation_and_rollback_sources() {
    let config_path = Path::new("canic.toml");
    let package_path = Path::new("store/Cargo.toml");
    let mut writes = 0;

    let error = commit_role_rename_sources_with_writer(
        config_path,
        "original config",
        "updated config",
        Some((package_path, "updated package")),
        |_, _| {
            writes += 1;
            match writes {
                1 => Ok(()),
                2 => Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    "package write failed",
                )),
                3 => Err(io::Error::new(
                    io::ErrorKind::StorageFull,
                    "config rollback failed",
                )),
                _ => unreachable!("rename commit performs at most three writes"),
            }
        },
    )
    .expect_err("rollback failure must retain both causes");

    let FleetConfigError::RollbackFailed { mutation, rollback } = error else {
        panic!("expected typed rollback failure");
    };
    assert_io_error(
        &mutation,
        FleetConfigIoOperation::WritePackageManifest,
        package_path,
        io::ErrorKind::PermissionDenied,
    );
    assert_io_error(
        &rollback,
        FleetConfigIoOperation::RestoreConfig,
        config_path,
        io::ErrorKind::StorageFull,
    );
}

fn assert_io_error(
    error: &FleetConfigError,
    expected_operation: FleetConfigIoOperation,
    expected_path: &Path,
    expected_kind: io::ErrorKind,
) {
    match error {
        FleetConfigError::Io {
            operation,
            path,
            source,
        } => {
            assert_eq!(*operation, expected_operation);
            assert_eq!(path, expected_path);
            assert_eq!(source.kind(), expected_kind);
        }
        other => panic!("expected typed I/O error, got {other:?}"),
    }
}

fn temp_root(label: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time after unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "canic-host-release-config-{label}-{}-{nanos}",
        std::process::id()
    ))
}