scraps 3.6.0

Scraps is a portable CLI knowledge hub for managing interconnected Markdown documentation with Wiki-link notation.
use crate::ir::loader;
use crate::mcp::json::scrap::SearchHitJson;
use crate::usecase::search::usecase::SearchUsecase;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorCode;
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::schemars::JsonSchema;
use rmcp::service::RequestContext;
use rmcp::{ErrorData, RoleServer};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tracing_opentelemetry::OpenTelemetrySpanExt;

/// Search logic for combining multiple keywords
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SearchLogic {
    /// All keywords must match
    And,
    /// Any keyword can match (default)
    #[default]
    Or,
}

impl From<SearchLogic> for scraps_libs::search::engine::SearchLogic {
    fn from(logic: SearchLogic) -> Self {
        match logic {
            SearchLogic::And => scraps_libs::search::engine::SearchLogic::And,
            SearchLogic::Or => scraps_libs::search::engine::SearchLogic::Or,
        }
    }
}

#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SearchRequest {
    /// Query string to search for
    pub query: String,
    /// Maximum number of results to return (default: 100)
    pub num: Option<usize>,
    /// Search logic: "or" (default, any keyword matches) or "and" (all keywords must match)
    pub logic: Option<SearchLogic>,
}

#[derive(Debug, Serialize)]
pub struct SearchResponse {
    pub results: Vec<SearchHitJson>,
    pub count: usize,
    pub next: String,
}

pub async fn search_scraps(
    scraps_dir: &Path,
    exclude_dirs: &[std::path::PathBuf],
    _context: RequestContext<RoleServer>,
    Parameters(request): Parameters<SearchRequest>,
) -> Result<CallToolResult, ErrorData> {
    // Load scraps from directory
    let scraps = loader::load_scraps(scraps_dir, exclude_dirs).map_err(|e| {
        ErrorData::new(
            ErrorCode(-32003),
            format!("Failed to load scraps: {e}"),
            None,
        )
    })?;

    // Create search usecase
    let search_usecase = SearchUsecase::new();

    // Execute search
    let num = request.num.unwrap_or(100);
    let logic = request.logic.unwrap_or_default().into();
    let results = tracing::info_span!("search")
        .in_scope(|| search_usecase.execute(&scraps, &request.query, num, logic))
        .map_err(|e| ErrorData::new(ErrorCode(-32004), format!("Search failed: {e}"), None))?;

    // Convert results to structured response
    let scrap_jsons: Vec<SearchHitJson> = results
        .into_iter()
        .map(|result| SearchHitJson {
            scrap: (&result.scrap).into(),
            description: result.description,
        })
        .collect();

    let count = scrap_jsons.len();
    // Tells a search the hits answered from one that found nothing, when no read follows.
    tracing::Span::current().set_attribute("scraps.search.result_count", count as i64);
    let next = if count == 0 {
        "No hits. Vary the keywords (default OR logic matches any), or start from the topic map with list_tags."
    } else {
        "Read a hit by passing its key back: get_scrap {key}; then traverse it with lookup_scrap_links."
    };
    let response = SearchResponse {
        results: scrap_jsons,
        count,
        next: next.to_string(),
    };

    Ok(CallToolResult::success(vec![ContentBlock::text(
        serde_json::to_string(&response).map_err(|e| {
            ErrorData::new(
                ErrorCode(-32005),
                format!("JSON serialization failed: {e}"),
                None,
            )
        })?,
    )]))
}