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