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 "_body_links" => {
146 let content = self.load_content();
152 let body = crate::frontmatter::extract_frontmatter(&content)
153 .map(|(_, body_start)| content[body_start..].to_string())
154 .unwrap_or(content);
155 let links: Vec<Value> = crate::links::extract_markdown_links(&body)
156 .into_iter()
157 .map(|(label, url)| {
158 let mut m = BTreeMap::new();
159 m.insert("label".to_string(), Value::String(label));
160 m.insert("url".to_string(), Value::String(url));
161 Value::Map(m)
162 })
163 .collect();
164 Some(Value::List(links))
165 }
166 _ => self.fields.get(key).cloned(),
167 }
168 }
169
170 fn load_content(&self) -> String {
172 if let Some(ref content) = self.raw_content {
173 content.clone()
174 } else {
175 std::fs::read_to_string(&self.path).unwrap_or_default()
176 }
177 }
178
179 pub fn virtual_name(&self) -> String {
181 let raw = self
182 .path
183 .file_stem()
184 .map(|s| s.to_string_lossy().into_owned())
185 .unwrap_or_default();
186 decode_percent_encoding(&raw)
187 }
188
189 pub fn virtual_path(&self, vault_root: &Path) -> String {
191 self.path
192 .strip_prefix(vault_root)
193 .unwrap_or(&self.path)
194 .to_string_lossy()
195 .into_owned()
196 }
197
198 pub fn virtual_folder(&self) -> String {
200 self.path
201 .parent()
202 .and_then(|p| p.file_name())
203 .map(|s| s.to_string_lossy().into_owned())
204 .unwrap_or_default()
205 }
206
207 fn virtual_modified(&self) -> Option<String> {
208 self.path
209 .metadata()
210 .ok()
211 .and_then(|m| m.modified().ok())
212 .map(format_system_time)
213 }
214
215 fn virtual_created(&self) -> Option<String> {
216 self.path
217 .metadata()
218 .ok()
219 .and_then(|m| m.created().ok())
220 .map(format_system_time)
221 }
222}
223
224fn decode_percent_encoding(input: &str) -> String {
226 let mut result = String::with_capacity(input.len());
227 let mut chars = input.chars();
228 while let Some(c) = chars.next() {
229 if c == '%' {
230 let hex: String = chars.by_ref().take(2).collect();
231 if hex.len() == 2
232 && let Ok(byte) = u8::from_str_radix(&hex, 16)
233 {
234 result.push(byte as char);
235 continue;
236 }
237 result.push('%');
239 result.push_str(&hex);
240 } else {
241 result.push(c);
242 }
243 }
244 result
245}
246
247fn format_system_time(t: SystemTime) -> String {
248 let duration = t.duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
249 let secs = duration.as_secs();
250 let days = secs / 86400;
252 let remaining = secs % 86400;
253 let hours = remaining / 3600;
254 let minutes = (remaining % 3600) / 60;
255 let (year, month, day) = epoch_days_to_date(days);
258 format!(
259 "{:04}-{:02}-{:02} {:02}:{:02}",
260 year, month, day, hours, minutes
261 )
262}
263
264pub fn today_string() -> String {
268 let secs = SystemTime::now()
269 .duration_since(SystemTime::UNIX_EPOCH)
270 .map(|d| d.as_secs())
271 .unwrap_or(0);
272 let days = secs / 86400;
273 let (y, m, d) = epoch_days_to_date(days);
274 format!("{:04}-{:02}-{:02}", y, m, d)
275}
276
277pub fn now_string() -> String {
282 format_system_time(SystemTime::now())
283}
284
285pub fn epoch_seconds() -> i64 {
288 SystemTime::now()
289 .duration_since(SystemTime::UNIX_EPOCH)
290 .map(|d| d.as_secs() as i64)
291 .unwrap_or(0)
292}
293
294fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
295 let z = days + 719468;
297 let era = z / 146097;
298 let doe = z - era * 146097;
299 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
300 let y = yoe + era * 400;
301 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
302 let mp = (5 * doy + 2) / 153;
303 let d = doy - (153 * mp + 2) / 5 + 1;
304 let m = if mp < 10 { mp + 3 } else { mp - 9 };
305 let y = if m <= 2 { y + 1 } else { y };
306 (y, m, d)
307}
308
309impl Value {
310 pub fn as_str(&self) -> Option<&str> {
312 match self {
313 Value::String(s) => Some(s),
314 _ => None,
315 }
316 }
317
318 pub fn as_integer(&self) -> Option<i64> {
320 match self {
321 Value::Integer(n) => Some(*n),
322 Value::Float(f) => Some(*f as i64),
323 Value::String(s) => s.parse().ok(),
324 _ => None,
325 }
326 }
327
328 pub fn as_float(&self) -> Option<f64> {
330 match self {
331 Value::Float(f) => Some(*f),
332 Value::Integer(n) => Some(*n as f64),
333 Value::String(s) => s.parse().ok(),
334 _ => None,
335 }
336 }
337
338 pub fn list_contains(&self, needle: &str) -> bool {
340 match self {
341 Value::List(items) => items.iter().any(|item| item.display_value() == needle),
342 Value::String(s) => s.contains(needle),
343 _ => false,
344 }
345 }
346
347 pub fn type_name(&self) -> &'static str {
349 match self {
350 Value::Null => "null",
351 Value::String(_) => "string",
352 Value::Integer(_) => "integer",
353 Value::Float(_) => "float",
354 Value::Bool(_) => "bool",
355 Value::List(_) => "list",
356 Value::Map(_) => "map",
357 }
358 }
359
360 pub fn display_value(&self) -> String {
362 match self {
363 Value::Null => String::new(),
364 Value::String(s) => s.clone(),
365 Value::Integer(n) => n.to_string(),
366 Value::Float(f) => f.to_string(),
367 Value::Bool(b) => b.to_string(),
368 Value::List(items) => {
369 let parts: Vec<String> = items.iter().map(|v| v.display_value()).collect();
370 parts.join(", ")
371 }
372 Value::Map(m) => {
373 let parts: Vec<String> = m
374 .iter()
375 .map(|(k, v)| format!("{}: {}", k, v.display_value()))
376 .collect();
377 parts.join(", ")
378 }
379 }
380 }
381
382 pub fn is_empty(&self) -> bool {
384 match self {
385 Value::Null => true,
386 Value::String(s) => s.is_empty(),
387 Value::List(l) => l.is_empty(),
388 Value::Map(m) => m.is_empty(),
389 _ => false,
390 }
391 }
392
393 pub fn as_bool(&self) -> Option<bool> {
395 match self {
396 Value::Bool(b) => Some(*b),
397 _ => None,
398 }
399 }
400
401 pub fn as_list(&self) -> Option<&[Value]> {
403 match self {
404 Value::List(v) => Some(v),
405 _ => None,
406 }
407 }
408
409 pub fn as_map(&self) -> Option<&std::collections::BTreeMap<String, Value>> {
411 match self {
412 Value::Map(m) => Some(m),
413 _ => None,
414 }
415 }
416
417 pub fn is_null(&self) -> bool {
419 matches!(self, Value::Null)
420 }
421
422 pub fn parse_scalar(s: &str) -> Self {
429 match s {
430 "true" => return Value::Bool(true),
431 "false" => return Value::Bool(false),
432 _ => {}
433 }
434 if let Ok(i) = s.parse::<i64>() {
435 return Value::Integer(i);
436 }
437 if let Ok(f) = s.parse::<f64>() {
438 return Value::Float(f);
439 }
440 Value::String(s.to_string())
441 }
442}
443
444impl std::fmt::Display for Value {
445 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
446 write!(f, "{}", self.display_value())
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn virtual_name_strips_extension() {
456 let record = Record {
457 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
458 fields: BTreeMap::new(),
459 raw_content: None,
460 };
461 assert_eq!(record.virtual_name(), "TypeScript");
462 }
463
464 #[test]
465 fn virtual_name_handles_chinese() {
466 let record = Record {
467 path: PathBuf::from("/vault/3-Notes/快.md"),
468 fields: BTreeMap::new(),
469 raw_content: None,
470 };
471 assert_eq!(record.virtual_name(), "快");
472 }
473
474 #[test]
475 fn virtual_path_relative_to_root() {
476 let record = Record {
477 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
478 fields: BTreeMap::new(),
479 raw_content: None,
480 };
481 assert_eq!(
482 record.virtual_path(Path::new("/vault")),
483 "3-Notes/TypeScript.md"
484 );
485 }
486
487 #[test]
488 fn virtual_folder() {
489 let record = Record {
490 path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
491 fields: BTreeMap::new(),
492 raw_content: None,
493 };
494 assert_eq!(record.virtual_folder(), "3-Notes");
495 }
496
497 #[test]
498 fn virtual_body_links_extracts_markdown_links() {
499 let record = Record {
500 path: PathBuf::from("/vault/notes/U.md"),
501 fields: BTreeMap::new(),
502 raw_content: Some(
503 "---\ndb-table: university\n---\n## Admissions Links\n\
504 - [Apply](https://grad.example.edu/apply)\n\
505 - [Tuition](https://example.edu/tuition)\n"
506 .to_string(),
507 ),
508 };
509 let v = record.get("_body_links", Path::new("/vault")).unwrap();
510 let Value::List(items) = v else {
511 panic!("expected a list");
512 };
513 assert_eq!(items.len(), 2);
514 let Value::Map(first) = &items[0] else {
515 panic!("expected a map");
516 };
517 assert_eq!(first.get("label"), Some(&Value::String("Apply".into())));
518 assert_eq!(
519 first.get("url"),
520 Some(&Value::String("https://grad.example.edu/apply".into()))
521 );
522 }
523
524 #[test]
525 fn field_value_list_contains() {
526 let val = Value::List(vec![
527 Value::String("type/concept".into()),
528 Value::String("topic/chinese".into()),
529 ]);
530 assert!(val.list_contains("topic/chinese"));
531 assert!(!val.list_contains("topic/movies"));
532 }
533
534 #[test]
535 fn field_value_string_contains_substring() {
536 let val = Value::String("hello world".into());
537 assert!(val.list_contains("world"));
538 }
539
540 #[test]
541 fn field_value_type_names() {
542 assert_eq!(Value::Null.type_name(), "null");
543 assert_eq!(Value::Integer(5).type_name(), "integer");
544 assert_eq!(Value::String("x".into()).type_name(), "string");
545 assert_eq!(Value::List(vec![]).type_name(), "list");
546 }
547
548 #[test]
549 fn field_value_numeric_coercion() {
550 assert_eq!(Value::Integer(42).as_float(), Some(42.0));
551 assert_eq!(Value::Float(3.5).as_integer(), Some(3));
552 assert_eq!(Value::String("7".into()).as_integer(), Some(7));
553 assert_eq!(Value::String("not a number".into()).as_integer(), None);
554 }
555
556 #[test]
557 fn display_value_formatting() {
558 assert_eq!(Value::Null.display_value(), "");
559 assert_eq!(Value::Integer(2019).display_value(), "2019");
560 assert_eq!(
561 Value::List(vec![Value::String("a".into()), Value::String("b".into()),])
562 .display_value(),
563 "a, b"
564 );
565 }
566
567 #[test]
568 fn record_serializes_with_path_as_string_and_skips_raw_content() {
569 let mut fields = std::collections::BTreeMap::new();
570 fields.insert("status".into(), Value::String("active".into()));
571 let r = Record {
572 path: std::path::PathBuf::from("/v/notes/a.md"),
573 fields,
574 raw_content: None,
575 };
576 let json = serde_json::to_string(&r).unwrap();
577 assert!(json.contains("/v/notes/a.md"));
578 assert!(json.contains("status"));
579 assert!(!json.contains("raw_content"));
580 }
581
582 #[test]
583 fn record_round_trips_through_serde() {
584 let mut fields = std::collections::BTreeMap::new();
585 fields.insert("k".into(), Value::Integer(1));
586 let r = Record {
587 path: std::path::PathBuf::from("/v/x.md"),
588 fields,
589 raw_content: None,
590 };
591 let json = serde_json::to_string(&r).unwrap();
592 let back: Record = serde_json::from_str(&json).unwrap();
593 assert_eq!(back.path, r.path);
594 assert_eq!(back.fields.get("k"), Some(&Value::Integer(1)));
595 assert!(back.raw_content.is_none());
596 }
597
598 #[test]
599 fn value_helpers_string() {
600 let v = Value::String("hi".into());
601 assert_eq!(v.as_str(), Some("hi"));
602 assert_eq!(v.as_integer(), None);
603 assert!(!v.is_null());
604 }
605
606 #[test]
607 fn value_helpers_integer() {
608 let v = Value::Integer(7);
609 assert_eq!(v.as_integer(), Some(7));
610 assert_eq!(v.as_float(), Some(7.0));
611 assert!(!v.is_null());
612 }
613
614 #[test]
615 fn value_helpers_float() {
616 let v = Value::Float(1.5);
617 assert_eq!(v.as_float(), Some(1.5));
618 }
619
620 #[test]
621 fn value_helpers_bool() {
622 let v = Value::Bool(true);
623 assert_eq!(v.as_bool(), Some(true));
624 }
625
626 #[test]
627 fn value_helpers_list() {
628 let v = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
629 assert_eq!(v.as_list().map(|s| s.len()), Some(2));
630 }
631
632 #[test]
633 fn value_helpers_map() {
634 let mut m = std::collections::BTreeMap::new();
635 m.insert("k".into(), Value::String("v".into()));
636 let v = Value::Map(m);
637 assert_eq!(v.as_map().map(|m| m.len()), Some(1));
638 }
639
640 #[test]
641 fn value_helpers_null() {
642 let v = Value::Null;
643 assert!(v.is_null());
644 assert_eq!(v.as_str(), None);
645 }
646
647 #[test]
648 fn value_serializes_untagged() {
649 let v = Value::List(vec![Value::Integer(1), Value::String("x".into())]);
650 let json = serde_json::to_string(&v).unwrap();
651 assert_eq!(json, r#"[1,"x"]"#);
652 }
653
654 #[test]
655 fn value_deserializes_untagged() {
656 let v: Value = serde_json::from_str(r#"[1,"x"]"#).unwrap();
657 assert_eq!(
658 v,
659 Value::List(vec![Value::Integer(1), Value::String("x".into())])
660 );
661 }
662
663 #[test]
666 fn parse_scalar_bool_literals() {
667 assert_eq!(Value::parse_scalar("true"), Value::Bool(true));
668 assert_eq!(Value::parse_scalar("false"), Value::Bool(false));
669 }
670
671 #[test]
672 fn parse_scalar_case_sensitive_for_bool() {
673 assert_eq!(Value::parse_scalar("True"), Value::String("True".into()));
677 assert_eq!(Value::parse_scalar("FALSE"), Value::String("FALSE".into()));
678 }
679
680 #[test]
681 fn parse_scalar_integer_then_float_then_string() {
682 assert_eq!(Value::parse_scalar("42"), Value::Integer(42));
683 assert_eq!(Value::parse_scalar("3.5"), Value::Float(3.5));
684 assert_eq!(Value::parse_scalar("hi"), Value::String("hi".into()));
685 }
686}