foundation_ai 0.0.2

AI foundation crate for the eweplatform
//! Search tools (F32) — `SearchContextTool` (knowledge recall) and
//! `SearchFileTool` (filesystem grep/find).
//!
//! Two distinct `ToolImpl`s per Decision 14 TODO #6: `search_context` wraps
//! `ContextProvider::search` for semantic/memory/graph recall, while
//! `search_file` wraps a `FileSearch` backend delegating to
//! `foundation_nativeapis::VfsSearcher` for the actual filesystem walk.

use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::agentic::context::{ContextProvider, SearchMode};
use crate::agentic::memory_store::MemoryStore;
use crate::agentic::tool_impl::{ToolArgs, ToolCallResult, ToolError, ToolImpl};
use crate::agentic::toolshed::ContextSearch;
use crate::types::{Args, Tool, ToolArguments, ToolDefinition};
use foundation_db::traits::DocumentStore;
use foundation_nativeapis::{VfsFileSystem, VfsSearchMatch, VfsSearcher};

// ---------------------------------------------------------------------------
// FileSearch types (re-exported from foundation_nativeapis)
// ---------------------------------------------------------------------------

pub use foundation_nativeapis::VfsSearchKind as FileSearchKind;

/// A single file-search hit.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMatch {
    pub path: String,
    pub line_number: u32,
    pub content: String,
    pub score: f32,
}

impl From<VfsSearchMatch> for FileMatch {
    fn from(m: VfsSearchMatch) -> Self {
        Self {
            path: m.path,
            line_number: m.line_number,
            content: m.content,
            score: m.score,
        }
    }
}

// ---------------------------------------------------------------------------
// FileSearch trait
// ---------------------------------------------------------------------------

/// Backend trait for filesystem search — delegates to `VfsSearcher`.
pub trait FileSearch: Send + Sync {
    /// # Errors
    /// Returns `ToolError::InvalidArguments` for invalid regex or `ToolError::Execution` for I/O failures.
    fn search(
        &self,
        query: &str,
        kind: FileSearchKind,
        paths: Option<&[String]>,
    ) -> Result<Vec<FileMatch>, ToolError>;
}

// ---------------------------------------------------------------------------
// VfsSearchBackend — wraps a VfsSearcher from foundation_nativeapis
// ---------------------------------------------------------------------------

/// File search backend delegating to a `VfsSearcher` from `foundation_nativeapis`.
/// The `VfsSearcher` handles the cascade: CLI tools (rg/grep) → in-code VFS walk.
pub struct VfsSearchBackend {
    searcher: Box<dyn VfsSearcher>,
    root: String,
}

impl VfsSearchBackend {
    #[must_use]
    pub fn new(searcher: Box<dyn VfsSearcher>, root: String) -> Self {
        Self { searcher, root }
    }

    #[must_use]
    pub fn from_vfs<F: VfsFileSystem + 'static>(fs: Arc<F>, root: String) -> Self {
        let searcher = foundation_nativeapis::vfs_searcher(fs);
        Self { searcher, root }
    }

    /// Native cascade backend (F18): routes through `native_vfs_searcher` —
    /// the real fff search engine first, then CLI `rg`/`grep`, then the in-code
    /// VFS walk as the fallback. Use this for searching the real filesystem on
    /// native targets; `from_vfs` (in-code only) remains the wasm/VFS path.
    #[cfg(not(target_family = "wasm"))]
    #[must_use]
    pub fn from_vfs_native<F: VfsFileSystem + 'static>(fs: Arc<F>, root: String) -> Self {
        let searcher = foundation_nativeapis::native_vfs_searcher(fs);
        Self { searcher, root }
    }
}

