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