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