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