cp-parser 0.3.1

Document parsing for PDF, Markdown, and text files
Documentation
use cp_core::{text, CPError, Result};
use std::path::Path;

/// Trait for document parsers
pub trait Parser: Send + Sync {
    /// Parse a file and return its text content
    fn parse(&self, path: &Path) -> Result<String>;

    /// Get supported file extensions
    fn supported_extensions(&self) -> &[&str];
}

/// Registry of available parsers
pub struct ParserRegistry {
    parsers: Vec<Box<dyn Parser>>,
}

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

impl ParserRegistry {
    /// Create a new registry with default parsers
    pub fn new() -> Self {
        Self {
            parsers: vec![
                Box::new(MarkdownParser),
                Box::new(TextParser),
                Box::new(PdfParser),
                Box::new(DocxParser),
            ],
        }
    }

    /// Find a parser for the given file extension
    pub fn find_parser(&self, extension: &str) -> Option<&dyn Parser> {
        for parser in &self.parsers {
            if parser
                .supported_extensions()
                .iter()
                .any(|e| e.eq_ignore_ascii_case(extension))
            {
                return Some(parser.as_ref());
            }
        }
        None
    }
}

/// Parse a file using the appropriate parser
pub fn parse_file(path: &Path) -> Result<String> {
    let registry = ParserRegistry::new();

    let extension = path
        .extension()
        .and_then(|e| e.to_str())
        .ok_or_else(|| CPError::Parse("No file extension".into()))?;

    let parser = registry
        .find_parser(extension)
        .ok_or_else(|| CPError::Parse(format!("No parser for extension: {extension}")))?;

    parser.parse(path)
}

/// Markdown parser using pulldown-cmark
struct MarkdownParser;

impl Parser for MarkdownParser {
    fn parse(&self, path: &Path) -> Result<String> {
        let content = std::fs::read_to_string(path)?;

        // Convert markdown to plain text
        let parser = pulldown_cmark::Parser::new(&content);
        let mut text = String::new();

        for event in parser {
            match event {
                pulldown_cmark::Event::Start(pulldown_cmark::Tag::Heading { level, .. }) => {
                    text.push('\n');
                    let level_str = match level {
                        pulldown_cmark::HeadingLevel::H1 => "# ",
                        pulldown_cmark::HeadingLevel::H2 => "## ",
                        pulldown_cmark::HeadingLevel::H3 => "### ",
                        pulldown_cmark::HeadingLevel::H4 => "#### ",
                        pulldown_cmark::HeadingLevel::H5 => "##### ",
                        pulldown_cmark::HeadingLevel::H6 => "###### ",
                    };
                    text.push_str(level_str);
                }
                pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Heading(_))
                | pulldown_cmark::Event::SoftBreak
                | pulldown_cmark::Event::HardBreak => {
                    text.push('\n');
                }
                pulldown_cmark::Event::Text(t) | pulldown_cmark::Event::Code(t) => {
                    text.push_str(&t);
                }
                pulldown_cmark::Event::End(pulldown_cmark::TagEnd::Paragraph) => {
                    text.push_str("\n\n");
                }
                _ => {}
            }
        }

        Ok(text::normalize(&text))
    }

    fn supported_extensions(&self) -> &[&str] {
        &["md", "markdown"]
    }
}

/// Plain text parser
struct TextParser;

impl Parser for TextParser {
    fn parse(&self, path: &Path) -> Result<String> {
        let content = std::fs::read_to_string(path)?;
        Ok(text::normalize(&content))
    }

    fn supported_extensions(&self) -> &[&str] {
        &["txt", "text"]
    }
}

/// PDF parser using pdf-extract
struct PdfParser;

impl Parser for PdfParser {
    fn parse(&self, path: &Path) -> Result<String> {
        let bytes = std::fs::read(path)?;
        let text = pdf_extract::extract_text_from_mem(&bytes)
            .map_err(|e| CPError::Parse(format!("PDF extraction failed: {e}")))?;
        Ok(text::normalize(&text))
    }

    fn supported_extensions(&self) -> &[&str] {
        &["pdf"]
    }
}

/// Word document parser (.docx) using dotext
struct DocxParser;

impl Parser for DocxParser {
    fn parse(&self, path: &Path) -> Result<String> {
        use dotext::MsDoc;
        use std::io::Read;

        let mut doc: dotext::Docx = dotext::Docx::open(path)
            .map_err(|e| CPError::Parse(format!("DOCX open failed: {e}")))?;
        let mut content = String::new();
        doc.read_to_string(&mut content)
            .map_err(|e| CPError::Parse(format!("DOCX read failed: {e}")))?;
        Ok(text::normalize(&content))
    }

    fn supported_extensions(&self) -> &[&str] {
        &["docx"]
    }
}

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

    #[test]
    fn test_registry_finds_markdown() {
        let registry = ParserRegistry::new();
        assert!(registry.find_parser("md").is_some());
        assert!(registry.find_parser("markdown").is_some());
    }

    #[test]
    fn test_registry_finds_pdf() {
        let registry = ParserRegistry::new();
        assert!(registry.find_parser("pdf").is_some());
    }

    #[test]
    fn test_unknown_extension() {
        let registry = ParserRegistry::new();
        assert!(registry.find_parser("xyz").is_none());
    }
}