alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Decode-boundary tests.

use super::{
    DecodedBufferEdit, DecodedCapabilityRef, DecodedCount, DecodedGuestString, DecodedPayload,
    DecodedTextRange, DecodedWorkspacePath, GuestDecodeError, GuestDecodeField, GuestDecodeLimits,
};
use crate::{
    plugin::{CapabilityAtom, PluginCapabilityRef, PluginCapabilityShape, WorkspacePathError},
    text_stream::TextRange,
};
use std::num::NonZeroU64;

#[test]
fn guest_decode_fields_render_stable_text() {
    let cases = [
        (GuestDecodeField::StatusText, "status.text"),
        (GuestDecodeField::Capability, "capability"),
        (GuestDecodeField::BufferObservePath, "buffer.observe.path"),
        (
            GuestDecodeField::BufferEditByteIndex,
            "buffer.edit.byte_index",
        ),
        (GuestDecodeField::BufferEditRange, "buffer.edit.range"),
        (GuestDecodeField::BufferEditText, "buffer.edit.text"),
        (
            GuestDecodeField::BufferProposeEditPath,
            "buffer.propose_edit.path",
        ),
        (
            GuestDecodeField::WorkspaceObservePath,
            "workspace.observe.path",
        ),
        (
            GuestDecodeField::WorkspaceArtifactWritePath,
            "workspace.artifact_write.path",
        ),
        (
            GuestDecodeField::WorkspaceArtifactWriteBytes,
            "workspace.artifact_write.bytes",
        ),
        (GuestDecodeField::StatusPublishPath, "status.publish.path"),
    ];

    for (field, expected) in cases {
        assert_eq!(field.as_str(), expected);
        assert_eq!(field.to_string(), expected);
        assert_eq!(format!("{field:?}"), expected);
    }
}

#[test]
fn guest_decode_limits_retain_non_zero_caps() {
    let limits = limits(64);

    assert_eq!(limits.max_string_bytes(), 64);
    assert_eq!(limits.max_payload_bytes(), 64);
    assert_eq!(limits.max_count(), 64);
    assert_eq!(
        format!("{limits:?}"),
        "GuestDecodeLimits { string_bytes: 64, payload_bytes: 64, count: 64 }"
    );
}

#[test]
fn guest_strings_reject_malformed_utf8_without_echoing_bytes() {
    let error =
        DecodedGuestString::from_bytes(GuestDecodeField::StatusText, b"\xffsecret", limits(64))
            .expect_err("invalid UTF-8 should fail");

    assert_eq!(
        error,
        GuestDecodeError::MalformedUtf8 {
            field: GuestDecodeField::StatusText,
            size: 7,
        }
    );
    assert!(!error.to_string().contains("secret"));
}

#[test]
fn payload_decode_rejects_oversized_bytes_and_redacts_debug() {
    let error = DecodedPayload::from_bytes(
        GuestDecodeField::WorkspaceArtifactWriteBytes,
        b"abcdef",
        limits(4),
    )
    .expect_err("payload should exceed cap");
    let payload = DecodedPayload::from_bytes(
        GuestDecodeField::WorkspaceArtifactWriteBytes,
        b"hide",
        limits(4),
    )
    .expect("payload should fit");

    assert_eq!(
        error,
        GuestDecodeError::PayloadTooLarge {
            field: GuestDecodeField::WorkspaceArtifactWriteBytes,
            size: 6,
            max: 4,
        }
    );
    assert_eq!(payload.as_bytes().len(), 4);
    assert!(!format!("{payload:?}").contains("hide"));
}

#[test]
fn workspace_paths_reject_traversal_without_echoing_path() {
    let error = DecodedWorkspacePath::from_bytes(
        GuestDecodeField::WorkspaceObservePath,
        b"docs/../secret",
        limits(64),
    )
    .expect_err("traversal should fail");

    assert_eq!(
        error,
        GuestDecodeError::InvalidWorkspacePath {
            field: GuestDecodeField::WorkspaceObservePath,
            source: WorkspacePathError::DotComponent,
        }
    );
    assert!(!error.to_string().contains("secret"));
}

