Skip to main content

mdql_core/
migrate.rs

1//! Field migration operations on markdown files.
2//!
3//! Both frontmatter keys and H2 sections are "fields" in MDQL.
4
5use 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
19// ── Section span detection ────────────────────────────────────────────────
20
21struct SectionSpan {
22    normalized_heading: String,
23    heading_line_idx: usize,
24    end_line_idx: usize, // exclusive
25}
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    // Skip frontmatter
34    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    // Fix up end indices
84    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
92// ── Frontmatter field operations ────────────────────────────────────────
93
94fn 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
172// ── Section operations ──────────────────────────────────────────────────
173
174pub 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 &sections {
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    // Remove from bottom up
217    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    // Collect bodies
252    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    // Replace first, delete rest
264    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
299// ── Schema update ─────────────────────────────────────────────────────────
300
301pub 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    // Frontmatter field operations
339    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    // Section operations
358    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    // Re-serialize
405    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}