lc_rag/loaders/
markdown.rs1use super::{Document, DocumentLoader, LoaderError};
7use async_trait::async_trait;
8use std::path::PathBuf;
9
10fn heading_regex(level: usize) -> &'static regex::Regex {
12 use std::sync::LazyLock;
13 static HEADING_RE: [LazyLock<regex::Regex>; 6] = [
14 LazyLock::new(|| regex::Regex::new(r"^#[ \t]+(.+)").unwrap()),
15 LazyLock::new(|| regex::Regex::new(r"^##[ \t]+(.+)").unwrap()),
16 LazyLock::new(|| regex::Regex::new(r"^###[ \t]+(.+)").unwrap()),
17 LazyLock::new(|| regex::Regex::new(r"^####[ \t]+(.+)").unwrap()),
18 LazyLock::new(|| regex::Regex::new(r"^#####[ \t]+(.+)").unwrap()),
19 LazyLock::new(|| regex::Regex::new(r"^######[ \t]+(.+)").unwrap()),
20 ];
21 &HEADING_RE[level.saturating_sub(1).min(5)]
22}
23
24pub struct MarkdownLoader {
28 pub path: PathBuf,
30
31 pub split_by_heading: bool,
34
35 pub heading_level: usize,
38}
39
40impl MarkdownLoader {
41 pub fn new(path: impl Into<PathBuf>) -> Self {
46 Self {
47 path: path.into(),
48 split_by_heading: false,
49 heading_level: 1,
50 }
51 }
52
53 pub fn new_with_heading_split(path: impl Into<PathBuf>, heading_level: usize) -> Self {
59 Self {
60 path: path.into(),
61 split_by_heading: true,
62 heading_level: heading_level.clamp(1, 6),
63 }
64 }
65
66 pub fn with_split_by_heading(mut self, split: bool) -> Self {
68 self.split_by_heading = split;
69 self
70 }
71
72 pub fn with_heading_level(mut self, level: usize) -> Self {
74 self.heading_level = level.clamp(1, 6);
75 self
76 }
77}
78
79#[async_trait]
80impl DocumentLoader for MarkdownLoader {
81 async fn load(&self) -> Result<Vec<Document>, LoaderError> {
82 if !self.path.exists() {
83 return Err(LoaderError::Other(format!(
84 "markdown file does not exist: {}",
85 self.path.display()
86 )));
87 }
88
89 let content = std::fs::read_to_string(&self.path)?;
90
91 if self.split_by_heading {
92 self.split_by_headings(&content)
93 } else {
94 let mut doc = Document::new(content);
95 doc = doc.with_metadata("source", self.path.display().to_string());
96 doc = doc.with_metadata("format", "markdown".to_string());
97 Ok(vec![doc])
98 }
99 }
100}
101
102impl MarkdownLoader {
103 fn split_by_headings(&self, content: &str) -> Result<Vec<Document>, LoaderError> {
104 let heading_regex = heading_regex(self.heading_level);
105
106 let mut documents = Vec::new();
107 let mut sections: Vec<(String, String)> = Vec::new();
108 let mut current_title = "Untitled".to_string();
109 let mut current_content = String::new();
110
111 for line in content.lines() {
112 if let Some(caps) = heading_regex.captures(line) {
113 if !current_content.trim().is_empty() {
114 sections.push((current_title.clone(), current_content.trim().to_string()));
115 }
116 current_title = caps
117 .get(1)
118 .map(|m| m.as_str().trim().to_string())
119 .unwrap_or_else(|| "Untitled".to_string());
120 current_content = String::new();
121 } else if !line.trim().is_empty() {
122 current_content.push_str(line);
123 current_content.push('\n');
124 }
125 }
126
127 if !current_content.trim().is_empty() {
128 sections.push((current_title, current_content.trim().to_string()));
129 }
130
131 for (title, section_content) in sections {
132 if section_content.trim().is_empty() {
133 continue;
134 }
135
136 let mut doc = Document::new(section_content);
137 doc = doc.with_metadata("source", self.path.display().to_string());
138 doc = doc.with_metadata("format", "markdown".to_string());
139 doc = doc.with_metadata("heading", title);
140 doc = doc.with_metadata("heading_level", self.heading_level.to_string());
141
142 documents.push(doc);
143 }
144
145 Ok(documents)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::io::Write;
153 use tempfile::NamedTempFile;
154
155 #[tokio::test]
156 async fn test_markdown_loader_nonexistent() {
157 let loader = MarkdownLoader::new("./nonexistent.md");
158 let result = loader.load().await;
159
160 assert!(result.is_err());
161 }
162
163 #[tokio::test]
164 async fn test_markdown_loader_single_document() {
165 let mut temp_file = NamedTempFile::new().unwrap();
166 write!(temp_file, "# Title\n\nContent here.").unwrap();
167
168 let loader = MarkdownLoader::new(temp_file.path());
169 let result = loader.load().await;
170
171 assert!(result.is_ok());
172 let docs = result.unwrap();
173 assert_eq!(docs.len(), 1);
174 assert!(docs[0].content.contains("Title"));
175 assert_eq!(
176 docs[0].metadata.get("format"),
177 Some(&serde_json::Value::String("markdown".to_string()))
178 );
179 }
180
181 #[tokio::test]
182 async fn test_markdown_loader_split_by_heading() {
183 let mut temp_file = NamedTempFile::new().unwrap();
184 writeln!(temp_file, "# Section 1").unwrap();
185 writeln!(temp_file, "Content for section 1.").unwrap();
186 writeln!(temp_file).unwrap();
187 writeln!(temp_file, "# Section 2").unwrap();
188 writeln!(temp_file, "Content for section 2.").unwrap();
189
190 let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 1);
191 let result = loader.load().await;
192
193 assert!(result.is_ok());
194 let docs = result.unwrap();
195 assert_eq!(docs.len(), 2);
196 assert_eq!(
197 docs[0].metadata.get("heading"),
198 Some(&serde_json::Value::String("Section 1".to_string()))
199 );
200 assert_eq!(
201 docs[1].metadata.get("heading"),
202 Some(&serde_json::Value::String("Section 2".to_string()))
203 );
204 }
205
206 #[tokio::test]
207 async fn test_markdown_loader_heading_level_2() {
208 let mut temp_file = NamedTempFile::new().unwrap();
209 writeln!(temp_file, "# Main Title").unwrap();
210 writeln!(temp_file, "Intro.").unwrap();
211 writeln!(temp_file).unwrap();
212 writeln!(temp_file, "## Subsection 1").unwrap();
213 writeln!(temp_file, "Sub content 1.").unwrap();
214 writeln!(temp_file).unwrap();
215 writeln!(temp_file, "## Subsection 2").unwrap();
216 writeln!(temp_file, "Sub content 2.").unwrap();
217
218 let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 2);
219 let result = loader.load().await;
220
221 assert!(result.is_ok());
222 let docs = result.unwrap();
223 assert_eq!(docs.len(), 3);
224 assert!(docs[0].content.contains("Main Title"));
225 }
226}