1use std::path::Path;
6
7use regex::Regex;
8use std::sync::LazyLock;
9
10use crate::errors::MdqlError;
11use crate::parser::{normalize_heading};
12use crate::txn::atomic_write;
13
14static FENCE_OPEN_RE: LazyLock<Regex> =
15 LazyLock::new(|| Regex::new(r"^(`{3,}|~{3,})").unwrap());
16static H2_RE: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"^##\s+(.+)$").unwrap());
18
19struct SectionSpan {
22 normalized_heading: String,
23 heading_line_idx: usize,
24 end_line_idx: usize, }
26
27fn find_sections(lines: &[String], normalize: bool) -> Vec<SectionSpan> {
28 let mut sections = Vec::new();
29 let mut in_fence = false;
30 let mut fence_char: Option<char> = None;
31 let mut fence_width: usize = 0;
32
33 let mut start = 0;
35 if !lines.is_empty() && lines[0].trim() == "---" {
36 for i in 1..lines.len() {
37 if lines[i].trim() == "---" {
38 start = i + 1;
39 break;
40 }
41 }
42 }
43
44 for i in start..lines.len() {
45 let line = &lines[i];
46
47 if let Some(caps) = FENCE_OPEN_RE.captures(line) {
48 let marker = caps.get(1).unwrap().as_str();
49 let char = marker.chars().next().unwrap();
50 let width = marker.len();
51 if !in_fence {
52 in_fence = true;
53 fence_char = Some(char);
54 fence_width = width;
55 continue;
56 } else if Some(char) == fence_char && width >= fence_width && line.trim() == marker {
57 in_fence = false;
58 fence_char = None;
59 fence_width = 0;
60 continue;
61 }
62 }
63
64 if in_fence {
65 continue;
66 }
67
68 if let Some(caps) = H2_RE.captures(line) {
69 let raw_h = caps.get(1).unwrap().as_str().trim().to_string();
70 let norm_h = if normalize {
71 normalize_heading(&raw_h)
72 } else {
73 raw_h.clone()
74 };
75 sections.push(SectionSpan {
76 normalized_heading: norm_h,
77 heading_line_idx: i,
78 end_line_idx: lines.len(),
79 });
80 }
81 }
82
83 for i in 0..sections.len().saturating_sub(1) {
85 let next_start = sections[i + 1].heading_line_idx;
86 sections[i].end_line_idx = next_start;
87 }
88
89 sections
90}
91
92fn find_frontmatter_bounds(lines: &[String]) -> Option<(usize, usize)> {
95 if lines.is_empty() || lines[0].trim() != "---" {
96 return None;
97 }
98 for i in 1..lines.len() {
99 if lines[i].trim() == "---" {
100 return Some((1, i));
101 }
102 }
103 None
104}
105
106pub fn rename_frontmatter_key_in_file(
107 path: &Path,
108 old_key: &str,
109 new_key: &str,
110) -> crate::errors::Result<bool> {
111 let text = std::fs::read_to_string(path)?;
112 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
113 let bounds = match find_frontmatter_bounds(&lines) {
114 Some(b) => b,
115 None => return Ok(false),
116 };
117
118 let pattern = Regex::new(&format!(r"^{}(\s*:.*)$", regex::escape(old_key))).unwrap();
119
120 let mut changed = false;
121 for i in bounds.0..bounds.1 {
122 if let Some(caps) = pattern.captures(&lines[i].clone()) {
123 lines[i] = format!("{}{}", new_key, caps.get(1).unwrap().as_str());
124 changed = true;
125 break;
126 }
127 }
128
129 if changed {
130 atomic_write(path, &lines.join("\n"))?;
131 }
132 Ok(changed)
133}
134
135pub fn drop_frontmatter_key_in_file(
136 path: &Path,
137 key: &str,
138) -> crate::errors::Result<bool> {
139 let text = std::fs::read_to_string(path)?;
140 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
141 let bounds = match find_frontmatter_bounds(&lines) {
142 Some(b) => b,
143 None => return Ok(false),
144 };
145
146 let pattern = Regex::new(&format!(r"^{}\s*:", regex::escape(key))).unwrap();
147
148 let mut key_range = None;
149 for i in bounds.0..bounds.1 {
150 if pattern.is_match(&lines[i]) {
151 let mut end = i + 1;
152 while end < bounds.1
153 && (lines[end].starts_with(' ') || lines[end].starts_with('\t'))
154 {
155 end += 1;
156 }
157 key_range = Some((i, end));
158 break;
159 }
160 }
161
162 match key_range {
163 Some((start, end)) => {
164 lines.drain(start..end);
165 atomic_write(path, &lines.join("\n"))?;
166 Ok(true)
167 }
168 None => Ok(false),
169 }
170}
171
172pub fn rename_section_in_file(
175 path: &Path,
176 old_name: &str,
177 new_name: &str,
178 normalize: bool,
179) -> crate::errors::Result<bool> {
180 let text = std::fs::read_to_string(path)?;
181 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
182 let sections = find_sections(&lines, normalize);
183
184 let mut changed = false;
185 for sec in §ions {
186 if sec.normalized_heading == old_name {
187 lines[sec.heading_line_idx] = format!("## {}", new_name);
188 changed = true;
189 }
190 }
191
192 if changed {
193 atomic_write(path, &lines.join("\n"))?;
194 }
195 Ok(changed)
196}
197
198pub fn drop_section_in_file(
199 path: &Path,
200 section_name: &str,
201 normalize: bool,
202) -> crate::errors::Result<bool> {
203 let text = std::fs::read_to_string(path)?;
204 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
205 let sections = find_sections(&lines, normalize);
206
207 let to_remove: Vec<_> = sections
208 .iter()
209 .filter(|s| s.normalized_heading == section_name)
210 .collect();
211
212 if to_remove.is_empty() {
213 return Ok(false);
214 }
215
216 for sec in to_remove.iter().rev() {
218 let mut start = sec.heading_line_idx;
219 let end = sec.end_line_idx;
220 if start > 0 && lines[start - 1].trim().is_empty() {
221 start -= 1;
222 }
223 lines.drain(start..end);
224 }
225
226 atomic_write(path, &lines.join("\n"))?;
227 Ok(true)
228}
229
230pub fn merge_sections_in_file(
231 path: &Path,
232 source_names: &[String],
233 into: &str,
234 normalize: bool,
235) -> crate::errors::Result<bool> {
236 let text = std::fs::read_to_string(path)?;
237 let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
238 let sections = find_sections(&lines, normalize);
239
240 let all_names: std::collections::HashSet<&str> =
241 source_names.iter().map(|s| s.as_str()).collect();
242 let matching: Vec<_> = sections
243 .iter()
244 .filter(|s| all_names.contains(s.normalized_heading.as_str()))
245 .collect();
246
247 if matching.len() < 2 {
248 return Ok(false);
249 }
250
251 let mut bodies = Vec::new();
253 for sec in &matching {
254 let body_lines = &lines[sec.heading_line_idx + 1..sec.end_line_idx];
255 let body = body_lines.join("\n").trim().to_string();
256 if !body.is_empty() {
257 bodies.push(body);
258 }
259 }
260
261 let merged_body = bodies.join("\n\n");
262
263 let target = matching[0];
265 let to_delete: Vec<_> = matching[1..].iter().collect();
266
267 let target_replacement = vec![
268 format!("## {}", into),
269 String::new(),
270 merged_body,
271 String::new(),
272 ];
273
274 let old_span = target.end_line_idx - target.heading_line_idx;
275 let new_span = target_replacement.len();
276
277 lines.splice(
278 target.heading_line_idx..target.end_line_idx,
279 target_replacement,
280 );
281
282 let mut shift = new_span as i64 - old_span as i64;
283
284 for sec in to_delete.iter().rev() {
285 let mut adj_start = (sec.heading_line_idx as i64 + shift) as usize;
286 let adj_end = (sec.end_line_idx as i64 + shift) as usize;
287 if adj_start > 0 && lines[adj_start - 1].trim().is_empty() {
288 adj_start -= 1;
289 }
290 let removed = adj_end - adj_start;
291 lines.drain(adj_start..adj_end);
292 shift -= removed as i64;
293 }
294
295 atomic_write(path, &lines.join("\n"))?;
296 Ok(true)
297}
298
299pub fn update_schema(
302 schema_path: &Path,
303 rename_frontmatter: Option<(&str, &str)>,
304 drop_frontmatter: Option<&str>,
305 rename_section: Option<(&str, &str)>,
306 drop_section: Option<&str>,
307 merge_sections: Option<(&[String], &str)>,
308) -> crate::errors::Result<()> {
309 let text = std::fs::read_to_string(schema_path)?;
310 let file_lines: Vec<&str> = text.split('\n').collect();
311
312 if file_lines.is_empty() || file_lines[0].trim() != "---" {
313 return Ok(());
314 }
315
316 let mut end_idx = None;
317 for i in 1..file_lines.len() {
318 if file_lines[i].trim() == "---" {
319 end_idx = Some(i);
320 break;
321 }
322 }
323
324 let end_idx = match end_idx {
325 Some(i) => i,
326 None => return Ok(()),
327 };
328
329 let fm_text = file_lines[1..end_idx].join("\n");
330 let mut fm: serde_yaml::Value =
331 serde_yaml::from_str(&fm_text).unwrap_or(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
332
333 let fm_map = match fm.as_mapping_mut() {
334 Some(m) => m,
335 None => return Err(MdqlError::General("schema frontmatter is not a YAML mapping".into())),
336 };
337
338 let fm_key = serde_yaml::Value::String("frontmatter".into());
340 let fm_fields = fm_map
341 .entry(fm_key.clone())
342 .or_insert(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
343
344 if let Some(fields_map) = fm_fields.as_mapping_mut() {
345 if let Some((old, new)) = rename_frontmatter {
346 let old_key = serde_yaml::Value::String(old.to_string());
347 let new_key = serde_yaml::Value::String(new.to_string());
348 if let Some(val) = fields_map.remove(&old_key) {
349 fields_map.insert(new_key, val);
350 }
351 }
352 if let Some(key) = drop_frontmatter {
353 fields_map.remove(&serde_yaml::Value::String(key.to_string()));
354 }
355 }
356
357 let sec_key = serde_yaml::Value::String("sections".into());
359 let sections = fm_map
360 .entry(sec_key.clone())
361 .or_insert(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
362
363 if let Some(sections_map) = sections.as_mapping_mut() {
364 if let Some((old, new)) = rename_section {
365 let old_key = serde_yaml::Value::String(old.to_string());
366 let new_key = serde_yaml::Value::String(new.to_string());
367 if let Some(val) = sections_map.remove(&old_key) {
368 sections_map.insert(new_key, val);
369 }
370 }
371 if let Some(key) = drop_section {
372 sections_map.remove(&serde_yaml::Value::String(key.to_string()));
373 }
374 if let Some((sources, target)) = merge_sections {
375 let mut target_config = None;
376 for s in sources {
377 let k = serde_yaml::Value::String(s.clone());
378 if target_config.is_none() {
379 target_config = sections_map.get(&k).cloned();
380 }
381 }
382 let target_config = target_config.unwrap_or_else(|| {
383 let mut m = serde_yaml::Mapping::new();
384 m.insert(
385 serde_yaml::Value::String("type".into()),
386 serde_yaml::Value::String("markdown".into()),
387 );
388 m.insert(
389 serde_yaml::Value::String("required".into()),
390 serde_yaml::Value::Bool(false),
391 );
392 serde_yaml::Value::Mapping(m)
393 });
394 for s in sources {
395 sections_map.remove(&serde_yaml::Value::String(s.clone()));
396 }
397 sections_map.insert(
398 serde_yaml::Value::String(target.to_string()),
399 target_config,
400 );
401 }
402 }
403
404 let new_fm = serde_yaml::to_string(&fm).unwrap_or_default();
406 let new_fm = new_fm.trim_end();
407
408 let mut new_lines = vec!["---".to_string(), new_fm.to_string(), "---".to_string()];
409 for line in &file_lines[end_idx + 1..] {
410 new_lines.push(line.to_string());
411 }
412
413 atomic_write(schema_path, &new_lines.join("\n"))?;
414 Ok(())
415}