claudix 0.2.0

Local semantic search plugin for Claude Code
pub mod bundled;
pub mod http;
#[cfg(any(test, feature = "test-stub"))]
pub mod stub;

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use async_trait::async_trait;

use crate::error::Result;
use crate::types::Dimension;

pub use bundled::BundledProvider;
pub use http::HttpProvider;
#[cfg(any(test, feature = "test-stub"))]
pub use stub::StubProvider;

#[async_trait]
pub trait Provider: Send + Sync {
    fn name(&self) -> &str;
    fn dimensions(&self) -> Dimension;
    fn model_id(&self) -> &str;
    async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>>;
    async fn health_check(&self) -> Result<()>;
}

pub struct FallbackProvider {
    primary: Arc<dyn Provider>,
    fallback: Arc<dyn Provider>,
    warned: AtomicBool,
}

impl FallbackProvider {
    pub fn new(primary: Arc<dyn Provider>, fallback: Arc<dyn Provider>) -> Self {
        Self {
            primary,
            fallback,
            warned: AtomicBool::new(false),
        }
    }
}

#[async_trait]
impl Provider for FallbackProvider {
    fn name(&self) -> &str {
        self.primary.name()
    }

    fn dimensions(&self) -> Dimension {
        self.primary.dimensions()
    }

    fn model_id(&self) -> &str {
        self.primary.model_id()
    }

    async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
        match self.primary.embed(batch).await {
            Ok(vectors) => Ok(vectors),
            Err(error) if error.is_endpoint_unavailable() => {
                if !self.warned.swap(true, Ordering::Relaxed) {
                    eprintln!(
                        "claudix warning: {error}; falling back to bundled embeddings for this session"
                    );
                }
                self.fallback.embed(batch).await
            }
            Err(error) => Err(error),
        }
    }

    async fn health_check(&self) -> Result<()> {
        self.primary.health_check().await.or_else(|error| {
            if error.is_endpoint_unavailable() {
                Ok(())
            } else {
                Err(error)
            }
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::ClaudixError;
    use std::time::Duration;

    use tokio::net::TcpListener;

    struct FixedProvider {
        vectors: Vec<Vec<f32>>,
    }

    #[async_trait]
    impl Provider for FixedProvider {
        fn name(&self) -> &str {
            "fixed"
        }

        fn dimensions(&self) -> Dimension {
            Dimension(2)
        }

        fn model_id(&self) -> &str {
            "fixed-model"
        }

        async fn embed(&self, batch: &[&str]) -> Result<Vec<Vec<f32>>> {
            Ok(self.vectors.iter().take(batch.len()).cloned().collect())
        }

        async fn health_check(&self) -> Result<()> {
            Ok(())
        }
    }

    #[tokio::test]
    async fn fallback_provider_uses_fallback_for_unreachable_primary() {
        let listener = TcpListener::bind("127.0.0.1:0").await;
        assert!(listener.is_ok());
        let listener = listener.ok().unwrap_or_else(|| unreachable!());
        let endpoint = format!(
            "http://{}",
            listener.local_addr().ok().unwrap_or_else(|| unreachable!())
        );
        drop(listener);

        let primary = HttpProvider::new(
            endpoint,
            "fixed-model",
            Dimension(2),
            Duration::from_millis(50),
            None,
        );
        assert!(primary.is_ok());
        let fallback = FixedProvider {
            vectors: vec![vec![0.1, 0.2]],
        };
        let provider = FallbackProvider::new(
            Arc::new(primary.ok().unwrap_or_else(|| unreachable!())),
            Arc::new(fallback),
        );

        let result = provider.embed(&["alpha"]).await;
        assert!(result.is_ok());
        assert_eq!(result.ok().unwrap_or_default(), vec![vec![0.1, 0.2]]);
    }

    #[tokio::test]
    async fn fallback_provider_does_not_fallback_for_primary_embedding_errors() {
        struct BadProvider;

        #[async_trait]
        impl Provider for BadProvider {
            fn name(&self) -> &str {
                "bad"
            }

            fn dimensions(&self) -> Dimension {
                Dimension(2)
            }

            fn model_id(&self) -> &str {
                "bad-model"
            }

            async fn embed(&self, _batch: &[&str]) -> Result<Vec<Vec<f32>>> {
                Err(ClaudixError::Embedding("bad payload".to_owned()))
            }

            async fn health_check(&self) -> Result<()> {
                Ok(())
            }
        }

        let provider = FallbackProvider::new(
            Arc::new(BadProvider),
            Arc::new(FixedProvider {
                vectors: vec![vec![0.1, 0.2]],
            }),
        );

        let result = provider.embed(&["alpha"]).await;
        assert!(
            matches!(result, Err(ClaudixError::Embedding(message)) if message == "bad payload")
        );
    }
}