use anyhow::Context;
use serde::{Deserialize, Serialize};
use serde_json;
use std::borrow::Borrow;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub use crate::longterm_memory::MemoryStrategyKind;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ScoreKind {
Rrf,
Cosine,
Bm25,
Weighted,
Mmr,
}
impl std::fmt::Display for ScoreKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Rrf => "rrf",
Self::Cosine => "cosine",
Self::Bm25 => "bm25",
Self::Weighted => "weighted",
Self::Mmr => "mmr",
})
}
}
#[derive(Clone, Copy, Debug)]
pub struct RankScore {
pub value: f64,
pub kind: ScoreKind,
}
impl RankScore {
pub fn new(value: f64, kind: ScoreKind) -> Self {
Self { value, kind }
}
pub fn rrf(value: f64) -> Self {
Self::new(value, ScoreKind::Rrf)
}
pub fn cosine(value: f64) -> Self {
Self::new(value, ScoreKind::Cosine)
}
pub fn bm25(value: f64) -> Self {
Self::new(value, ScoreKind::Bm25)
}
pub fn weighted(value: f64) -> Self {
Self::new(value, ScoreKind::Weighted)
}
pub fn mmr(value: f64) -> Self {
Self::new(value, ScoreKind::Mmr)
}
}
impl std::fmt::Display for RankScore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.4}", self.value)
}
}
impl PartialEq for RankScore {
fn eq(&self, other: &Self) -> bool {
if self.value.is_nan() {
return other.value.is_nan();
}
if other.value.is_nan() {
return false;
}
self.value == other.value
}
}
impl PartialOrd for RankScore {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.value.total_cmp(&other.value))
}
}
impl PartialEq<f64> for RankScore {
fn eq(&self, other: &f64) -> bool {
if self.value.is_nan() {
return other.is_nan();
}
if other.is_nan() {
return false;
}
self.value == *other
}
}
impl PartialOrd<f64> for RankScore {
fn partial_cmp(&self, other: &f64) -> Option<std::cmp::Ordering> {
Some(self.value.total_cmp(other))
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct DocumentId(pub String);
impl std::fmt::Display for DocumentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<&str> for DocumentId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl From<String> for DocumentId {
fn from(s: String) -> Self {
Self(s)
}
}
impl Borrow<str> for DocumentId {
fn borrow(&self) -> &str {
&self.0
}
}
impl PartialEq<str> for DocumentId {
fn eq(&self, other: &str) -> bool {
self.0 == other
}
}
impl PartialEq<&str> for DocumentId {
fn eq(&self, other: &&str) -> bool {
self.0 == *other
}
}
#[non_exhaustive]
#[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: DocumentId,
pub hash: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct IndexManifest {
pub created: String,
pub chunk_size: usize,
pub chunk_overlap: usize,
#[serde(default)]
pub chunker: String,
pub embedding_model: String,
pub embedding_dimensions: usize,
pub document_count: usize,
pub total_chunks: usize,
pub file_hashes: Vec<FileHashEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DocumentChunk {
pub text: String,
pub document: DocumentId,
#[serde(default)]
pub meta: ChunkMeta,
#[serde(default)]
pub provenance: PipelineProvenance,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PipelineProvenance {
#[serde(default, alias = "source")]
pub corpus: String,
#[serde(default)]
pub parser: String,
#[serde(default)]
pub chunker: String,
#[serde(default)]
pub embedder: String,
#[serde(default)]
pub pipeline: String,
}
impl PipelineProvenance {
pub fn new(
parser: impl Into<String>,
chunker: impl Into<String>,
embedder: impl Into<String>,
) -> Self {
Self {
corpus: String::new(),
parser: parser.into(),
chunker: chunker.into(),
embedder: embedder.into(),
pipeline: String::new(),
}
}
pub fn new_with_corpus(
corpus: impl Into<String>,
parser: impl Into<String>,
chunker: impl Into<String>,
embedder: impl Into<String>,
) -> Self {
Self {
corpus: corpus.into(),
parser: parser.into(),
chunker: chunker.into(),
embedder: embedder.into(),
pipeline: String::new(),
}
}
pub fn new_with_corpus_and_pipeline(
corpus: impl Into<String>,
parser: impl Into<String>,
chunker: impl Into<String>,
embedder: impl Into<String>,
pipeline: impl Into<String>,
) -> Self {
Self {
corpus: corpus.into(),
parser: parser.into(),
chunker: chunker.into(),
embedder: embedder.into(),
pipeline: pipeline.into(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PipelineFilter {
#[serde(default, alias = "source")]
pub corpus: Option<String>,
pub parser: Option<String>,
pub chunker: Option<String>,
pub embedder: Option<String>,
#[serde(default)]
pub pipeline: Option<String>,
}
impl PipelineFilter {
pub fn any() -> Self {
Self::default()
}
pub fn for_chunker(name: impl Into<String>) -> Self {
Self {
chunker: Some(name.into()),
..Self::default()
}
}
pub fn for_corpus(name: impl Into<String>) -> Self {
Self {
corpus: Some(name.into()),
..Self::default()
}
}
pub fn for_parser(name: impl Into<String>) -> Self {
Self {
parser: Some(name.into()),
..Self::default()
}
}
pub fn for_embedder(id: impl Into<String>) -> Self {
Self {
embedder: Some(id.into()),
..Self::default()
}
}
pub fn for_pipeline(id: impl Into<String>) -> Self {
Self {
pipeline: Some(id.into()),
..Self::default()
}
}
pub fn matches(&self, provenance: &PipelineProvenance) -> bool {
self.corpus
.as_deref()
.is_none_or(|s| s == provenance.corpus)
&& self
.parser
.as_deref()
.is_none_or(|p| p == provenance.parser)
&& self
.chunker
.as_deref()
.is_none_or(|c| c == provenance.chunker)
&& self
.embedder
.as_deref()
.is_none_or(|e| e == provenance.embedder)
&& self
.pipeline
.as_deref()
.is_none_or(|p| p == provenance.pipeline)
}
pub fn is_empty(&self) -> bool {
self.corpus.is_none()
&& self.parser.is_none()
&& self.chunker.is_none()
&& self.embedder.is_none()
&& self.pipeline.is_none()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct ChunkMeta {
pub document_id: Option<String>,
pub section: Option<String>,
pub page_number: Option<u32>,
pub byte_offset: Option<u64>,
pub char_offset: Option<u64>,
#[serde(default)]
pub document_meta: BTreeMap<String, String>,
}
#[non_exhaustive]
#[derive(Clone, Debug)]
pub struct ChunkConfig {
pub size: usize,
pub overlap: usize,
}
impl Default for ChunkConfig {
fn default() -> Self {
Self {
size: 1024,
overlap: 128,
}
}
}
impl ChunkConfig {
pub fn new(size: usize, overlap: usize) -> anyhow::Result<Self> {
let cfg = Self { size, overlap };
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> anyhow::Result<()> {
if self.size == 0 {
return Err(anyhow::anyhow!(
"ChunkConfig::size must be > 0, got {}",
self.size
));
}
if self.overlap >= self.size {
return Err(anyhow::anyhow!(
"ChunkConfig::overlap ({}) must be less than size ({})",
self.overlap,
self.size
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct AttachedDocument {
pub name: String,
pub content: String,
}
#[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()
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
#[default]
#[serde(alias = "Ollama")]
Ollama,
#[serde(alias = "Deepseek")]
Deepseek,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
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
&& 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))
}
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum EmbeddingProvider {
#[default]
#[serde(alias = "Ollama")]
Ollama,
#[cfg(feature = "internal-embed")]
#[serde(alias = "Fastembed")]
Fastembed,
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PdfParserBackend {
#[cfg(feature = "kreuzberg")]
#[serde(alias = "Kreuzberg")]
Kreuzberg,
#[serde(alias = "Unpdf")]
Unpdf,
#[serde(alias = "Sink")]
Sink,
#[serde(alias = "Extract")]
Extract,
#[serde(alias = "Internal")]
Internal,
#[serde(alias = "Vision")]
Vision,
}
#[non_exhaustive]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum EpubParserBackend {
Epub,
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ContextSizeMode {
#[default]
Auto,
Forced,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct ChatConfig {
pub provider: Provider,
pub model: String,
pub deepseek_api_key: Option<String>,
pub deepseek_model: String,
pub params: GenerationParams,
pub context_tokens: usize,
pub context_size_mode: ContextSizeMode,
pub system_prompt_path: Option<PathBuf>,
pub request_timeout_secs: Option<u64>,
}
impl Default for ChatConfig {
fn default() -> Self {
Self {
provider: Provider::default(),
model: "qwen3.5:9b".into(),
deepseek_api_key: None,
deepseek_model: "deepseek-v4-pro".into(),
params: GenerationParams::default(),
context_tokens: 4096,
context_size_mode: ContextSizeMode::default(),
system_prompt_path: None,
request_timeout_secs: None,
}
}
}
impl ChatConfig {
pub fn override_with(&mut self, cli: &ChatConfig, defaults: &ChatConfig) {
if cli.provider != defaults.provider {
self.provider = cli.provider.clone();
}
if cli.model != defaults.model {
self.model.clone_from(&cli.model);
}
if cli.deepseek_api_key != defaults.deepseek_api_key {
self.deepseek_api_key.clone_from(&cli.deepseek_api_key);
}
if cli.deepseek_model != defaults.deepseek_model {
self.deepseek_model.clone_from(&cli.deepseek_model);
}
if cli.params.temperature != defaults.params.temperature {
self.params.temperature = cli.params.temperature;
}
if cli.params.top_p != defaults.params.top_p {
self.params.top_p = cli.params.top_p;
}
if cli.params.max_tokens != defaults.params.max_tokens {
self.params.max_tokens = cli.params.max_tokens;
}
if cli.params.seed != defaults.params.seed {
self.params.seed = cli.params.seed;
}
if cli.context_tokens != defaults.context_tokens {
self.context_tokens = cli.context_tokens;
}
if cli.context_size_mode != defaults.context_size_mode {
self.context_size_mode = cli.context_size_mode;
}
if cli.system_prompt_path != defaults.system_prompt_path {
self.system_prompt_path.clone_from(&cli.system_prompt_path);
}
if cli.request_timeout_secs != defaults.request_timeout_secs {
self.request_timeout_secs = cli.request_timeout_secs;
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbedConfig {
pub provider: EmbeddingProvider,
pub model: String,
pub top_k: usize,
pub similarity_threshold: f64,
pub request_timeout_secs: Option<u64>,
}
impl Default for EmbedConfig {
fn default() -> Self {
Self {
provider: EmbeddingProvider::default(),
model: "nomic-embed-text:latest".into(),
top_k: 50,
similarity_threshold: 0.04,
request_timeout_secs: None,
}
}
}
impl EmbedConfig {
pub fn override_with(&mut self, cli: &EmbedConfig, defaults: &EmbedConfig) {
if cli.provider != defaults.provider {
self.provider = cli.provider.clone();
}
if cli.model != defaults.model {
self.model.clone_from(&cli.model);
}
if cli.top_k != defaults.top_k {
self.top_k = cli.top_k;
}
if (cli.similarity_threshold - defaults.similarity_threshold).abs() > f64::EPSILON {
self.similarity_threshold = cli.similarity_threshold;
}
if cli.request_timeout_secs != defaults.request_timeout_secs {
self.request_timeout_secs = cli.request_timeout_secs;
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct ParseConfig {
pub pdf_parser: PdfParserBackend,
pub sloppy_pdf: bool,
pub chunk_size: usize,
pub chunk_overlap: usize,
}
impl Default for ParseConfig {
fn default() -> Self {
Self {
pdf_parser: PdfParserBackend::Extract,
sloppy_pdf: false,
chunk_size: 1024,
chunk_overlap: 128,
}
}
}
impl ParseConfig {
pub fn override_with(&mut self, cli: &ParseConfig, defaults: &ParseConfig) {
if cli.pdf_parser != defaults.pdf_parser {
self.pdf_parser = cli.pdf_parser.clone();
}
if cli.sloppy_pdf != defaults.sloppy_pdf {
self.sloppy_pdf = cli.sloppy_pdf;
}
if cli.chunk_size != defaults.chunk_size {
self.chunk_size = cli.chunk_size;
}
if cli.chunk_overlap != defaults.chunk_overlap {
self.chunk_overlap = cli.chunk_overlap;
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct MemoryConfig {
pub model: String,
pub rewrite_prompt_path: Option<PathBuf>,
pub strategy: MemoryStrategyKind,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
model: "qwen2.5:1.5b".into(),
rewrite_prompt_path: None,
strategy: MemoryStrategyKind::default(),
}
}
}
impl MemoryConfig {
pub fn override_with(&mut self, cli: &MemoryConfig, defaults: &MemoryConfig) {
if cli.model != defaults.model {
self.model.clone_from(&cli.model);
}
if cli.rewrite_prompt_path != defaults.rewrite_prompt_path {
self.rewrite_prompt_path
.clone_from(&cli.rewrite_prompt_path);
}
if cli.strategy != defaults.strategy {
self.strategy = cli.strategy.clone();
}
}
}
fn default_workspace() -> PathBuf {
PathBuf::from(".")
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RagrigConfig {
#[serde(default = "default_workspace", alias = "folder")]
pub workspace: PathBuf,
#[serde(default)]
pub chat: ChatConfig,
#[serde(default)]
pub embed: EmbedConfig,
#[serde(default)]
pub parse: ParseConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default, alias = "source_dirs")]
pub corpus_dirs: Vec<String>,
#[serde(default, alias = "source_urls")]
pub corpus_urls: Vec<String>,
pub semantic_scholar_api_key: Option<String>,
}
impl Default for RagrigConfig {
fn default() -> Self {
Self {
workspace: default_workspace(),
chat: ChatConfig::default(),
embed: EmbedConfig::default(),
parse: ParseConfig::default(),
memory: MemoryConfig::default(),
corpus_dirs: Vec::new(),
corpus_urls: Vec::new(),
semantic_scholar_api_key: None,
}
}
}
impl RagrigConfig {
pub fn from_toml(src: &str) -> anyhow::Result<Self> {
Ok(toml::from_str(src)?)
}
pub fn from_json(src: &str) -> anyhow::Result<Self> {
Ok(serde_json::from_str(src)?)
}
pub fn override_with(&mut self, cli: &RagrigConfig) {
let defaults = RagrigConfig::default();
self.workspace.clone_from(&cli.workspace);
if !cli.corpus_dirs.is_empty() {
self.corpus_dirs.clone_from(&cli.corpus_dirs);
}
if !cli.corpus_urls.is_empty() {
self.corpus_urls.clone_from(&cli.corpus_urls);
}
self.chat.override_with(&cli.chat, &defaults.chat);
self.embed.override_with(&cli.embed, &defaults.embed);
self.parse.override_with(&cli.parse, &defaults.parse);
self.memory.override_with(&cli.memory, &defaults.memory);
if cli.semantic_scholar_api_key != defaults.semantic_scholar_api_key {
self.semantic_scholar_api_key
.clone_from(&cli.semantic_scholar_api_key);
}
}
pub fn profiles_dir(folder: &Path) -> PathBuf {
folder.join(".ragrig").join("profiles")
}
pub fn save_to_profile(&self, folder: &Path, name: &str) -> Result<(), anyhow::Error> {
let dir = Self::profiles_dir(folder);
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{}.json", name));
let mut value = serde_json::to_value(self)?;
if let serde_json::Value::Object(ref mut map) = value {
map.remove("workspace");
}
let json = serde_json::to_string_pretty(&value)?;
std::fs::write(&path, json)?;
log::info!("Profile '{}' saved to {}", name, path.display());
Ok(())
}
pub fn load_from_profile(folder: &Path, name: &str) -> Result<Self, anyhow::Error> {
let path = Self::profiles_dir(folder).join(format!("{}.json", name));
let json = std::fs::read_to_string(&path)
.with_context(|| format!("Profile '{}' not found at {}", name, path.display()))?;
let mut config: Self = serde_json::from_str(&json)
.with_context(|| format!("Profile '{}' is corrupt JSON", name))?;
config.workspace = folder.to_path_buf();
Ok(config)
}
pub fn list_profiles(folder: &Path) -> Result<Vec<String>, anyhow::Error> {
let dir = Self::profiles_dir(folder);
if !dir.exists() {
return Ok(Vec::new());
}
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|e| e == "json")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
names.push(stem.to_string());
}
}
names.sort();
Ok(names)
}
}
#[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 config_default_context_tokens() {
let config = RagrigConfig::default();
assert_eq!(config.chat.context_tokens, 4096);
}
#[test]
fn config_default_chat_model() {
let config = RagrigConfig::default();
assert_eq!(config.chat.model, "qwen3.5:9b");
}
#[test]
fn config_default_embed_model() {
let config = RagrigConfig::default();
assert_eq!(config.embed.model, "nomic-embed-text:latest");
assert_eq!(config.embed.top_k, 50);
assert_eq!(config.chat.request_timeout_secs, None);
assert_eq!(config.embed.request_timeout_secs, None);
}
#[test]
fn config_default_parse() {
let config = RagrigConfig::default();
assert_eq!(config.parse.chunk_size, 1024);
assert_eq!(config.parse.chunk_overlap, 128);
assert_eq!(config.parse.pdf_parser, PdfParserBackend::Extract);
assert!(!config.parse.sloppy_pdf);
}
#[test]
fn config_default_memory() {
let config = RagrigConfig::default();
assert_eq!(config.memory.model, "qwen2.5:1.5b");
assert!(config.memory.rewrite_prompt_path.is_none());
}
#[test]
fn config_custom_chat() {
let config = RagrigConfig {
chat: ChatConfig {
model: "gemma4:e4b".into(),
..Default::default()
},
..Default::default()
};
assert_eq!(config.chat.model, "gemma4:e4b");
assert_eq!(config.chat.context_tokens, 4096);
}
#[test]
fn config_serde_roundtrip() {
let config = RagrigConfig {
workspace: "/tmp/docs".into(),
chat: ChatConfig {
provider: Provider::Deepseek,
model: "deepseek-v4-pro".into(),
deepseek_api_key: Some("sk-test".into()),
context_tokens: 16384,
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string_pretty(&config).unwrap();
let roundtripped: RagrigConfig = serde_json::from_str(&json).unwrap();
assert_eq!(roundtripped.workspace, PathBuf::from("/tmp/docs"));
assert_eq!(roundtripped.chat.provider, Provider::Deepseek);
assert_eq!(roundtripped.chat.deepseek_api_key, Some("sk-test".into()));
assert_eq!(roundtripped.chat.context_tokens, 16384);
assert_eq!(roundtripped.embed.model, "nomic-embed-text:latest");
assert_eq!(roundtripped.chat.request_timeout_secs, None);
assert_eq!(roundtripped.embed.request_timeout_secs, None);
}
#[test]
fn config_serde_accepts_legacy_folder_key() {
let legacy = serde_json::to_string_pretty(&RagrigConfig::default())
.unwrap()
.replace("\"workspace\"", "\"folder\"");
let config: RagrigConfig = serde_json::from_str(&legacy).unwrap();
assert_eq!(config.workspace, default_workspace());
}
#[test]
fn config_serde_fills_missing_fields_from_defaults() {
let partial = r#"{
"chat": {"context_tokens": 2048},
"embed": {"model": "my-embed"},
"parse": {"chunk_size": 512},
"memory": {}
}"#;
let config: RagrigConfig = serde_json::from_str(partial).unwrap();
assert_eq!(config.chat.context_tokens, 2048);
assert_eq!(config.chat.model, ChatConfig::default().model);
assert_eq!(config.embed.model, "my-embed");
assert_eq!(config.embed.top_k, EmbedConfig::default().top_k);
assert_eq!(config.parse.chunk_size, 512);
assert_eq!(config.parse.pdf_parser, PdfParserBackend::Extract);
assert_eq!(config.memory.model, MemoryConfig::default().model);
let chat: ChatConfig = serde_json::from_str(r#"{"model": "x"}"#).unwrap();
assert_eq!(chat.model, "x");
assert_eq!(chat.context_tokens, ChatConfig::default().context_tokens);
}
#[test]
fn config_serde_accepts_lowercase_backend_names() {
let cfg: RagrigConfig = serde_json::from_str(
r#"{"chat":{"provider":"deepseek"},"embed":{"provider":"ollama"},"parse":{"pdf_parser":"unpdf"}}"#,
)
.unwrap();
assert_eq!(cfg.chat.provider, Provider::Deepseek);
assert_eq!(cfg.embed.provider, EmbeddingProvider::Ollama);
assert_eq!(cfg.parse.pdf_parser, PdfParserBackend::Unpdf);
let legacy: RagrigConfig = serde_json::from_str(
r#"{"chat":{"provider":"Ollama"},"parse":{"pdf_parser":"Extract"}}"#,
)
.unwrap();
assert_eq!(legacy.chat.provider, Provider::Ollama);
assert_eq!(legacy.parse.pdf_parser, PdfParserBackend::Extract);
let roundtrip = serde_json::to_string(&cfg).unwrap();
assert!(roundtrip.contains("\"deepseek\""));
assert!(!roundtrip.contains("\"Deepseek\""));
}
#[test]
fn config_from_toml_and_json_helpers() {
let toml_src = "[chat]\nmodel = \"gemma2:latest\"\n";
let cfg = RagrigConfig::from_toml(toml_src).unwrap();
assert_eq!(cfg.chat.model, "gemma2:latest");
assert_eq!(cfg.embed.top_k, EmbedConfig::default().top_k);
let json_src = r#"{"chat": {"context_tokens": 2048}}"#;
let cfg = RagrigConfig::from_json(json_src).unwrap();
assert_eq!(cfg.chat.context_tokens, 2048);
}
#[test]
fn provenance_serde_defaults_missing_corpus() {
let legacy = r#"{"parser":"unpdf","chunker":"markdown","embedder":"Ollama/x"}"#;
let p: PipelineProvenance = serde_json::from_str(legacy).unwrap();
assert_eq!(p.corpus, "");
assert_eq!(p.parser, "unpdf");
let with = PipelineProvenance::new_with_corpus("docs", "unpdf", "markdown", "Ollama/x");
let roundtripped: PipelineProvenance =
serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
assert_eq!(roundtripped.corpus, "docs");
}
#[test]
fn provenance_serde_accepts_legacy_source_key() {
let legacy =
r#"{"source":"docs","parser":"unpdf","chunker":"markdown","embedder":"Ollama/x"}"#;
let p: PipelineProvenance = serde_json::from_str(legacy).unwrap();
assert_eq!(p.corpus, "docs");
}
#[test]
fn filter_serde_accepts_legacy_source_key() {
let legacy = r#"{"source":"docs","chunker":"markdown"}"#;
let f: PipelineFilter = serde_json::from_str(legacy).unwrap();
assert_eq!(f.corpus, Some("docs".to_string()));
assert_eq!(f.chunker, Some("markdown".to_string()));
}
#[test]
fn pipeline_filter_matches_semantics() {
let prov = PipelineProvenance::new_with_corpus("papers", "unpdf", "markdown", "Ollama/x");
let legacy = PipelineProvenance::new("unpdf", "markdown", "Ollama/x");
let any = PipelineFilter::any();
assert!(any.is_empty());
assert!(any.matches(&prov));
assert!(any.matches(&legacy));
assert!(PipelineFilter::for_corpus("papers").matches(&prov));
assert!(!PipelineFilter::for_corpus("urls").matches(&prov));
assert!(!PipelineFilter::for_corpus("papers").matches(&legacy));
assert!(PipelineFilter::for_parser("unpdf").matches(&prov));
assert!(!PipelineFilter::for_parser("epub-parser").matches(&prov));
assert!(PipelineFilter::for_chunker("markdown").matches(&prov));
assert!(!PipelineFilter::for_chunker("chunkedrs-markdown").matches(&prov));
assert!(PipelineFilter::for_embedder("Ollama/x").matches(&prov));
assert!(!PipelineFilter::for_embedder("Ollama/y").matches(&prov));
assert!(!PipelineFilter::for_embedder("Ollama").matches(&prov));
let combined = PipelineFilter {
corpus: Some("papers".into()),
chunker: Some("markdown".into()),
..PipelineFilter::default()
};
assert!(combined.matches(&prov));
let wrong_chunker = PipelineFilter {
corpus: Some("papers".into()),
chunker: Some("other".into()),
..PipelineFilter::default()
};
assert!(!wrong_chunker.matches(&prov));
let wrong_corpus = PipelineFilter {
corpus: Some("urls".into()),
chunker: Some("markdown".into()),
..PipelineFilter::default()
};
assert!(!wrong_corpus.matches(&prov));
let tagged = PipelineProvenance::new_with_corpus_and_pipeline(
"papers", "unpdf", "markdown", "Ollama/x", "pipe-a",
);
assert!(PipelineFilter::for_pipeline("pipe-a").matches(&tagged));
assert!(!PipelineFilter::for_pipeline("pipe-b").matches(&tagged));
assert!(!PipelineFilter::for_pipeline("pipe-a").matches(&prov));
assert!(any.matches(&tagged));
}
#[test]
fn config_serde_accepts_legacy_source_keys() {
let legacy = serde_json::to_string_pretty(&RagrigConfig::default())
.unwrap()
.replace("\"corpus_dirs\"", "\"source_dirs\"")
.replace("\"corpus_urls\"", "\"source_urls\"");
let config: RagrigConfig = serde_json::from_str(&legacy).unwrap();
assert!(config.corpus_dirs.is_empty());
assert!(config.corpus_urls.is_empty());
let mut named: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&RagrigConfig::default()).unwrap())
.unwrap();
named.as_object_mut().unwrap().remove("corpus_dirs");
named.as_object_mut().unwrap().remove("corpus_urls");
named["source_dirs"] = serde_json::json!(["a=./x"]);
named["source_urls"] = serde_json::json!(["b=https://e.com/f.pdf"]);
let config: RagrigConfig = serde_json::from_value(named).unwrap();
assert_eq!(config.corpus_dirs, vec!["a=./x"]);
assert_eq!(config.corpus_urls, vec!["b=https://e.com/f.pdf"]);
}
#[test]
fn corpus_config_serde_roundtrip() {
let config = RagrigConfig {
corpus_dirs: vec!["papers=/tmp/papers".to_string()],
corpus_urls: vec!["urls=https://example.com/a.pdf".to_string()],
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let roundtripped: RagrigConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
roundtripped.corpus_dirs,
vec!["papers=/tmp/papers".to_string()]
);
assert_eq!(
roundtripped.corpus_urls,
vec!["urls=https://example.com/a.pdf".to_string()]
);
}
#[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);
}
#[test]
fn override_chat_applies_changed_fields() {
let mut base = ChatConfig::default();
let cli = ChatConfig {
model: "gemma4:e4b".into(),
context_tokens: 16384,
..Default::default()
};
let defaults = ChatConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.model, "gemma4:e4b");
assert_eq!(base.context_tokens, 16384);
assert_eq!(base.provider, Provider::Ollama);
}
#[test]
fn override_chat_ignores_default_values() {
let mut base = ChatConfig {
model: "custom-from-profile".into(),
..Default::default()
};
let cli = ChatConfig::default(); let defaults = ChatConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.model, "custom-from-profile");
}
#[test]
fn override_embed_applies_changed_fields() {
let mut base = EmbedConfig::default();
let cli = EmbedConfig {
top_k: 10,
similarity_threshold: 0.1,
request_timeout_secs: Some(30),
..Default::default()
};
let defaults = EmbedConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.top_k, 10);
assert!((base.similarity_threshold - 0.1).abs() < f64::EPSILON);
assert_eq!(base.model, "nomic-embed-text:latest"); assert_eq!(base.request_timeout_secs, Some(30));
}
#[test]
fn override_parse_applies_changed_fields() {
let mut base = ParseConfig::default();
let cli = ParseConfig {
chunk_size: 512,
pdf_parser: PdfParserBackend::Unpdf,
..Default::default()
};
let defaults = ParseConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.chunk_size, 512);
assert_eq!(base.pdf_parser, PdfParserBackend::Unpdf);
assert_eq!(base.chunk_overlap, 128); assert!(!base.sloppy_pdf); }
#[test]
fn override_memory_applies_changed_fields() {
let mut base = MemoryConfig::default();
let cli = MemoryConfig {
model: "llama3.2:3b".into(),
rewrite_prompt_path: Some(PathBuf::from("/tmp/prompt.md")),
strategy: MemoryStrategyKind::Summary,
};
let defaults = MemoryConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.model, "llama3.2:3b");
assert_eq!(
base.rewrite_prompt_path,
Some(PathBuf::from("/tmp/prompt.md"))
);
assert_eq!(base.strategy, MemoryStrategyKind::Summary);
}
#[test]
fn override_memory_default_strategy_leaves_profile_untouched() {
let mut base = MemoryConfig {
strategy: MemoryStrategyKind::Log,
..Default::default()
};
let cli = MemoryConfig::default();
let defaults = MemoryConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.strategy, MemoryStrategyKind::Log);
}
#[test]
fn override_ragrig_config_top_level() {
let mut base = RagrigConfig {
workspace: PathBuf::from("/tmp/base"),
chat: ChatConfig {
model: "profile-model".into(),
..Default::default()
},
..Default::default()
};
let cli = RagrigConfig {
workspace: PathBuf::from("/tmp/cli"),
chat: ChatConfig {
model: "gemma4:e4b".into(),
..Default::default()
},
..Default::default()
};
base.override_with(&cli);
assert_eq!(base.workspace, PathBuf::from("/tmp/cli"));
assert_eq!(base.chat.model, "gemma4:e4b");
assert_eq!(base.embed.model, "nomic-embed-text:latest");
assert_eq!(base.chat.request_timeout_secs, None);
assert_eq!(base.embed.request_timeout_secs, None);
}
#[test]
fn override_with_corpus_merge_semantics() {
let mut profile = RagrigConfig {
corpus_dirs: vec!["papers=/srv/papers".to_string()],
corpus_urls: vec!["refs=https://example.com/a.pdf".to_string()],
..Default::default()
};
profile.override_with(&RagrigConfig::default());
assert_eq!(profile.corpus_dirs, vec!["papers=/srv/papers".to_string()]);
assert_eq!(
profile.corpus_urls,
vec!["refs=https://example.com/a.pdf".to_string()]
);
let mut profile = RagrigConfig {
corpus_dirs: vec!["papers=/srv/papers".to_string()],
..Default::default()
};
let cli = RagrigConfig {
corpus_dirs: vec!["notes=/home/mart/notes".to_string()],
..Default::default()
};
profile.override_with(&cli);
assert_eq!(
profile.corpus_dirs,
vec!["notes=/home/mart/notes".to_string()]
);
let mut profile = RagrigConfig {
corpus_urls: vec!["refs=https://example.com/a.pdf".to_string()],
..Default::default()
};
profile.override_with(&RagrigConfig::default()); assert_eq!(
profile.corpus_urls,
vec!["refs=https://example.com/a.pdf".to_string()]
);
let cli = RagrigConfig {
corpus_urls: vec!["sota=https://example.com/b.pdf".to_string()],
..Default::default()
};
profile.override_with(&cli); assert_eq!(
profile.corpus_urls,
vec!["sota=https://example.com/b.pdf".to_string()]
);
}
#[test]
fn override_params_temperature_applied() {
let mut base = ChatConfig::default();
let cli = ChatConfig {
params: GenerationParams {
temperature: Some(0.1),
..Default::default()
},
..Default::default()
};
let defaults = ChatConfig::default();
base.override_with(&cli, &defaults);
assert_eq!(base.params.temperature, Some(0.1));
assert!(base.params.top_p.is_none());
assert!(base.params.max_tokens.is_none());
}
#[test]
fn profile_save_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let config = RagrigConfig {
workspace: folder.clone(),
chat: ChatConfig {
model: "gemma4:e4b".into(),
context_tokens: 16384,
params: GenerationParams {
temperature: Some(0.1),
..Default::default()
},
..Default::default()
},
embed: EmbedConfig {
top_k: 10,
request_timeout_secs: Some(60),
..Default::default()
},
..Default::default()
};
config.save_to_profile(&folder, "test-roundtrip").unwrap();
let loaded = RagrigConfig::load_from_profile(&folder, "test-roundtrip").unwrap();
assert_eq!(loaded.chat.model, "gemma4:e4b");
assert_eq!(loaded.chat.context_tokens, 16384);
assert_eq!(loaded.chat.params.temperature, Some(0.1));
assert_eq!(loaded.embed.top_k, 10);
assert_eq!(loaded.embed.model, "nomic-embed-text:latest");
assert_eq!(loaded.embed.request_timeout_secs, Some(60));
assert_eq!(loaded.chat.request_timeout_secs, None);
}
#[test]
fn profile_list_finds_saved_profiles() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let config = RagrigConfig {
workspace: folder.clone(),
..Default::default()
};
config.save_to_profile(&folder, "alpha").unwrap();
config.save_to_profile(&folder, "beta").unwrap();
let names = RagrigConfig::list_profiles(&folder).unwrap();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn profile_list_empty_when_no_profiles() {
let tmp = tempfile::tempdir().unwrap();
let names = RagrigConfig::list_profiles(tmp.path()).unwrap();
assert!(names.is_empty());
}
#[test]
fn profile_load_nonexistent_is_error() {
let tmp = tempfile::tempdir().unwrap();
let result = RagrigConfig::load_from_profile(tmp.path(), "nonexistent");
assert!(result.is_err());
}
#[test]
fn profile_json_is_valid_utf8() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let config = RagrigConfig {
workspace: folder.clone(),
..Default::default()
};
config.save_to_profile(&folder, "utf8").unwrap();
let path = RagrigConfig::profiles_dir(&folder).join("utf8.json");
let json = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
}
#[test]
fn profile_save_strips_workspace() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let config = RagrigConfig {
workspace: PathBuf::from("/some/foreign/machine/path"),
..Default::default()
};
config.save_to_profile(&folder, "portable").unwrap();
let path = RagrigConfig::profiles_dir(&folder).join("portable.json");
let json = std::fs::read_to_string(&path).unwrap();
assert!(
!json.contains("workspace"),
"saved profile must not contain a workspace, got: {json}"
);
let loaded = RagrigConfig::load_from_profile(&folder, "portable").unwrap();
assert_eq!(loaded.workspace, folder);
}
#[test]
fn profile_load_ignores_foreign_workspace() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let legacy = serde_json::json!({
"workspace": "/home/someone-else/project",
"chat": { "model": "legacy-model" }
});
let dir = RagrigConfig::profiles_dir(&folder);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("legacy.json"), legacy.to_string()).unwrap();
let loaded = RagrigConfig::load_from_profile(&folder, "legacy").unwrap();
assert_eq!(loaded.workspace, folder);
assert_eq!(loaded.chat.model, "legacy-model");
}
#[test]
fn profile_roundtrip_preserves_memory_strategy() {
let tmp = tempfile::tempdir().unwrap();
let folder = tmp.path().to_path_buf();
let config = RagrigConfig {
workspace: folder.clone(),
memory: MemoryConfig {
model: "qwen2.5:1.5b".into(),
strategy: MemoryStrategyKind::Summary,
..Default::default()
},
..Default::default()
};
config.save_to_profile(&folder, "summary").unwrap();
let loaded = RagrigConfig::load_from_profile(&folder, "summary").unwrap();
assert_eq!(loaded.memory.strategy, MemoryStrategyKind::Summary);
assert_eq!(loaded.memory.model, "qwen2.5:1.5b");
}
#[test]
fn memory_strategy_defaults_to_rewrite_on_partial_profile() {
let partial = r#"{ "memory": { "model": "my-memory-model" } }"#;
let config: RagrigConfig = serde_json::from_str(partial).unwrap();
assert_eq!(config.memory.model, "my-memory-model");
assert_eq!(config.memory.strategy, MemoryStrategyKind::Rewrite);
}
#[test]
fn memory_strategy_serde_lowercase_roundtrip() {
for (kind, name) in [
(MemoryStrategyKind::Rewrite, "rewrite"),
(MemoryStrategyKind::Transcript, "transcript"),
(MemoryStrategyKind::Log, "log"),
(MemoryStrategyKind::Summary, "summary"),
(MemoryStrategyKind::Off, "off"),
] {
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, format!("\"{name}\""));
let back: MemoryStrategyKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
}
}
#[test]
fn chunk_config_default_is_valid() {
ChunkConfig::default().validate().unwrap();
}
#[test]
fn chunk_config_new_valid() {
ChunkConfig::new(512, 64).unwrap();
}
#[test]
fn chunk_config_size_zero_is_invalid() {
assert!(ChunkConfig::new(0, 0).is_err());
let cfg = ChunkConfig {
size: 0,
overlap: 0,
};
assert!(cfg.validate().is_err());
}
#[test]
fn chunk_config_overlap_equals_size_is_invalid() {
assert!(ChunkConfig::new(100, 100).is_err());
let cfg = ChunkConfig {
size: 100,
overlap: 100,
};
assert!(cfg.validate().is_err());
}
#[test]
fn chunk_config_overlap_exceeds_size_is_invalid() {
assert!(ChunkConfig::new(100, 500).is_err());
let cfg = ChunkConfig {
size: 100,
overlap: 500,
};
assert!(cfg.validate().is_err());
}
#[test]
fn chunk_config_zero_overlap_is_valid() {
ChunkConfig::new(100, 0).unwrap();
}
}