mant 0.11.0

Local-first TUI, structured CLI, and MCP server for manuals and Markdown
//! Bounded in-process execution of read-only engine queries.

use std::sync::Arc;

use mant_engine::QueryViewResult;
use mant_loader::LoadPolicy;
use mant_protocol::{
    CatalogQuery, DocumentCatalog, QueryRequest, ScopeQueryRequest, ScopeQueryResponse,
};
use tokio::{sync::Semaphore, task};

/// Bounds synchronous parser and filesystem work away from the protocol loop.
#[derive(Debug, Clone)]
pub(super) struct QueryService {
    gate: Arc<Semaphore>,
}

impl QueryService {
    pub(super) fn new() -> Self {
        Self {
            // Bound blocking filesystem and parser work. Patched libmandoc
            // sessions can run concurrently, but neither native parsing nor
            // independent Markdown/catalog reads should grow without limit.
            gate: Arc::new(Semaphore::new(4)),
        }
    }

    pub(super) async fn query(&self, request: QueryRequest) -> Result<QueryViewResult, String> {
        let permit = Arc::clone(&self.gate)
            .acquire_owned()
            .await
            .map_err(|_| "MCP query service is shutting down".to_owned())?;
        task::spawn_blocking(move || {
            let _permit = permit;
            mant_engine::execute_query(&request, LoadPolicy::default()).map_err(query_error_for_mcp)
        })
        .await
        .map_err(|_| "MCP query worker failed".to_owned())?
    }

    pub(super) async fn query_scope(
        &self,
        request: ScopeQueryRequest,
    ) -> Result<ScopeQueryResponse, String> {
        let permit = Arc::clone(&self.gate)
            .acquire_owned()
            .await
            .map_err(|_| "MCP query service is shutting down".to_owned())?;
        task::spawn_blocking(move || {
            let _permit = permit;
            mant_engine::execute_scope_query(&request).map_err(scope_error_for_mcp)
        })
        .await
        .map_err(|_| "MCP scope-query worker failed".to_owned())?
    }

    pub(super) async fn discover(&self, query: CatalogQuery) -> Result<DocumentCatalog, String> {
        let permit = Arc::clone(&self.gate)
            .acquire_owned()
            .await
            .map_err(|_| "MCP query service is shutting down".to_owned())?;
        task::spawn_blocking(move || {
            let _permit = permit;
            mant_loader::discover_documents(&query).map_err(discovery_error_for_mcp)
        })
        .await
        .map_err(|_| "MCP document discovery worker failed".to_owned())?
    }
}

fn discovery_error_for_mcp(_error: String) -> String {
    "registered document discovery failed".to_owned()
}

fn scope_error_for_mcp(error: mant_engine::ScopeQueryError) -> String {
    use mant_engine::ScopeQueryError;

    match error {
        // Resolution errors can contain host paths. The individual selectors
        // remain visible in the tool input, so the aggregate result is enough
        // for this path-safe boundary.
        ScopeQueryError::Load(mant_loader::ScopeLoadError::NoResolvedDocuments { .. })
        | ScopeQueryError::Execution(mant_query::ScopeExecutionError::NoReadableDocuments {
            ..
        }) => "none of the requested documents could be resolved".to_owned(),
        other => other.to_string(),
    }
}

pub(super) fn query_error_for_mcp(error: mant_engine::QueryExecutionError) -> String {
    use mant_engine::{QueryError, QueryExecutionError};
    use mant_loader::{LoadError, ManualLoadError};
    use mant_query::ProjectionError;

    fn manual_error_for_mcp(error: &ManualLoadError) -> String {
        match error {
            ManualLoadError::NotFound { name, .. } => format!("manual '{name}' was not found"),
            ManualLoadError::Parse { name, .. } => format!("could not parse manual '{name}'"),
            ManualLoadError::Empty { name, .. } => {
                format!("manual '{name}' contained no readable sections")
            }
        }
    }

    let QueryExecutionError::Query(error) = error else {
        return match error {
            QueryExecutionError::Projection(ProjectionError::UnknownSelector {
                document,
                selector,
            }) => format!(
                "document '{document}' has no outline node '{selector}'; call mant_outline with entries.kind=all for available selectors"
            ),
            other => other.to_string(),
        };
    };
    let QueryError::Load(error) = error else {
        return error.to_string();
    };
    match error {
        LoadError::NativeBackendUnavailable { tldr_topic } => tldr_topic.map_or_else(
            || "native manual loading is unavailable in this build".to_owned(),
            |topic| format!("native manual loading is unavailable in this build; a tldr entry is available for '{topic}'"),
        ),
        LoadError::Markdown { .. } => {
            "could not load or parse the selected Markdown document".to_owned()
        }
        LoadError::EmptyMarkdown { .. } => {
            "the selected Markdown document has no readable content".to_owned()
        }
        LoadError::Registry { .. } => "registered document discovery failed".to_owned(),
        LoadError::Manual(error) => manual_error_for_mcp(&error),
        LoadError::ManualWithTldr { error, topic } => format!(
            "{}; a tldr entry is available for '{topic}'",
            manual_error_for_mcp(&error)
        ),
        LoadError::Tldr { topic, .. } => {
            format!("could not load the tldr entry for '{topic}'")
        }
        other => other.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::{discovery_error_for_mcp, scope_error_for_mcp};

    #[test]
    fn unavailable_native_backend_has_actionable_path_free_guidance() {
        let error =
            mant_engine::QueryError::Load(mant_loader::LoadError::NativeBackendUnavailable {
                tldr_topic: Some("tool".to_owned()),
            });
        assert_eq!(
            super::query_error_for_mcp(mant_engine::QueryExecutionError::Query(error)),
            "native manual loading is unavailable in this build; a tldr entry is available for 'tool'"
        );
    }

    #[test]
    fn scope_loading_errors_redact_host_paths_but_preserve_usage_guidance() {
        use mant_engine::ScopeQueryError;
        use mant_loader::ScopeLoadError;
        use mant_query::ScopeExecutionError;

        assert_eq!(
            scope_error_for_mcp(ScopeQueryError::Load(ScopeLoadError::NoResolvedDocuments {
                reasons: vec!["/home/demo/private/source.md: permission denied".into()],
            })),
            "none of the requested documents could be resolved"
        );
        assert_eq!(
            scope_error_for_mcp(ScopeQueryError::Execution(
                ScopeExecutionError::NoReadableDocuments {
                    reasons: vec!["/home/demo/private/source.md: no readable body".into()],
                },
            )),
            "none of the requested documents could be resolved"
        );
        assert_eq!(
            scope_error_for_mcp(ScopeQueryError::Load(ScopeLoadError::EmptyScope)),
            "at least one document is required"
        );
    }

    #[test]
    fn discovery_errors_never_expose_configuration_paths_or_source_lines() {
        let error = discovery_error_for_mcp(
            "/home/demo/.config/mant/sources.toml:4: invalid table\nrepo = [".to_owned(),
        );
        assert_eq!(error, "registered document discovery failed");
        assert!(!error.contains("/home/demo"));
        assert!(!error.contains("repo ="));
    }
}