omena-query 0.3.0

Omena query boundary over CME producer query fragments
Documentation
use crate::{
    OmenaError, OmenaErrorClassV0, OmenaErrorContextV0, OmenaErrorRecoverabilityV0,
    OmenaErrorSeverityV0, OmenaSdkResponsePartitionV0, OmenaWorkspaceSessionHandshakeRequestV0,
    OmenaWorkspaceSessionHandshakeResponseV0, OmenaWorkspaceSessionResponseV0,
    OmenaWorkspaceSnapshotIdV0,
};

pub const OMENA_WORKSPACE_SESSION_PROTOCOL_VERSION_V0: &str = "0";

const WORKSPACE_SESSION_CAPABILITIES_V0: &[&str] = &[
    "cancel",
    "check",
    "diagnostics",
    "explain",
    "format",
    "lint",
    "replaceStyleSources",
    "shutdown",
];

pub fn negotiate_omena_workspace_session_v0(
    request: &OmenaWorkspaceSessionHandshakeRequestV0,
    snapshot_id: OmenaWorkspaceSnapshotIdV0,
) -> Result<OmenaWorkspaceSessionHandshakeResponseV0, OmenaError> {
    if request.protocol_version != OMENA_WORKSPACE_SESSION_PROTOCOL_VERSION_V0 {
        return Err(session_error(
            OmenaErrorClassV0::Unsupported,
            format!(
                "unsupported workspace session protocol version {:?}",
                request.protocol_version
            ),
            "workspace-session.protocol-version",
            OmenaErrorRecoverabilityV0::UserAction,
        ));
    }
    if request.workspace_root.trim().is_empty() {
        return Err(session_error(
            OmenaErrorClassV0::Input,
            "workspace session root must not be empty",
            "workspace-session.empty-root",
            OmenaErrorRecoverabilityV0::UserAction,
        ));
    }
    if request.limits.deadline_ms == 0 || request.limits.max_response_bytes == 0 {
        return Err(session_error(
            OmenaErrorClassV0::Input,
            "workspace session limits must be positive",
            "workspace-session.invalid-limits",
            OmenaErrorRecoverabilityV0::UserAction,
        ));
    }

    Ok(OmenaWorkspaceSessionHandshakeResponseV0 {
        protocol_version: OMENA_WORKSPACE_SESSION_PROTOCOL_VERSION_V0.to_string(),
        snapshot_id,
        partition: OmenaSdkResponsePartitionV0::Public,
        workspace_root: request.workspace_root.clone(),
        config_content_digest: request.config_content_digest.clone(),
        capabilities: WORKSPACE_SESSION_CAPABILITIES_V0
            .iter()
            .map(|capability| (*capability).to_string())
            .collect(),
    })
}

pub fn omena_workspace_session_success_v0(
    request_id: impl Into<String>,
    snapshot_id: OmenaWorkspaceSnapshotIdV0,
    payload: serde_json::Value,
) -> OmenaWorkspaceSessionResponseV0 {
    OmenaWorkspaceSessionResponseV0 {
        request_id: request_id.into(),
        protocol_version: OMENA_WORKSPACE_SESSION_PROTOCOL_VERSION_V0.to_string(),
        snapshot_id,
        partition: OmenaSdkResponsePartitionV0::Public,
        ok: true,
        payload: Some(payload),
        error: None,
    }
}

pub fn omena_workspace_session_failure_v0(
    request_id: impl Into<String>,
    snapshot_id: OmenaWorkspaceSnapshotIdV0,
    error: OmenaError,
) -> OmenaWorkspaceSessionResponseV0 {
    OmenaWorkspaceSessionResponseV0 {
        request_id: request_id.into(),
        protocol_version: OMENA_WORKSPACE_SESSION_PROTOCOL_VERSION_V0.to_string(),
        snapshot_id,
        partition: OmenaSdkResponsePartitionV0::Public,
        ok: false,
        payload: None,
        error: Some(error),
    }
}

fn session_error(
    class: OmenaErrorClassV0,
    message: impl Into<String>,
    code: &str,
    recoverability: OmenaErrorRecoverabilityV0,
) -> OmenaError {
    OmenaError::new(
        class,
        message,
        OmenaErrorContextV0 {
            code: code.to_string(),
            severity: OmenaErrorSeverityV0::Error,
            recoverability,
            evidence: Vec::new(),
        },
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        IncrementalRevisionV0, OmenaQueryStyleSourceInputV0, OmenaWorkspaceSessionLimitsV0,
    };

    fn request() -> OmenaWorkspaceSessionHandshakeRequestV0 {
        OmenaWorkspaceSessionHandshakeRequestV0 {
            protocol_version: "0".to_string(),
            workspace_root: "/workspace".to_string(),
            config_content_digest: Some("digest".to_string()),
            style_sources: vec![OmenaQueryStyleSourceInputV0 {
                style_path: "/workspace/app.css".to_string(),
                style_source: ".app { color: red; }".to_string(),
            }],
            limits: OmenaWorkspaceSessionLimitsV0 {
                deadline_ms: 1_000,
                max_response_bytes: 1_048_576,
            },
        }
    }

    #[test]
    fn handshake_reuses_snapshot_identity_and_generated_wire_shape() -> Result<(), String> {
        let snapshot_id =
            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 7 });
        let response = negotiate_omena_workspace_session_v0(&request(), snapshot_id)
            .map_err(|error| error.to_string())?;
        assert_eq!(response.snapshot_id, snapshot_id);
        assert_eq!(response.config_content_digest.as_deref(), Some("digest"));
        assert!(
            response
                .capabilities
                .iter()
                .any(|capability| capability == "diagnostics")
        );
        assert!(
            response
                .capabilities
                .iter()
                .any(|capability| capability == "format")
        );

        let wire = serde_json::to_value(&response).map_err(|error| error.to_string())?;
        assert_eq!(wire["protocolVersion"], "0");
        assert_eq!(wire["snapshotId"]["value"], 7);
        Ok(())
    }

    #[test]
    fn handshake_rejects_protocol_drift_and_unbounded_requests() -> Result<(), String> {
        let snapshot_id =
            OmenaWorkspaceSnapshotIdV0::from_revision(IncrementalRevisionV0 { value: 1 });
        let mut unsupported = request();
        unsupported.protocol_version = "1".to_string();
        let Err(protocol_error) = negotiate_omena_workspace_session_v0(&unsupported, snapshot_id)
        else {
            return Err("protocol drift must fail".to_string());
        };
        assert_eq!(
            protocol_error.context.code,
            "workspace-session.protocol-version"
        );

        let mut unbounded = request();
        unbounded.limits.max_response_bytes = 0;
        let Err(limit_error) = negotiate_omena_workspace_session_v0(&unbounded, snapshot_id) else {
            return Err("unbounded response must fail".to_string());
        };
        assert_eq!(limit_error.context.code, "workspace-session.invalid-limits");
        Ok(())
    }
}