compose-lens 0.1.3

Loss-aware parsing, processing, validation, and rendering of Compose projects
Documentation
//! Deterministic canonical rendering and recovery behavior.

use compose_lens::interpolation::MapEnvironment;
use compose_lens::loader::{DocumentInput, DocumentOrigin, LoadedProject};
use compose_lens::merge::{MergedProject, merge_project};
use compose_lens::profiles::{ProfileRequest, select_profiles};
use compose_lens::render::{
    CanonicalFormatting, IndentWidth, LineEnding, UNRENDERABLE_ALIAS, render_canonical,
    render_canonical_with_formatting,
};
use compose_lens::resolution::SELECTION_PROJECT_MISMATCH;
use compose_lens::source::SourceId;
use compose_lens::syntax::SyntaxDocument;

const BASE: &str = include_str!("../fixtures/roundtrip/canonical-merged/compose.yaml");
const OVERRIDE: &str = include_str!("../fixtures/roundtrip/canonical-merged/compose.override.yaml");
const EXPECTED: &str = include_str!("../fixtures/roundtrip/canonical-merged/expected.yaml");

#[test]
fn renders_the_merged_project_in_one_deterministic_form() -> Result<(), Box<dyn std::error::Error>> {
    let project = canonical_project()?;
    let first = render_canonical(&project, None);
    let second = render_canonical(&project, None);

    assert!(first.is_valid(), "{:#?}", first.diagnostics());
    assert_eq!(first.output(), EXPECTED);
    assert_eq!(first, second);
    assert!(!first.is_sensitive());
    Ok(())
}

#[test]
fn canonical_output_is_stable_across_parse_merge_render() -> Result<(), Box<dyn std::error::Error>> {
    let project = canonical_project()?;
    let first = render_canonical(&project, None);
    let syntax = SyntaxDocument::parse(SourceId::new(263), first.output())?;
    assert!(syntax.is_valid(), "{:#?}", syntax.diagnostics());

    let loaded = LoadedProject::load([DocumentInput::new(
        SourceId::new(264),
        DocumentOrigin::new("canonical.yaml", "workspace/project"),
        first.output(),
    )])?;
    let merge = merge_project(&loaded, None);
    let reparsed = merge.project().ok_or("reparsed merged project expected")?;
    let second = render_canonical(reparsed, None);

    assert!(second.is_valid(), "{:#?}", second.diagnostics());
    assert_eq!(second.output(), first.output());
    Ok(())
}

#[test]
fn default_formatting_is_byte_identical_to_canonical_v1() -> Result<(), Box<dyn std::error::Error>> {
    let project = canonical_project()?;
    let canonical = render_canonical(&project, None);
    let formatted = render_canonical_with_formatting(&project, None, &CanonicalFormatting::default());

    assert_eq!(formatted, canonical);
    Ok(())
}

#[test]
fn formatting_options_change_presentation_without_changing_semantics() -> Result<(), Box<dyn std::error::Error>> {
    let source = "services:\n  app:\n    image: example/app:1\n    command: [serve, \"--port=80\"]\n";
    let project = one_file_project(source, 291)?;
    let indent = IndentWidth::new(4).ok_or("four-space indentation expected")?;
    let formatting = CanonicalFormatting::default()
        .with_indent_width(indent)
        .with_line_ending(LineEnding::CrLf)
        .with_document_marker(true)
        .with_final_newline(false);
    let formatted = render_canonical_with_formatting(&project, None, &formatting);
    let expected = "---\r\n\"services\":\r\n    \"app\":\r\n        \"image\": \"example/app:1\"\r\n        \"command\":\r\n            - \"serve\"\r\n            - \"--port=80\"";

    assert!(formatted.is_valid(), "{:#?}", formatted.diagnostics());
    assert_eq!(formatting.indent_width(), indent);
    assert_eq!(formatting.line_ending(), LineEnding::CrLf);
    assert!(formatting.document_marker());
    assert!(!formatting.final_newline());
    assert_eq!(formatted.output(), expected);
    assert!(!formatted.output().ends_with(['\r', '\n']));

    let reparsed = SyntaxDocument::parse(SourceId::new(292), formatted.output())?;
    assert!(reparsed.is_valid(), "{:#?}", reparsed.diagnostics());
    let reloaded = LoadedProject::load([DocumentInput::new(
        SourceId::new(293),
        DocumentOrigin::new("formatted.yaml", "workspace/project"),
        formatted.output(),
    )])?;
    let remerged = merge_project(&reloaded, None);
    let reproject = remerged.project().ok_or("remerged project expected")?;
    assert_eq!(
        render_canonical(reproject, None).output(),
        render_canonical(&project, None).output()
    );
    Ok(())
}