impl FileSearch for VfsSearchBackend {
    fn search(
        &self,
        query: &str,
        kind: FileSearchKind,
        paths: Option<&[String]>,
    ) -> Result<Vec<FileMatch>, ToolError> {
        let search_roots: Vec<String> = match paths {
            Some(ps) if !ps.is_empty() => ps
                .iter()
                .map(|p| {
                    if p.starts_with('/') {
                        p.clone()
                    } else if self.root == "/" {
                        format!("/{p}")
                    } else {
                        format!("{}/{p}", self.root)
                    }
                })
                .collect(),
            _ => vec![self.root.clone()],
        };

        let vfs_matches = self
            .searcher
            .search(query, kind, &search_roots)
            .map_err(|e| ToolError::Execution {
                tool: "search_file".into(),
                reason: format!("{e}"),
            })?;

        Ok(vfs_matches.into_iter().map(FileMatch::from).collect())
    }
}

// ---------------------------------------------------------------------------
// SearchContextTool
// ---------------------------------------------------------------------------

/// Tool that searches knowledge surfaces (semantic/memory/graph) via
/// `ContextProvider::search`. Category: `search`.
///
/// Holds the context behind [`ContextSearch`], so the tool type doesn't carry
/// the session's store types.
pub struct SearchContextTool {
    context: Arc<dyn ContextSearch>,
}

impl SearchContextTool {
    /// The tool over a `ContextProvider<D, M>` (by value) or an
    /// `Arc<dyn ContextSearch>` (e.g. `SessionParts::context`).
    #[must_use]
    pub fn new(context: impl Into<Arc<dyn ContextSearch>>) -> Self {
        Self {
            context: context.into(),
        }
    }
}

impl<D: DocumentStore + 'static, M: MemoryStore + 'static> From<ContextProvider<D, M>>
    for Arc<dyn ContextSearch>
{
    fn from(context: ContextProvider<D, M>) -> Self {
        Arc::new(context)
    }
}

#[async_trait]
impl ToolImpl for SearchContextTool {
    fn definition(&self) -> Tool {
        Tool::SingleCommand(ToolDefinition {
            name: "search_context".into(),
            description: "Search your knowledge — semantic recall over prior messages, \
                          distilled memory (observations/reflections), and the code-graph. \
                          NOT the live filesystem — use search_file for that."
                .into(),
            arguments: Args::new(
                foundation_jsonschema::scheme::object()
                    .required("query", foundation_jsonschema::scheme::string().min_len(1))
                    .required(
                        "mode",
                        foundation_jsonschema::scheme::string()
                            .enum_values(vec![
                                "Semantic".into(),
                                "Memory".into(),
                                "Graph".into(),
                                "Hybrid".into(),
                            ]),
                    )
                    .optional("k", foundation_jsonschema::scheme::integer().min(1))
                    .build(),
            ),
            category: "search".into(),
            returns: None,
        })
    }

    async fn execute(&self, arguments: ToolArguments) -> Result<ToolCallResult, ToolError> {
        let args = ToolArgs::new("search_context", &arguments);
        let query = args.str("query")?.to_string();
        let mode = match args.opt_str("mode")? {
            Some(mode) => parse_search_mode(mode)?,
            None => SearchMode::Hybrid,
        };
        // A negative or non-numeric `k` is an argument error, not a huge usize.
        let k = args.opt_usize("k")?.unwrap_or(10);

        let hits = self.context.search(&query, mode, k).await;
        let json = serde_json::to_string(&hits).map_err(|e| ToolError::Execution {
            tool: "search_context".into(),
            reason: format!("serializing the hits failed: {e}"),
        })?;

        Ok(ToolCallResult::text(json))
    }
}

/// # Errors
/// Returns [`ToolError`] if the query is missing or invalid.
pub fn parse_search_mode(s: &str) -> Result<SearchMode, ToolError> {
    match s {
        "Semantic" | "semantic" => Ok(SearchMode::Semantic),
        "Memory" | "memory" => Ok(SearchMode::Memory),
        "Graph" | "graph" => Ok(SearchMode::Graph),
        "Hybrid" | "hybrid" => Ok(SearchMode::Hybrid),
        other => Err(ToolError::InvalidArguments {
            tool: "search_context".into(),
            reason: format!("unknown search mode: {other}"),
        }),
    }
}

