lix 0.16.0

Embeddable version control for apps and AI agents.
Documentation
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use uuid::Uuid;

use crate::LixError;
use crate::changelog::ChangeId;

const PREFIX: &str = "d1.";
const BEFORE_PRESENT: u8 = 1;
const AFTER_PRESENT: u8 = 2;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct DiffSides {
    pub(crate) before: Option<ChangeId>,
    pub(crate) after: Option<ChangeId>,
}

pub(crate) fn encode_diff_id(
    before: Option<ChangeId>,
    after: Option<ChangeId>,
) -> Result<String, LixError> {
    if before.is_none() && after.is_none() {
        return Err(invalid_diff_id("a diff must contain at least one side"));
    }
    let mut bytes = Vec::with_capacity(33);
    bytes.push(
        u8::from(before.is_some()) * BEFORE_PRESENT + u8::from(after.is_some()) * AFTER_PRESENT,
    );
    if let Some(change_id) = before {
        bytes.extend_from_slice(change_id.as_uuid().as_bytes());
    }
    if let Some(change_id) = after {
        bytes.extend_from_slice(change_id.as_uuid().as_bytes());
    }
    Ok(format!("{PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)))
}

pub(crate) fn decode_diff_id(value: &str) -> Result<DiffSides, LixError> {
    let payload = value
        .strip_prefix(PREFIX)
        .ok_or_else(|| invalid_diff_id("unsupported or missing version"))?;
    let bytes = URL_SAFE_NO_PAD
        .decode(payload)
        .map_err(|_| invalid_diff_id("payload is not valid base64url"))?;
    let Some(flags) = bytes.first().copied() else {
        return Err(invalid_diff_id("payload is empty"));
    };
    if flags == 0 || flags & !(BEFORE_PRESENT | AFTER_PRESENT) != 0 {
        return Err(invalid_diff_id("side flags are invalid"));
    }
    let expected = 1
        + usize::from(flags & BEFORE_PRESENT != 0) * 16
        + usize::from(flags & AFTER_PRESENT != 0) * 16;
    if bytes.len() != expected {
        return Err(invalid_diff_id(
            "payload length does not match its side flags",
        ));
    }
    let mut offset = 1;
    let mut take_change_id = |present: bool| {
        if !present {
            return None;
        }
        let uuid = Uuid::from_slice(&bytes[offset..offset + 16])
            .expect("validated diff id UUID slice length");
        offset += 16;
        Some(ChangeId::new(uuid))
    };
    Ok(DiffSides {
        before: take_change_id(flags & BEFORE_PRESENT != 0),
        after: take_change_id(flags & AFTER_PRESENT != 0),
    })
}

fn invalid_diff_id(message: &str) -> LixError {
    LixError::new(
        LixError::CODE_TYPE_MISMATCH,
        format!("invalid diff_id: {message}"),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn round_trips_all_valid_side_shapes() {
        let before = ChangeId::for_test_label("before");
        let after = ChangeId::for_test_label("after");
        for sides in [
            DiffSides {
                before: Some(before),
                after: Some(after),
            },
            DiffSides {
                before: Some(before),
                after: None,
            },
            DiffSides {
                before: None,
                after: Some(after),
            },
        ] {
            let encoded = encode_diff_id(sides.before, sides.after).expect("encode diff id");
            assert!(encoded.starts_with("d1."));
            assert_eq!(decode_diff_id(&encoded).expect("decode diff id"), sides);
        }
    }

    #[test]
    fn rejects_malformed_and_unknown_versions() {
        for value in ["", "d2.AQ", "d1.", "d1.AA", "d1.AQ"] {
            assert!(decode_diff_id(value).is_err(), "{value}");
        }
    }
}