1use std::collections::HashMap;
4
5use indexmap::IndexMap;
6
7use crate::parser::ParsedFile;
8use crate::schema::{FieldType, Schema};
9
10pub type Row = HashMap<String, Value>;
12
13#[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 _ => self.to_display_string().partial_cmp(&other.to_display_string()),
102 }
103 }
104}
105
106pub 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 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 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
170pub 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 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 if let Some(ref h1) = parsed.h1 {
194 row.insert("h1".to_string(), Value::String(h1.clone()));
195 }
196
197 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}