Skip to main content

mdql_core/
model.rs

1//! Convert parsed files to normalized row dicts.
2
3use std::collections::HashMap;
4
5use indexmap::IndexMap;
6
7use crate::parser::ParsedFile;
8use crate::schema::{FieldType, Schema};
9
10/// A row is a flat map from column name to value.
11pub type Row = HashMap<String, Value>;
12
13/// Dynamic value type for row entries.
14#[derive(Debug, Clone, PartialEq)]
15pub enum Value {
16    Null,
17    String(String),
18    Int(i64),
19    Float(f64),
20    Bool(bool),
21    Date(chrono::NaiveDate),
22    DateTime(chrono::NaiveDateTime),
23    List(Vec<String>),
24    Dict(IndexMap<String, Value>),
25}
26
27impl Value {
28    pub fn as_str(&self) -> Option<&str> {
29        match self {
30            Value::String(s) => Some(s),
31            _ => None,
32        }
33    }
34
35    pub fn as_i64(&self) -> Option<i64> {
36        match self {
37            Value::Int(n) => Some(*n),
38            _ => None,
39        }
40    }
41
42    pub fn as_f64(&self) -> Option<f64> {
43        match self {
44            Value::Float(f) => Some(*f),
45            Value::Int(n) => Some(*n as f64),
46            _ => None,
47        }
48    }
49
50    pub fn as_bool(&self) -> Option<bool> {
51        match self {
52            Value::Bool(b) => Some(*b),
53            _ => None,
54        }
55    }
56
57    pub fn is_null(&self) -> bool {
58        matches!(self, Value::Null)
59    }
60
61    pub fn to_display_string(&self) -> String {
62        match self {
63            Value::Null => String::new(),
64            Value::String(s) => s.clone(),
65            Value::Int(n) => n.to_string(),
66            Value::Float(f) => format!("{}", f),
67            Value::Bool(b) => b.to_string(),
68            Value::Date(d) => d.format("%Y-%m-%d").to_string(),
69            Value::DateTime(dt) => dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
70            Value::List(items) => items.join(", "),
71            Value::Dict(map) => {
72                let pairs: Vec<String> = map.iter()
73                    .map(|(k, v)| format!("{}: {}", k, v.to_display_string()))
74                    .collect();
75                format!("{{{}}}", pairs.join(", "))
76            }
77        }
78    }
79}
80
81impl std::fmt::Display for Value {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}", self.to_display_string())
84    }
85}
86
87impl PartialOrd for Value {
88    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
89        match (self, other) {
90            (Value::Int(a), Value::Int(b)) => a.partial_cmp(b),
91            (Value::Float(a), Value::Float(b)) => a.partial_cmp(b),
92            (Value::Int(a), Value::Float(b)) => (*a as f64).partial_cmp(b),
93            (Value::Float(a), Value::Int(b)) => a.partial_cmp(&(*b as f64)),
94            (Value::String(a), Value::String(b)) => a.partial_cmp(b),
95            (Value::Bool(a), Value::Bool(b)) => a.partial_cmp(b),
96            (Value::Date(a), Value::Date(b)) => a.partial_cmp(b),
97            (Value::DateTime(a), Value::DateTime(b)) => a.partial_cmp(b),
98            (Value::Date(a), Value::DateTime(b)) => a.and_hms_opt(0, 0, 0).unwrap().partial_cmp(b),
99            (Value::DateTime(a), Value::Date(b)) => a.partial_cmp(&b.and_hms_opt(0, 0, 0).unwrap()),
100            // Fallback: compare as strings
101            _ => self.to_display_string().partial_cmp(&other.to_display_string()),
102        }
103    }
104}
105
106/// Public wrapper for yaml_to_value, used by api.rs for CLI coercion.
107pub fn yaml_to_value_pub(val: &serde_yaml::Value, field_type: Option<&FieldType>) -> Value {
108    yaml_to_value(val, field_type)
109}
110
111fn yaml_to_value(val: &serde_yaml::Value, field_type: Option<&FieldType>) -> Value {
112    match val {
113        serde_yaml::Value::Null => Value::Null,
114        serde_yaml::Value::Bool(b) => Value::Bool(*b),
115        serde_yaml::Value::Number(n) => {
116            if let Some(FieldType::Float) = field_type {
117                Value::Float(n.as_f64().unwrap_or(0.0))
118            } else if let Some(i) = n.as_i64() {
119                Value::Int(i)
120            } else if let Some(u) = n.as_u64() {
121                Value::Int(u as i64)
122            } else if let Some(f) = n.as_f64() {
123                Value::Float(f)
124            } else {
125                Value::Null
126            }
127        }
128        serde_yaml::Value::String(s) => {
129            // DateTime coercion
130            if let Some(FieldType::DateTime) = field_type {
131                if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
132                    return Value::DateTime(dt);
133                }
134                if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
135                    return Value::DateTime(dt);
136                }
137            }
138            // Date coercion
139            if let Some(FieldType::Date) = field_type {
140                if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
141                    return Value::Date(date);
142                }
143            }
144            Value::String(s.clone())
145        }
146        serde_yaml::Value::Sequence(seq) => {
147            let items: Vec<String> = seq
148                .iter()
149                .filter_map(|v| v.as_str().map(|s| s.to_string()))
150                .collect();
151            Value::List(items)
152        }
153        serde_yaml::Value::Mapping(mapping) => {
154            if let Some(FieldType::Dict) = field_type {
155                let mut dict = IndexMap::new();
156                for (k, v) in mapping {
157                    if let Some(key) = k.as_str() {
158                        dict.insert(key.to_string(), yaml_to_value(v, None));
159                    }
160                }
161                Value::Dict(dict)
162            } else {
163                Value::String(format!("{:?}", val))
164            }
165        }
166        _ => Value::String(format!("{:?}", val)),
167    }
168}
169
170/// Convert a validated ParsedFile into a flat row dict.
171pub fn to_row(parsed: &ParsedFile, schema: &Schema) -> Row {
172    let mut row = Row::new();
173    row.insert("path".to_string(), Value::String(parsed.path.clone()));
174
175    // Frontmatter fields — coerce types
176    if let Some(fm_map) = parsed.raw_frontmatter.as_mapping() {
177        for (key_val, value) in fm_map {
178            if let Some(key) = key_val.as_str() {
179                let field_type = schema.frontmatter.get(key).map(|fd| &fd.field_type)
180                    .or_else(|| {
181                        if crate::stamp::TIMESTAMP_FIELDS.contains(&key) {
182                            Some(&FieldType::DateTime)
183                        } else {
184                            None
185                        }
186                    });
187                row.insert(key.to_string(), yaml_to_value(value, field_type));
188            }
189        }
190    }
191
192    // H1
193    if let Some(ref h1) = parsed.h1 {
194        row.insert("h1".to_string(), Value::String(h1.clone()));
195    }
196
197    // Sections
198    for section in &parsed.sections {
199        row.insert(
200            section.normalized_heading.clone(),
201            Value::String(section.body.clone()),
202        );
203    }
204
205    row
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::parser::parse_text;
212    use crate::schema::*;
213    use indexmap::IndexMap;
214
215    fn test_schema() -> Schema {
216        let mut frontmatter = IndexMap::new();
217        frontmatter.insert("title".to_string(), FieldDef {
218            field_type: FieldType::String,
219            required: true,
220            enum_values: None,
221        });
222        frontmatter.insert("count".to_string(), FieldDef {
223            field_type: FieldType::Int,
224            required: true,
225            enum_values: None,
226        });
227
228        Schema {
229            table: "test".to_string(),
230            primary_key: "path".to_string(),
231            frontmatter,
232            h1_required: false,
233            h1_must_equal_frontmatter: None,
234            sections: IndexMap::new(),
235            rules: Rules {
236                reject_unknown_frontmatter: false,
237                reject_unknown_sections: false,
238                reject_duplicate_sections: true,
239                normalize_numbered_headings: false,
240            },
241        }
242    }
243
244    #[test]
245    fn test_to_row_basic() {
246        let text = "---\ntitle: \"Hello\"\ncount: 42\n---\n\n## Summary\n\nA summary.\n";
247        let parsed = parse_text(text, "test.md", false);
248        let row = to_row(&parsed, &test_schema());
249        assert_eq!(row["path"], Value::String("test.md".into()));
250        assert_eq!(row["title"], Value::String("Hello".into()));
251        assert_eq!(row["count"], Value::Int(42));
252        assert_eq!(row["Summary"], Value::String("A summary.".into()));
253    }
254
255    #[test]
256    fn test_to_row_with_h1() {
257        let text = "---\ntitle: \"Test\"\ncount: 1\n---\n\n# My Title\n\n## Section\n\nBody.\n";
258        let parsed = parse_text(text, "test.md", false);
259        let row = to_row(&parsed, &test_schema());
260        assert_eq!(row["h1"], Value::String("My Title".into()));
261    }
262
263    #[test]
264    fn test_date_coercion() {
265        let mut frontmatter = IndexMap::new();
266        frontmatter.insert("created".to_string(), FieldDef {
267            field_type: FieldType::Date,
268            required: true,
269            enum_values: None,
270        });
271
272        let schema = Schema {
273            table: "test".to_string(),
274            primary_key: "path".to_string(),
275            frontmatter,
276            h1_required: false,
277            h1_must_equal_frontmatter: None,
278            sections: IndexMap::new(),
279            rules: Rules {
280                reject_unknown_frontmatter: false,
281                reject_unknown_sections: false,
282                reject_duplicate_sections: true,
283                normalize_numbered_headings: false,
284            },
285        };
286
287        let text = "---\ncreated: \"2026-04-04\"\n---\n";
288        let parsed = parse_text(text, "test.md", false);
289        let row = to_row(&parsed, &schema);
290        assert_eq!(
291            row["created"],
292            Value::Date(chrono::NaiveDate::from_ymd_opt(2026, 4, 4).unwrap())
293        );
294    }
295}