use std::collections::HashSet;
use std::fs;
use crate::archive::{list_archives, ArchiveLayout};
use crate::config::Config;
use crate::errors::{Result, SiteforgeError};
use crate::storage::{SearchResult, Storage};
const READABLE_SEARCH_PAGE_BATCH: usize = 500;
pub fn search_archives(
config: &Config,
archive_id: Option<&str>,
query: &str,
limit: usize,
) -> Result<Vec<SearchResult>> {
let root = config.resolved_archive_root()?;
let archive_ids = match archive_id {
Some(id) => vec![id.to_string()],
None => list_archives(config)?
.into_iter()
.map(|manifest| manifest.archive_id)
.collect(),
};
let mut results = Vec::new();
for id in archive_ids {
let layout = ArchiveLayout::new(root.clone(), id.clone());
if !layout.database().exists() {
if archive_id.is_some() {
return Err(SiteforgeError::ArchiveNotFound(id));
}
continue;
}
let storage = Storage::open(&layout.database())?;
let mut seen_pages = HashSet::new();
for mut result in storage.search(query, limit)? {
if let Some(path) = &result.local_file_path {
result.local_file_path = Some(layout.base().join(path).display().to_string());
}
seen_pages.insert(result.page_id.clone());
results.push(result);
}
if results.len() < limit {
results.extend(search_readable_files(
&id,
&layout,
&storage,
query,
limit - results.len(),
&seen_pages,
)?);
}
}
results.truncate(limit);
Ok(results)
}
fn search_readable_files(
archive_id: &str,
layout: &ArchiveLayout,
storage: &Storage,
query: &str,
limit: usize,
seen_pages: &HashSet<String>,
) -> Result<Vec<SearchResult>> {
let query_lower = query.to_ascii_lowercase();
let mut results = Vec::new();
let mut offset = 0usize;
while results.len() < limit {
let pages = storage.list_pages_page(READABLE_SEARCH_PAGE_BATCH, offset)?;
if pages.is_empty() {
break;
}
offset = offset.saturating_add(pages.len());
for page in pages {
if seen_pages.contains(&page.page_id) {
continue;
}
let readable_path = page
.text_path
.as_deref()
.or(page.markdown_path.as_deref())
.map(|path| layout.base().join(path));
let Some(path) = readable_path else {
continue;
};
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
if !text.to_ascii_lowercase().contains(&query_lower) {
continue;
}
results.push(SearchResult {
archive_id: archive_id.to_string(),
page_id: page.page_id,
source_url: page.source_url,
title: page.title,
matched_heading: None,
snippet: file_snippet(&text, query),
local_file_path: Some(path.display().to_string()),
});
if results.len() >= limit {
break;
}
}
}
Ok(results)
}
fn file_snippet(text: &str, query: &str) -> String {
let lower = text.to_ascii_lowercase();
let query_lower = query.to_ascii_lowercase();
let pos = lower.find(&query_lower).unwrap_or(0);
let start = pos.saturating_sub(80);
let end = (pos + query.len() + 160).min(text.len());
text.get(start..end).unwrap_or(text).replace('\n', " ")
}