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