rustledger-importer 0.8.0

Import framework for rustledger - extract transactions from bank files
Documentation
//! Registry for importers.

use crate::{ImportResult, Importer};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;

/// Registry of importers.
///
/// The registry holds a collection of importers and can automatically
/// identify which importer to use for a given file.
pub struct ImporterRegistry {
    importers: Vec<Arc<dyn Importer>>,
}

impl ImporterRegistry {
    /// Create a new empty registry.
    pub fn new() -> Self {
        Self {
            importers: Vec::new(),
        }
    }

    /// Register a new importer.
    pub fn register(&mut self, importer: impl Importer + 'static) {
        self.importers.push(Arc::new(importer));
    }

    /// Find an importer that can handle the given file.
    pub fn identify(&self, path: &Path) -> Option<Arc<dyn Importer>> {
        for importer in &self.importers {
            if importer.identify(path) {
                return Some(Arc::clone(importer));
            }
        }
        None
    }

    /// Extract transactions from a file using the appropriate importer.
    pub fn extract(&self, path: &Path) -> Result<ImportResult> {
        let importer = self
            .identify(path)
            .with_context(|| format!("No importer found for file: {}", path.display()))?;

        importer
            .extract(path)
            .with_context(|| format!("Failed to extract from: {}", path.display()))
    }

    /// List all registered importers.
    pub fn list_importers(&self) -> Vec<(&str, &str)> {
        self.importers
            .iter()
            .map(|i| (i.name(), i.description()))
            .collect()
    }

    /// Get the number of registered importers.
    pub fn len(&self) -> usize {
        self.importers.len()
    }

    /// Check if the registry is empty.
    pub fn is_empty(&self) -> bool {
        self.importers.is_empty()
    }
}

impl Default for ImporterRegistry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    struct MockImporter {
        name: &'static str,
        extension: &'static str,
    }

    impl Importer for MockImporter {
        fn name(&self) -> &str {
            self.name
        }

        fn identify(&self, path: &Path) -> bool {
            path.extension().is_some_and(|ext| ext == self.extension)
        }

        fn extract(&self, _path: &Path) -> Result<ImportResult> {
            Ok(ImportResult::empty())
        }

        fn description(&self) -> &'static str {
            "Mock importer for testing"
        }
    }

    #[test]
    fn test_registry_basic() {
        let mut registry = ImporterRegistry::new();
        assert!(registry.is_empty());

        registry.register(MockImporter {
            name: "CSV",
            extension: "csv",
        });
        registry.register(MockImporter {
            name: "OFX",
            extension: "ofx",
        });

        assert_eq!(registry.len(), 2);
        assert!(!registry.is_empty());
    }

    #[test]
    fn test_registry_identify() {
        let mut registry = ImporterRegistry::new();
        registry.register(MockImporter {
            name: "CSV",
            extension: "csv",
        });
        registry.register(MockImporter {
            name: "OFX",
            extension: "ofx",
        });

        let csv_path = Path::new("transactions.csv");
        let ofx_path = Path::new("statement.ofx");
        let unknown_path = Path::new("document.pdf");

        assert!(registry.identify(csv_path).is_some());
        assert_eq!(registry.identify(csv_path).unwrap().name(), "CSV");

        assert!(registry.identify(ofx_path).is_some());
        assert_eq!(registry.identify(ofx_path).unwrap().name(), "OFX");

        assert!(registry.identify(unknown_path).is_none());
    }

    #[test]
    fn test_registry_default() {
        let registry = ImporterRegistry::default();
        assert!(registry.is_empty());
        assert_eq!(registry.len(), 0);
    }

    #[test]
    fn test_registry_list_importers() {
        let mut registry = ImporterRegistry::new();
        registry.register(MockImporter {
            name: "CSV",
            extension: "csv",
        });
        registry.register(MockImporter {
            name: "OFX",
            extension: "ofx",
        });

        let list = registry.list_importers();
        assert_eq!(list.len(), 2);
        assert!(list.iter().any(|(name, _)| *name == "CSV"));
        assert!(list.iter().any(|(name, _)| *name == "OFX"));
        // Check descriptions are present
        for (_, desc) in &list {
            assert_eq!(*desc, "Mock importer for testing");
        }
    }

    #[test]
    fn test_registry_extract_unknown_file() {
        let registry = ImporterRegistry::new();
        let unknown_path = Path::new("document.pdf");
        let result = registry.extract(unknown_path);
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("No importer found")
        );
    }

    #[test]
    fn test_registry_identify_returns_first_match() {
        let mut registry = ImporterRegistry::new();
        // Register two importers that match the same extension
        registry.register(MockImporter {
            name: "CSV1",
            extension: "csv",
        });
        registry.register(MockImporter {
            name: "CSV2",
            extension: "csv",
        });

        let csv_path = Path::new("transactions.csv");
        let importer = registry.identify(csv_path).unwrap();
        // Should return the first matching importer
        assert_eq!(importer.name(), "CSV1");
    }

    #[test]
    fn test_registry_empty_list_importers() {
        let registry = ImporterRegistry::new();
        let list = registry.list_importers();
        assert!(list.is_empty());
    }
}