compose-lens 0.3.0

Loss-aware parsing, processing, validation, and rendering of Compose projects
Documentation
//! Regression coverage for the final closed Compose resource and develop-exec values.

use compose_lens::loader::{DocumentInput, DocumentOrigin, LoadedProject};
use compose_lens::merge::merge_project;
use compose_lens::model::{
    BooleanValue, Command, ComposeDocument, Environment, GPU_MISSING_CAPABILITIES, Labels, Located,
    RESOURCE_EXTERNAL_CREATION_CONFIGURATION, RESOURCE_EXTERNAL_NAME_CONFLICT,
    RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED, ResourceExternal,
};
use compose_lens::project::build_project_view;
use compose_lens::source::SourceId;
use compose_lens::syntax::SyntaxDocument;

#[test]
fn retains_resource_metadata_deprecated_external_names_and_typed_develop_exec() -> Result<(), Box<dyn std::error::Error>>
{
    let source = concat!(
        "configs:\n",
        "  app:\n",
        "    external: {name: deprecated-config, x-note: retained}\n",
        "    name: modern-config\n",
        "    labels: [team=platform]\n",
        "    template_driver: golang\n",
        "secrets:\n",
        "  token:\n",
        "    external: true\n",
        "    driver: vault\n",
        "    driver_opts: {retries: 2, namespace: platform}\n",
        "    labels: {owner: platform}\n",
        "    template_driver: json\n",
        "services:\n",
        "  app:\n",
        "    develop:\n",
        "      watch:\n",
        "        - action: sync+exec\n",
        "          path: .\n",
        "          target: /src\n",
        "          exec:\n",
        "            command: [cargo, test]\n",
        "            user: app\n",
        "            privileged: ${DEV_PRIVILEGED:-false}\n",
        "            working_dir: /src\n",
        "            environment:\n              TOKEN: seeded-canary\n              MODE: dev\n",
    );
    let syntax = SyntaxDocument::parse(SourceId::new(9901), source)?;
    let parsed = ComposeDocument::parse(syntax.document());
    let document = parsed.document().ok_or("document expected")?;

    let config = document.configs().first().ok_or("config expected")?;
    assert!(matches!(config.labels(), Some(Labels::List { values, .. }) if values.len() == 1));
    assert_eq!(
        config.template_driver().map(|value| value.value().as_str()),
        Some("golang")
    );
    assert!(matches!(config.external(), Some(ResourceExternal::NameMapping(mapping)) if mapping.name().is_some()));
    let secret = document.secrets().first().ok_or("secret expected")?;
    assert_eq!(secret.driver().map(|value| value.value().as_str()), Some("vault"));
    assert!(!secret.driver_opts().is_empty());
    assert!(matches!(secret.labels(), Some(Labels::Map { entries, .. }) if entries.len() == 1));
    assert_eq!(
        secret.template_driver().map(|value| value.value().as_str()),
        Some("json")
    );
    assert!(matches!(
        secret.external(),
        Some(ResourceExternal::Boolean(value))
            if matches!(value.value(), BooleanValue::Literal(true))
    ));

    let exec = document
        .service("app")
        .and_then(|service| service.develop())
        .and_then(|develop| develop.watch().first())
        .and_then(|watch| watch.exec())
        .ok_or("typed exec expected")?;
    assert!(matches!(exec.command(), Some(Command::List { values, .. }) if values.len() == 2));
    assert_eq!(exec.user().map(|value| value.value().as_str()), Some("app"));
    assert!(matches!(
        exec.privileged().map(Located::value),
        Some(BooleanValue::Expression(_))
    ));
    assert_eq!(exec.working_dir().map(|value| value.value().as_str()), Some("/src"));
    assert!(matches!(exec.environment(), Some(Environment::Map { entries, .. }) if entries.len() == 2));

    for code in [
        RESOURCE_EXTERNAL_NAME_MAPPING_DEPRECATED,
        RESOURCE_EXTERNAL_NAME_CONFLICT,
        RESOURCE_EXTERNAL_CREATION_CONFIGURATION,
    ] {
        assert!(parsed.diagnostics().iter().any(|diagnostic| diagnostic.code() == code));
    }
    assert!(!format!("{document:?}").contains("seeded-canary"));
    Ok(())
}

#[test]
fn retains_invalid_deprecated_mapping_and_exec_members_as_source_evidence() -> Result<(), Box<dyn std::error::Error>> {
    let source = concat!(
        "networks:\n  n:\n    external:\n      name: 4\n      future: true\n",
        "services:\n  app:\n    develop:\n      watch:\n        - action: sync+exec\n          path: .\n          target: /src\n          exec:\n            command: []\n            user: 4\n            environment: false\n            x-note: kept\n",
    );
    let syntax = SyntaxDocument::parse(SourceId::new(9902), source)?;
    let parsed = ComposeDocument::parse(syntax.document());
    let document = parsed.document().ok_or("document expected")?;
    let network = document.networks().first().ok_or("network expected")?;
    assert!(matches!(network.external(), Some(ResourceExternal::NameMapping(mapping)) if mapping.name().is_none()));
    let exec = document
        .service("app")
        .and_then(|service| service.develop())
        .and_then(|develop| develop.watch().first())
        .and_then(|watch| watch.exec())
        .ok_or("typed exec expected")?;
    assert!(matches!(exec.command(), Some(Command::List { values, .. }) if values.is_empty()));
    assert_eq!(exec.unmodeled_fields().len(), 3);
    assert!(
        parsed
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code().as_str() == "compose.develop.watch.exec.missing-command")
    );
    Ok(())
}

