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