#[test]
fn indentation_width_rejects_values_that_cannot_form_yaml_nesting() {
    assert_eq!(IndentWidth::new(0), None);
    assert_eq!(IndentWidth::new(1).map(IndentWidth::spaces), Some(1));
    assert_eq!(IndentWidth::new(u8::MAX).map(IndentWidth::spaces), Some(u8::MAX));
}

#[test]
fn renders_only_services_in_an_explicit_profile_selection() -> Result<(), Box<dyn std::error::Error>> {
    let project = canonical_project()?;
    let selection = select_profiles(&project, &ProfileRequest::new());
    let selected = render_canonical(&project, Some(&selection));

    assert!(selected.is_valid(), "{:#?}", selected.diagnostics());
    assert!(selected.output().contains("\"app\":"));
    assert!(!selected.output().contains("\"debug\":"));
    assert!(selected.output().contains("\"networks\":"));
    Ok(())
}

#[test]
fn rejects_a_profile_selection_from_another_project() -> Result<(), Box<dyn std::error::Error>> {
    let selected_project = one_file_project("services:\n  selected:\n    image: example/selected\n", 266)?;
    let rendered_project = one_file_project("services:\n  rendered:\n    image: example/rendered\n", 267)?;
    let selection = select_profiles(&selected_project, &ProfileRequest::new());
    let rendered = render_canonical(&rendered_project, Some(&selection));

    assert!(!rendered.is_valid());
    assert!(rendered.output().is_empty());
    assert!(
        rendered
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == SELECTION_PROJECT_MISMATCH)
    );
    Ok(())
}

#[test]
fn unresolved_aliases_produce_valid_recovery_yaml_and_an_error() -> Result<(), Box<dyn std::error::Error>> {
    let project = one_file_project("services:\n  app:\n    image: *missing\n", 271)?;
    let rendered = render_canonical(&project, None);

    assert!(!rendered.is_valid());
    assert_eq!(rendered.output(), "\"services\":\n  \"app\":\n    \"image\": null\n");
    assert!(
        rendered
            .diagnostics()
            .iter()
            .any(|diagnostic| diagnostic.code() == UNRENDERABLE_ALIAS)
    );
    let recovered = SyntaxDocument::parse(SourceId::new(272), rendered.output())?;
    assert!(recovered.is_valid(), "{:#?}", recovered.diagnostics());
    Ok(())
}

#[test]
fn canonical_render_debug_output_redacts_interpolated_values() -> Result<(), Box<dyn std::error::Error>> {
    let source = "services:\n  app:\n    image: example/app:${PRIVATE}\n";
    let loaded = LoadedProject::load([DocumentInput::new(
        SourceId::new(281),
        DocumentOrigin::new("compose.yaml", "workspace/project"),
        source,
    )])?;
    let mut environment = MapEnvironment::new();
    let _ = environment.insert_sensitive("PRIVATE", "private-render-value");
    let interpolation = loaded.interpolate(&environment);
    let merge = merge_project(&loaded, Some(&interpolation));
    let project = merge.project().ok_or("merged project expected")?;
    let rendered = render_canonical(project, None);

    assert!(rendered.is_sensitive());
    assert!(rendered.output().contains("private-render-value"));
    assert!(!format!("{rendered:?}").contains("private-render-value"));
    assert!(rendered.diagnostics().iter().all(|diagnostic| {
        !diagnostic.message().contains("private-render-value")
            && diagnostic
                .labels()
                .iter()
                .all(|label| !label.message().contains("private-render-value"))
            && diagnostic
                .notes()
                .iter()
                .all(|note| !note.contains("private-render-value"))
    }));
    Ok(())
}

fn canonical_project() -> Result<MergedProject, Box<dyn std::error::Error>> {
    let loaded = LoadedProject::load([
        DocumentInput::new(
            SourceId::new(261),
            DocumentOrigin::new("compose.yaml", "workspace/project"),
            BASE,
        ),
        DocumentInput::new(
            SourceId::new(262),
            DocumentOrigin::new("compose.override.yaml", "workspace/overrides"),
            OVERRIDE,
        ),
    ])?;
    let merge = merge_project(&loaded, None);
    merge.project().cloned().ok_or_else(|| "merged project expected".into())
}

fn one_file_project(source: &'static str, source_id: u32) -> Result<MergedProject, Box<dyn std::error::Error>> {
    let loaded = LoadedProject::load([DocumentInput::new(
        SourceId::new(source_id),
        DocumentOrigin::new("compose.yaml", "workspace/project"),
        source,
    )])?;
    let merge = merge_project(&loaded, None);
    merge.project().cloned().ok_or_else(|| "merged project expected".into())
}