1use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
11#[serde(untagged)]
12#[non_exhaustive]
13pub enum Value {
14 Null,
15 String(String),
16 Integer(i64),
17 Float(f64),
18 Bool(bool),
19 List(Vec<Value>),
20 Map(BTreeMap<String, Value>),
21}
22
23impl From<&str> for Value {
29 fn from(v: &str) -> Self {
30 Value::String(v.to_string())
31 }
32}
33impl From<String> for Value {
34 fn from(v: String) -> Self {
35 Value::String(v)
36 }
37}
38impl From<bool> for Value {
39 fn from(v: bool) -> Self {
40 Value::Bool(v)
41 }
42}
43impl From<i32> for Value {
44 fn from(v: i32) -> Self {
45 Value::Integer(v as i64)
46 }
47}
48impl From<i64> for Value {
49 fn from(v: i64) -> Self {
50 Value::Integer(v)
51 }
52}
53impl From<u32> for Value {
54 fn from(v: u32) -> Self {
55 Value::Integer(v as i64)
56 }
57}
58impl From<f32> for Value {
59 fn from(v: f32) -> Self {
60 Value::Float(v as f64)
61 }
62}
63impl From<f64> for Value {
64 fn from(v: f64) -> Self {
65 Value::Float(v)
66 }
67}
68impl<T: Into<Value>> From<Vec<T>> for Value {
69 fn from(v: Vec<T>) -> Self {
70 Value::List(v.into_iter().map(Into::into).collect())
71 }
72}
73
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct Record {
83 pub path: PathBuf,
85 pub fields: BTreeMap<String, Value>,
87 #[serde(skip_serializing_if = "Option::is_none", default)]
89 pub raw_content: Option<String>,
90}
91
92impl Record {
93 pub fn get(&self, key: &str, vault_root: &Path) -> Option<Value> {
95 self.get_with_links(key, vault_root, None)
96 }
97
98 pub fn get_with_links(
100 &self,
101 key: &str,
102 vault_root: &Path,
103 link_index: Option<&crate::links::LinkGraph>,
104 ) -> Option<Value> {
105 match key {
106 "_name" => Some(Value::String(self.virtual_name())),
107 "_path" => Some(Value::String(self.virtual_path(vault_root))),
108 "_folder" => Some(Value::String(self.virtual_folder())),
109 "_modified" => self.virtual_modified().map(Value::String),
110 "_created" => self.virtual_created().map(Value::String),
111 "_links" | "_link_count" | "_backlinks" | "_backlink_count" => {
112 let name = self.virtual_name();
113 link_index.and_then(|idx| {
114 idx.virtual_fields(&name)
115 .into_iter()
116 .find(|(k, _)| *k == key)
117 .map(|(_, v)| v)
118 })
119 }
120 "_length" => {
121 let content = self.load_content();
122 Some(Value::Integer(content.len() as i64))
123 }
124 "_body_length" => {
125 let content = self.load_content();
126 let body_len = crate::frontmatter::extract_frontmatter(&content)
127 .map(|(_, body_start)| content[body_start..].trim().len())
128 .unwrap_or(content.trim().len());
129 Some(Value::Integer(body_len as i64))
130 }
131 "_body" => {
132 let content = self.load_content();
140 let body = crate::frontmatter::extract_frontmatter(&content)
141 .map(|(_, body_start)| content[body_start..].to_string())
142 .unwrap_or(content);
143 Some(Value::String(body))
144 }
145 _ => self.fields.get(key).cloned(),
146 }
147 }
148
149 fn load_content(&self) -> String {
151 if let Some(ref content) = self.raw_content {
152 content.clone()
153 } else {
154 std::fs::read_to_string(&self.path).unwrap_or_default()
155 }
156 }
157
158 pub fn virtual_name(&self) -> String {
160 let raw = self
161 .path
162 .file_stem()
163 .map(|s| s.to_string_lossy().into_owned())
164 .unwrap_or_default();
165 decode_percent_encoding(&raw)
166 }
167
168 pub fn virtual_path(&self, vault_root: &Path) -> String {
170 self.path
171 .strip_prefix(vault_root)
172 .unwrap_or(&self.path)
173 .to_string_lossy()
174 .into_owned()
175 }
176
177 pub fn virtual_folder(&self) -> String {
179 self.path
180 .parent()
181 .and_then(|p| p.file_name())
182 .map(|s| s.to_string_lossy().into_owned())
183 .unwrap_or_default()
184 }
185
186 fn virtual_modified(&self) -> Option<String> {
187 self.path
188 .metadata()
189 .ok()
190 .and_then(|m| m.modified().ok())
191 .map(format_system_time)
192 }
193
194 fn virtual_created(&self) -> Option<String> {
195 self.path
196 .metadata()
197 .ok()
198 .and_then(|m| m.created().ok())
199 .map(format_system_time)
200 }
201}
202
203fn decode_percent_encoding(input: &str) -> String {
205 let mut result = String::with_capacity(input.len());
206 let mut chars = input.chars();
207 while let Some(c) = chars.next() {
208 if c == '%' {
209 let hex: String = chars.by_ref().take(2).collect();
210 if hex.len() == 2
211 && let Ok(byte) = u8::from_str_radix(&hex, 16)
212 {
213 result.push(byte as char);
214 continue;
215 }
216 result.push('%');
218 result.push_str(&hex);
219 } else {
220 result.push(c);
221 }
222 }
223 result
224}
225
226fn format_system_time(t: SystemTime) -> String {
227 let duration = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
228 let secs = duration.as_secs();
229 let days = secs / 86400;
231 let remaining = secs % 86400;
232 let hours = remaining / 3600;
233 let minutes = (remaining % 3600) / 60;
234 let (year, month, day) = epoch_days_to_date(days);
237 format!(
238 "{:04}-{:02}-{:02} {:02}:{:02}",
239 year, month, day, hours, minutes
240 )
241}
242
243pub fn today_string() -> String {
247 let secs = SystemTime::now()
248 .duration_since(SystemTime::UNIX_EPOCH)
249 .map(|d| d.as_secs())
250 .unwrap_or(0);
251 let days = secs / 86400;
252 let (y, m, d) = epoch_days_to_date(days);
253 format!("{:04}-{:02}-{:02}", y, m, d)
254}
255
256pub fn now_string() -> String {
261 format_system_time(SystemTime::now())
262}
263
264pub fn epoch_seconds() -> i64 {
267 SystemTime::now()
268 .duration_since(SystemTime::UNIX_EPOCH)
269 .map(|d| d.as_secs() as i64)
270 .unwrap_or(0)
271}
272
273fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
274 let z = days + 719468;
276 let era = z / 146097;
277 let doe = z - era * 146097;
278 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
279 let y = yoe + era * 400;
280 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
281 let mp = (5 * doy + 2) / 153;
282 let d = doy - (153 * mp + 2) / 5 + 1;
283 let m = if mp < 10 { mp + 3 } else { mp - 9 };
284 let y = if m <= 2 { y + 1 } else { y };
285 (y, m, d)
286}
287
288impl Value {
289 pub fn as_str(&self) -> Option<&str> {
291 match self {
292 Value::String(s) => Some(s),
293 _ => None,
294 }
295 }
296
297 pub fn as_integer(&self) -> Option<i64> {
299 match self {
300 Value::Integer(n) => Some(*n),
301 Value::Float(f) => Some(*f as i64),
302 Value::String(s) => s.parse().ok(),
303 _ => None,
304 }
305 }
306
307 pub fn as_float(&self) -> Option<f64> {
309 match self {
310 Value::Float(f) => Some(*f),
311 Value::Integer(n) => Some(*n as f64),
312 Value::String(s) => s.parse().ok(),
313 _ => None,
314 }
315 }
316
317 pub fn list_contains(&self, needle: &str) -> bool {
319 match self {
320 Value::List(items) => items.iter().any(|item| item.display_value() == needle),
321 Value::String(s) => s.contains(needle),
322 _ => false,
323 }
324 }
325
326 pub fn type_name(&self) -> &'static str {
328 match self {
329 Value::Null => "null",
330 Value::String(_) => "string",
331 Value::Integer(_) => "integer",
332 Value::Float(_) => "float",
333 Value::Bool(_) => "bool",
334 Value::List(_) => "list",
335 Value::Map(_) => "map",
336 }
337 }
338
339 pub fn display_value(&self) -> String {
341 match self {
342 Value::Null => String::new(),
343 Value::String(s) => s.clone(),
344 Value::Integer(n) => n.to_string(),
345 Value::Float(f) => f.to_string(),
346 Value::Bool(b) => b.to_string(),
347 Value::List(items) => {
348 let parts: Vec<String> = items.iter().map(|v| v.display_value()).collect();
349 parts.join(", ")
350 }
351 Value::Map(m) => {
352 let parts: Vec<String> = m
353 .iter()
354 .map(|(k, v)| format!("{}: {}", k, v.display_value()))
355 .collect();
356 parts.join(", ")
357 }
358 }
359 }
360
361 pub fn is_empty(&self) -> bool {
363 match self {
364 Value::Null => true,
365 Value::String(s) => s.is_empty(),
366 Value::List(l) => l.is_empty(),
367 Value::Map(m) => m.is_empty(),
368 _ => false,
369 }
370 }
371
372 pub fn as_bool(&self) -> Option<bool> {
374 match self {
375 Value::Bool(b) => Some(*b),
376 _ => None,
377 }
378 }
379
380 pub fn as_list(&self) -> Option<&[Value]> {
382 match self {
383 Value::List(v) => Some(v),
384 _ => None,
385 }
386 }
387
388 pub fn as_map(&self) -> Option<&std::collections::BTreeMap<String, Value>> {
390 match self {
391 Value::Map(m) => Some(m),
392 _ => None,
393 }
394 }
395
396 pub fn is_null(&self) -> bool {
398 matches!(self, Value::Null)
399 }
400
401 pub fn parse_scalar(s: &str) -> Self {
408 match s {
409 "true" => return Value::Bool(true),
410 "false" => return Value::Bool(false),
411 _ => {}
412 }
413 if let Ok(i) = s.parse::<i64>() {
414 return Value::Integer(i);
415 }
416 if let Ok(f) = s.parse::<f64>() {
417 return Value::Float(f);
418 }
419 Value::String(s.to_string())
420 }
421}
422
423impl std::fmt::Display for Value {
424 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425 write!(f, "{}", self.display_value())
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn virtual_name_strips_extension() {
435 let record = Record {
436 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
437 fields: BTreeMap::new(),
438 raw_content: None,
439 };
440 assert_eq!(record.virtual_name(), "TypeScript");
441 }
442
443 #[test]
444 fn virtual_name_handles_chinese() {
445 let record = Record {
446 path: PathBuf::from("/vault/3-Notes/快.md"),
447 fields: BTreeMap::new(),
448 raw_content: None,
449 };
450 assert_eq!(record.virtual_name(), "快");
451 }
452
453 #[test]
454 fn virtual_path_relative_to_root() {
455 let record = Record {
456 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
457 fields: BTreeMap::new(),
458 raw_content: None,
459 };
460 assert_eq!(
461 record.virtual_path(Path::new("/vault")),
462 "3-Notes/TypeScript.md"
463 );
464 }
465
466 #[test]
467 fn virtual_folder() {
468 let record = Record {
469 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
470 fields: BTreeMap::new(),
471 raw_content: None,
472 };
473 assert_eq!(record.virtual_folder(), "3-Notes");
474 }
475
476 #[test]
477 fn field_value_list_contains() {
478 let val = Value::List(vec![
479 Value::String("type/concept".into()),
480 Value::String("topic/chinese".into()),
481 ]);
482 assert!(val.list_contains("topic/chinese"));
483 assert!(!val.list_contains("topic/movies"));
484 }
485
486 #[test]
487 fn field_value_string_contains_substring() {
488 let val = Value::String("hello world".into());
489 assert!(val.list_contains("world"));
490 }
491
492 #[test]
493 fn field_value_type_names() {
494 assert_eq!(Value::Null.type_name(), "null");
495 assert_eq!(Value::Integer(5).type_name(), "integer");
496 assert_eq!(Value::String("x".into()).type_name(), "string");
497 assert_eq!(Value::List(vec![]).type_name(), "list");
498 }
499
500 #[test]
501 fn field_value_numeric_coercion() {
502 assert_eq!(Value::Integer(42).as_float(), Some(42.0));
503 assert_eq!(Value::Float(3.5).as_integer(), Some(3));
504 assert_eq!(Value::String("7".into()).as_integer(), Some(7));
505 assert_eq!(Value::String("not a number".into()).as_integer(), None);
506 }
507
508 #[test]
509 fn display_value_formatting() {
510 assert_eq!(Value::Null.display_value(), "");
511 assert_eq!(Value::Integer(2019).display_value(), "2019");
512 assert_eq!(
513 Value::List(vec![Value::String("a".into()), Value::String("b".into()),])
514 .display_value(),
515 "a, b"
516 );
517 }
518
519 #[test]
520 fn record_serializes_with_path_as_string_and_skips_raw_content() {
521 let mut fields = std::collections::BTreeMap::new();
522 fields.insert("status".into(), Value::String("active".into()));
523 let r = Record {
524 path: std::path::PathBuf::from("/v/notes/a.md"),
525 fields,
526 raw_content: None,
527 };
528 let json = serde_json::to_string(&r).unwrap();
529 assert!(json.contains("/v/notes/a.md"));
530 assert!(json.contains("status"));
531 assert!(!json.contains("raw_content"));
532 }
533
534 #[test]
535 fn record_round_trips_through_serde() {
536 let mut fields = std::collections::BTreeMap::new();
537 fields.insert("k".into(), Value::Integer(1));
538 let r = Record {
539 path: std::path::PathBuf::from("/v/x.md"),
540 fields,
541 raw_content: None,
542 };
543 let json = serde_json::to_string(&r).unwrap();
544 let back: Record = serde_json::from_str(&json).unwrap();
545 assert_eq!(back.path, r.path);
546 assert_eq!(back.fields.get("k"), Some(&Value::Integer(1)));
547 assert!(back.raw_content.is_none());
548 }
549
550 #[test]
551 fn value_helpers_string() {
552 let v = Value::String("hi".into());
553 assert_eq!(v.as_str(), Some("hi"));
554 assert_eq!(v.as_integer(), None);
555 assert!(!v.is_null());
556 }
557
558 #[test]
559 fn value_helpers_integer() {
560 let v = Value::Integer(7);
561 assert_eq!(v.as_integer(), Some(7));
562 assert_eq!(v.as_float(), Some(7.0));
563 assert!(!v.is_null());
564 }
565
566 #[test]
567 fn value_helpers_float() {
568 let v = Value::Float(1.5);
569 assert_eq!(v.as_float(), Some(1.5));
570 }
571
572 #[test]
573 fn value_helpers_bool() {
574 let v = Value::Bool(true);
575 assert_eq!(v.as_bool(), Some(true));
576 }
577
578 #[test]
579 fn value_helpers_list() {
580 let v = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
581 assert_eq!(v.as_list().map(|s| s.len()), Some(2));
582 }
583
584 #[test]
585 fn value_helpers_map() {
586 let mut m = std::collections::BTreeMap::new();
587 m.insert("k".into(), Value::String("v".into()));
588 let v = Value::Map(m);
589 assert_eq!(v.as_map().map(|m| m.len()), Some(1));
590 }
591
592 #[test]
593 fn value_helpers_null() {
594 let v = Value::Null;
595 assert!(v.is_null());
596 assert_eq!(v.as_str(), None);
597 }
598
599 #[test]
600 fn value_serializes_untagged() {
601 let v = Value::List(vec![Value::Integer(1), Value::String("x".into())]);
602 let json = serde_json::to_string(&v).unwrap();
603 assert_eq!(json, r#"[1,"x"]"#);
604 }
605
606 #[test]
607 fn value_deserializes_untagged() {
608 let v: Value = serde_json::from_str(r#"[1,"x"]"#).unwrap();
609 assert_eq!(
610 v,
611 Value::List(vec![Value::Integer(1), Value::String("x".into())])
612 );
613 }
614
615 #[test]
618 fn parse_scalar_bool_literals() {
619 assert_eq!(Value::parse_scalar("true"), Value::Bool(true));
620 assert_eq!(Value::parse_scalar("false"), Value::Bool(false));
621 }
622
623 #[test]
624 fn parse_scalar_case_sensitive_for_bool() {
625 assert_eq!(Value::parse_scalar("True"), Value::String("True".into()));
629 assert_eq!(Value::parse_scalar("FALSE"), Value::String("FALSE".into()));
630 }
631
632 #[test]
633 fn parse_scalar_integer_then_float_then_string() {
634 assert_eq!(Value::parse_scalar("42"), Value::Integer(42));
635 assert_eq!(Value::parse_scalar("3.5"), Value::Float(3.5));
636 assert_eq!(Value::parse_scalar("hi"), Value::String("hi".into()));
637 }
638}