lix 0.15.1

Embeddable version control for apps and AI agents.
Documentation
use datafusion::error::DataFusionError;

use crate::GLOBAL_BRANCH_ID;
use crate::LixError;
use crate::branch::BranchRefReader;

pub(crate) enum SqlBranchScope {
    Active(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BranchBinding {
    Active { branch_id: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WriteBranchScope {
    pub(crate) branch_id: String,
    pub(crate) global: bool,
}

impl BranchBinding {
    pub(crate) fn active(branch_id: impl Into<String>) -> Self {
        Self::Active {
            branch_id: branch_id.into(),
        }
    }

    pub(crate) fn active_branch_id(&self) -> Option<&str> {
        match self {
            Self::Active { branch_id } => Some(branch_id),
        }
    }
}

pub(crate) fn resolve_write_branch_scope(
    explicit_global: Option<bool>,
    fallback_branch_id: Option<&str>,
    action: &str,
) -> Result<WriteBranchScope, DataFusionError> {
    if explicit_global == Some(true) {
        return Ok(WriteBranchScope {
            branch_id: GLOBAL_BRANCH_ID.to_string(),
            global: true,
        });
    }

    let branch_id = fallback_branch_id
        .map(ToOwned::to_owned)
        .ok_or_else(|| DataFusionError::Execution(format!("{action} requires an active branch")))?;
    Ok(WriteBranchScope {
        global: explicit_global.unwrap_or(branch_id == GLOBAL_BRANCH_ID),
        branch_id,
    })
}

impl SqlBranchScope {
    pub(crate) fn from_provider(
        binding: &BranchBinding,
        _requested_branch_ids: Vec<String>,
    ) -> Self {
        match binding {
            BranchBinding::Active { branch_id } => Self::Active(branch_id.clone()),
        }
    }
}

pub(crate) async fn resolve_sql_branch_scope(
    branch_ref: &dyn BranchRefReader,
    scope: SqlBranchScope,
) -> Result<Vec<String>, LixError> {
    match scope {
        SqlBranchScope::Active(branch_id) => {
            if branch_ref.load_head(&branch_id).await?.is_none() {
                return Err(LixError::branch_not_found(
                    branch_id,
                    "resolve SQL active branch scope",
                    "active branch",
                ));
            }
            Ok(vec![branch_id])
        }
    }
}

pub(crate) async fn resolve_provider_branch_ids(
    branch_ref: &dyn BranchRefReader,
    binding: &BranchBinding,
    requested_branch_ids: Vec<String>,
) -> Result<Vec<String>, LixError> {
    resolve_sql_branch_scope(
        branch_ref,
        SqlBranchScope::from_provider(binding, requested_branch_ids),
    )
    .await
}


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

    use super::*;
    use crate::branch::BranchHead;
    use crate::changelog::CommitId;

    #[tokio::test]
    async fn active_scope_uses_session_branch() {
        let branch_ref = RowsBranchRefReader::new(vec![BranchHead {
            branch_id: "main".to_string(),
            commit_id: CommitId::for_test_label("commit-main"),
        }]);
        let ids =
            resolve_provider_branch_ids(&branch_ref, &BranchBinding::active("main"), Vec::new())
                .await
                .expect("scope should resolve");

        assert_eq!(ids, vec!["main".to_string()]);
    }

    #[tokio::test]
    async fn active_scope_rejects_missing_branch_ref() {
        let branch_ref = RowsBranchRefReader::new(Vec::new());
        let error =
            resolve_provider_branch_ids(&branch_ref, &BranchBinding::active("main"), Vec::new())
                .await
                .expect_err("missing active branch should be rejected");

        assert_eq!(error.code, LixError::CODE_BRANCH_NOT_FOUND);
        assert!(error.message.contains("branch 'main' was not found"));
    }




    #[test]
    fn write_scope_uses_fallback_branch_when_branch_is_implicit() {
        let scope = resolve_write_branch_scope(
            None,
            Some("active-branch"),
            "INSERT into surface",
        )
        .expect("scope should resolve");

        assert_eq!(
            scope,
            WriteBranchScope {
                branch_id: "active-branch".to_string(),
                global: false,
            }
        );
    }

    #[test]
    fn write_scope_requires_branch_without_fallback() {
        let error = resolve_write_branch_scope(None, None, "INSERT into surface")
            .expect_err("missing branch should be rejected");

        assert!(
            error
                .to_string()
                .contains("INSERT into surface requires an active branch")
        );
    }

    #[test]
    fn write_scope_derives_global_from_global_branch_id() {
        let scope = resolve_write_branch_scope(
            None,
            Some(GLOBAL_BRANCH_ID),
            "INSERT into surface",
        )
        .expect("scope should resolve");

        assert_eq!(
            scope,
            WriteBranchScope {
                branch_id: GLOBAL_BRANCH_ID.to_string(),
                global: true,
            }
        );
    }

    struct RowsBranchRefReader {
        heads: Vec<BranchHead>,
    }

    impl RowsBranchRefReader {
        fn new(heads: Vec<BranchHead>) -> Self {
            Self { heads }
        }
    }

    #[async_trait]
    impl BranchRefReader for RowsBranchRefReader {
        async fn load_head(&self, branch_id: &str) -> Result<Option<BranchHead>, LixError> {
            Ok(self
                .heads
                .iter()
                .find(|head| head.branch_id == branch_id)
                .cloned())
        }

        async fn scan_heads(&self) -> Result<Vec<BranchHead>, LixError> {
            Ok(self.heads.clone())
        }
    }
}