sim-run-core 0.3.0

Core command entry API for the SIM bootloader.
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
};

use sim_config::{
    ConfigDir, ConfigLayer, ConfigProbeReport, ConfigProbeStatus, ConfigRoots, ConfigSecretField,
    ConfigSource, ConfigTable, ProbeMode,
};
use sim_kernel::{AbiVersion, Expr, Lib, LibManifest, LibTarget, Linker, LoadCx, Symbol, Version};

use crate::{
    CliBoot, ConfigLoadOptions, ConfigReportKind, ConfigReportRequest, LibSourceSpec, LoadSession,
    LoadedStateReport, RuntimeConfigState, format_config_status, format_config_status_json,
    format_effective_config, format_effective_config_json, load_config_sources,
};

fn lib(namespace: &str, name: &str) -> Symbol {
    Symbol::qualified(namespace, name)
}

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

fn write_file(path: &Path, contents: &str) {
    fs::create_dir_all(path.parent().expect("test path has parent")).unwrap();
    fs::write(path, contents).unwrap();
}

fn roots(home: &Path, work: &Path) -> ConfigLoadOptions {
    ConfigLoadOptions::with_roots(ConfigRoots::new(
        Some(home.to_path_buf()),
        work.to_path_buf(),
    ))
}

#[test]
fn config_status_reports_loaded_libs_and_source_provenance() {
    let base = temp_root("status");
    let home = base.join("home");
    let work = base.join("work");
    write_file(
        &home.join("libs").join("test").join("demo.toml"),
        r#"mode = "home"
"#,
    );
    let boot = CliBoot {
        codec: Some("test".to_owned()),
        loads: vec![LibSourceSpec::Host("test/demo".to_owned())],
        config: roots(&home, &work),
        config_report: Some(ConfigReportRequest {
            kind: ConfigReportKind::Status,
            json: false,
        }),
        ..CliBoot::default()
    };
    let mut session = LoadSession::new()
        .with_host_factory("codec/test", || Box::new(FixtureLib::new("codec/test")))
        .with_host_factory("test/demo", || Box::new(FixtureLib::new("test/demo")));

    let output = session.run_config_report(&boot).unwrap();

    assert!(
        output.contains("role=boot-codec lib=codec/test"),
        "{output}"
    );
    assert!(output.contains("role=library lib=test/demo"), "{output}");
    assert!(output.contains("source=home-file:"), "{output}");
    assert!(output.contains("status=found"), "{output}");
    assert!(output.contains("status=missing"), "{output}");

    let _ = fs::remove_dir_all(base);
}

#[test]
fn effective_config_json_matches_stable_shape() {
    let base = temp_root("effective-json");
    let config_file = base.join("sim.toml");
    write_file(
        &config_file,
        r#"[sim/cookbook]
minimum_loaded = ["codec/lisp"]
"#,
    );
    let boot = CliBoot {
        config: ConfigLoadOptions {
            roots: ConfigRoots::new(None, base.join("work")),
            read_files: true,
            single_file: Some(config_file),
            site_sources: Vec::new(),
        },
        config_report: Some(ConfigReportRequest {
            kind: ConfigReportKind::Effective {
                lib: lib("sim", "cookbook"),
            },
            json: true,
        }),
        ..CliBoot::default()
    };
    let mut session = LoadSession::new();

    let output = session.run_config_report(&boot).unwrap();

    assert_eq!(
        output,
        "{\"lib\":\"sim/cookbook\",\"table\":{\"minimum_loaded\":[\"codec/lisp\"]}}\n"
    );

    let _ = fs::remove_dir_all(base);
}

#[test]
fn effective_config_discovers_requested_per_lib_file() {
    let base = temp_root("effective-per-lib");
    let home = base.join("home");
    let work = base.join("work");
    write_file(
        &work.join("libs").join("sim").join("cookbook.toml"),
        r#"minimum_loaded = ["codec/lisp"]

[[loadable_lib]]
id = "numbers/cas"
source = "symbol:numbers/cas"
"#,
    );
    let boot = CliBoot {
        config: roots(&home, &work),
        config_report: Some(ConfigReportRequest {
            kind: ConfigReportKind::Effective {
                lib: lib("sim", "cookbook"),
            },
            json: false,
        }),
        ..CliBoot::default()
    };
    let mut session = LoadSession::new();

    let output = session.run_config_report(&boot).unwrap();

    assert!(output.starts_with("lib sim/cookbook\n"), "{output}");
    assert!(
        output.contains("minimum_loaded = [\"codec/lisp\"]"),
        "{output}"
    );
    assert!(output.contains("[[loadable_lib]]"), "{output}");
    assert!(output.contains("id = \"numbers/cas\""), "{output}");

    let _ = fs::remove_dir_all(base);
}

#[test]
fn effective_cookbook_override_reports_reordered_subset() {
    let base = temp_root("cookbook-override");
    let home = base.join("home");
    let work = base.join("work");
    write_file(
        &home.join("libs").join("sim").join("cookbook.toml"),
        r#"minimum_loaded = ["codec/lisp"]
"#,
    );
    write_file(
        &work.join("libs").join("sim").join("cookbook.toml"),
        r#"[[loadable_lib]]
id = "demo/beta"
source = "symbol:demo/beta"

[[loadable_lib]]
id = "demo/alpha"
source = "symbol:demo/alpha"
"#,
    );
    let boot = CliBoot {
        config: roots(&home, &work),
        config_report: Some(ConfigReportRequest {
            kind: ConfigReportKind::Effective {
                lib: lib("sim", "cookbook"),
            },
            json: false,
        }),
        ..CliBoot::default()
    };
    let mut session = LoadSession::new();

    let output = session.run_config_report(&boot).unwrap();

    assert!(
        output.contains("minimum_loaded = [\"codec/lisp\"]"),
        "{output}"
    );
    let beta = output.find("id = \"demo/beta\"").unwrap();
    let alpha = output.find("id = \"demo/alpha\"").unwrap();
    assert!(beta < alpha, "{output}");
    assert!(!output.contains("numbers/cas"), "{output}");

    let _ = fs::remove_dir_all(base);
}