#[test]
fn requires_non_empty_gpu_capabilities_at_authored_and_effective_boundaries() -> Result<(), Box<dyn std::error::Error>>
{
    let source = concat!(
        "services:\n",
        "  valid:\n    gpus: [{capabilities: [gpu]}]\n",
        "  absent:\n    gpus: [{driver: nvidia}]\n",
        "  empty:\n    gpus: [{capabilities: []}]\n",
        "  malformed:\n    gpus: [{capabilities: [false]}]\n",
        "  scalar:\n    gpus: [{capabilities: gpu}]\n",
        "  mapping:\n    gpus: [{capabilities: {kind: gpu}}]\n",
        "  reset:\n    gpus: [{capabilities: [gpu]}]\n",
    );
    let syntax = SyntaxDocument::parse(SourceId::new(9903), source)?;
    let parsed = ComposeDocument::parse(syntax.document());
    assert_eq!(
        parsed
            .diagnostics()
            .iter()
            .filter(|diagnostic| diagnostic.code() == GPU_MISSING_CAPABILITIES)
            .count(),
        5
    );
    let scalar = parsed
        .document()
        .and_then(|document| document.service("scalar"))
        .and_then(|service| service.gpus())
        .and_then(|gpus| match gpus {
            compose_lens::model::Gpus::Devices { devices, .. } => devices.first(),
            compose_lens::model::Gpus::All(_) => None,
        })
        .ok_or("scalar GPU selector expected")?;
    assert!(scalar.capabilities().is_empty());
    assert_eq!(scalar.unmodeled_fields().len(), 1);

    let loaded = LoadedProject::load([
        DocumentInput::new(
            SourceId::new(9904),
            DocumentOrigin::new("base.yaml", "workspace"),
            source,
        ),
        DocumentInput::new(
            SourceId::new(9905),
            DocumentOrigin::new("override.yaml", "workspace"),
            "services:\n  reset:\n    gpus: !reset []\n  valid:\n    gpus: !override [{capabilities: [compute]}]\n",
        ),
    ])?;
    let merged = merge_project(&loaded, None);
    let effective = build_project_view(merged.project().ok_or("project expected")?, None);
    assert_eq!(
        effective
            .diagnostics()
            .iter()
            .filter(|diagnostic| diagnostic.code() == GPU_MISSING_CAPABILITIES)
            .count(),
        5
    );
    assert!(
        effective
            .view()
            .ok_or("view expected")?
            .service("scalar")
            .ok_or("scalar service expected")?
            .unmodeled_fields()
            .iter()
            .any(|field| field.path().iter().any(|segment| segment == "capabilities"))
    );
    assert!(
        effective
            .view()
            .ok_or("view expected")?
            .service("reset")
            .and_then(|service| service.gpus())
            .is_some_and(
                |gpus| matches!(gpus.value(), compose_lens::model::Gpus::Devices { devices, .. } if devices.is_empty())
            )
    );
    Ok(())
}

#[test]
fn keeps_invalid_deprecated_external_name_evidence_in_the_effective_resource() -> Result<(), Box<dyn std::error::Error>>
{
    let loaded = LoadedProject::load([
        DocumentInput::new(
            SourceId::new(9906),
            DocumentOrigin::new("base.yaml", "workspace"),
            "networks:\n  app:\n    external: {name: \"\", x-base: retained}\n",
        ),
        DocumentInput::new(
            SourceId::new(9907),
            DocumentOrigin::new("override.yaml", "workspace"),
            "networks:\n  app:\n    external: {name: 4, future: retained}\n",
        ),
    ])?;
    let merged = merge_project(&loaded, None);
    let result = build_project_view(merged.project().ok_or("project expected")?, None);
    let network = result
        .view()
        .ok_or("view expected")?
        .networks()
        .first()
        .ok_or("network expected")?
        .definition()
        .value();
    let ResourceExternal::NameMapping(mapping) = network.external().ok_or("name mapping expected")? else {
        return Err("name mapping expected".into());
    };
    assert!(mapping.name().is_none());
    assert!(
        mapping
            .unknown_fields()
            .iter()
            .any(|field| field.name().value() == "name")
    );
    assert!(
        mapping
            .unknown_fields()
            .iter()
            .any(|field| field.name().value() == "future")
    );
    assert!(
        mapping
            .extension_fields()
            .iter()
            .any(|field| field.name().value() == "x-base")
    );
    Ok(())
}