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
243fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
244    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
245    let z = days + 719468;
246    let era = z / 146097;
247    let doe = z - era * 146097;
248    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
249    let y = yoe + era * 400;
250    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
251    let mp = (5 * doy + 2) / 153;
252    let d = doy - (153 * mp + 2) / 5 + 1;
253    let m = if mp < 10 { mp + 3 } else { mp - 9 };
254    let y = if m <= 2 { y + 1 } else { y };
255    (y, m, d)
256}
257
258impl Value {
259    /// Try to get a string reference.
260    pub fn as_str(&self) -> Option<&str> {
261        match self {
262            Value::String(s) => Some(s),
263            _ => None,
264        }
265    }
266
267    /// Try to interpret as i64 (from Integer, or by parsing a String).
268    pub fn as_integer(&self) -> Option<i64> {
269        match self {
270            Value::Integer(n) => Some(*n),
271            Value::Float(f) => Some(*f as i64),
272            Value::String(s) => s.parse().ok(),
273            _ => None,
274        }
275    }
276
277    /// Try to interpret as f64.
278    pub fn as_float(&self) -> Option<f64> {
279        match self {
280            Value::Float(f) => Some(*f),
281            Value::Integer(n) => Some(*n as f64),
282            Value::String(s) => s.parse().ok(),
283            _ => None,
284        }
285    }
286
287    /// Check if this value (as a List) contains an item matching the needle string.
288    pub fn list_contains(&self, needle: &str) -> bool {
289        match self {
290            Value::List(items) => items.iter().any(|item| item.display_value() == needle),
291            Value::String(s) => s.contains(needle),
292            _ => false,
293        }
294    }
295
296    /// Human-readable type name.
297    pub fn type_name(&self) -> &'static str {
298        match self {
299            Value::Null => "null",
300            Value::String(_) => "string",
301            Value::Integer(_) => "integer",
302            Value::Float(_) => "float",
303            Value::Bool(_) => "bool",
304            Value::List(_) => "list",
305            Value::Map(_) => "map",
306        }
307    }
308
309    /// Display-friendly string representation.
310    pub fn display_value(&self) -> String {
311        match self {
312            Value::Null => String::new(),
313            Value::String(s) => s.clone(),
314            Value::Integer(n) => n.to_string(),
315            Value::Float(f) => f.to_string(),
316            Value::Bool(b) => b.to_string(),
317            Value::List(items) => {
318                let parts: Vec<String> = items.iter().map(|v| v.display_value()).collect();
319                parts.join(", ")
320            }
321            Value::Map(m) => {
322                let parts: Vec<String> = m
323                    .iter()
324                    .map(|(k, v)| format!("{}: {}", k, v.display_value()))
325                    .collect();
326                parts.join(", ")
327            }
328        }
329    }
330
331    /// Whether this value is null or an empty collection.
332    pub fn is_empty(&self) -> bool {
333        match self {
334            Value::Null => true,
335            Value::String(s) => s.is_empty(),
336            Value::List(l) => l.is_empty(),
337            Value::Map(m) => m.is_empty(),
338            _ => false,
339        }
340    }
341
342    /// Returns the inner bool if this is `Value::Bool`, else `None`.
343    pub fn as_bool(&self) -> Option<bool> {
344        match self {
345            Value::Bool(b) => Some(*b),
346            _ => None,
347        }
348    }
349
350    /// Returns the inner list if this is `Value::List`, else `None`.
351    pub fn as_list(&self) -> Option<&[Value]> {
352        match self {
353            Value::List(v) => Some(v),
354            _ => None,
355        }
356    }
357
358    /// Returns the inner map if this is `Value::Map`, else `None`.
359    pub fn as_map(&self) -> Option<&std::collections::BTreeMap<String, Value>> {
360        match self {
361            Value::Map(m) => Some(m),
362            _ => None,
363        }
364    }
365
366    /// Returns true if this value is `Value::Null`.
367    pub fn is_null(&self) -> bool {
368        matches!(self, Value::Null)
369    }
370}
371
372impl std::fmt::Display for Value {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        write!(f, "{}", self.display_value())
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn virtual_name_strips_extension() {
384        let record = Record {
385            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
386            fields: BTreeMap::new(),
387            raw_content: None,
388        };
389        assert_eq!(record.virtual_name(), "TypeScript");
390    }
391
392    #[test]
393    fn virtual_name_handles_chinese() {
394        let record = Record {
395            path: PathBuf::from("/vault/3-Notes/快.md"),
396            fields: BTreeMap::new(),
397            raw_content: None,
398        };
399        assert_eq!(record.virtual_name(), "快");
400    }
401
402    #[test]
403    fn virtual_path_relative_to_root() {
404        let record = Record {
405            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
406            fields: BTreeMap::new(),
407            raw_content: None,
408        };
409        assert_eq!(
410            record.virtual_path(Path::new("/vault")),
411            "3-Notes/TypeScript.md"
412        );
413    }
414
415    #[test]
416    fn virtual_folder() {
417        let record = Record {
418            path: PathBuf::from("/vault/3-Notes/TypeScript.md"),
419            fields: BTreeMap::new(),
420            raw_content: None,
421        };
422        assert_eq!(record.virtual_folder(), "3-Notes");
423    }
424
425    #[test]
426    fn field_value_list_contains() {
427        let val = Value::List(vec![
428            Value::String("type/concept".into()),
429            Value::String("topic/chinese".into()),
430        ]);
431        assert!(val.list_contains("topic/chinese"));
432        assert!(!val.list_contains("topic/movies"));
433    }
434
435    #[test]
436    fn field_value_string_contains_substring() {
437        let val = Value::String("hello world".into());
438        assert!(val.list_contains("world"));
439    }
440
441    #[test]
442    fn field_value_type_names() {
443        assert_eq!(Value::Null.type_name(), "null");
444        assert_eq!(Value::Integer(5).type_name(), "integer");
445        assert_eq!(Value::String("x".into()).type_name(), "string");
446        assert_eq!(Value::List(vec![]).type_name(), "list");
447    }
448
449    #[test]
450    fn field_value_numeric_coercion() {
451        assert_eq!(Value::Integer(42).as_float(), Some(42.0));
452        assert_eq!(Value::Float(3.5).as_integer(), Some(3));
453        assert_eq!(Value::String("7".into()).as_integer(), Some(7));
454        assert_eq!(Value::String("not a number".into()).as_integer(), None);
455    }
456
457    #[test]
458    fn display_value_formatting() {
459        assert_eq!(Value::Null.display_value(), "");
460        assert_eq!(Value::Integer(2019).display_value(), "2019");
461        assert_eq!(
462            Value::List(vec![Value::String("a".into()), Value::String("b".into()),])
463                .display_value(),
464            "a, b"
465        );
466    }
467
468    #[test]
469    fn record_serializes_with_path_as_string_and_skips_raw_content() {
470        let mut fields = std::collections::BTreeMap::new();
471        fields.insert("status".into(), Value::String("active".into()));
472        let r = Record {
473            path: std::path::PathBuf::from("/v/notes/a.md"),
474            fields,
475            raw_content: None,
476        };
477        let json = serde_json::to_string(&r).unwrap();
478        assert!(json.contains("/v/notes/a.md"));
479        assert!(json.contains("status"));
480        assert!(!json.contains("raw_content"));
481    }
482
483    #[test]
484    fn record_round_trips_through_serde() {
485        let mut fields = std::collections::BTreeMap::new();
486        fields.insert("k".into(), Value::Integer(1));
487        let r = Record {
488            path: std::path::PathBuf::from("/v/x.md"),
489            fields,
490            raw_content: None,
491        };
492        let json = serde_json::to_string(&r).unwrap();
493        let back: Record = serde_json::from_str(&json).unwrap();
494        assert_eq!(back.path, r.path);
495        assert_eq!(back.fields.get("k"), Some(&Value::Integer(1)));
496        assert!(back.raw_content.is_none());
497    }
498
499    #[test]
500    fn value_helpers_string() {
501        let v = Value::String("hi".into());
502        assert_eq!(v.as_str(), Some("hi"));
503        assert_eq!(v.as_integer(), None);
504        assert!(!v.is_null());
505    }
506
507    #[test]
508    fn value_helpers_integer() {
509        let v = Value::Integer(7);
510        assert_eq!(v.as_integer(), Some(7));
511        assert_eq!(v.as_float(), Some(7.0));
512        assert!(!v.is_null());
513    }
514
515    #[test]
516    fn value_helpers_float() {
517        let v = Value::Float(1.5);
518        assert_eq!(v.as_float(), Some(1.5));
519    }
520
521    #[test]
522    fn value_helpers_bool() {
523        let v = Value::Bool(true);
524        assert_eq!(v.as_bool(), Some(true));
525    }
526
527    #[test]
528    fn value_helpers_list() {
529        let v = Value::List(vec![Value::Integer(1), Value::Integer(2)]);
530        assert_eq!(v.as_list().map(|s| s.len()), Some(2));
531    }
532
533    #[test]
534    fn value_helpers_map() {
535        let mut m = std::collections::BTreeMap::new();
536        m.insert("k".into(), Value::String("v".into()));
537        let v = Value::Map(m);
538        assert_eq!(v.as_map().map(|m| m.len()), Some(1));
539    }
540
541    #[test]
542    fn value_helpers_null() {
543        let v = Value::Null;
544        assert!(v.is_null());
545        assert_eq!(v.as_str(), None);
546    }
547
548    #[test]
549    fn value_serializes_untagged() {
550        let v = Value::List(vec![Value::Integer(1), Value::String("x".into())]);
551        let json = serde_json::to_string(&v).unwrap();
552        assert_eq!(json, r#"[1,"x"]"#);
553    }
554
555    #[test]
556    fn value_deserializes_untagged() {
557        let v: Value = serde_json::from_str(r#"[1,"x"]"#).unwrap();
558        assert_eq!(
559            v,
560            Value::List(vec![Value::Integer(1), Value::String("x".into())])
561        );
562    }
563}