use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::domain::entities::{DocumentChunk, ExportFormat};
#[derive(Error, Debug)]
pub enum ExporterError {
#[error("No se pudo crear el directorio de salida: {0}")]
DirectoryCreation(#[from] std::io::Error),
#[error("Error de escritura: {0}")]
WriteError(String),
#[error("Configuración inválida: {0}")]
InvalidConfig(String),
#[error("Error de serialización: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Error en batch: {0}")]
BatchError(String),
#[error("Error en state store: {0}")]
StateStore(#[from] crate::error::ScraperError),
}
pub type ExportResult<T> = std::result::Result<T, ExporterError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExporterConfig {
pub output_dir: PathBuf,
pub format: ExportFormat,
pub filename: String,
pub append: bool,
pub batch_size: Option<usize>,
}
impl ExporterConfig {
pub fn new(output_dir: PathBuf, format: ExportFormat, filename: impl Into<String>) -> Self {
Self {
output_dir,
format,
filename: filename.into(),
append: false,
batch_size: None,
}
}
#[must_use]
pub fn with_append(mut self, append: bool) -> Self {
self.append = append;
self
}
#[must_use]
pub fn with_batch_size(mut self, size: usize) -> Self {
self.batch_size = Some(size);
self
}
#[must_use]
pub fn output_path(&self) -> PathBuf {
let ext = self.format.extension();
self.output_dir.join(format!("{}.{}", self.filename, ext))
}
#[must_use]
pub fn state_path(&self) -> PathBuf {
let state_dir = self.output_dir.join("state");
let domain = self.filename.clone();
state_dir.join(format!("{}.json", domain))
}
}
impl Default for ExporterConfig {
fn default() -> Self {
Self {
output_dir: PathBuf::from("./output"),
format: ExportFormat::Jsonl,
filename: "export".to_string(),
append: false,
batch_size: None,
}
}
}
pub trait Exporter: Send + Sync + 'static {
fn export(&self, document: DocumentChunk) -> ExportResult<()>;
fn export_batch(&self, documents: Vec<DocumentChunk>) -> ExportResult<()>;
fn config(&self) -> &ExporterConfig;
fn format(&self) -> ExportFormat {
self.config().format
}
}
pub trait ExporterExt: Exporter {
fn export_scraped(&self, scraped: &crate::domain::ScrapedContent) -> ExportResult<()> {
let chunk = DocumentChunk::from_scraped_content(scraped);
self.export(chunk)
}
fn export_scraped_batch(
&self,
scraped_contents: Vec<crate::domain::ScrapedContent>,
) -> ExportResult<()> {
let chunks: Vec<DocumentChunk> = scraped_contents
.iter()
.map(DocumentChunk::from_scraped_content)
.collect();
self.export_batch(chunks)
}
fn is_append_mode(&self) -> bool {
self.config().append
}
fn output_path(&self) -> PathBuf {
self.config().output_path()
}
}
impl<T: Exporter> ExporterExt for T {}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn test_export_format_extension() {
assert_eq!(ExportFormat::Markdown.extension(), "md");
assert_eq!(ExportFormat::Jsonl.extension(), "jsonl");
assert_eq!(ExportFormat::Zvec.extension(), "zvec");
}
#[test]
fn test_export_format_name() {
assert_eq!(ExportFormat::Markdown.name(), "Markdown");
assert_eq!(ExportFormat::Jsonl.name(), "JSONL");
assert_eq!(ExportFormat::Zvec.name(), "Zvec");
}
#[test]
fn test_exporter_config_default() {
let config = ExporterConfig::default();
assert_eq!(config.format, ExportFormat::Jsonl);
assert_eq!(config.filename, "export");
assert!(!config.append);
}
#[test]
fn test_exporter_config_output_path() {
let config = ExporterConfig::new(
PathBuf::from("/tmp/output"),
ExportFormat::Jsonl,
"test_export",
);
assert_eq!(
config.output_path(),
PathBuf::from("/tmp/output/test_export.jsonl")
);
}
#[test]
fn test_exporter_config_with_builder_pattern() {
let config = ExporterConfig::new(PathBuf::from("/data"), ExportFormat::Zvec, "my_data")
.with_append(true)
.with_batch_size(1000);
assert_eq!(config.output_dir, PathBuf::from("/data"));
assert_eq!(config.format, ExportFormat::Zvec);
assert_eq!(config.filename, "my_data");
assert!(config.append);
assert_eq!(config.batch_size, Some(1000));
}
#[test]
fn test_exporter_error_messages() {
let io_error = std::io::Error::new(std::io::ErrorKind::Other, "path error");
let err = ExporterError::DirectoryCreation(io_error);
assert!(err.to_string().to_lowercase().contains("directorio"));
let err = ExporterError::WriteError("disk full".to_string());
assert!(err.to_string().to_lowercase().contains("escritura"));
let err = ExporterError::InvalidConfig("missing path".to_string());
assert!(err.to_string().to_lowercase().contains("inválida"));
}
}