genja-core 0.1.0

Core Genja primitives for task execution, inventory modeling, settings loading, connection state, and structured task results
Documentation
use genja_core::settings::{InventoryConfig, OptionsConfig};
use genja_core::{
    ConfigLoadError, InventoryFileKind, InventoryLoadError, Settings, SshConfigError,
};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

struct Context {
    _tempdir: tempfile::TempDir,
    filename: PathBuf,
}

fn write_temp_ssh_config(contents: &str) -> Context {
    let tempdir = tempfile::tempdir().expect("tempdir should be created");
    let unique = format!(
        "sshconfig_test_{}_{}",
        std::process::id(),
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time should be after epoch")
            .as_nanos()
    );
    let filename = tempdir.path().join(unique);
    let mut file = fs::File::create(&filename).expect("ssh config file should be created");
    file.write_all(contents.as_bytes())
        .expect("ssh config should be written");
    Context {
        _tempdir: tempdir,
        filename,
    }
}

fn yaml_single_quoted(value: &str) -> String {
    value.replace('\'', "''")
}

#[test]
fn settings_from_file_loads_populated_yaml_via_public_api() {
    let ssh_context = write_temp_ssh_config("Host example\n  HostName example.com\n");
    let tempdir = tempfile::tempdir().expect("tempdir should be created");
    let file_path = tempdir.path().join("settings.yaml");
    let yaml = format!(
        r#"
core:
  raise_on_error: "yes"
inventory:
  plugin: "CustomInventoryPlugin"
  options:
    hosts_file: "./inventory/hosts.yaml"
ssh:
  config_file: '{}'
runner:
  plugin: "serial"
  worker_count: 4
  max_task_depth: 6
  max_connection_attempts: 8
logging:
  enabled: "no"
  level: "debug"
  log_file: "./custom.log"
  to_console: "yes"
"#,
        yaml_single_quoted(ssh_context.filename.to_string_lossy().as_ref())
    );
    fs::write(&file_path, yaml).expect("settings file should be written");

    let settings = Settings::from_file(file_path.to_string_lossy().as_ref())
        .expect("settings should load from yaml");

    assert!(settings.core().raise_on_error());
    assert_eq!(settings.inventory().plugin(), "CustomInventoryPlugin");
    assert_eq!(
        settings.inventory().options().hosts_file(),
        Some("./inventory/hosts.yaml")
    );
    assert_eq!(
        settings.ssh().config_file(),
        Some(ssh_context.filename.to_string_lossy().as_ref())
    );
    assert_eq!(settings.runner().plugin(), "serial");
    assert_eq!(settings.runner().worker_count(), Some(4));
    assert_eq!(settings.runner().max_task_depth(), 6);
    assert_eq!(settings.runner().max_connection_attempts(), 8);
    assert!(!settings.logging().enabled());
    assert_eq!(settings.logging().level(), "debug");
    assert_eq!(settings.logging().log_file(), "./custom.log");
    assert!(settings.logging().to_console());
}

#[test]
fn settings_from_file_rejects_invalid_ssh_config_via_public_api() {
    let ssh_context = write_temp_ssh_config("Contents that are not valid ssh config contents\n");
    let tempdir = tempfile::tempdir().expect("tempdir should be created");
    let file_path = tempdir.path().join("settings.yaml");
    let yaml = format!(
        r#"
ssh:
  config_file: '{}'
"#,
        yaml_single_quoted(ssh_context.filename.to_string_lossy().as_ref())
    );
    fs::write(&file_path, yaml).expect("settings file should be written");

    let err = Settings::from_file(file_path.to_string_lossy().as_ref())
        .expect_err("invalid ssh config should fail");

    assert!(matches!(
        err,
        ConfigLoadError::SshConfig(SshConfigError::ParseFailed { .. })
    ));
}

#[test]
fn inventory_config_load_inventory_files_reads_public_files() {
    let tempdir = tempfile::tempdir().expect("tempdir should be created");
    let hosts_path = tempdir.path().join("hosts.yaml");
    let groups_path = tempdir.path().join("groups.yaml");
    let defaults_path = tempdir.path().join("defaults.yaml");

    fs::write(
        &hosts_path,
        r#"
router1:
  hostname: 10.0.0.1
  username: admin
"#,
    )
    .expect("hosts file should be written");
    fs::write(
        &groups_path,
        r#"
core:
  username: netops
"#,
    )
    .expect("groups file should be written");
    fs::write(
        &defaults_path,
        r#"
username: ubuntu
"#,
    )
    .expect("defaults file should be written");

    let config = InventoryConfig::builder()
        .options(
            OptionsConfig::builder()
                .hosts_file(path_str(&hosts_path))
                .groups_file(path_str(&groups_path))
                .defaults_file(path_str(&defaults_path))
                .build(),
        )
        .build();

    let (hosts, groups, defaults) = config
        .load_inventory_files()
        .expect("inventory files should load");

    assert_eq!(hosts.len(), 1);
    assert_eq!(
        hosts.get("router1").and_then(|host| host.hostname()),
        Some("10.0.0.1")
    );

    let groups = groups.expect("groups should be loaded");
    assert_eq!(groups.len(), 1);
    assert_eq!(
        groups.get("core").and_then(|group| group.username()),
        Some("netops")
    );

    let defaults = defaults.expect("defaults should be loaded");
    assert_eq!(defaults.username(), Some("ubuntu"));
}

#[test]
fn inventory_config_load_inventory_files_surfaces_typed_errors_publicly() {
    let tempdir = tempfile::tempdir().expect("tempdir should be created");
    let missing = tempdir.path().join("missing.json");
    let config = InventoryConfig::builder()
        .options(
            OptionsConfig::builder()
                .hosts_file(path_str(&missing))
                .build(),
        )
        .build();

    let err = config
        .load_inventory_files()
        .expect_err("missing hosts file should fail");

    assert!(matches!(
        err,
        InventoryLoadError::Read {
            kind: InventoryFileKind::Hosts,
            ..
        }
    ));
}

fn path_str(path: &Path) -> &str {
    path.to_str().expect("path should be valid utf-8")
}