Skip to main content

mdql_core/
api.rs

1//! Object-oriented API for MDQL databases and tables.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use regex::Regex;
7use std::sync::LazyLock;
8
9use crate::database::{DatabaseConfig, load_database_config};
10use crate::errors::{MdqlError, ValidationError};
11use crate::migrate;
12use crate::model::{Row, Value};
13
14pub use crate::migrate::{
15    rename_frontmatter_key_in_file, drop_frontmatter_key_in_file,
16    rename_section_in_file, drop_section_in_file,
17    merge_sections_in_file, update_schema,
18};
19use crate::parser::parse_file;
20use crate::query_engine::{evaluate, sql_value_to_value};
21use crate::query_parser::*;
22use crate::schema::{FieldType, Schema, MDQL_FILENAME, load_schema};
23use crate::stamp::TIMESTAMP_FIELDS;
24use crate::txn::{TableLock, TableTransaction, atomic_write, recover_journal, with_multi_file_txn};
25use crate::validator::validate_file;
26
27static SLUGIFY_NON_WORD: LazyLock<Regex> =
28    LazyLock::new(|| Regex::new(r"[^\w\s-]").unwrap());
29static SLUGIFY_WHITESPACE: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"[\s_]+").unwrap());
31static SLUGIFY_MULTI_DASH: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"-+").unwrap());
33
34fn normalize_filename(name: &str) -> String {
35    if name.ends_with(".md") { name.to_string() } else { format!("{}.md", name) }
36}
37
38pub fn slugify(text: &str, max_length: usize) -> String {
39    let slug = text.to_lowercase();
40    let slug = slug.trim();
41    let slug = SLUGIFY_NON_WORD.replace_all(&slug, "");
42    let slug = SLUGIFY_WHITESPACE.replace_all(&slug, "-");
43    let slug = SLUGIFY_MULTI_DASH.replace_all(&slug, "-");
44    let slug = slug.trim_matches('-').to_string();
45    if slug.len() > max_length {
46        slug[..max_length].trim_end_matches('-').to_string()
47    } else {
48        slug
49    }
50}
51
52fn write_and_validate(
53    filepath: &Path,
54    content: &str,
55    old_content: Option<&str>,
56    schema: &Schema,
57    table_path: &Path,
58) -> crate::errors::Result<()> {
59    atomic_write(filepath, content)?;
60
61    let parsed = parse_file(filepath, Some(table_path), schema.rules.normalize_numbered_headings)?;
62    let errors = validate_file(&parsed, schema);
63
64    if !errors.is_empty() {
65        if let Some(old) = old_content {
66            atomic_write(filepath, old)?;
67        } else {
68            let _ = std::fs::remove_file(filepath);
69        }
70        let msgs: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
71        return Err(MdqlError::General(format!("Validation failed: {}", msgs.join("; "))));
72    }
73
74    Ok(())
75}
76
77fn format_yaml_value(value: &Value, field_type: &FieldType) -> String {
78    match (value, field_type) {
79        (Value::String(s), FieldType::String) => format!("\"{}\"", s),
80        (Value::String(s), FieldType::Date) => format!("\"{}\"", s),
81        (Value::String(s), FieldType::DateTime) => format!("\"{}\"", s),
82        (Value::Date(d), _) => format!("\"{}\"", d.format("%Y-%m-%d")),
83        (Value::DateTime(dt), _) => format!("\"{}\"", dt.format("%Y-%m-%dT%H:%M:%S")),
84        (Value::Int(n), _) => n.to_string(),
85        (Value::Float(f), _) => format!("{}", f),
86        (Value::Bool(b), _) => if *b { "true" } else { "false" }.to_string(),
87        (Value::List(items), _) => {
88            if items.is_empty() {
89                "[]".to_string()
90            } else {
91                let list: Vec<String> = items.iter().map(|i| format!("  - {}", i)).collect();
92                format!("\n{}", list.join("\n"))
93            }
94        }
95        (Value::Dict(map), _) => {
96            if map.is_empty() {
97                "{}".to_string()
98            } else {
99                let lines: Vec<String> = map.iter()
100                    .map(|(k, v)| {
101                        let val_str = match v {
102                            Value::String(s) => format!("\"{}\"", s),
103                            Value::Int(n) => n.to_string(),
104                            Value::Float(f) => format!("{}", f),
105                            Value::Bool(b) => b.to_string(),
106                            _ => v.to_display_string(),
107                        };
108                        format!("  {}: {}", k, val_str)
109                    })
110                    .collect();
111                format!("\n{}", lines.join("\n"))
112            }
113        }
114        (Value::Null, _) => "null".to_string(),
115        _ => value.to_display_string(),
116    }
117}
118
119fn serialize_frontmatter(
120    data: &HashMap<String, Value>,
121    schema: &Schema,
122    preserve_created: Option<&str>,
123) -> String {
124    let now = chrono::Local::now().naive_local().format("%Y-%m-%dT%H:%M:%S").to_string();
125
126    let mut fm_lines: Vec<String> = Vec::new();
127
128    for (name, field_def) in &schema.frontmatter {
129        if TIMESTAMP_FIELDS.contains(&name.as_str()) {
130            continue;
131        }
132        if let Some(val) = data.get(name) {
133            let formatted = format_yaml_value(val, &field_def.field_type);
134            let is_multiline_list = matches!(field_def.field_type, FieldType::StringArray) && !matches!(val, Value::List(items) if items.is_empty());
135            let is_multiline_dict = matches!(field_def.field_type, FieldType::Dict) && !matches!(val, Value::Dict(m) if m.is_empty());
136            if is_multiline_list || is_multiline_dict {
137                fm_lines.push(format!("{}:{}", name, formatted));
138            } else {
139                fm_lines.push(format!("{}: {}", name, formatted));
140            }
141        }
142    }
143
144    // Also write any unknown frontmatter keys that were in data
145    for (name, val) in data {
146        if !schema.frontmatter.contains_key(name)
147            && !TIMESTAMP_FIELDS.contains(&name.as_str())
148            && !schema.sections.contains_key(name)
149            && name != "path"
150            && name != "h1"
151        {
152            fm_lines.push(format!("{}: \"{}\"", name, val.to_display_string()));
153        }
154    }
155
156    let created = preserve_created
157        .map(|s| s.to_string())
158        .unwrap_or_else(|| {
159            data.get("created")
160                .map(|v| v.to_display_string())
161                .unwrap_or_else(|| now.clone())
162        });
163    fm_lines.push(format!("created: \"{}\"", created));
164    fm_lines.push(format!("modified: \"{}\"", now));
165
166    format!("---\n{}\n---\n", fm_lines.join("\n"))
167}
168
169fn serialize_body(data: &HashMap<String, Value>, schema: &Schema) -> String {
170    let mut body = String::new();
171
172    if schema.h1_required {
173        let h1_text = if let Some(ref field) = schema.h1_must_equal_frontmatter {
174            data.get(field)
175                .map(|v| v.to_display_string())
176                .unwrap_or_default()
177        } else {
178            data.get("h1")
179                .or_else(|| data.get("title"))
180                .map(|v| v.to_display_string())
181                .unwrap_or_default()
182        };
183        body.push_str(&format!("\n# {}\n", h1_text));
184    }
185
186    for (name, section_def) in &schema.sections {
187        let section_body = data
188            .get(name)
189            .map(|v| v.to_display_string())
190            .unwrap_or_default();
191        if section_def.required || !section_body.is_empty() {
192            body.push_str(&format!("\n## {}\n\n{}\n", name, section_body));
193        }
194    }
195
196    body
197}
198
199fn read_existing(filepath: &Path) -> crate::errors::Result<(HashMap<String, String>, String)> {
200    let text = std::fs::read_to_string(filepath)?;
201    let lines: Vec<&str> = text.split('\n').collect();
202
203    if lines.is_empty() || lines[0].trim() != "---" {
204        return Err(MdqlError::General(format!(
205            "No frontmatter in {}",
206            filepath.file_name().unwrap_or_default().to_string_lossy()
207        )));
208    }
209
210    let mut end_idx = None;
211    for i in 1..lines.len() {
212        if lines[i].trim() == "---" {
213            end_idx = Some(i);
214            break;
215        }
216    }
217
218    let end_idx = end_idx.ok_or_else(|| {
219        MdqlError::General(format!(
220            "Unclosed frontmatter in {}",
221            filepath.file_name().unwrap_or_default().to_string_lossy()
222        ))
223    })?;
224
225    let fm_text = lines[1..end_idx].join("\n");
226    let fm: serde_yaml::Value = serde_yaml::from_str(&fm_text).unwrap_or(serde_yaml::Value::Null);
227    let mut fm_map = HashMap::new();
228    if let Some(mapping) = fm.as_mapping() {
229        for (k, v) in mapping {
230            if let Some(key) = k.as_str() {
231                let val = match v {
232                    serde_yaml::Value::String(s) => s.clone(),
233                    serde_yaml::Value::Number(n) => n.to_string(),
234                    serde_yaml::Value::Bool(b) => b.to_string(),
235                    _ => format!("{:?}", v),
236                };
237                fm_map.insert(key.to_string(), val);
238            }
239        }
240    }
241
242    let raw_body = lines[end_idx + 1..].join("\n");
243    Ok((fm_map, raw_body))
244}
245
246pub fn coerce_cli_value(raw: &str, field_type: &FieldType) -> crate::errors::Result<Value> {
247    match field_type {
248        FieldType::Int => raw
249            .parse::<i64>()
250            .map(Value::Int)
251            .map_err(|e| MdqlError::General(e.to_string())),
252        FieldType::Float => raw
253            .parse::<f64>()
254            .map(Value::Float)
255            .map_err(|e| MdqlError::General(e.to_string())),
256        FieldType::Bool => Ok(Value::Bool(
257            matches!(raw.to_lowercase().as_str(), "true" | "1" | "yes"),
258        )),
259        FieldType::StringArray => Ok(Value::List(
260            raw.split(',').map(|s| s.trim().to_string()).collect(),
261        )),
262        FieldType::Date => {
263            if let Ok(d) = chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d") {
264                Ok(Value::Date(d))
265            } else {
266                Ok(Value::String(raw.to_string()))
267            }
268        }
269        FieldType::DateTime => {
270            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S") {
271                Ok(Value::DateTime(dt))
272            } else if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.f") {
273                Ok(Value::DateTime(dt))
274            } else {
275                Ok(Value::String(raw.to_string()))
276            }
277        }
278        FieldType::String => Ok(Value::String(raw.to_string())),
279        FieldType::Dict => {
280            match serde_yaml::from_str::<serde_yaml::Value>(raw) {
281                Ok(serde_yaml::Value::Mapping(m)) => {
282                    let mut dict = indexmap::IndexMap::new();
283                    for (k, v) in m {
284                        if let Some(key) = k.as_str() {
285                            let val = crate::model::yaml_to_value_pub(&v, None);
286                            dict.insert(key.to_string(), val);
287                        }
288                    }
289                    Ok(Value::Dict(dict))
290                }
291                _ => Err(MdqlError::General(format!(
292                    "Expected YAML mapping for dict field, got: {}", raw
293                ))),
294            }
295        }
296    }
297}
298
299// ── Table ─────────────────────────────────────────────────────────────────
300
301pub struct Table {
302    pub path: PathBuf,
303    schema: Schema,
304    cache: std::sync::Mutex<crate::cache::TableCache>,
305}
306
307impl Table {
308    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
309        let path = path.into();
310        recover_journal(&path)?;
311        let schema = load_schema(&path)?;
312        Ok(Table {
313            path,
314            schema,
315            cache: std::sync::Mutex::new(crate::cache::TableCache::new()),
316        })
317    }
318
319    pub fn schema(&self) -> &Schema {
320        &self.schema
321    }
322
323    pub fn name(&self) -> &str {
324        &self.schema.table
325    }
326
327    pub fn insert(
328        &self,
329        data: &HashMap<String, Value>,
330        body: Option<&str>,
331        filename: Option<&str>,
332        replace: bool,
333    ) -> crate::errors::Result<PathBuf> {
334        let fname = match filename {
335            Some(f) => f.to_string(),
336            None => {
337                let title = data
338                    .get("title")
339                    .ok_or_else(|| {
340                        MdqlError::General(
341                            "Cannot derive filename: provide 'title' in data or pass filename"
342                                .into(),
343                        )
344                    })?
345                    .to_display_string();
346                slugify(&title, 80)
347            }
348        };
349
350        let fname = normalize_filename(&fname);
351
352        let filepath = self.path.join(&fname);
353
354        let _lock = TableLock::acquire(&self.path)?;
355
356        let mut preserve_created: Option<String> = None;
357        let mut old_content: Option<String> = None;
358
359        if filepath.exists() {
360            if !replace {
361                return Err(MdqlError::General(format!(
362                    "File already exists: {}",
363                    fname
364                )));
365            }
366            let (old_fm, _) = read_existing(&filepath)?;
367            if let Some(c) = old_fm.get("created") {
368                preserve_created = Some(c.clone());
369            }
370            old_content = Some(std::fs::read_to_string(&filepath)?);
371        }
372
373        let mut content = serialize_frontmatter(
374            data,
375            &self.schema,
376            preserve_created.as_deref(),
377        );
378
379        if let Some(b) = body {
380            if !b.starts_with('\n') {
381                content.push('\n');
382            }
383            content.push_str(b);
384            if !b.ends_with('\n') {
385                content.push('\n');
386            }
387        } else {
388            content.push_str(&serialize_body(data, &self.schema));
389        }
390
391        write_and_validate(&filepath, &content, old_content.as_deref(), &self.schema, &self.path)?;
392
393        self.cache.lock().unwrap().invalidate_all();
394        Ok(filepath)
395    }
396
397    pub fn update(
398        &self,
399        filename: &str,
400        data: &HashMap<String, Value>,
401        body: Option<&str>,
402    ) -> crate::errors::Result<PathBuf> {
403        let _lock = TableLock::acquire(&self.path)?;
404        self.update_no_lock(filename, data, body)
405    }
406
407    fn update_no_lock(
408        &self,
409        filename: &str,
410        data: &HashMap<String, Value>,
411        body: Option<&str>,
412    ) -> crate::errors::Result<PathBuf> {
413        let fname = normalize_filename(filename);
414
415        let filepath = self.path.join(&fname);
416        if !filepath.exists() {
417            return Err(MdqlError::General(format!("File not found: {}", fname)));
418        }
419
420        let old_content = std::fs::read_to_string(&filepath)?;
421        let (old_fm_raw, old_body) = read_existing(&filepath)?;
422
423        // Merge: read existing frontmatter as Value, overlay with data
424        let parsed = parse_file(
425            &filepath,
426            Some(&self.path),
427            self.schema.rules.normalize_numbered_headings,
428        )?;
429        let existing_row = crate::model::to_row(&parsed, &self.schema);
430
431        // Collect section headings so we can exclude them from frontmatter
432        let section_keys: std::collections::HashSet<&str> = parsed
433            .sections
434            .iter()
435            .map(|s| s.normalized_heading.as_str())
436            .collect();
437
438        let mut merged = existing_row;
439        for (k, v) in data {
440            merged.insert(k.clone(), v.clone());
441        }
442
443        // Remove section keys — they belong in the body, not frontmatter
444        merged.retain(|k, _| !section_keys.contains(k.as_str()));
445
446        let preserve_created = old_fm_raw.get("created").map(|s| s.as_str());
447
448        let mut content = serialize_frontmatter(&merged, &self.schema, preserve_created);
449
450        if let Some(b) = body {
451            if !b.starts_with('\n') {
452                content.push('\n');
453            }
454            content.push_str(b);
455            if !b.ends_with('\n') {
456                content.push('\n');
457            }
458        } else {
459            content.push_str(&old_body);
460        }
461
462        write_and_validate(&filepath, &content, Some(&old_content), &self.schema, &self.path)?;
463
464        self.cache.lock().unwrap().invalidate_all();
465        Ok(filepath)
466    }
467
468    pub fn delete(&self, filename: &str) -> crate::errors::Result<PathBuf> {
469        let _lock = TableLock::acquire(&self.path)?;
470        self.delete_no_lock(filename)
471    }
472
473    fn delete_no_lock(&self, filename: &str) -> crate::errors::Result<PathBuf> {
474        let fname = normalize_filename(filename);
475
476        let filepath = self.path.join(&fname);
477        if !filepath.exists() {
478            return Err(MdqlError::General(format!("File not found: {}", fname)));
479        }
480
481        std::fs::remove_file(&filepath)?;
482        self.cache.lock().unwrap().invalidate_all();
483        Ok(filepath)
484    }
485
486    pub fn execute_sql(&mut self, sql: &str) -> crate::errors::Result<String> {
487        let stmt = parse_query(sql)?;
488
489        match stmt {
490            Statement::Select(q) => self.exec_select(&q),
491            Statement::Insert(q) => self.exec_insert(&q),
492            Statement::Update(q) => self.exec_update(&q),
493            Statement::Delete(q) => self.exec_delete(&q),
494            Statement::AlterRename(q) => {
495                let count = self.rename_field(&q.old_name, &q.new_name)?;
496                Ok(format!(
497                    "ALTER TABLE — renamed '{}' to '{}' in {} files",
498                    q.old_name, q.new_name, count
499                ))
500            }
501            Statement::AlterDrop(q) => {
502                let count = self.drop_field(&q.field_name)?;
503                Ok(format!(
504                    "ALTER TABLE — dropped '{}' from {} files",
505                    q.field_name, count
506                ))
507            }
508            Statement::AlterMerge(q) => {
509                let count = self.merge_fields(&q.sources, &q.into)?;
510                let names: Vec<String> = q.sources.iter().map(|s| format!("'{}'", s)).collect();
511                Ok(format!(
512                    "ALTER TABLE — merged {} into '{}' in {} files",
513                    names.join(", "),
514                    q.into,
515                    count
516                ))
517            }
518            Statement::CreateView(_) | Statement::DropView(_) => {
519                Err(MdqlError::QueryExecution(
520                    "CREATE VIEW and DROP VIEW require a database directory, not a single table".into(),
521                ))
522            }
523        }
524    }
525
526    /// Execute a SELECT query and return structured results.
527    pub fn query_sql(&mut self, sql: &str) -> crate::errors::Result<(Vec<Row>, Vec<String>)> {
528        let stmt = parse_query(sql)?;
529        let select = match stmt {
530            Statement::Select(q) => q,
531            _ => return Err(MdqlError::QueryParse("Only SELECT queries supported".into())),
532        };
533        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
534        crate::query_engine::execute_query(&select, &rows, &self.schema)
535    }
536
537    fn exec_select(&self, query: &SelectQuery) -> crate::errors::Result<String> {
538        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
539        let (result_rows, result_columns) = crate::query_engine::execute_query(query, &rows, &self.schema)?;
540        Ok(crate::projector::format_results(
541            &result_rows,
542            Some(&result_columns),
543            "table",
544            0,
545        ))
546    }
547
548    fn exec_insert(&self, query: &InsertQuery) -> crate::errors::Result<String> {
549        let mut data: HashMap<String, Value> = HashMap::new();
550        for (col, val) in query.columns.iter().zip(query.values.iter()) {
551            let field_def = self.schema.frontmatter.get(col);
552            if let Some(fd) = field_def {
553                if matches!(fd.field_type, FieldType::StringArray) {
554                    if let SqlValue::String(s) = val {
555                        data.insert(
556                            col.clone(),
557                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
558                        );
559                        continue;
560                    }
561                }
562            }
563            data.insert(col.clone(), sql_value_to_value(val));
564        }
565        let filepath = self.insert(&data, None, None, false)?;
566        Ok(format!(
567            "INSERT 1 ({})",
568            filepath.file_name().unwrap_or_default().to_string_lossy()
569        ))
570    }
571
572    fn exec_update(&self, query: &UpdateQuery) -> crate::errors::Result<String> {
573        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
574
575        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
576            rows.iter().filter(|r| evaluate(wc, r)).collect()
577        } else {
578            rows.iter().collect()
579        };
580
581        if matching.is_empty() {
582            return Ok("UPDATE 0".to_string());
583        }
584
585        let mut data: HashMap<String, Value> = HashMap::new();
586        for (col, val) in &query.assignments {
587            let field_def = self.schema.frontmatter.get(col);
588            if let Some(fd) = field_def {
589                if matches!(fd.field_type, FieldType::StringArray) {
590                    if let SqlValue::String(s) = val {
591                        data.insert(
592                            col.clone(),
593                            Value::List(s.split(',').map(|v| v.trim().to_string()).collect()),
594                        );
595                        continue;
596                    }
597                }
598            }
599            data.insert(col.clone(), sql_value_to_value(val));
600        }
601
602        let paths: Vec<String> = matching
603            .iter()
604            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
605            .collect();
606
607        let _lock = TableLock::acquire(&self.path)?;
608        let count;
609        {
610            let mut txn = TableTransaction::new(&self.path, "UPDATE")?;
611            let mut c = 0;
612            for path_str in &paths {
613                let filepath = self.path.join(path_str);
614                txn.backup(&filepath)?;
615                self.update_no_lock(path_str, &data, None)?;
616                c += 1;
617            }
618            count = c;
619            txn.commit()?;
620        }
621
622        Ok(format!("UPDATE {}", count))
623    }
624
625    fn exec_delete(&self, query: &DeleteQuery) -> crate::errors::Result<String> {
626        let (_, rows, _) = crate::loader::load_table_cached(&self.path, &mut self.cache.lock().unwrap())?;
627
628        let matching: Vec<&Row> = if let Some(ref wc) = query.where_clause {
629            rows.iter().filter(|r| evaluate(wc, r)).collect()
630        } else {
631            rows.iter().collect()
632        };
633
634        if matching.is_empty() {
635            return Ok("DELETE 0".to_string());
636        }
637
638        let paths: Vec<String> = matching
639            .iter()
640            .filter_map(|r| r.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()))
641            .collect();
642
643        let _lock = TableLock::acquire(&self.path)?;
644        let count;
645        {
646            let mut txn = TableTransaction::new(&self.path, "DELETE")?;
647            let mut c = 0;
648            for path_str in &paths {
649                let filepath = self.path.join(path_str);
650                let content = std::fs::read_to_string(&filepath)?;
651                txn.record_delete(&filepath, &content)?;
652                self.delete_no_lock(path_str)?;
653                c += 1;
654            }
655            count = c;
656            txn.commit()?;
657        }
658
659        Ok(format!("DELETE {}", count))
660    }
661
662    fn data_files(&self) -> Vec<PathBuf> {
663        let mut files: Vec<PathBuf> = std::fs::read_dir(&self.path)
664            .into_iter()
665            .flatten()
666            .filter_map(|e| e.ok())
667            .map(|e| e.path())
668            .filter(|p| {
669                p.extension().map_or(false, |e| e == "md")
670                    && p.file_name()
671                        .map_or(false, |n| n.to_string_lossy() != MDQL_FILENAME)
672            })
673            .collect();
674        files.sort();
675        files
676    }
677
678    fn field_kind(&self, name: &str) -> crate::errors::Result<&str> {
679        if self.schema.frontmatter.contains_key(name) {
680            return Ok("frontmatter");
681        }
682        if self.schema.sections.contains_key(name) {
683            return Ok("section");
684        }
685        Err(MdqlError::General(format!(
686            "Field '{}' not found in schema (not a frontmatter field or section)",
687            name
688        )))
689    }
690
691    pub fn rename_field(&mut self, old_name: &str, new_name: &str) -> crate::errors::Result<usize> {
692        let kind = self.field_kind(old_name)?.to_string();
693        let normalize = self.schema.rules.normalize_numbered_headings;
694
695        let _lock = TableLock::acquire(&self.path)?;
696        let mut count = 0;
697
698        with_multi_file_txn(
699            &self.path,
700            &format!("RENAME FIELD {} -> {}", old_name, new_name),
701            |txn| {
702                for md_file in self.data_files() {
703                    txn.backup(&md_file)?;
704                    if kind == "frontmatter" {
705                        if migrate::rename_frontmatter_key_in_file(&md_file, old_name, new_name)? {
706                            count += 1;
707                        }
708                    } else {
709                        if migrate::rename_section_in_file(&md_file, old_name, new_name, normalize)? {
710                            count += 1;
711                        }
712                    }
713                }
714
715                let schema_path = self.path.join(MDQL_FILENAME);
716                txn.backup(&schema_path)?;
717                if kind == "frontmatter" {
718                    migrate::update_schema(&schema_path, Some((old_name, new_name)), None, None, None, None)?;
719                } else {
720                    migrate::update_schema(&schema_path, None, None, Some((old_name, new_name)), None, None)?;
721                }
722                Ok(())
723            },
724        )?;
725
726        self.schema = load_schema(&self.path)?;
727        Ok(count)
728    }
729
730    pub fn drop_field(&mut self, field_name: &str) -> crate::errors::Result<usize> {
731        let kind = self.field_kind(field_name)?.to_string();
732        let normalize = self.schema.rules.normalize_numbered_headings;
733
734        let _lock = TableLock::acquire(&self.path)?;
735        let mut count = 0;
736
737        with_multi_file_txn(
738            &self.path,
739            &format!("DROP FIELD {}", field_name),
740            |txn| {
741                for md_file in self.data_files() {
742                    txn.backup(&md_file)?;
743                    if kind == "frontmatter" {
744                        if migrate::drop_frontmatter_key_in_file(&md_file, field_name)? {
745                            count += 1;
746                        }
747                    } else {
748                        if migrate::drop_section_in_file(&md_file, field_name, normalize)? {
749                            count += 1;
750                        }
751                    }
752                }
753
754                let schema_path = self.path.join(MDQL_FILENAME);
755                txn.backup(&schema_path)?;
756                if kind == "frontmatter" {
757                    migrate::update_schema(&schema_path, None, Some(field_name), None, None, None)?;
758                } else {
759                    migrate::update_schema(&schema_path, None, None, None, Some(field_name), None)?;
760                }
761                Ok(())
762            },
763        )?;
764
765        self.schema = load_schema(&self.path)?;
766        Ok(count)
767    }
768
769    pub fn merge_fields(&mut self, sources: &[String], into: &str) -> crate::errors::Result<usize> {
770        for name in sources {
771            let kind = self.field_kind(name)?;
772            if kind != "section" {
773                return Err(MdqlError::General(format!(
774                    "Cannot merge frontmatter field '{}' — merge is only supported for section fields",
775                    name
776                )));
777            }
778        }
779
780        let normalize = self.schema.rules.normalize_numbered_headings;
781        let _lock = TableLock::acquire(&self.path)?;
782        let mut count = 0;
783
784        let sources_owned: Vec<String> = sources.to_vec();
785
786        with_multi_file_txn(
787            &self.path,
788            &format!("MERGE FIELDS -> {}", into),
789            |txn| {
790                for md_file in self.data_files() {
791                    txn.backup(&md_file)?;
792                    if migrate::merge_sections_in_file(&md_file, &sources_owned, into, normalize)? {
793                        count += 1;
794                    }
795                }
796
797                let schema_path = self.path.join(MDQL_FILENAME);
798                txn.backup(&schema_path)?;
799                migrate::update_schema(
800                    &schema_path,
801                    None, None, None, None,
802                    Some((&sources_owned, into)),
803                )?;
804                Ok(())
805            },
806        )?;
807
808        self.schema = load_schema(&self.path)?;
809        Ok(count)
810    }
811
812    pub fn load(&self) -> crate::errors::Result<(Vec<Row>, Vec<ValidationError>)> {
813        let (_, rows, errors) = crate::loader::load_table_cached(
814            &self.path,
815            &mut self.cache.lock().unwrap(),
816        )?;
817        Ok((rows, errors))
818    }
819
820    pub fn validate(&self) -> crate::errors::Result<Vec<ValidationError>> {
821        let (_, _, errors) = crate::loader::load_table_cached(
822            &self.path,
823            &mut self.cache.lock().unwrap(),
824        )?;
825        Ok(errors)
826    }
827}
828
829// ── Database ──────────────────────────────────────────────────────────────
830
831pub struct Database {
832    pub path: PathBuf,
833    config: DatabaseConfig,
834    tables: HashMap<String, Table>,
835}
836
837impl Database {
838    pub fn new(path: impl Into<PathBuf>) -> crate::errors::Result<Self> {
839        let path = path.into();
840        let config = load_database_config(&path)?;
841        let mut tables = HashMap::new();
842
843        let mut children: Vec<_> = std::fs::read_dir(&path)?
844            .filter_map(|e| e.ok())
845            .map(|e| e.path())
846            .filter(|p| p.is_dir() && p.join(MDQL_FILENAME).exists())
847            .collect();
848        children.sort();
849
850        for child in children {
851            let t = Table::new(&child)?;
852            tables.insert(t.name().to_string(), t);
853        }
854
855        Ok(Database {
856            path,
857            config,
858            tables,
859        })
860    }
861
862    pub fn name(&self) -> &str {
863        &self.config.name
864    }
865
866    pub fn config(&self) -> &DatabaseConfig {
867        &self.config
868    }
869
870    pub fn table_names(&self) -> Vec<String> {
871        let mut names: Vec<String> = self.tables.keys().cloned().collect();
872        names.sort();
873        names
874    }
875
876    /// Rename a file and update all foreign key references across the database.
877    pub fn rename(
878        &self,
879        table_name: &str,
880        old_filename: &str,
881        new_filename: &str,
882    ) -> crate::errors::Result<String> {
883        let old_name = normalize_filename(old_filename);
884        let new_name = normalize_filename(new_filename);
885
886        let table = self.tables.get(table_name).ok_or_else(|| {
887            MdqlError::General(format!("Table '{}' not found", table_name))
888        })?;
889
890        let old_path = table.path.join(&old_name);
891        if !old_path.exists() {
892            return Err(MdqlError::General(format!(
893                "File not found: {}/{}",
894                table_name, old_name
895            )));
896        }
897
898        let new_path = table.path.join(&new_name);
899        if new_path.exists() {
900            return Err(MdqlError::General(format!(
901                "Target already exists: {}/{}",
902                table_name, new_name
903            )));
904        }
905
906        // Find all foreign keys that reference this table
907        let referencing_fks: Vec<_> = self
908            .config
909            .foreign_keys
910            .iter()
911            .filter(|fk| fk.to_table == table_name && fk.to_column == "path")
912            .collect();
913
914        // Collect files that need updating
915        let mut updates: Vec<(PathBuf, String, String)> = Vec::new(); // (file_path, column, old_value)
916
917        for fk in &referencing_fks {
918            let ref_table = self.tables.get(&fk.from_table).ok_or_else(|| {
919                MdqlError::General(format!(
920                    "Referencing table '{}' not found",
921                    fk.from_table
922                ))
923            })?;
924
925            // Scan files in the referencing table
926            let entries: Vec<_> = std::fs::read_dir(&ref_table.path)?
927                .filter_map(|e| e.ok())
928                .map(|e| e.path())
929                .filter(|p| {
930                    p.extension().and_then(|e| e.to_str()) == Some("md")
931                        && p.file_name()
932                            .and_then(|n| n.to_str())
933                            .map_or(true, |n| n != MDQL_FILENAME)
934                })
935                .collect();
936
937            for entry in entries {
938                if let Ok((fm, _body)) = read_existing(&entry) {
939                    if let Some(val) = fm.get(&fk.from_column) {
940                        if val == &old_name {
941                            updates.push((
942                                entry,
943                                fk.from_column.clone(),
944                                val.clone(),
945                            ));
946                        }
947                    }
948                }
949            }
950        }
951
952        // Perform all changes: update references first, then rename
953        let mut ref_count = 0;
954        for (filepath, column, _old_val) in &updates {
955            let text = std::fs::read_to_string(filepath)?;
956            // Replace the frontmatter value: "column: old_name" → "column: new_name"
957            let old_pattern = format!("{}: {}", column, old_name);
958            let new_pattern = format!("{}: {}", column, new_name);
959            let updated = text.replacen(&old_pattern, &new_pattern, 1);
960            // Also handle quoted form
961            let old_quoted = format!("{}: \"{}\"", column, old_name);
962            let new_quoted = format!("{}: \"{}\"", column, new_name);
963            let updated = updated.replacen(&old_quoted, &new_quoted, 1);
964            atomic_write(filepath, &updated)?;
965            ref_count += 1;
966        }
967
968        // Rename the file itself
969        std::fs::rename(&old_path, &new_path)?;
970
971        let mut msg = format!("RENAME {}/{} → {}", table_name, old_name, new_name);
972        if ref_count > 0 {
973            msg.push_str(&format!(
974                " — updated {} reference{}",
975                ref_count,
976                if ref_count == 1 { "" } else { "s" }
977            ));
978        }
979        Ok(msg)
980    }
981
982    pub fn table(&mut self, name: &str) -> crate::errors::Result<&mut Table> {
983        if self.tables.contains_key(name) {
984            Ok(self.tables.get_mut(name).expect("key verified above"))
985        } else {
986            let available: Vec<String> = self.tables.keys().cloned().collect();
987            Err(MdqlError::General(format!(
988                "Table '{}' not found in database '{}'. Available: {}",
989                name,
990                self.config.name,
991                if available.is_empty() {
992                    "(none)".to_string()
993                } else {
994                    available.join(", ")
995                }
996            )))
997        }
998    }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    #[test]
1006    fn test_slugify_basic() {
1007        assert_eq!(slugify("Hello World", 80), "hello-world");
1008    }
1009
1010    #[test]
1011    fn test_slugify_special_chars() {
1012        assert_eq!(slugify("My Strategy: Alpha & Beta!", 80), "my-strategy-alpha-beta");
1013    }
1014
1015    #[test]
1016    fn test_slugify_max_length() {
1017        let result = slugify("a very long title that exceeds the limit", 10);
1018        assert!(result.len() <= 10);
1019        assert!(!result.ends_with('-'));
1020    }
1021
1022    #[test]
1023    fn test_slugify_whitespace() {
1024        assert_eq!(slugify("  hello   world  ", 80), "hello-world");
1025    }
1026
1027    #[test]
1028    fn test_coerce_int() {
1029        let v = coerce_cli_value("42", &FieldType::Int).unwrap();
1030        assert_eq!(v, Value::Int(42));
1031    }
1032
1033    #[test]
1034    fn test_coerce_int_error() {
1035        assert!(coerce_cli_value("abc", &FieldType::Int).is_err());
1036    }
1037
1038    #[test]
1039    fn test_coerce_float() {
1040        let v = coerce_cli_value("3.14", &FieldType::Float).unwrap();
1041        assert_eq!(v, Value::Float(3.14));
1042    }
1043
1044    #[test]
1045    fn test_coerce_bool_true() {
1046        assert_eq!(coerce_cli_value("true", &FieldType::Bool).unwrap(), Value::Bool(true));
1047        assert_eq!(coerce_cli_value("yes", &FieldType::Bool).unwrap(), Value::Bool(true));
1048        assert_eq!(coerce_cli_value("1", &FieldType::Bool).unwrap(), Value::Bool(true));
1049    }
1050
1051    #[test]
1052    fn test_coerce_bool_false() {
1053        assert_eq!(coerce_cli_value("false", &FieldType::Bool).unwrap(), Value::Bool(false));
1054        assert_eq!(coerce_cli_value("no", &FieldType::Bool).unwrap(), Value::Bool(false));
1055    }
1056
1057    #[test]
1058    fn test_coerce_string_array() {
1059        let v = coerce_cli_value("a, b, c", &FieldType::StringArray).unwrap();
1060        assert_eq!(v, Value::List(vec!["a".into(), "b".into(), "c".into()]));
1061    }
1062
1063    #[test]
1064    fn test_coerce_date() {
1065        let v = coerce_cli_value("2026-04-16", &FieldType::Date).unwrap();
1066        assert_eq!(v, Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap()));
1067    }
1068
1069    #[test]
1070    fn test_coerce_datetime() {
1071        let v = coerce_cli_value("2026-04-16T10:30:00", &FieldType::DateTime).unwrap();
1072        match v {
1073            Value::DateTime(dt) => {
1074                assert_eq!(dt.date(), chrono::NaiveDate::from_ymd_opt(2026, 4, 16).unwrap());
1075            }
1076            _ => panic!("expected DateTime"),
1077        }
1078    }
1079
1080    #[test]
1081    fn test_coerce_string() {
1082        let v = coerce_cli_value("hello", &FieldType::String).unwrap();
1083        assert_eq!(v, Value::String("hello".into()));
1084    }
1085
1086    #[test]
1087    fn test_table_new_missing_schema() {
1088        let dir = tempfile::tempdir().unwrap();
1089        let result = Table::new(dir.path());
1090        assert!(result.is_err());
1091    }
1092
1093    #[test]
1094    fn test_table_insert_and_load() {
1095        let dir = tempfile::tempdir().unwrap();
1096        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1097        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1098
1099        let table = Table::new(dir.path()).unwrap();
1100        let mut data = HashMap::new();
1101        data.insert("title".into(), Value::String("Hello".into()));
1102
1103        let path = table.insert(&data, None, Some("hello"), false).unwrap();
1104        assert!(path.exists());
1105
1106        let (rows, errors) = table.load().unwrap();
1107        assert_eq!(rows.len(), 1);
1108        assert_eq!(rows[0].get("title"), Some(&Value::String("Hello".into())));
1109        assert!(errors.is_empty());
1110    }
1111
1112    #[test]
1113    fn test_table_insert_duplicate_rejected() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1116        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1117
1118        let table = Table::new(dir.path()).unwrap();
1119        let mut data = HashMap::new();
1120        data.insert("title".into(), Value::String("Hello".into()));
1121
1122        table.insert(&data, None, Some("hello"), false).unwrap();
1123        let result = table.insert(&data, None, Some("hello"), false);
1124        assert!(result.is_err());
1125    }
1126
1127    #[test]
1128    fn test_table_update() {
1129        let dir = tempfile::tempdir().unwrap();
1130        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n  score:\n    type: int\n---\n";
1131        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1132
1133        let table = Table::new(dir.path()).unwrap();
1134        let mut data = HashMap::new();
1135        data.insert("title".into(), Value::String("Test".into()));
1136        data.insert("score".into(), Value::Int(10));
1137        table.insert(&data, None, Some("test"), false).unwrap();
1138
1139        let mut update = HashMap::new();
1140        update.insert("score".into(), Value::Int(20));
1141        table.update("test", &update, None).unwrap();
1142
1143        let (rows, _) = table.load().unwrap();
1144        assert_eq!(rows[0].get("score"), Some(&Value::Int(20)));
1145    }
1146
1147    #[test]
1148    fn test_table_delete() {
1149        let dir = tempfile::tempdir().unwrap();
1150        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1151        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1152
1153        let table = Table::new(dir.path()).unwrap();
1154        let mut data = HashMap::new();
1155        data.insert("title".into(), Value::String("Doomed".into()));
1156        table.insert(&data, None, Some("doomed"), false).unwrap();
1157
1158        table.delete("doomed").unwrap();
1159        let (rows, _) = table.load().unwrap();
1160        assert!(rows.is_empty());
1161    }
1162
1163    #[test]
1164    fn test_table_validate() {
1165        let dir = tempfile::tempdir().unwrap();
1166        let schema_content = "---\ntype: schema\ntable: test\nprimary_key: path\nfrontmatter:\n  title:\n    type: string\n    required: true\n---\n";
1167        std::fs::write(dir.path().join("_mdql.md"), schema_content).unwrap();
1168        std::fs::write(dir.path().join("bad.md"), "---\n---\nNo title field\n").unwrap();
1169
1170        let table = Table::new(dir.path()).unwrap();
1171        let errors = table.validate().unwrap();
1172        assert!(!errors.is_empty());
1173    }
1174}