kbolt-core 0.1.7

Core engine for kbolt local-first retrieval
Documentation
use std::collections::HashSet;
use std::path::{Component, Path};

use kbolt_types::KboltError;

use crate::Result;

pub(super) fn normalized_extension_filter(raw: Option<&[String]>) -> Option<HashSet<String>> {
    raw.map(|items| {
        items
            .iter()
            .filter_map(|item| {
                let normalized = item.trim().trim_start_matches('.').to_ascii_lowercase();
                if normalized.is_empty() {
                    None
                } else {
                    Some(normalized)
                }
            })
            .collect::<HashSet<_>>()
    })
    .filter(|items| !items.is_empty())
}

pub(super) fn normalize_list_prefix(prefix: Option<&str>) -> Result<Option<String>> {
    let Some(raw_prefix) = prefix else {
        return Ok(None);
    };

    let trimmed = raw_prefix.trim();
    if trimmed.is_empty() {
        return Ok(None);
    }

    let parsed = Path::new(trimmed);
    if parsed.is_absolute() {
        return Err(KboltError::InvalidInput("prefix must be relative".to_string()).into());
    }

    let mut parts = Vec::new();
    for component in parsed.components() {
        match component {
            Component::Normal(item) => parts.push(item.to_string_lossy().into_owned()),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(KboltError::InvalidInput(
                    "prefix must not traverse directories".to_string(),
                )
                .into())
            }
        }
    }

    if parts.is_empty() {
        return Ok(None);
    }

    Ok(Some(parts.join("/")))
}

pub(super) fn split_collection_path(locator: &str) -> Result<(String, String)> {
    let trimmed = locator.trim();
    if trimmed.is_empty() {
        return Err(KboltError::InvalidInput(
            "path locator must be '<collection>/<path>'".to_string(),
        )
        .into());
    }

    let parsed = Path::new(trimmed);
    if parsed.is_absolute() {
        return Err(KboltError::InvalidInput("path locator must be relative".to_string()).into());
    }

    let mut parts = Vec::new();
    for component in parsed.components() {
        match component {
            Component::Normal(item) => parts.push(item.to_string_lossy().into_owned()),
            Component::CurDir => {}
            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                return Err(KboltError::InvalidInput(
                    "path locator must not traverse directories".to_string(),
                )
                .into())
            }
        }
    }

    if parts.len() < 2 {
        return Err(KboltError::InvalidInput(
            "path locator must be '<collection>/<path>'".to_string(),
        )
        .into());
    }

    Ok((parts[0].clone(), parts[1..].join("/")))
}

pub(super) fn normalize_docid(raw: &str) -> Result<String> {
    let normalized = raw.trim().trim_start_matches('#').to_string();
    if normalized.is_empty() {
        return Err(KboltError::InvalidInput("docid cannot be empty".to_string()).into());
    }
    Ok(normalized)
}

pub(super) fn path_matches_prefix(path: &str, prefix: &str) -> bool {
    path == prefix
        || path
            .strip_prefix(prefix)
            .is_some_and(|rest| rest.starts_with('/'))
}

pub(super) fn short_docid(hash: &str) -> String {
    let short = hash.get(..6).unwrap_or(hash);
    format!("#{short}")
}

pub(super) fn extension_allowed(path: &Path, filter: Option<&HashSet<String>>) -> bool {
    match filter {
        None => true,
        Some(allowed) => path
            .extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| allowed.contains(&ext.to_ascii_lowercase()))
            .unwrap_or(false),
    }
}

pub(super) fn collection_relative_path(
    root: &Path,
    full_path: &Path,
) -> std::result::Result<String, KboltError> {
    let relative = full_path
        .strip_prefix(root)
        .map_err(|_| KboltError::InvalidPath(full_path.to_path_buf()))?;

    let parts = relative
        .components()
        .filter_map(|component| match component {
            Component::Normal(item) => Some(item.to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>();

    if parts.is_empty() {
        return Err(KboltError::InvalidPath(full_path.to_path_buf()));
    }

    Ok(parts.join("/"))
}