episteme-local 0.1.1

Local-first knowledge ingestion and intelligence workflows
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;
use episteme::classification::ClassificationPipeline;
use episteme::domain::{
    AnalysisProvenance, ClassificationRecord, DocumentClassification, DocumentKind, DomainError,
    ExtractedDocument, ExtractionMethod, SourceDigest, SourceFileName, StagedSource,
};
use episteme::ports::{
    AnalysisError, ClassificationStore, ClassificationStoreError, DocumentClassifier,
    DocumentExtractor, ExtractionError,
};

#[test]
fn classification_rejects_missing_required_fields() {
    let invalid = [
        DocumentClassification {
            title: " ".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: "paper".to_owned(),
            language: "en".to_owned(),
            topics: vec!["agents".to_owned()],
        },
        DocumentClassification {
            title: "Agent Systems".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: String::new(),
            language: "en".to_owned(),
            topics: vec!["agents".to_owned()],
        },
        DocumentClassification {
            title: "Agent Systems".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: "paper".to_owned(),
            language: String::new(),
            topics: vec!["agents".to_owned()],
        },
        DocumentClassification {
            title: "Agent Systems".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: "paper".to_owned(),
            language: "en".to_owned(),
            topics: Vec::new(),
        },
        DocumentClassification {
            title: "Agent\nSystems".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: "paper".to_owned(),
            language: "en".to_owned(),
            topics: vec!["agents".to_owned()],
        },
        DocumentClassification {
            title: "Agent Systems".to_owned(),
            authors: vec!["Researcher".to_owned()],
            source_type: "paper".to_owned(),
            language: "en".to_owned(),
            topics: vec!["x".repeat(129)],
        },
    ];

    for classification in invalid {
        assert_eq!(
            classification.validate(),
            Err(DomainError::InvalidClassification)
        );
    }
}

#[derive(Debug)]
struct FakeExtractor {
    calls: Arc<AtomicUsize>,
}

#[async_trait]
impl DocumentExtractor for FakeExtractor {
    async fn extract(&self, source: &StagedSource) -> Result<ExtractedDocument, ExtractionError> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        ExtractedDocument::new(
            source.clone(),
            "Agent systems source text",
            ExtractionMethod::HtmlPandoc,
        )
        .map_err(|error| ExtractionError::Invalid(error.to_string()))
    }
}

#[derive(Debug)]
struct FakeClassifier {
    calls: Arc<AtomicUsize>,
}

#[async_trait]
impl DocumentClassifier for FakeClassifier {
    async fn classify(
        &self,
        _document: &ExtractedDocument,
    ) -> Result<DocumentClassification, AnalysisError> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        Ok(valid_classification())
    }
}

#[derive(Debug, Default)]
struct FakeClassificationStore {
    record: Mutex<Option<ClassificationRecord>>,
}

impl ClassificationStore for FakeClassificationStore {
    fn load(
        &self,
        _digest: &SourceDigest,
    ) -> Result<Option<ClassificationRecord>, ClassificationStoreError> {
        self.record
            .lock()
            .map(|record| record.clone())
            .map_err(|_| ClassificationStoreError("fake store lock was poisoned".to_owned()))
    }

    fn save(&self, record: &ClassificationRecord) -> Result<(), ClassificationStoreError> {
        *self
            .record
            .lock()
            .map_err(|_| ClassificationStoreError("fake store lock was poisoned".to_owned()))? =
            Some(record.clone());
        Ok(())
    }
}

#[tokio::test]
async fn classification_pipeline_persists_and_reuses_digest_records()
-> Result<(), Box<dyn std::error::Error>> {
    let extractor_calls = Arc::new(AtomicUsize::new(0));
    let classifier_calls = Arc::new(AtomicUsize::new(0));
    let pipeline = ClassificationPipeline::new(
        FakeExtractor {
            calls: Arc::clone(&extractor_calls),
        },
        FakeClassifier {
            calls: Arc::clone(&classifier_calls),
        },
        FakeClassificationStore::default(),
    );
    let source = staged_source()?;
    let provenance = AnalysisProvenance {
        function: "ClassifyDocument".to_owned(),
        client: "LocalClassifier".to_owned(),
        model: "local-model".to_owned(),
        pipeline_version: "0.1.0".to_owned(),
        processed_at: "2026-09-19T00:00:00Z".to_owned(),
    };

    let first = pipeline.classify(&source, provenance.clone()).await?;
    let second = pipeline.classify(&source, provenance).await?;

    assert_eq!(first, second);
    assert_eq!(first.classification, valid_classification());
    assert_eq!(extractor_calls.load(Ordering::SeqCst), 1);
    assert_eq!(classifier_calls.load(Ordering::SeqCst), 1);
    Ok(())
}

fn valid_classification() -> DocumentClassification {
    DocumentClassification {
        title: "Agent Systems".to_owned(),
        authors: vec!["Researcher".to_owned()],
        source_type: "paper".to_owned(),
        language: "en".to_owned(),
        topics: vec!["agents".to_owned()],
    }
}

fn staged_source() -> Result<StagedSource, DomainError> {
    Ok(StagedSource::new(
        PathBuf::from("paper.html"),
        SourceDigest::from_bytes(b"source"),
        SourceFileName::new("paper.html")?,
        DocumentKind::Html,
    ))
}