claudix 0.1.1

Local semantic search plugin for Claude Code
Documentation
use crate::chunking::DEFAULT_CHUNK_LINES;
use crate::error::{ClaudixError, RecoveryHint, Result};

use super::{Config, EmbeddingProvider, validate_project_relative_path};

pub fn validate(config: &Config) -> Result<()> {
    validate_project_relative_path(&config.paths.index_dir, "paths.index_dir")?;
    validate_project_relative_path(&config.paths.log_dir, "paths.log_dir")?;

    let allow_stub_embedding_model = allow_stub_embedding_model(config);

    if matches!(config.embedding.provider, EmbeddingProvider::Http)
        && config.embedding.endpoint.trim().is_empty()
    {
        return Err(ClaudixError::ConfigInvalid {
            message: "embedding.endpoint is required when embedding.provider = \"http\"".into(),
            recovery: RecoveryHint("Set [embedding].endpoint or switch provider to bundled"),
        });
    }

    if matches!(config.embedding.provider, EmbeddingProvider::Bundled)
        && !allow_stub_embedding_model
        && config.embedding.model != crate::embedding::bundled::BUNDLED_MODEL_ID
    {
        return Err(ClaudixError::ConfigInvalid {
            message: format!(
                "embedding.model must be {} when embedding.provider = \"bundled\"",
                crate::embedding::bundled::BUNDLED_MODEL_ID
            ),
            recovery: RecoveryHint(
                "Set [embedding].model = \"bge-small-en-v1.5\" for the bundled provider",
            ),
        });
    }

    if matches!(config.embedding.provider, EmbeddingProvider::Bundled)
        && !allow_stub_embedding_model
        && config.embedding.dimensions != crate::embedding::bundled::BUNDLED_DIMENSIONS.0
    {
        return Err(ClaudixError::ConfigInvalid {
            message: format!(
                "embedding.dimensions must be {} when embedding.provider = \"bundled\"",
                crate::embedding::bundled::BUNDLED_DIMENSIONS.0
            ),
            recovery: RecoveryHint("Set [embedding].dimensions = 384 for the bundled provider"),
        });
    }

    if config.embedding.dimensions == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "embedding.dimensions must be > 0".into(),
            recovery: RecoveryHint("Set [embedding].dimensions to a positive integer"),
        });
    }

    if config.embedding.batch_size == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "embedding.batch_size must be > 0".into(),
            recovery: RecoveryHint("Set [embedding].batch_size to a positive integer"),
        });
    }

    if config.embedding.timeout_ms == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "embedding.timeout_ms must be > 0".into(),
            recovery: RecoveryHint("Set [embedding].timeout_ms to a positive integer"),
        });
    }

    if config.indexing.max_file_size_kb == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "indexing.max_file_size_kb must be > 0".into(),
            recovery: RecoveryHint("Set [indexing].max_file_size_kb to a positive integer"),
        });
    }

    if config.indexing.chunk_overlap_lines >= DEFAULT_CHUNK_LINES {
        return Err(ClaudixError::ConfigInvalid {
            message: format!(
                "indexing.chunk_overlap_lines must be less than {DEFAULT_CHUNK_LINES}"
            ),
            recovery: RecoveryHint("Set [indexing].chunk_overlap_lines between 0 and 59"),
        });
    }

    if config.indexing.reindex_after_hours == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "indexing.reindex_after_hours must be > 0".into(),
            recovery: RecoveryHint("Set [indexing].reindex_after_hours to a positive integer"),
        });
    }

    if config.search.top_k == 0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.top_k must be > 0".into(),
            recovery: RecoveryHint("Set [search].top_k to a positive integer"),
        });
    }

    if !(0.0..=1.0).contains(&config.search.similarity_threshold) {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.similarity_threshold must be between 0 and 1".into(),
            recovery: RecoveryHint("Set [search].similarity_threshold to a value in [0, 1]"),
        });
    }

    if !(0.0..=1.0).contains(&config.search.min_score) {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.min_score must be between 0 and 1".into(),
            recovery: RecoveryHint("Set [search].min_score to a value in [0, 1]"),
        });
    }

    if !config.search.identifier_boost.is_finite() || config.search.identifier_boost <= 0.0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.identifier_boost must be a finite positive number".into(),
            recovery: RecoveryHint("Set [search].identifier_boost to a finite positive number"),
        });
    }

    let weights = &config.search.hybrid_weights;
    if !weights.dense.is_finite() || !weights.bm25.is_finite() || !weights.rrf.is_finite() {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.hybrid_weights components must be finite numbers".into(),
            recovery: RecoveryHint("Set all [search].hybrid_weights values to finite numbers"),
        });
    }
    if weights.dense < 0.0 || weights.bm25 < 0.0 || weights.rrf < 0.0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.hybrid_weights components must be >= 0".into(),
            recovery: RecoveryHint(
                "Set all [search].hybrid_weights values to non-negative numbers",
            ),
        });
    }
    if weights.dense == 0.0 && weights.bm25 == 0.0 && weights.rrf == 0.0 {
        return Err(ClaudixError::ConfigInvalid {
            message: "search.hybrid_weights must not all be zero".into(),
            recovery: RecoveryHint(
                "Set at least one of [search].hybrid_weights.dense, .bm25, .rrf to a positive value",
            ),
        });
    }

    Ok(())
}

#[cfg(any(test, feature = "test-stub"))]
fn allow_stub_embedding_model(config: &Config) -> bool {
    config.embedding.model.starts_with("stub")
}

#[cfg(not(any(test, feature = "test-stub")))]
fn allow_stub_embedding_model(_config: &Config) -> bool {
    false
}