Skip to main content

vaultdb_core/
record.rs

1//! [`Record`] (one parsed `.md` file) and [`Value`] (the typed cell values).
2//! Records have virtual fields (`_name`, `_path`, `_modified`, etc.)
3//! computed lazily from the path and frontmatter.
4
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9/// A value from YAML frontmatter, preserving type information.
10#[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
23// Ergonomic `From` impls so callers building predicates programmatically
24// (notably `vaultdb-orm`'s typed field accessors) can write `.eq(2024)`
25// or `.contains("topic/ai")` and have it produce the right `Value`
26// variant without an explicit constructor.
27
28impl 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/// One parsed .md file = one record.
75///
76/// Serialization note: `path` serializes as a string. For machine-portable
77/// JSON, store records relative to the vault root before round-tripping;
78/// absolute paths are host-specific. `raw_content` is skipped when `None`
79/// so the wire format stays compact for record listings that don't include
80/// body text.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82pub struct Record {
83    /// Absolute path to the .md file.
84    pub path: PathBuf,
85    /// Parsed frontmatter fields.
86    pub fields: BTreeMap<String, Value>,
87    /// Raw file content — only loaded for write operations.
88    #[serde(skip_serializing_if = "Option::is_none", default)]
89    pub raw_content: Option<String>,
90}
91
92impl Record {
93    /// Look up a field by name, checking virtual fields first.
94    pub fn get(&self, key: &str, vault_root: &Path) -> Option<Value> {
95        self.get_with_links(key, vault_root, None)
96    }
97
98    /// Look up a field, including graph virtual fields when a link index is provided.
99    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                // Full body text (everything after the closing `---` of
133                // the frontmatter). Used by body-search predicates such as
134                // `_body contains "search term"` and `_body matches
135                // "^prefix"`. The DSL parser doesn't need to know about
136                // this — it's a normal virtual field that flows through
137                // the existing `Contains` / `Matches` / `StartsWith` /
138                // `EndsWith` predicates.
139                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                // Markdown links `[label](url)` in the body, as a list of
147                // {label, url} maps. Complements the wiki-link graph
148                // (`_links`, which captures `[[Note]]` internal links); this
149                // captures external markdown links. Query element-wise with
150                // `matches`, e.g. `_body_links matches "cs\\.stanford\\.edu"`.
151                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    /// Get file content — from raw_content if loaded, otherwise read from disk.
171    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    /// Filename without .md extension, with URL-encoded characters decoded.
180    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    /// Relative path from vault root.
190    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    /// Parent folder name.
199    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
224/// Decode percent-encoded characters in a string (e.g., %20 -> space).
225fn 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            // Failed to parse — keep the original
238            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    // Simple ISO-ish format without pulling in chrono for now
251    let days = secs / 86400;
252    let remaining = secs % 86400;
253    let hours = remaining / 3600;
254    let minutes = (remaining % 3600) / 60;
255    // Approximate date from epoch days — good enough for sorting and display
256    // For proper formatting we'd use chrono, but this avoids the dependency for virtual fields
257    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
264/// Today's calendar date in `YYYY-MM-DD` form. Used by
265/// `FieldSchema::default_expr = today` and by any consumer that needs
266/// a date matching the format vaultdb's virtual-field outputs use.
267pub 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
277/// Wall-clock now in `YYYY-MM-DD HH:MM` form. Used by
278/// `FieldSchema::default_expr = now`. Format matches the existing
279/// `_modified` / `_created` virtual fields so values are sortable
280/// together.
281pub fn now_string() -> String {
282    format_system_time(SystemTime::now())
283}
284
285/// Seconds since the Unix epoch. Used by
286/// `FieldSchema::default_expr = epoch`.
287pub 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    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
296    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    /// Try to get a string reference.
311    pub fn as_str(&self) -> Option<&str> {
312        match self {
313            Value::String(s) => Some(s),
314            _ => None,
315        }
316    }
317
318    /// Try to interpret as i64 (from Integer, or by parsing a String).
319    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    /// Try to interpret as f64.
329    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    /// Check if this value (as a List) contains an item matching the needle string.
339    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    /// Human-readable type name.
348    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    /// Display-friendly string representation.
361    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    /// Whether this value is null or an empty collection.
383    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    /// Returns the inner bool if this is `Value::Bool`, else `None`.
394    pub fn as_bool(&self) -> Option<bool> {
395        match self {
396            Value::Bool(b) => Some(*b),
397            _ => None,
398        }
399    }
400
401    /// Returns the inner list if this is `Value::List`, else `None`.
402    pub fn as_list(&self) -> Option<&[Value]> {
403        match self {
404            Value::List(v) => Some(v),
405            _ => None,
406        }
407    }
408
409    /// Returns the inner map if this is `Value::Map`, else `None`.
410    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    /// Returns true if this value is `Value::Null`.
418    pub fn is_null(&self) -> bool {
419        matches!(self, Value::Null)
420    }
421
422    /// Best-effort parse of a CLI-style `FIELD=VALUE` scalar string into a
423    /// typed `Value`. Tries bool literal (`true` / `false` exact match —
424    /// case-sensitive to match YAML), then integer, then float, then falls
425    /// back to a string. Shared by every frontend that accepts
426    /// loosely-typed user input — CLI `--set`, MCP `plan_update`'s legacy
427    /// string-set path, and `CreateBuilder` set-field via the CLI.
428    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    // ── parse_scalar coverage ─────────────────────────────────────────
664
665    #[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        // YAML's bool literals are lowercase only; mixed-case stays string
674        // so that values like "True" or "FALSE" don't accidentally get
675        // coerced to bool.
676        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}