#[test]
fn capabilities_reject_missing_workspace_paths_and_scalar_paths() {
    assert_eq!(
        DecodedCapabilityRef::from_guest(b"workspace.observe", None, limits(64)),
        Err(GuestDecodeError::MissingWorkspacePath {
            capability: PluginCapabilityShape::WorkspaceObserve,
        })
    );
    assert_eq!(
        DecodedCapabilityRef::from_guest(b"status.publish", Some(b"docs/out.txt"), limits(64)),
        Err(GuestDecodeError::UnexpectedWorkspacePath {
            capability: PluginCapabilityShape::StatusPublish,
        })
    );
}

#[test]
fn capabilities_reject_unknown_names_without_echoing_name() {
    let error = DecodedCapabilityRef::from_guest(b"workspace.delete.secret", None, limits(64))
        .expect_err("unknown capability should fail");

    assert_eq!(error, GuestDecodeError::UnknownCapability { byte_len: 23 });
    assert!(!error.to_string().contains("secret"));
}

#[test]
fn decoded_workspace_capability_borrows_as_authorization_ref() {
    let capability = DecodedCapabilityRef::from_guest(
        b"workspace.artifact_write",
        Some(b"docs/out.txt"),
        limits(64),
    )
    .expect("capability should decode");

    assert_eq!(capability.atom(), CapabilityAtom::WorkspaceArtifactWrite);
    assert!(matches!(
        capability.as_ref(),
        PluginCapabilityRef::WorkspaceArtifactWrite(path) if path.as_str() == "docs/out.txt"
    ));
}

#[test]
fn counts_and_ranges_are_bounded_before_host_use() {
    assert_eq!(
        DecodedCount::new(GuestDecodeField::BufferEditByteIndex, 4, limits(4))
            .expect("count")
            .get(),
        4
    );
    assert_eq!(
        DecodedCount::new(GuestDecodeField::BufferEditByteIndex, 5, limits(4)),
        Err(GuestDecodeError::CountTooLarge {
            field: GuestDecodeField::BufferEditByteIndex,
            value: 5,
            max: 4,
        })
    );
    assert_eq!(
        DecodedTextRange::new(GuestDecodeField::BufferEditRange, 1, 3, limits(8))
            .expect("range")
            .get(),
        TextRange::new(1, 3)
    );
    assert_eq!(
        DecodedTextRange::new(GuestDecodeField::BufferEditRange, 3, 1, limits(8)),
        Err(GuestDecodeError::ReversedRange {
            field: GuestDecodeField::BufferEditRange,
            start: 3,
            end: 1,
        })
    );
}

#[test]
fn buffer_edit_decode_bounds_text_and_ranges_before_owner_use() {
    let edit = DecodedBufferEdit::insert(2, "ok", limits(4))
        .expect("insert should decode")
        .into_buffer_edit();
    let shape = crate::buffer::BufferEditShape::from_edit(&edit);

    assert_eq!(shape.byte_index(), Some(2));
    assert_eq!(shape.replacement_byte_len(), Some(2));
    assert_eq!(
        DecodedBufferEdit::insert(0, "secret", limits(4)),
        Err(GuestDecodeError::PayloadTooLarge {
            field: GuestDecodeField::BufferEditText,
            size: 6,
            max: 4,
        })
    );
    assert_eq!(
        DecodedBufferEdit::delete(5, 1, limits(8)),
        Err(GuestDecodeError::ReversedRange {
            field: GuestDecodeField::BufferEditRange,
            start: 5,
            end: 1,
        })
    );
}

fn limits(max: u64) -> GuestDecodeLimits {
    let max = NonZeroU64::new(max).expect("test decode limit should be non-zero");
    GuestDecodeLimits::new(max, max, max)
}