#[test]
fn missing_explicit_source_reports_diagnostic() {
    let base = temp_root("missing-source");
    let missing = base.join("missing.toml");
    let mut cx = sim_kernel::Cx::new(
        std::sync::Arc::new(sim_kernel::NoopEvalPolicy),
        std::sync::Arc::new(sim_kernel::DefaultFactory),
    );
    let state = load_config_sources(
        &mut cx,
        &ConfigLoadOptions {
            roots: ConfigRoots::new(None, base.join("work")),
            read_files: true,
            single_file: Some(missing.clone()),
            site_sources: Vec::new(),
        },
        &[lib("codec", "lisp")],
    );

    assert!(state.source_reports().iter().any(
        |report| matches!(report.source, ConfigSource::SingleFile { ref path } if path == &missing)
            && report.status == crate::SourceStatus::Missing
    ));
    assert_eq!(
        state.diagnostics(),
        &[format!("config file not found: {}", missing.display())]
    );

    let _ = fs::remove_dir_all(base);
}

#[test]
fn text_and_json_renderers_redact_secret_fields() {
    let model = lib("model", "defaults");
    let table = ConfigTable::new(
        model.clone(),
        Expr::Map(vec![
            (
                Expr::Symbol(Symbol::new("provider")),
                Expr::String("openai".to_owned()),
            ),
            (
                Expr::Symbol(Symbol::new("api_key")),
                Expr::String("raw-secret-key".to_owned()),
            ),
        ]),
    )
    .unwrap();
    let mut state = RuntimeConfigState::default();
    state.push_layer(ConfigLayer::new(
        ConfigSource::Explicit {
            label: "test".to_owned(),
        },
        ConfigDir {
            entries: vec![table],
        },
    ));
    state.extend_secret_fields([ConfigSecretField {
        lib: model.clone(),
        key: "api_key".to_owned(),
    }]);
    let mut session = LoadSession::new();
    *session.config_state_mut() = state;
    let report = LoadedStateReport::from_session(&session);

    let text = format_effective_config(&report, &model);
    let json = format_effective_config_json(&report, &model);
    let status_json = format_config_status_json(&report);

    for rendered in [&text, &json, &status_json] {
        assert!(!rendered.contains("raw-secret-key"), "{rendered}");
        assert!(rendered.contains("[redacted]"), "{rendered}");
    }
}

#[test]
fn config_status_renders_typed_probe_report_records() {
    let lib = lib("stream", "host");
    let mut state = RuntimeConfigState::default();
    state.push_probe_report(ConfigProbeReport {
        probe: Symbol::qualified("config", "fake"),
        lib: lib.clone(),
        mode: ProbeMode::Modeled,
        status: ConfigProbeStatus::Applied,
        emitted_keys: vec!["backend".to_owned()],
    });
    state.push_probe_report(ConfigProbeReport {
        probe: Symbol::qualified("config", "real"),
        lib,
        mode: ProbeMode::Real,
        status: ConfigProbeStatus::Denied {
            capability: "hardware_inventory".to_owned(),
        },
        emitted_keys: Vec::new(),
    });
    let mut session = LoadSession::new();
    *session.config_state_mut() = state;
    let report = LoadedStateReport::from_session(&session);

    let text = format_config_status(&report);
    let json = format_config_status_json(&report);

    assert!(
        text.contains(
            "probe=config/fake lib=stream/host mode=modeled status=applied emitted=backend"
        ),
        "{text}"
    );
    assert!(
        text.contains(
            "probe=config/real lib=stream/host mode=real status=denied capability=hardware_inventory emitted=-"
        ),
        "{text}"
    );
    assert!(
        json.contains(
            "\"probe\":\"config/fake\",\"lib\":\"stream/host\",\"mode\":\"modeled\",\"status\":\"applied\",\"emitted_keys\":[\"backend\"]"
        ),
        "{json}"
    );
    assert!(
        json.contains(
            "\"probe\":\"config/real\",\"lib\":\"stream/host\",\"mode\":\"real\",\"status\":\"denied\",\"capability\":\"hardware_inventory\",\"emitted_keys\":[]"
        ),
        "{json}"
    );
}

struct FixtureLib {
    id: Symbol,
}

impl FixtureLib {
    fn new(id: &str) -> Self {
        Self {
            id: crate::source::symbol_from_text(id),
        }
    }
}

impl Lib for FixtureLib {
    fn manifest(&self) -> LibManifest {
        LibManifest {
            id: self.id.clone(),
            version: Version("0.1.0".to_owned()),
            abi: AbiVersion { major: 0, minor: 1 },
            target: LibTarget::HostRegistered,
            requires: Vec::new(),
            capabilities: Vec::new(),
            exports: Vec::new(),
        }
    }

    fn load(&self, _cx: &mut LoadCx, _linker: &mut Linker) -> sim_kernel::Result<()> {
        Ok(())
    }
}