Delete src
Browse files- src/indexer.rs +0 -236
- src/main.rs +0 -77
- src/mcp.rs +0 -388
- src/parser.rs +0 -179
src/indexer.rs
DELETED
|
@@ -1,236 +0,0 @@
|
|
| 1 |
-
use crate::parser::ParsedDocument;
|
| 2 |
-
use anyhow::{Context, Result};
|
| 3 |
-
use chrono::Utc;
|
| 4 |
-
use serde::{Deserialize, Serialize};
|
| 5 |
-
use std::fs;
|
| 6 |
-
use std::path::{Path, PathBuf};
|
| 7 |
-
use std::sync::Arc;
|
| 8 |
-
use tantivy::collector::TopDocs;
|
| 9 |
-
use tantivy::query::{BooleanQuery, Occur, Query, QueryParser, TermQuery};
|
| 10 |
-
use tantivy::schema::*;
|
| 11 |
-
use tantivy::snippet::SnippetGenerator;
|
| 12 |
-
use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy, TantivyDocument, Term};
|
| 13 |
-
use tokio::sync::RwLock;
|
| 14 |
-
|
| 15 |
-
#[derive(Clone)]
|
| 16 |
-
pub struct IndexSchemaFields {
|
| 17 |
-
pub id: Field,
|
| 18 |
-
pub collection: Field,
|
| 19 |
-
pub path: Field,
|
| 20 |
-
pub title: Field,
|
| 21 |
-
pub content: Field,
|
| 22 |
-
pub extension: Field,
|
| 23 |
-
pub file_size: Field,
|
| 24 |
-
pub indexed_at: Field,
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 28 |
-
pub struct SearchHit {
|
| 29 |
-
pub score: f32,
|
| 30 |
-
pub collection: String,
|
| 31 |
-
pub path: String,
|
| 32 |
-
pub title: String,
|
| 33 |
-
pub snippet: String,
|
| 34 |
-
pub extension: String,
|
| 35 |
-
pub file_size: u64,
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 39 |
-
pub struct CollectionStats {
|
| 40 |
-
pub collection_name: String,
|
| 41 |
-
pub doc_count: u64,
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 45 |
-
pub struct IndexStats {
|
| 46 |
-
pub total_documents: u64,
|
| 47 |
-
pub num_segments: usize,
|
| 48 |
-
pub index_path: String,
|
| 49 |
-
pub collections: Vec<CollectionStats>,
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
pub struct TantivyEngine {
|
| 53 |
-
index: Index,
|
| 54 |
-
reader: IndexReader,
|
| 55 |
-
writer: Arc<RwLock<IndexWriter>>,
|
| 56 |
-
fields: IndexSchemaFields,
|
| 57 |
-
base_path: PathBuf,
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
impl TantivyEngine {
|
| 61 |
-
pub fn new<P: AsRef<Path>>(base_path: P) -> Result<Self> {
|
| 62 |
-
let path_buf = base_path.as_ref().to_path_buf();
|
| 63 |
-
fs::create_dir_all(&path_buf)?;
|
| 64 |
-
|
| 65 |
-
let mut schema_builder = Schema::builder();
|
| 66 |
-
let id = schema_builder.add_text_field("id", STRING | STORED);
|
| 67 |
-
let collection = schema_builder.add_text_field("collection", STRING | STORED | FAST);
|
| 68 |
-
let path = schema_builder.add_text_field("path", STRING | STORED);
|
| 69 |
-
let title = schema_builder.add_text_field("title", TEXT | STORED);
|
| 70 |
-
let content = schema_builder.add_text_field("content", TEXT | STORED);
|
| 71 |
-
let extension = schema_builder.add_text_field("extension", STRING | STORED);
|
| 72 |
-
let file_size = schema_builder.add_u64_field("file_size", STORED | FAST);
|
| 73 |
-
let indexed_at = schema_builder.add_i64_field("indexed_at", STORED | FAST);
|
| 74 |
-
|
| 75 |
-
let schema = schema_builder.build();
|
| 76 |
-
|
| 77 |
-
let index = Index::open_or_create(
|
| 78 |
-
tantivy::directory::MmapDirectory::open(&path_buf)?,
|
| 79 |
-
schema.clone(),
|
| 80 |
-
)?;
|
| 81 |
-
|
| 82 |
-
// 100MB memory budget for the writer buffer
|
| 83 |
-
let writer = index.writer(100 * 1024 * 1024)?;
|
| 84 |
-
let reader = index
|
| 85 |
-
.reader_builder()
|
| 86 |
-
.reload_policy(ReloadPolicy::OnCommitWithDelay)
|
| 87 |
-
.try_into()?;
|
| 88 |
-
|
| 89 |
-
let fields = IndexSchemaFields {
|
| 90 |
-
id,
|
| 91 |
-
collection,
|
| 92 |
-
path,
|
| 93 |
-
title,
|
| 94 |
-
content,
|
| 95 |
-
extension,
|
| 96 |
-
file_size,
|
| 97 |
-
indexed_at,
|
| 98 |
-
};
|
| 99 |
-
|
| 100 |
-
Ok(Self {
|
| 101 |
-
index,
|
| 102 |
-
reader,
|
| 103 |
-
writer: Arc::new(RwLock::new(writer)),
|
| 104 |
-
fields,
|
| 105 |
-
base_path: path_buf,
|
| 106 |
-
})
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
pub async fn add_documents(&self, collection_name: &str, docs: Vec<ParsedDocument>) -> Result<usize> {
|
| 110 |
-
let count = docs.len();
|
| 111 |
-
let writer = self.writer.write().await;
|
| 112 |
-
|
| 113 |
-
for doc in docs {
|
| 114 |
-
let mut tantivy_doc = TantivyDocument::default();
|
| 115 |
-
let doc_id = uuid::Uuid::new_v4().to_string();
|
| 116 |
-
|
| 117 |
-
tantivy_doc.add_text(self.fields.id, &doc_id);
|
| 118 |
-
tantivy_doc.add_text(self.fields.collection, collection_name);
|
| 119 |
-
tantivy_doc.add_text(self.fields.path, &doc.path);
|
| 120 |
-
tantivy_doc.add_text(self.fields.title, &doc.title);
|
| 121 |
-
tantivy_doc.add_text(self.fields.content, &doc.content);
|
| 122 |
-
tantivy_doc.add_text(self.fields.extension, &doc.extension);
|
| 123 |
-
tantivy_doc.add_u64(self.fields.file_size, doc.size_bytes);
|
| 124 |
-
tantivy_doc.add_i64(self.fields.indexed_at, Utc::now().timestamp());
|
| 125 |
-
|
| 126 |
-
writer.add_document(tantivy_doc)?;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
// Commit and reload index reader
|
| 130 |
-
let mut writer_guard = writer;
|
| 131 |
-
writer_guard.commit()?;
|
| 132 |
-
drop(writer_guard);
|
| 133 |
-
|
| 134 |
-
self.reader.reload()?;
|
| 135 |
-
Ok(count)
|
| 136 |
-
}
|
| 137 |
-
|
| 138 |
-
pub fn search(
|
| 139 |
-
&self,
|
| 140 |
-
query_str: &str,
|
| 141 |
-
collection_filter: Option<&str>,
|
| 142 |
-
limit: usize,
|
| 143 |
-
) -> Result<Vec<SearchHit>> {
|
| 144 |
-
let searcher = self.reader.searcher();
|
| 145 |
-
let query_parser = QueryParser::for_index(
|
| 146 |
-
&self.index,
|
| 147 |
-
vec![self.fields.title, self.fields.content],
|
| 148 |
-
);
|
| 149 |
-
|
| 150 |
-
let parsed_query = query_parser
|
| 151 |
-
.parse_query(query_str)
|
| 152 |
-
.context("Query parse error")?;
|
| 153 |
-
|
| 154 |
-
let final_query: Box<dyn Query> = if let Some(coll) = collection_filter {
|
| 155 |
-
let coll_term = Term::from_field_text(self.fields.collection, coll);
|
| 156 |
-
let coll_query = TermQuery::new(coll_term, IndexRecordOption::Basic);
|
| 157 |
-
|
| 158 |
-
Box::new(BooleanQuery::new(vec![
|
| 159 |
-
(Occur::Must, parsed_query),
|
| 160 |
-
(Occur::Must, Box::new(coll_query)),
|
| 161 |
-
]))
|
| 162 |
-
} else {
|
| 163 |
-
parsed_query
|
| 164 |
-
};
|
| 165 |
-
|
| 166 |
-
let top_docs = searcher.search(&final_query, &TopDocs::with_limit(limit))?;
|
| 167 |
-
let mut snippet_generator =
|
| 168 |
-
SnippetGenerator::create(&searcher, &*final_query, self.fields.content)?;
|
| 169 |
-
snippet_generator.set_max_num_chars(250);
|
| 170 |
-
|
| 171 |
-
let mut hits = Vec::with_capacity(top_docs.len());
|
| 172 |
-
|
| 173 |
-
for (score, doc_address) in top_docs {
|
| 174 |
-
let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?;
|
| 175 |
-
|
| 176 |
-
let coll_val = retrieved_doc
|
| 177 |
-
.get_first(self.fields.collection)
|
| 178 |
-
.and_then(|v| v.as_str())
|
| 179 |
-
.unwrap_or("default")
|
| 180 |
-
.to_string();
|
| 181 |
-
|
| 182 |
-
let path_val = retrieved_doc
|
| 183 |
-
.get_first(self.fields.path)
|
| 184 |
-
.and_then(|v| v.as_str())
|
| 185 |
-
.unwrap_or("")
|
| 186 |
-
.to_string();
|
| 187 |
-
|
| 188 |
-
let title_val = retrieved_doc
|
| 189 |
-
.get_first(self.fields.title)
|
| 190 |
-
.and_then(|v| v.as_str())
|
| 191 |
-
.unwrap_or("")
|
| 192 |
-
.to_string();
|
| 193 |
-
|
| 194 |
-
let ext_val = retrieved_doc
|
| 195 |
-
.get_first(self.fields.extension)
|
| 196 |
-
.and_then(|v| v.as_str())
|
| 197 |
-
.unwrap_or("")
|
| 198 |
-
.to_string();
|
| 199 |
-
|
| 200 |
-
let size_val = retrieved_doc
|
| 201 |
-
.get_first(self.fields.file_size)
|
| 202 |
-
.and_then(|v| v.as_u64())
|
| 203 |
-
.unwrap_or(0);
|
| 204 |
-
|
| 205 |
-
let snippet = snippet_generator.snippet_from_doc(&retrieved_doc).to_html();
|
| 206 |
-
|
| 207 |
-
hits.push(SearchHit {
|
| 208 |
-
score,
|
| 209 |
-
collection: coll_val,
|
| 210 |
-
path: path_val,
|
| 211 |
-
title: title_val,
|
| 212 |
-
snippet,
|
| 213 |
-
extension: ext_val,
|
| 214 |
-
file_size: size_val,
|
| 215 |
-
});
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
Ok(hits)
|
| 219 |
-
}
|
| 220 |
-
|
| 221 |
-
pub fn get_stats(&self) -> Result<IndexStats> {
|
| 222 |
-
let searcher = self.reader.searcher();
|
| 223 |
-
let total_docs = searcher.num_docs();
|
| 224 |
-
let num_segments = searcher.segment_readers().len();
|
| 225 |
-
|
| 226 |
-
Ok(IndexStats {
|
| 227 |
-
total_documents: total_docs,
|
| 228 |
-
num_segments,
|
| 229 |
-
index_path: self.base_path.to_string_lossy().to_string(),
|
| 230 |
-
collections: vec![CollectionStats {
|
| 231 |
-
collection_name: "global".to_string(),
|
| 232 |
-
doc_count: total_docs,
|
| 233 |
-
}],
|
| 234 |
-
})
|
| 235 |
-
}
|
| 236 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/main.rs
DELETED
|
@@ -1,77 +0,0 @@
|
|
| 1 |
-
mod indexer;
|
| 2 |
-
mod mcp;
|
| 3 |
-
mod parser;
|
| 4 |
-
|
| 5 |
-
use clap::Parser;
|
| 6 |
-
use indexer::TantivyEngine;
|
| 7 |
-
use mcp::McpServer;
|
| 8 |
-
use std::path::PathBuf;
|
| 9 |
-
use std::sync::Arc;
|
| 10 |
-
use tracing::info;
|
| 11 |
-
|
| 12 |
-
#[derive(Parser, Debug)]
|
| 13 |
-
#[command(name = "rust-mcp-search")]
|
| 14 |
-
#[command(about = "Ultra-high-performance Tantivy-backed Model Context Protocol server", long_about = None)]
|
| 15 |
-
struct Args {
|
| 16 |
-
/// Transport mode: 'stdio' or 'http'
|
| 17 |
-
#[arg(short, long, env = "MCP_TRANSPORT", default_value = "stdio")]
|
| 18 |
-
transport: String,
|
| 19 |
-
|
| 20 |
-
/// HTTP host binding
|
| 21 |
-
#[arg(long, env = "HOST", default_value = "0.0.0.0")]
|
| 22 |
-
host: String,
|
| 23 |
-
|
| 24 |
-
/// HTTP port binding (defaults to HF Spaces standard 7860)
|
| 25 |
-
#[arg(short, long, env = "PORT", default_value = "7860")]
|
| 26 |
-
port: u16,
|
| 27 |
-
|
| 28 |
-
/// Tantivy index directory path
|
| 29 |
-
#[arg(short, long, env = "DATA_DIR", default_value = "./data/index")]
|
| 30 |
-
index_dir: PathBuf,
|
| 31 |
-
|
| 32 |
-
/// Rayon thread pool concurrency limit (0 = auto-detect hardware concurrency)
|
| 33 |
-
#[arg(long, env = "RAYON_NUM_THREADS", default_value = "0")]
|
| 34 |
-
threads: usize,
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
#[tokio::main]
|
| 38 |
-
async fn main() -> anyhow::Result<()> {
|
| 39 |
-
let args = Args::parse();
|
| 40 |
-
|
| 41 |
-
// Direct all logs strictly to stderr to prevent breaking the MCP STDIO JSON protocol
|
| 42 |
-
tracing_subscriber::fmt()
|
| 43 |
-
.with_writer(std::io::stderr)
|
| 44 |
-
.with_env_filter(
|
| 45 |
-
tracing_subscriber::EnvFilter::from_default_env()
|
| 46 |
-
.add_directive(tracing::Level::INFO.into()),
|
| 47 |
-
)
|
| 48 |
-
.init();
|
| 49 |
-
|
| 50 |
-
if args.threads > 0 {
|
| 51 |
-
rayon::ThreadPoolBuilder::new()
|
| 52 |
-
.num_threads(args.threads)
|
| 53 |
-
.build_global()?;
|
| 54 |
-
info!("Configured Rayon thread pool with {} threads", args.threads);
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
info!("Initializing Tantivy index engine at: {:?}", args.index_dir);
|
| 58 |
-
let engine = Arc::new(TantivyEngine::new(&args.index_dir)?);
|
| 59 |
-
let server = Arc::new(McpServer::new(engine));
|
| 60 |
-
|
| 61 |
-
match args.transport.to_lowercase().as_str() {
|
| 62 |
-
"http" | "sse" => {
|
| 63 |
-
info!("Starting MCP HTTP/SSE transport...");
|
| 64 |
-
server.run_http(&args.host, args.port).await?;
|
| 65 |
-
}
|
| 66 |
-
"stdio" => {
|
| 67 |
-
info!("Starting MCP STDIO transport...");
|
| 68 |
-
server.run_stdio().await?;
|
| 69 |
-
}
|
| 70 |
-
other => {
|
| 71 |
-
eprintln!("Unknown transport '{}'. Expected 'stdio' or 'http'.", other);
|
| 72 |
-
std::process::exit(1);
|
| 73 |
-
}
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
Ok(())
|
| 77 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/mcp.rs
DELETED
|
@@ -1,388 +0,0 @@
|
|
| 1 |
-
use crate::indexer::TantivyEngine;
|
| 2 |
-
use crate::parser::{DocumentParser, ParsedDocument};
|
| 3 |
-
use anyhow::Result;
|
| 4 |
-
use axum::{
|
| 5 |
-
extract::State,
|
| 6 |
-
http::{header, HeaderMap, HeaderValue},
|
| 7 |
-
response::sse::{Event, KeepAlive, Sse},
|
| 8 |
-
routing::{get, post},
|
| 9 |
-
Json, Router,
|
| 10 |
-
};
|
| 11 |
-
use futures::stream::Stream;
|
| 12 |
-
use rayon::prelude::*;
|
| 13 |
-
use serde::{Deserialize, Serialize};
|
| 14 |
-
use serde_json::{json, Value};
|
| 15 |
-
use std::convert::Infallible;
|
| 16 |
-
use std::path::Path;
|
| 17 |
-
use std::sync::Arc;
|
| 18 |
-
use std::time::Duration;
|
| 19 |
-
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
| 20 |
-
use tokio_stream::StreamExt;
|
| 21 |
-
use tracing::info;
|
| 22 |
-
use walkdir::WalkDir;
|
| 23 |
-
|
| 24 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 25 |
-
pub struct JsonRpcRequest {
|
| 26 |
-
pub jsonrpc: String,
|
| 27 |
-
pub id: Option<Value>,
|
| 28 |
-
pub method: String,
|
| 29 |
-
pub params: Option<Value>,
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 33 |
-
pub struct JsonRpcResponse {
|
| 34 |
-
pub jsonrpc: String,
|
| 35 |
-
#[serde(skip_serializing_if = "Option::is_none")]
|
| 36 |
-
pub id: Option<Value>,
|
| 37 |
-
#[serde(skip_serializing_if = "Option::is_none")]
|
| 38 |
-
pub result: Option<Value>,
|
| 39 |
-
#[serde(skip_serializing_if = "Option::is_none")]
|
| 40 |
-
pub error: Option<JsonRpcError>,
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
#[derive(Serialize, Deserialize, Debug, Clone)]
|
| 44 |
-
pub struct JsonRpcError {
|
| 45 |
-
pub code: i64,
|
| 46 |
-
pub message: String,
|
| 47 |
-
#[serde(skip_serializing_if = "Option::is_none")]
|
| 48 |
-
pub data: Option<Value>,
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
pub struct McpServer {
|
| 52 |
-
engine: Arc<TantivyEngine>,
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
impl McpServer {
|
| 56 |
-
pub fn new(engine: Arc<TantivyEngine>) -> Self {
|
| 57 |
-
Self { engine }
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
pub async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
|
| 61 |
-
let req_id = req.id.clone();
|
| 62 |
-
|
| 63 |
-
match req.method.as_str() {
|
| 64 |
-
"initialize" => {
|
| 65 |
-
let init_result = json!({
|
| 66 |
-
"protocolVersion": "2024-11-05",
|
| 67 |
-
"capabilities": {
|
| 68 |
-
"tools": {
|
| 69 |
-
"listChanged": true
|
| 70 |
-
}
|
| 71 |
-
},
|
| 72 |
-
"serverInfo": {
|
| 73 |
-
"name": "rust-mcp-search",
|
| 74 |
-
"version": "0.1.0"
|
| 75 |
-
}
|
| 76 |
-
});
|
| 77 |
-
Self::success_response(req_id, init_result)
|
| 78 |
-
}
|
| 79 |
-
"notifications/initialized" | "ping" => {
|
| 80 |
-
Self::success_response(req_id, json!({}))
|
| 81 |
-
}
|
| 82 |
-
"tools/list" => {
|
| 83 |
-
let tools = json!({
|
| 84 |
-
"tools": [
|
| 85 |
-
{
|
| 86 |
-
"name": "parse_and_index",
|
| 87 |
-
"description": "Recursively scans a directory and indexes plain text, Markdown, JSON, PDF, CSV, and DOCX files in parallel using Rayon and Tantivy.",
|
| 88 |
-
"inputSchema": {
|
| 89 |
-
"type": "object",
|
| 90 |
-
"properties": {
|
| 91 |
-
"directory_path": {
|
| 92 |
-
"type": "string",
|
| 93 |
-
"description": "Absolute or relative directory path to index"
|
| 94 |
-
},
|
| 95 |
-
"collection_name": {
|
| 96 |
-
"type": "string",
|
| 97 |
-
"description": "Collection namespace identifier (default: 'default')"
|
| 98 |
-
}
|
| 99 |
-
},
|
| 100 |
-
"required": ["directory_path"]
|
| 101 |
-
}
|
| 102 |
-
},
|
| 103 |
-
{
|
| 104 |
-
"name": "search_documents",
|
| 105 |
-
"description": "Full-text BM25 search over indexed documents with highlighted snippets and relevance scores.",
|
| 106 |
-
"inputSchema": {
|
| 107 |
-
"type": "object",
|
| 108 |
-
"properties": {
|
| 109 |
-
"query": {
|
| 110 |
-
"type": "string",
|
| 111 |
-
"description": "Tantivy BM25 search query"
|
| 112 |
-
},
|
| 113 |
-
"collection_name": {
|
| 114 |
-
"type": "string",
|
| 115 |
-
"description": "Filter results by collection"
|
| 116 |
-
},
|
| 117 |
-
"limit": {
|
| 118 |
-
"type": "integer",
|
| 119 |
-
"description": "Max hits to return (default: 10)"
|
| 120 |
-
}
|
| 121 |
-
},
|
| 122 |
-
"required": ["query"]
|
| 123 |
-
}
|
| 124 |
-
},
|
| 125 |
-
{
|
| 126 |
-
"name": "extract_document_text",
|
| 127 |
-
"description": "High-speed raw text extraction from a single file (TXT, MD, PDF, DOCX, CSV, JSON).",
|
| 128 |
-
"inputSchema": {
|
| 129 |
-
"type": "object",
|
| 130 |
-
"properties": {
|
| 131 |
-
"file_path": {
|
| 132 |
-
"type": "string",
|
| 133 |
-
"description": "Path to document"
|
| 134 |
-
}
|
| 135 |
-
},
|
| 136 |
-
"required": ["file_path"]
|
| 137 |
-
}
|
| 138 |
-
},
|
| 139 |
-
{
|
| 140 |
-
"name": "get_index_stats",
|
| 141 |
-
"description": "Returns indexed document counts, segment counts, active collections, and memory/storage status.",
|
| 142 |
-
"inputSchema": {
|
| 143 |
-
"type": "object",
|
| 144 |
-
"properties": {}
|
| 145 |
-
}
|
| 146 |
-
}
|
| 147 |
-
]
|
| 148 |
-
});
|
| 149 |
-
Self::success_response(req_id, tools)
|
| 150 |
-
}
|
| 151 |
-
"tools/call" => {
|
| 152 |
-
let params = req.params.unwrap_or_default();
|
| 153 |
-
let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
| 154 |
-
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
| 155 |
-
|
| 156 |
-
match self.dispatch_tool(tool_name, args).await {
|
| 157 |
-
Ok(tool_output) => Self::success_response(
|
| 158 |
-
req_id,
|
| 159 |
-
json!({
|
| 160 |
-
"content": [
|
| 161 |
-
{
|
| 162 |
-
"type": "text",
|
| 163 |
-
"text": tool_output
|
| 164 |
-
}
|
| 165 |
-
],
|
| 166 |
-
"isError": false
|
| 167 |
-
}),
|
| 168 |
-
),
|
| 169 |
-
Err(e) => Self::success_response(
|
| 170 |
-
req_id,
|
| 171 |
-
json!({
|
| 172 |
-
"content": [
|
| 173 |
-
{
|
| 174 |
-
"type": "text",
|
| 175 |
-
"text": format!("Error executing tool '{}': {}", tool_name, e)
|
| 176 |
-
}
|
| 177 |
-
],
|
| 178 |
-
"isError": true
|
| 179 |
-
}),
|
| 180 |
-
),
|
| 181 |
-
}
|
| 182 |
-
}
|
| 183 |
-
_ => Self::error_response(req_id, -32601, format!("Method '{}' not found", req.method)),
|
| 184 |
-
}
|
| 185 |
-
}
|
| 186 |
-
|
| 187 |
-
async fn dispatch_tool(&self, name: &str, args: Value) -> Result<String> {
|
| 188 |
-
match name {
|
| 189 |
-
"parse_and_index" => {
|
| 190 |
-
let dir = args
|
| 191 |
-
.get("directory_path")
|
| 192 |
-
.and_then(|v| v.as_str())
|
| 193 |
-
.ok_or_else(|| anyhow::anyhow!("Missing 'directory_path' argument"))?;
|
| 194 |
-
let collection = args
|
| 195 |
-
.get("collection_name")
|
| 196 |
-
.and_then(|v| v.as_str())
|
| 197 |
-
.unwrap_or("default");
|
| 198 |
-
|
| 199 |
-
let dir_path = Path::new(dir).to_path_buf();
|
| 200 |
-
if !dir_path.exists() {
|
| 201 |
-
return Err(anyhow::anyhow!("Directory does not exist: {:?}", dir_path));
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
let paths: Vec<_> = WalkDir::new(&dir_path)
|
| 205 |
-
.into_iter()
|
| 206 |
-
.filter_map(|e| e.ok())
|
| 207 |
-
.filter(|e| e.file_type().is_file())
|
| 208 |
-
.map(|e| e.into_path())
|
| 209 |
-
.collect();
|
| 210 |
-
|
| 211 |
-
let total_found = paths.len();
|
| 212 |
-
|
| 213 |
-
let parsed_docs: Vec<ParsedDocument> = paths
|
| 214 |
-
.par_iter()
|
| 215 |
-
.filter_map(|path| match DocumentParser::parse_file(path) {
|
| 216 |
-
Ok(doc) => Some(doc),
|
| 217 |
-
Err(err) => {
|
| 218 |
-
tracing::warn!("Skipping {:?}: {}", path, err);
|
| 219 |
-
None
|
| 220 |
-
}
|
| 221 |
-
})
|
| 222 |
-
.collect();
|
| 223 |
-
|
| 224 |
-
let parsed_count = parsed_docs.len();
|
| 225 |
-
let indexed_count = self.engine.add_documents(collection, parsed_docs).await?;
|
| 226 |
-
|
| 227 |
-
Ok(json!({
|
| 228 |
-
"status": "success",
|
| 229 |
-
"collection": collection,
|
| 230 |
-
"scanned_files": total_found,
|
| 231 |
-
"parsed_documents": parsed_count,
|
| 232 |
-
"indexed_documents": indexed_count,
|
| 233 |
-
"source_path": dir
|
| 234 |
-
})
|
| 235 |
-
.to_string())
|
| 236 |
-
}
|
| 237 |
-
"search_documents" => {
|
| 238 |
-
let query = args
|
| 239 |
-
.get("query")
|
| 240 |
-
.and_then(|v| v.as_str())
|
| 241 |
-
.ok_or_else(|| anyhow::anyhow!("Missing 'query' argument"))?;
|
| 242 |
-
let collection = args.get("collection_name").and_then(|v| v.as_str());
|
| 243 |
-
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
| 244 |
-
|
| 245 |
-
let hits = self.engine.search(query, collection, limit)?;
|
| 246 |
-
Ok(serde_json::to_string_pretty(&hits)?)
|
| 247 |
-
}
|
| 248 |
-
"extract_document_text" => {
|
| 249 |
-
let path_str = args
|
| 250 |
-
.get("file_path")
|
| 251 |
-
.and_then(|v| v.as_str())
|
| 252 |
-
.ok_or_else(|| anyhow::anyhow!("Missing 'file_path' argument"))?;
|
| 253 |
-
|
| 254 |
-
let doc = DocumentParser::parse_file(path_str)?;
|
| 255 |
-
Ok(json!({
|
| 256 |
-
"path": doc.path,
|
| 257 |
-
"title": doc.title,
|
| 258 |
-
"extension": doc.extension,
|
| 259 |
-
"size_bytes": doc.size_bytes,
|
| 260 |
-
"content": doc.content
|
| 261 |
-
})
|
| 262 |
-
.to_string())
|
| 263 |
-
}
|
| 264 |
-
"get_index_stats" => {
|
| 265 |
-
let stats = self.engine.get_stats()?;
|
| 266 |
-
Ok(serde_json::to_string_pretty(&stats)?)
|
| 267 |
-
}
|
| 268 |
-
unknown => Err(anyhow::anyhow!("Unknown tool name: {}", unknown)),
|
| 269 |
-
}
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
fn success_response(id: Option<Value>, result: Value) -> JsonRpcResponse {
|
| 273 |
-
JsonRpcResponse {
|
| 274 |
-
jsonrpc: "2.0".to_string(),
|
| 275 |
-
id,
|
| 276 |
-
result: Some(result),
|
| 277 |
-
error: None,
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
fn error_response(id: Option<Value>, code: i64, message: String) -> JsonRpcResponse {
|
| 282 |
-
JsonRpcResponse {
|
| 283 |
-
jsonrpc: "2.0".to_string(),
|
| 284 |
-
id,
|
| 285 |
-
result: None,
|
| 286 |
-
error: Some(JsonRpcError {
|
| 287 |
-
code,
|
| 288 |
-
message,
|
| 289 |
-
data: None,
|
| 290 |
-
}),
|
| 291 |
-
}
|
| 292 |
-
}
|
| 293 |
-
|
| 294 |
-
pub async fn run_stdio(self: Arc<Self>) -> Result<()> {
|
| 295 |
-
let stdin = tokio::io::stdin();
|
| 296 |
-
let mut reader = BufReader::new(stdin).lines();
|
| 297 |
-
let mut stdout = tokio::io::stdout();
|
| 298 |
-
|
| 299 |
-
while let Some(line) = reader.next_line().await? {
|
| 300 |
-
if line.trim().is_empty() {
|
| 301 |
-
continue;
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
match serde_json::from_str::<JsonRpcRequest>(&line) {
|
| 305 |
-
Ok(request) => {
|
| 306 |
-
let response = self.handle_request(request).await;
|
| 307 |
-
let out_json = serde_json::to_string(&response)?;
|
| 308 |
-
stdout.write_all(out_json.as_bytes()).await?;
|
| 309 |
-
stdout.write_all(b"\n").await?;
|
| 310 |
-
stdout.flush().await?;
|
| 311 |
-
}
|
| 312 |
-
Err(e) => {
|
| 313 |
-
let err_resp = Self::error_response(None, -32700, format!("Parse error: {}", e));
|
| 314 |
-
let out_json = serde_json::to_string(&err_resp)?;
|
| 315 |
-
stdout.write_all(out_json.as_bytes()).await?;
|
| 316 |
-
stdout.write_all(b"\n").await?;
|
| 317 |
-
stdout.flush().await?;
|
| 318 |
-
}
|
| 319 |
-
}
|
| 320 |
-
}
|
| 321 |
-
Ok(())
|
| 322 |
-
}
|
| 323 |
-
|
| 324 |
-
pub async fn run_http(self: Arc<Self>, host: &str, port: u16) -> Result<()> {
|
| 325 |
-
let app = Router::new()
|
| 326 |
-
.route("/rpc", post(handle_http_rpc))
|
| 327 |
-
.route("/sse", get(handle_sse))
|
| 328 |
-
.route("/health", get(|| async { "healthy" }))
|
| 329 |
-
.layer(tower_http::cors::CorsLayer::permissive())
|
| 330 |
-
.with_state(self);
|
| 331 |
-
|
| 332 |
-
let addr = format!("{}:{}", host, port);
|
| 333 |
-
info!("MCP HTTP/SSE server listening on http://{}", addr);
|
| 334 |
-
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
| 335 |
-
axum::serve(listener, app).await?;
|
| 336 |
-
Ok(())
|
| 337 |
-
}
|
| 338 |
-
}
|
| 339 |
-
|
| 340 |
-
async fn handle_http_rpc(
|
| 341 |
-
State(server): State<Arc<McpServer>>,
|
| 342 |
-
Json(payload): Json<JsonRpcRequest>,
|
| 343 |
-
) -> Json<JsonRpcResponse> {
|
| 344 |
-
let resp = server.handle_request(payload).await;
|
| 345 |
-
Json(resp)
|
| 346 |
-
}
|
| 347 |
-
|
| 348 |
-
async fn handle_sse(
|
| 349 |
-
State(_server): State<Arc<McpServer>>,
|
| 350 |
-
) -> (HeaderMap, Sse<impl Stream<Item = Result<Event, Infallible>>>) {
|
| 351 |
-
let mut headers = HeaderMap::new();
|
| 352 |
-
headers.insert("x-accel-buffering", HeaderValue::from_static("no"));
|
| 353 |
-
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache, no-transform"));
|
| 354 |
-
headers.insert(header::CONNECTION, HeaderValue::from_static("keep-alive"));
|
| 355 |
-
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
| 356 |
-
|
| 357 |
-
// Emit initial endpoint AND tool availability notification immediately
|
| 358 |
-
let initial_events = vec![
|
| 359 |
-
Ok(Event::default().event("endpoint").data("/rpc")),
|
| 360 |
-
Ok(Event::default().event("message").data(
|
| 361 |
-
json!({
|
| 362 |
-
"jsonrpc": "2.0",
|
| 363 |
-
"method": "notifications/tools/list_changed",
|
| 364 |
-
"params": {}
|
| 365 |
-
})
|
| 366 |
-
.to_string(),
|
| 367 |
-
)),
|
| 368 |
-
];
|
| 369 |
-
|
| 370 |
-
let initial = tokio_stream::iter(initial_events);
|
| 371 |
-
|
| 372 |
-
// 5-second heartbeats keep connection alive on Hugging Face Spaces proxy
|
| 373 |
-
let interval = tokio_stream::wrappers::IntervalStream::new(tokio::time::interval(
|
| 374 |
-
Duration::from_secs(5),
|
| 375 |
-
))
|
| 376 |
-
.map(|_| Ok(Event::default().comment("keep-alive")));
|
| 377 |
-
|
| 378 |
-
let stream = initial.chain(interval);
|
| 379 |
-
|
| 380 |
-
(
|
| 381 |
-
headers,
|
| 382 |
-
Sse::new(stream).keep_alive(
|
| 383 |
-
KeepAlive::new()
|
| 384 |
-
.interval(Duration::from_secs(5))
|
| 385 |
-
.text("keep-alive"),
|
| 386 |
-
),
|
| 387 |
-
)
|
| 388 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/parser.rs
DELETED
|
@@ -1,179 +0,0 @@
|
|
| 1 |
-
use anyhow::{anyhow, Context, Result};
|
| 2 |
-
use memmap2::Mmap;
|
| 3 |
-
use quick_xml::events::Event;
|
| 4 |
-
use quick_xml::reader::Reader;
|
| 5 |
-
use std::fs::File;
|
| 6 |
-
use std::io::{Cursor, Read};
|
| 7 |
-
use std::path::Path;
|
| 8 |
-
use thiserror::Error;
|
| 9 |
-
|
| 10 |
-
#[derive(Error, Debug)]
|
| 11 |
-
pub enum ParserError {
|
| 12 |
-
#[error("I/O error: {0}")]
|
| 13 |
-
Io(#[from] std::io::Error),
|
| 14 |
-
#[error("Unsupported file format: {0}")]
|
| 15 |
-
UnsupportedFormat(String),
|
| 16 |
-
#[error("Corrupt document: {0}")]
|
| 17 |
-
CorruptDocument(String),
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
#[derive(Debug, Clone)]
|
| 21 |
-
pub struct ParsedDocument {
|
| 22 |
-
pub path: String,
|
| 23 |
-
pub title: String,
|
| 24 |
-
pub content: String,
|
| 25 |
-
pub extension: String,
|
| 26 |
-
pub size_bytes: u64,
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
pub struct DocumentParser;
|
| 30 |
-
|
| 31 |
-
impl DocumentParser {
|
| 32 |
-
pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<ParsedDocument> {
|
| 33 |
-
let path_ref = path.as_ref();
|
| 34 |
-
let metadata = std::fs::metadata(path_ref)
|
| 35 |
-
.with_context(|| format!("Failed to read metadata for {:?}", path_ref))?;
|
| 36 |
-
let size_bytes = metadata.len();
|
| 37 |
-
|
| 38 |
-
if size_bytes == 0 {
|
| 39 |
-
return Ok(ParsedDocument {
|
| 40 |
-
path: path_ref.to_string_lossy().to_string(),
|
| 41 |
-
title: Self::extract_title(path_ref),
|
| 42 |
-
content: String::new(),
|
| 43 |
-
extension: Self::get_extension(path_ref),
|
| 44 |
-
size_bytes: 0,
|
| 45 |
-
});
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
let extension = Self::get_extension(path_ref);
|
| 49 |
-
let title = Self::extract_title(path_ref);
|
| 50 |
-
|
| 51 |
-
let file = File::open(path_ref)?;
|
| 52 |
-
|
| 53 |
-
let content = if size_bytes >= 16 * 1024 {
|
| 54 |
-
let mmap = unsafe { Mmap::map(&file)? };
|
| 55 |
-
Self::extract_content_from_bytes(&mmap, &extension)?
|
| 56 |
-
} else {
|
| 57 |
-
let mut buffer = Vec::with_capacity(size_bytes as usize);
|
| 58 |
-
let mut reader = std::io::BufReader::new(file);
|
| 59 |
-
reader.read_to_end(&mut buffer)?;
|
| 60 |
-
Self::extract_content_from_bytes(&buffer, &extension)?
|
| 61 |
-
};
|
| 62 |
-
|
| 63 |
-
Ok(ParsedDocument {
|
| 64 |
-
path: path_ref.to_string_lossy().to_string(),
|
| 65 |
-
title,
|
| 66 |
-
content,
|
| 67 |
-
extension,
|
| 68 |
-
size_bytes,
|
| 69 |
-
})
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
fn extract_content_from_bytes(bytes: &[u8], extension: &str) -> Result<String> {
|
| 73 |
-
match extension.to_lowercase().as_str() {
|
| 74 |
-
"txt" | "md" | "markdown" | "log" | "rs" | "py" | "js" | "ts" | "toml" | "yaml" | "yml" => {
|
| 75 |
-
match std::str::from_utf8(bytes) {
|
| 76 |
-
Ok(valid_str) => Ok(valid_str.to_string()),
|
| 77 |
-
Err(_) => Ok(String::from_utf8_lossy(bytes).into_owned()),
|
| 78 |
-
}
|
| 79 |
-
}
|
| 80 |
-
"json" => {
|
| 81 |
-
let val: serde_json::Value = serde_json::from_slice(bytes)
|
| 82 |
-
.context("Invalid JSON document")?;
|
| 83 |
-
if let Some(text) = val.as_str() {
|
| 84 |
-
Ok(text.to_string())
|
| 85 |
-
} else {
|
| 86 |
-
Ok(serde_json::to_string_pretty(&val)?)
|
| 87 |
-
}
|
| 88 |
-
}
|
| 89 |
-
"csv" => Self::parse_csv(bytes),
|
| 90 |
-
"docx" => Self::parse_docx(bytes),
|
| 91 |
-
"pdf" => Self::parse_pdf(bytes),
|
| 92 |
-
ext => Err(ParserError::UnsupportedFormat(ext.to_string()).into()),
|
| 93 |
-
}
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
fn parse_csv(bytes: &[u8]) -> Result<String> {
|
| 97 |
-
let mut rdr = csv::ReaderBuilder::new()
|
| 98 |
-
.flexible(true)
|
| 99 |
-
.has_headers(true)
|
| 100 |
-
.from_reader(Cursor::new(bytes));
|
| 101 |
-
|
| 102 |
-
let mut output = String::with_capacity(bytes.len());
|
| 103 |
-
|
| 104 |
-
if let Ok(headers) = rdr.headers() {
|
| 105 |
-
output.push_str(&headers.iter().collect::<Vec<_>>().join(" | "));
|
| 106 |
-
output.push('\n');
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
for result in rdr.records() {
|
| 110 |
-
let record = result?;
|
| 111 |
-
output.push_str(&record.iter().collect::<Vec<_>>().join(" | "));
|
| 112 |
-
output.push('\n');
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
Ok(output)
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
fn parse_docx(bytes: &[u8]) -> Result<String> {
|
| 119 |
-
let cursor = Cursor::new(bytes);
|
| 120 |
-
let mut archive = zip::ZipArchive::new(cursor)
|
| 121 |
-
.context("Failed to open DOCX as ZIP archive")?;
|
| 122 |
-
|
| 123 |
-
let mut document_xml = archive
|
| 124 |
-
.by_name("word/document.xml")
|
| 125 |
-
.context("Missing word/document.xml inside DOCX archive")?;
|
| 126 |
-
|
| 127 |
-
let mut xml_bytes = Vec::new();
|
| 128 |
-
document_xml.read_to_end(&mut xml_bytes)?;
|
| 129 |
-
|
| 130 |
-
let mut reader = Reader::from_reader(Cursor::new(xml_bytes));
|
| 131 |
-
reader.config_mut().trim_text(true);
|
| 132 |
-
|
| 133 |
-
let mut txt = String::new();
|
| 134 |
-
let mut buf = Vec::new();
|
| 135 |
-
let mut in_text_node = false;
|
| 136 |
-
|
| 137 |
-
loop {
|
| 138 |
-
match reader.read_event_into(&mut buf) {
|
| 139 |
-
Ok(Event::Start(ref e)) if e.name().as_ref() == b"w:t" => {
|
| 140 |
-
in_text_node = true;
|
| 141 |
-
}
|
| 142 |
-
Ok(Event::End(ref e)) if e.name().as_ref() == b"w:t" => {
|
| 143 |
-
in_text_node = false;
|
| 144 |
-
}
|
| 145 |
-
Ok(Event::End(ref e)) if e.name().as_ref() == b"w:p" => {
|
| 146 |
-
txt.push('\n');
|
| 147 |
-
}
|
| 148 |
-
Ok(Event::Text(e)) if in_text_node => {
|
| 149 |
-
if let Ok(s) = e.unescape() {
|
| 150 |
-
txt.push_str(&s);
|
| 151 |
-
}
|
| 152 |
-
}
|
| 153 |
-
Ok(Event::Eof) => break,
|
| 154 |
-
Err(e) => return Err(anyhow!("XML Parsing error in DOCX: {}", e)),
|
| 155 |
-
_ => (),
|
| 156 |
-
}
|
| 157 |
-
buf.clear();
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
Ok(txt)
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
fn parse_pdf(bytes: &[u8]) -> Result<String> {
|
| 164 |
-
pdf_extract::extract_text_from_mem(bytes)
|
| 165 |
-
.context("Failed to extract text stream from PDF")
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
fn extract_title(path: &Path) -> String {
|
| 169 |
-
path.file_name()
|
| 170 |
-
.map(|f| f.to_string_lossy().to_string())
|
| 171 |
-
.unwrap_or_else(|| "Untitled".to_string())
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
fn get_extension(path: &Path) -> String {
|
| 175 |
-
path.extension()
|
| 176 |
-
.map(|e| e.to_string_lossy().to_string())
|
| 177 |
-
.unwrap_or_else(|| "txt".to_string())
|
| 178 |
-
}
|
| 179 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|