use clap::Parser;
use serde::{Deserialize, Serialize};
use serde_json;
use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum DocumentType {
Pdf(PathBuf),
Epub(PathBuf),
Html(PathBuf),
Docx(PathBuf),
Markdown(PathBuf),
}
impl DocumentType {
pub fn file_name(&self) -> &str {
self.path()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
}
pub fn path(&self) -> &PathBuf {
match self {
Self::Pdf(p) => p,
Self::Epub(p) => p,
Self::Html(p) => p,
Self::Docx(p) => p,
Self::Markdown(p) => p,
}
}
}
impl DocumentType {
pub fn from_extension(ext: &str, path: impl Into<PathBuf>) -> Option<Self> {
let path = path.into();
match ext.to_lowercase().as_str() {
"pdf" => Some(Self::Pdf(path)),
"epub" => Some(Self::Epub(path)),
"html" | "htm" => Some(Self::Html(path)),
"docx" => Some(Self::Docx(path)),
"md" | "rmd" | "qmd" => Some(Self::Markdown(path)),
_ => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct FileHashEntry {
pub file_name: String,
pub hash: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DocumentChunk {
pub text: String,
pub source_file: String,
}
#[derive(Clone, Debug)]
pub struct ChunkConfig {
pub size: usize,
pub overlap: usize,
}
impl Default for ChunkConfig {
fn default() -> Self {
Self {
size: 1024,
overlap: 128,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct PaperResult {
pub title: String,
pub authors: Vec<String>,
pub year: Option<i32>,
pub arxiv_id: Option<String>,
pub doi: Option<String>,
pub pdf_url: Option<String>,
}
impl PaperResult {
pub fn format_authors(&self) -> String {
if self.authors.len() > 3 {
format!("{}, et al.", self.authors[0])
} else {
self.authors.join(", ")
}
}
pub fn format_year(&self) -> String {
self.year.map(|y| format!(" ({})", y)).unwrap_or_default()
}
pub fn best_pdf_url(&self) -> String {
if let Some(ref url) = self.pdf_url {
url.clone()
} else if let Some(ref id) = self.arxiv_id {
format!("https://arxiv.org/pdf/{}.pdf", id)
} else {
String::new()
}
}
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum Provider {
Ollama,
Deepseek,
}
#[derive(Clone, Debug, Default)]
pub struct GenerationParams {
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub max_tokens: Option<usize>,
pub seed: Option<u64>,
}
impl GenerationParams {
pub fn is_empty(&self) -> bool {
self.temperature.is_none()
&& self.top_p.is_none()
&& self.max_tokens.is_none()
&& self.seed.is_none()
}
pub fn additional_json(&self) -> Option<serde_json::Value> {
if self.top_p.is_none() && self.seed.is_none() {
return None;
}
let mut extra = serde_json::Map::new();
if let Some(p) = self.top_p {
if let Some(n) = serde_json::Number::from_f64(p) {
extra.insert("top_p".into(), serde_json::Value::Number(n));
}
}
if let Some(s) = self.seed {
extra.insert("seed".into(), serde_json::Value::Number(s.into()));
}
Some(serde_json::Value::Object(extra))
}
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum EmbeddingProvider {
Ollama,
#[cfg(feature = "internal-embed")]
Fastembed,
}
#[derive(Clone, Debug, PartialEq, clap::ValueEnum)]
pub enum PdfParserBackend {
Unpdf,
#[deprecated(since = "0.10.0", note = "performance was lousy; use Unpdf instead")]
Sink,
Extract,
Internal,
}
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum EpubParserBackend {
Epub,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum ContextSizeMode {
Auto,
Forced,
}
#[derive(Parser, Debug)]
#[command(about = "Pure Rust local RAG — chunkedrs + rig + Ollama/DeepSeek/Fastembed")]
pub struct Args {
#[arg(short, long)]
pub folder: PathBuf,
#[arg(long, default_value = "ollama")]
pub provider: Provider,
#[arg(long, env = "DEEPSEEK_API_KEY")]
pub deepseek_api_key: Option<String>,
#[arg(long, default_value = "deepseek-v4-pro")]
pub deepseek_model: String,
#[arg(long, env = "SEMANTIC_SCHOLAR_API_KEY")]
pub semantic_scholar_api_key: Option<String>,
#[arg(short, long, default_value = "gemma2:latest")]
pub model: String,
#[arg(long)]
pub temperature: Option<f64>,
#[arg(long)]
pub top_p: Option<f64>,
#[arg(long)]
pub max_tokens: Option<usize>,
#[arg(long)]
pub seed: Option<u64>,
#[arg(long, default_value = "ollama")]
pub embedding_provider: EmbeddingProvider,
#[arg(short, long, default_value = "nomic-embed-text")]
pub embedding_model: String,
#[arg(long, default_value = "qwen2.5:1.5b")]
pub memory_model: String,
#[arg(long)]
pub prompt_chat: Option<PathBuf>,
#[arg(long)]
pub prompt_rewrite: Option<PathBuf>,
#[arg(long)]
pub sloppy_pdf: bool,
#[arg(long, default_value = "unpdf")]
pub pdf_parser: PdfParserBackend,
#[arg(long, default_value = "1024")]
pub chunk_size: usize,
#[arg(long, default_value = "128")]
pub chunk_overlap: usize,
#[arg(long, default_value = "10")]
pub top_k: usize,
#[arg(long, default_value = "0.4")]
pub similarity_threshold: f64,
#[arg(long, default_value = "4096")]
pub model_ctx_tokens: usize,
#[arg(long, default_value = "auto")]
pub context_size_forced: ContextSizeMode,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_type_file_name() {
let dt = DocumentType::Pdf(PathBuf::from("/tmp/paper.pdf"));
assert_eq!(dt.file_name(), "paper.pdf");
assert_eq!(dt.path(), &PathBuf::from("/tmp/paper.pdf"));
}
#[test]
fn document_type_file_name_unknown() {
let dt = DocumentType::Epub(PathBuf::from("/"));
assert_eq!(dt.file_name(), "unknown");
}
#[test]
fn paper_result_format_authors_short() {
let p = PaperResult {
title: "Test".into(),
authors: vec!["Smith".into(), "Jones".into()],
year: Some(2023),
arxiv_id: None,
doi: None,
pdf_url: None,
};
assert_eq!(p.format_authors(), "Smith, Jones");
}
#[test]
fn paper_result_format_authors_long() {
let p = PaperResult {
title: "Test".into(),
authors: vec!["A".into(), "B".into(), "C".into(), "D".into()],
year: None,
arxiv_id: None,
doi: None,
pdf_url: None,
};
assert_eq!(p.format_authors(), "A, et al.");
}
#[test]
fn paper_result_format_year() {
let p = PaperResult {
title: "Test".into(),
authors: vec![],
year: Some(2023),
arxiv_id: None,
doi: None,
pdf_url: None,
};
assert_eq!(p.format_year(), " (2023)");
}
#[test]
fn paper_result_format_year_none() {
let p = PaperResult {
title: "Test".into(),
authors: vec![],
year: None,
arxiv_id: None,
doi: None,
pdf_url: None,
};
assert!(p.format_year().is_empty());
}
#[test]
fn paper_result_best_pdf_url_direct() {
let p = PaperResult {
title: "Test".into(),
authors: vec![],
year: None,
arxiv_id: None,
doi: None,
pdf_url: Some("https://example.com/paper.pdf".into()),
};
assert_eq!(p.best_pdf_url(), "https://example.com/paper.pdf");
}
#[test]
fn paper_result_best_pdf_url_arxiv_fallback() {
let p = PaperResult {
title: "Test".into(),
authors: vec![],
year: None,
arxiv_id: Some("2301.12345".into()),
doi: None,
pdf_url: None,
};
assert_eq!(p.best_pdf_url(), "https://arxiv.org/pdf/2301.12345.pdf");
}
#[test]
fn paper_result_best_pdf_url_empty() {
let p = PaperResult {
title: "Test".into(),
authors: vec![],
year: None,
arxiv_id: None,
doi: None,
pdf_url: None,
};
assert!(p.best_pdf_url().is_empty());
}
#[test]
fn args_default_model_ctx_tokens() {
let args = Args::parse_from(["test", "--folder", "/tmp"]);
assert_eq!(args.model_ctx_tokens, 4096);
}
#[test]
fn args_default_model() {
let args = Args::parse_from(["test", "--folder", "/tmp"]);
assert_eq!(args.model, "gemma2:latest");
}
#[test]
fn generation_params_default_all_none() {
let p = GenerationParams::default();
assert!(p.temperature.is_none());
assert!(p.top_p.is_none());
assert!(p.max_tokens.is_none());
assert!(p.seed.is_none());
}
#[test]
fn generation_params_with_values() {
let p = GenerationParams {
temperature: Some(0.1),
top_p: Some(0.9),
max_tokens: Some(2048),
seed: Some(42),
};
assert_eq!(p.temperature, Some(0.1));
assert_eq!(p.top_p, Some(0.9));
assert_eq!(p.max_tokens, Some(2048));
assert_eq!(p.seed, Some(42));
}
#[test]
fn params_is_empty_when_all_none() {
assert!(GenerationParams::default().is_empty());
}
#[test]
fn params_is_not_empty_when_temperature_set() {
let p = GenerationParams { temperature: Some(0.5), ..Default::default() };
assert!(!p.is_empty());
}
#[test]
fn params_is_not_empty_when_seed_set() {
let p = GenerationParams { seed: Some(1), ..Default::default() };
assert!(!p.is_empty());
}
#[test]
fn additional_json_none_when_top_p_and_seed_absent() {
let p = GenerationParams { temperature: Some(0.5), max_tokens: Some(100), ..Default::default() };
assert!(p.additional_json().is_none());
}
#[test]
fn additional_json_none_for_default_params() {
assert!(GenerationParams::default().additional_json().is_none());
}
#[test]
fn additional_json_includes_top_p_when_set() {
let p = GenerationParams { top_p: Some(0.9), ..Default::default() };
let json = p.additional_json().unwrap();
assert_eq!(json["top_p"].as_f64(), Some(0.9));
assert!(json.get("seed").is_none());
}
#[test]
fn additional_json_includes_seed_when_set() {
let p = GenerationParams { seed: Some(42), ..Default::default() };
let json = p.additional_json().unwrap();
assert_eq!(json["seed"].as_u64(), Some(42));
assert!(json.get("top_p").is_none());
}
#[test]
fn additional_json_includes_both_when_set() {
let p = GenerationParams { top_p: Some(0.8), seed: Some(123), ..Default::default() };
let json = p.additional_json().unwrap();
assert_eq!(json["top_p"].as_f64(), Some(0.8));
assert_eq!(json["seed"].as_u64(), Some(123));
}
#[test]
fn additional_json_f64_precision_preserved() {
let p = GenerationParams { top_p: Some(0.95), ..Default::default() };
let json = p.additional_json().unwrap();
let returned = json["top_p"].as_f64().unwrap();
assert!((returned - 0.95).abs() < 1e-10);
}
}