use crate::sync_util::{read_or_recover, write_or_recover};
use crate::types::DocumentId;
use crate::vector::scan_document_files;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
pub(crate) const LEGACY_CORPUS_NAME: &str = "folder";
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum DocumentData {
Path(PathBuf),
Bytes(Vec<u8>),
}
impl DocumentData {
pub fn len(&self) -> u64 {
match self {
DocumentData::Path(p) => std::fs::metadata(p).map(|m| m.len()).unwrap_or(0),
DocumentData::Bytes(b) => b.len() as u64,
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn content_hash(&self) -> Result<String> {
match self {
DocumentData::Path(p) => crate::documents::compute_file_hash(p),
DocumentData::Bytes(b) => {
let digest = Sha256::digest(b);
Ok(crate::documents::to_hex(&digest))
}
}
}
}
#[derive(Clone, Debug)]
pub struct Document {
pub corpus: String,
pub id: DocumentId,
pub format: String,
pub data: DocumentData,
pub meta: BTreeMap<String, String>,
}
#[async_trait]
pub trait Corpus: Send + Sync + std::fmt::Debug {
fn name(&self) -> String;
async fn documents(&self) -> Result<Vec<Document>>;
}
#[derive(Clone, Debug)]
pub struct FolderCorpus {
name: Option<String>,
folder: PathBuf,
}
impl FolderCorpus {
pub fn new(folder: impl Into<PathBuf>) -> Self {
Self {
name: None,
folder: folder.into(),
}
}
pub fn named(name: impl Into<String>, folder: impl Into<PathBuf>) -> Self {
Self {
name: Some(name.into()),
folder: folder.into(),
}
}
pub fn folder(&self) -> &Path {
&self.folder
}
}
#[async_trait]
impl Corpus for FolderCorpus {
fn name(&self) -> String {
if let Some(name) = &self.name {
return name.clone();
}
self.folder
.canonicalize()
.unwrap_or_else(|_| self.folder.clone())
.to_string_lossy()
.into_owned()
}
async fn documents(&self) -> Result<Vec<Document>> {
let name = self.name();
Ok(scan_document_files(&self.folder)
.into_iter()
.map(|(doc_type, file_name)| Document {
corpus: name.clone(),
id: DocumentId::from(file_name),
format: doc_type
.path()
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase(),
data: DocumentData::Path(doc_type.path().clone()),
meta: Default::default(),
})
.collect())
}
}
#[derive(Clone, Debug)]
pub struct UrlCorpus {
name: String,
client: reqwest::Client,
urls: Arc<RwLock<Vec<String>>>,
max_download_bytes: Option<u64>,
}
impl UrlCorpus {
pub fn new(name: impl Into<String>, client: reqwest::Client) -> Self {
Self {
name: name.into(),
client,
urls: Arc::new(RwLock::new(Vec::new())),
max_download_bytes: None,
}
}
pub fn with_max_download_bytes(mut self, max: Option<u64>) -> Self {
self.max_download_bytes = max;
self
}
pub fn add_url(&self, url: impl Into<String>) {
write_or_recover(&self.urls).push(url.into());
}
pub fn remove_url(&self, url: &str) {
write_or_recover(&self.urls).retain(|u| u != url);
}
pub fn urls(&self) -> Vec<String> {
read_or_recover(&self.urls).clone()
}
pub fn len(&self) -> usize {
read_or_recover(&self.urls).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[async_trait]
impl Corpus for UrlCorpus {
fn name(&self) -> String {
self.name.clone()
}
async fn documents(&self) -> Result<Vec<Document>> {
let urls = read_or_recover(&self.urls).clone();
let total = urls.len();
let mut docs = Vec::with_capacity(total);
let mut errors: Vec<String> = Vec::new();
for url in urls {
match crate::web::fetch_url(&self.client, &url, self.max_download_bytes).await {
Ok((bytes, filename, content_type)) => {
match crate::web::format_hint(&filename, content_type.as_deref()) {
Some(format) => docs.push(Document {
corpus: self.name.clone(),
id: DocumentId::from(url),
format,
data: DocumentData::Bytes(bytes),
meta: Default::default(),
}),
None => {
log::warn!(
"UrlCorpus '{}': cannot determine document format for {}",
self.name,
url
);
errors.push(format!("{}: unrecognised document format", url));
}
}
}
Err(e) => {
log::warn!("UrlCorpus '{}': failed to fetch {}: {}", self.name, url, e);
errors.push(format!("{}: {e}", url));
}
}
}
if !errors.is_empty() {
return Err(anyhow!(
"UrlCorpus '{}': {} of {} URLs failed:\n {}",
self.name,
errors.len(),
total,
errors.join("\n ")
));
}
Ok(docs)
}
}
#[derive(Debug, Default)]
pub struct MultiCorpus {
label: String,
corpora: Vec<Box<dyn Corpus>>,
}
impl MultiCorpus {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
corpora: Vec::new(),
}
}
pub fn push(&mut self, corpus: Box<dyn Corpus>) {
self.corpora.push(corpus);
}
pub fn as_slice(&self) -> &[Box<dyn Corpus>] {
&self.corpora
}
pub fn len(&self) -> usize {
self.corpora.len()
}
pub fn is_empty(&self) -> bool {
self.corpora.is_empty()
}
}
#[async_trait]
impl Corpus for MultiCorpus {
fn name(&self) -> String {
self.label.clone()
}
async fn documents(&self) -> Result<Vec<Document>> {
let mut docs = Vec::new();
for corpus in &self.corpora {
docs.extend(corpus.documents().await?);
}
Ok(docs)
}
}
pub(crate) fn legacy_corpus_document(
doc_type: &crate::types::DocumentType,
name: &str,
) -> Document {
Document {
corpus: LEGACY_CORPUS_NAME.to_string(),
id: DocumentId::from(name.to_string()),
format: doc_type
.path()
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase(),
data: DocumentData::Path(doc_type.path().clone()),
meta: Default::default(),
}
}
pub(crate) fn temp_file_path(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("ragrig_parse_{}_{}_{}", std::process::id(), tag, n))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("ragrig_sources_{}_{}", std::process::id(), n));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[tokio::test]
async fn folder_corpus_enumerates_supported_files() {
let dir = temp_dir();
std::fs::write(dir.join("a.md"), b"# Title\n\nBody text.").unwrap();
std::fs::write(dir.join("b.txt"), b"ignored").unwrap();
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("c.pdf"), b"%PDF-1.4 fake").unwrap();
let corpus = FolderCorpus::new(&dir);
let docs = corpus.documents().await.unwrap();
let names: Vec<&str> = docs.iter().map(|d| d.id.0.as_str()).collect();
assert!(names.contains(&"a.md"));
assert!(names.contains(&"c.pdf"));
assert!(!names.contains(&"b.txt"));
let a = docs.iter().find(|d| d.id.0 == "a.md").unwrap();
assert_eq!(a.format, "md");
assert_eq!(a.corpus, corpus.name());
assert!(matches!(a.data, DocumentData::Path(_)));
std::fs::write(dir.join("d.md"), b"new").unwrap();
let docs2 = corpus.documents().await.unwrap();
assert_eq!(docs2.len(), 3);
assert!(docs2.iter().any(|d| d.id.0 == "d.md"));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn folder_corpus_name_is_stable_per_folder() {
let dir = temp_dir();
let a = FolderCorpus::new(&dir);
let b = FolderCorpus::new(dir.canonicalize().unwrap());
assert_eq!(a.name(), b.name());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn folder_corpus_explicit_name_wins() {
let dir = temp_dir();
let corpus = FolderCorpus::named("my-docs", &dir);
assert_eq!(corpus.name(), "my-docs");
let docs = corpus.documents().await.unwrap();
assert!(docs.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn url_corpus_add_and_remove() {
let corpus = UrlCorpus::new("test", reqwest::Client::new());
assert!(corpus.is_empty());
corpus.add_url("https://example.com/a.pdf");
corpus.add_url("https://example.com/b.pdf");
assert_eq!(corpus.urls().len(), 2);
corpus.remove_url("https://example.com/a.pdf");
assert_eq!(corpus.urls(), vec!["https://example.com/b.pdf"]);
}
#[tokio::test]
async fn url_corpus_blocks_ssrf_urls() {
let corpus = UrlCorpus::new("ssrf", reqwest::Client::new());
corpus.add_url("http://169.254.169.254/latest/meta-data/");
corpus.add_url("http://127.0.0.1:1/a.pdf");
let err = corpus.documents().await.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("blocked"), "unexpected error: {msg}");
}
#[tokio::test]
async fn url_corpus_all_or_nothing_lists_every_failure() {
let corpus = UrlCorpus::new("ssrf", reqwest::Client::new());
corpus.add_url("http://169.254.169.254/latest/meta-data/");
corpus.add_url("http://127.0.0.1:1/a.pdf");
let err = corpus.documents().await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("2 of 2 URLs failed"),
"unexpected error: {msg}"
);
assert!(msg.contains("169.254.169.254"), "missing first URL: {msg}");
assert!(msg.contains("127.0.0.1"), "missing second URL: {msg}");
}
#[tokio::test]
async fn multi_corpus_propagates_inner_errors() {
#[derive(Debug)]
struct FailingCorpus;
#[async_trait]
impl Corpus for FailingCorpus {
fn name(&self) -> String {
"failing".into()
}
async fn documents(&self) -> Result<Vec<Document>> {
Err(anyhow!("boom"))
}
}
let mut multi = MultiCorpus::new("multi");
multi.push(Box::new(FailingCorpus));
let err = multi.documents().await.unwrap_err();
assert!(err.to_string().contains("boom"));
}
#[test]
fn bytes_content_hash_is_deterministic() {
let data = DocumentData::Bytes(b"hello".to_vec());
assert_eq!(data.content_hash().unwrap(), data.content_hash().unwrap());
assert_eq!(data.content_hash().unwrap().len(), 64);
let other = DocumentData::Bytes(b"hello!".to_vec());
assert_ne!(data.content_hash().unwrap(), other.content_hash().unwrap());
}
#[test]
fn path_content_hash_matches_file() {
let dir = temp_dir();
let file = dir.join("f.txt");
std::fs::write(&file, b"content").unwrap();
let data = DocumentData::Path(file.clone());
assert_eq!(
data.content_hash().unwrap(),
crate::documents::compute_file_hash(&file).unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn multi_corpus_holds_corpora() {
let mut multi = MultiCorpus::new("corpus");
assert!(multi.is_empty());
multi.push(Box::new(FolderCorpus::named("a", "/tmp")));
assert_eq!(multi.len(), 1);
assert_eq!(multi.as_slice()[0].name(), "a");
assert_eq!(multi.name(), "corpus");
}
#[tokio::test]
async fn multi_corpus_tags_documents_with_inner_corpus() {
let dir = temp_dir();
std::fs::write(dir.join("a.md"), b"# A\n\nDoc a.").unwrap();
let mut multi = MultiCorpus::new("corpus");
multi.push(Box::new(FolderCorpus::named("docs", &dir)));
let docs = multi.documents().await.unwrap();
assert_eq!(docs.len(), 1);
assert_eq!(docs[0].corpus, "docs");
let _ = std::fs::remove_dir_all(&dir);
}
}