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            _ => self.fields.get(key).cloned(),
146        }
147    }
148
149    /// Get file content — from raw_content if loaded, otherwise read from disk.
150    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    /// Filename without .md extension, with URL-encoded characters decoded.
159    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    /// Relative path from vault root.
169    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    /// Parent folder name.
178    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
203/// Decode percent-encoded characters in a string (e.g., %20 -> space).
204fn 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            // Failed to parse — keep the original
217            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    // Simple ISO-ish format without pulling in chrono for now
230    let days = secs / 86400;
231    let remaining = secs % 86400;
232    let hours = remaining / 3600;
233    let minutes = (remaining % 3600) / 60;
234    // Approximate date from epoch days — good enough for sorting and display
235    // For proper formatting we'd use chrono, but this avoids the dependency for virtual fields
236    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
243/// Today's calendar date in `YYYY-MM-DD` form. Used by
244/// `FieldSchema::default_expr = today` and by any consumer that needs
245/// a date matching the format vaultdb's virtual-field outputs use.
246pub 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
256/// Wall-clock now in `YYYY-MM-DD HH:MM` form. Used by
257/// `FieldSchema::default_expr = now`. Format matches the existing
258/// `_modified` / `_created` virtual fields so values are sortable
259/// together.
260pub fn now_string() -> String {
261    format_system_time(SystemTime::now())
262}
263
264/// Seconds since the Unix epoch. Used by
265/// `FieldSchema::default_expr = epoch`.
266pub 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    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
275    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    /// Try to get a string reference.
290    pub fn as_str(&self) -> Option<&str> {
291        match self {
292            Value::String(s) => Some(s),
293            _ => None,
294        }
295    }
296
297    /// Try to interpret as i64 (from Integer, or by parsing a String).
298    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    /// Try to interpret as f64.
308    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    /// Check if this value (as a List) contains an item matching the needle string.
318    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    /// Human-readable type name.
327    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    /// Display-friendly string representation.
340    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    /// Whether this value is null or an empty collection.
362    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    /// Returns the inner bool if this is `Value::Bool`, else `None`.
373    pub fn as_bool(&self) -> Option<bool> {
374        match self {
375            Value::Bool(b) => Some(*b),
376            _ => None,
377        }
378    }
379
380    /// Returns the inner list if this is `Value::List`, else `None`.
381    pub fn as_list(&self) -> Option<&[Value]> {
382        match self {
383            Value::List(v) => Some(v),
384            _ => None,
385        }
386    }
387
388    /// Returns the inner map if this is `Value::Map`, else `None`.
389    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    /// Returns true if this value is `Value::Null`.
397    pub fn is_null(&self) -> bool {
398        matches!(self, Value::Null)
399    }
400
401    /// Best-effort parse of a CLI-style `FIELD=VALUE` scalar string into a
402    /// typed `Value`. Tries integer, then float, then falls back to a
403    /// string. Shared by every frontend that accepts loosely-typed user
404    /// input — CLI `--set`, MCP `plan_update`, the upcoming
405    /// `CreateBuilder` set-field path.
406    pub fn parse_scalar(s: &str) -> Self {
407        if let Ok(i) = s.parse::<i64>() {
408            return Value::Integer(i);
409        }
410        if let Ok(f) = s.parse::<f64>() {
411            return Value::Float(f);
412        }
413        Value::String(s.to_string())
414    }
415}
416
417impl std::fmt::Display for Value {
418    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419        write!(f, "{}", self.display_value())
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn virtual_name_strips_extension() {
429        let record = Record {
430            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
431            fields: BTreeMap::new(),
432            raw_content: None,
433        };
434        assert_eq!(record.virtual_name(), "TypeScript");
435    }
436
437    #[test]
438    fn virtual_name_handles_chinese() {
439        let record = Record {
440            path: PathBuf::from("/vault/3-Notes/快.md"),
441            fields: BTreeMap::new(),
442            raw_content: None,
443        };
444        assert_eq!(record.virtual_name(), "快");
445    }
446
447    #[test]
448    fn virtual_path_relative_to_root() {
449        let record = Record {
450            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
451            fields: BTreeMap::new(),
452            raw_content: None,
453        };
454        assert_eq!(
455            record.virtual_path(Path::new("/vault")),
456            "3-Notes/TypeScript.md"
457        );
458    }
459
460    #[test]
461    fn virtual_folder() {
462        let record = Record {
463            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
464            fields: BTreeMap::new(),
465            raw_content: None,
466        };
467        assert_eq!(record.virtual_folder(), "3-Notes");
468    }
469
470    #[test]
471    fn field_value_list_contains() {
472        let val = Value::List(vec![
473            Value::String("type/concept".into()),
474            Value::String("topic/chinese".into()),
475        ]);
476        assert!(val.list_contains("topic/chinese"));
477        assert!(!val.list_contains("topic/movies"));
478    }
479
480    #[test]
481    fn field_value_string_contains_substring() {
482        let val = Value::String("hello world".into());
483        assert!(val.list_contains("world"));
484    }
485
486    #[test]
487    fn field_value_type_names() {
488        assert_eq!(Value::Null.type_name(), "null");
489        assert_eq!(Value::Integer(5).type_name(), "integer");
490        assert_eq!(Value::String("x".into()).type_name(), "string");
491        assert_eq!(Value::List(vec![]).type_name(), "list");
492    }
493
494    #[test]
495    fn field_value_numeric_coercion() {
496        assert_eq!(Value::Integer(42).as_float(), Some(42.0));
497        assert_eq!(Value::Float(3.5).as_integer(), Some(3));
498        assert_eq!(Value::String("7".into()).as_integer(), Some(7));
499        assert_eq!(Value::String("not a number".into()).as_integer(), None);
500    }
501
502    #[test]
503    fn display_value_formatting() {
504        assert_eq!(Value::Null.display_value(), "");
505        assert_eq!(Value::Integer(2019).display_value(), "2019");
506        assert_eq!(
507            Value::List(vec![Value::String("a".into()), Value::String("b".into()),])
508                .display_value(),
509            "a, b"
510        );
511    }
512
513    #[test]
514    fn record_serializes_with_path_as_string_and_skips_raw_content() {
515        let mut fields = std::collections::BTreeMap::new();
516        fields.insert("status".into(), Value::String("active".into()));
517        let r = Record {
518            path: std::path::PathBuf::from("/v/notes/a.md"),
519            fields,
520            raw_content: None,
521        };
522        let json = serde_json::to_string(&r).unwrap();
523        assert!(json.contains("/v/notes/a.md"));
524        assert!(json.contains("status"));
525        assert!(!json.contains("raw_content"));
526    }
527
528    #[test]
529    fn record_round_trips_through_serde() {
530        let mut fields = std::collections::BTreeMap::new();
531        fields.insert("k".into(), Value::Integer(1));
532        let r = Record {
533            path: std::path::PathBuf::from("/v/x.md"),
534            fields,
535            raw_content: None,
536        };
537        let json = serde_json::to_string(&r).unwrap();
538        let back: Record = serde_json::from_str(&json).unwrap();
539        assert_eq!(back.path, r.path);
540        assert_eq!(back.fields.get("k"), Some(&Value::Integer(1)));
541        assert!(back.raw_content.is_none());
542    }
543
544    #[test]
545    fn value_helpers_string() {
546        let v = Value::String("hi".into());
547        assert_eq!(v.as_str(), Some("hi"));
548        assert_eq!(v.as_integer(), None);
549        assert!(!v.is_null());
550    }
551
552    #[test]
553    fn value_helpers_integer() {
554        let v = Value::Integer(7);
555        assert_eq!(v.as_integer(), Some(7));
556        assert_eq!(v.as_float(), Some(7.0));
557        assert!(!v.is_null());
558    }
559
560    #[test]
561    fn value_helpers_float() {
562        let v = Value::Float(1.5);
563        assert_eq!(v.as_float(), Some(1.5));
564    }
565
566    #[test]
567    fn value_helpers_bool() {
568        let v = Value::Bool(true);
569        assert_eq!(v.as_bool(), Some(true));
570    }
571
572    #[test]
573    fn value_helpers_list() {
574        let v = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
575        assert_eq!(v.as_list().map(|s| s.len()), Some(2));
576    }
577
578    #[test]
579    fn value_helpers_map() {
580        let mut m = std::collections::BTreeMap::new();
581        m.insert("k".into(), Value::String("v".into()));
582        let v = Value::Map(m);
583        assert_eq!(v.as_map().map(|m| m.len()), Some(1));
584    }
585
586    #[test]
587    fn value_helpers_null() {
588        let v = Value::Null;
589        assert!(v.is_null());
590        assert_eq!(v.as_str(), None);
591    }
592
593    #[test]
594    fn value_serializes_untagged() {
595        let v = Value::List(vec![Value::Integer(1), Value::String("x".into())]);
596        let json = serde_json::to_string(&v).unwrap();
597        assert_eq!(json, r#"[1,"x"]"#);
598    }
599
600    #[test]
601    fn value_deserializes_untagged() {
602        let v: Value = serde_json::from_str(r#"[1,"x"]"#).unwrap();
603        assert_eq!(
604            v,
605            Value::List(vec![Value::Integer(1), Value::String("x".into())])
606        );
607    }
608}