// ---------------------------------------------------------------------------
// SearchFileTool
// ---------------------------------------------------------------------------

/// Tool that searches the filesystem via grep/find. Category: `search_files`.
pub struct SearchFileTool {
    backend: Arc<dyn FileSearch>,
}

impl SearchFileTool {
    #[must_use]
    pub fn new(backend: Arc<dyn FileSearch>) -> Self {
        Self { backend }
    }

    #[must_use]
    pub fn from_vfs<F: VfsFileSystem + 'static>(fs: Arc<F>, root: String) -> Self {
        Self {
            backend: Arc::new(VfsSearchBackend::from_vfs(fs, root)),
        }
    }

    #[must_use]
    pub fn from_searcher(searcher: Box<dyn VfsSearcher>, root: String) -> Self {
        Self {
            backend: Arc::new(VfsSearchBackend::new(searcher, root)),
        }
    }

    /// Native `search_file` backed by the fff cascade (F18). On native this uses
    /// the production fff engine (falling back to CLI/in-code); prefer this over
    /// [`Self::from_vfs`] when searching the real filesystem.
    #[cfg(not(target_family = "wasm"))]
    #[must_use]
    pub fn native<F: VfsFileSystem + 'static>(fs: Arc<F>, root: String) -> Self {
        Self {
            backend: Arc::new(VfsSearchBackend::from_vfs_native(fs, root)),
        }
    }
}

#[async_trait]
impl ToolImpl for SearchFileTool {
    fn definition(&self) -> Tool {
        Tool::SingleCommand(ToolDefinition {
            name: "search_file".into(),
            description: "Search files: grep file content, find by path pattern, \
                          multi-file grep. NOT for memory recall — use search_context."
                .into(),
            arguments: Args::new(
                foundation_jsonschema::scheme::object()
                    .required("query", foundation_jsonschema::scheme::string().min_len(1))
                    .optional(
                        "kind",
                        foundation_jsonschema::scheme::string()
                            .enum_values(vec![
                                "Grep".into(),
                                "Find".into(),
                                "MultiGrep".into(),
                            ]),
                    )
                    .optional(
                        "paths",
                        foundation_jsonschema::scheme::array_of(
                            foundation_jsonschema::scheme::string().build_schema(),
                        ),
                    )
                    .build(),
            ),
            category: "search_files".into(),
            returns: None,
        })
    }

    async fn execute(&self, arguments: ToolArguments) -> Result<ToolCallResult, ToolError> {
        let args = ToolArgs::new("search_file", &arguments);
        let query = args.str("query")?.to_string();
        let kind = match args.opt_str("kind")? {
            Some(kind) => parse_file_search_kind(kind)?,
            None => FileSearchKind::Grep,
        };
        let paths: Option<Vec<String>> = args
            .opt_value("paths")
            .map(serde_json::from_value)
            .transpose()
            .map_err(|e| ToolError::InvalidArguments {
                tool: "search_file".into(),
                reason: format!("`paths` must be a list of strings: {e}"),
            })?;

        let matches = self
            .backend
            .search(&query, kind, paths.as_deref())?;

        let json = serde_json::to_string(&matches).map_err(|e| ToolError::Execution {
            tool: "search_file".into(),
            reason: format!("serializing the matches failed: {e}"),
        })?;

        Ok(ToolCallResult::text(json))
    }
}

/// # Errors
/// Returns [`ToolError`] if the query is missing or invalid.
pub fn parse_file_search_kind(s: &str) -> Result<FileSearchKind, ToolError> {
    match s {
        "Grep" | "grep" => Ok(FileSearchKind::Grep),
        "Find" | "find" => Ok(FileSearchKind::Find),
        "MultiGrep" | "multi_grep" | "multigrep" => Ok(FileSearchKind::MultiGrep),
        other => Err(ToolError::InvalidArguments {
            tool: "search_file".into(),
            reason: format!("unknown file search kind: {other}"),
        }),
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------