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
243fn epoch_days_to_date(days: u64) -> (u64, u64, u64) {
244 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 pub fn as_str(&self) -> Option<&str> {
261 match self {
262 Value::String(s) => Some(s),
263 _ => None,
264 }
265 }
266
267 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 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 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 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 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 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 pub fn as_bool(&self) -> Option<bool> {
344 match self {
345 Value::Bool(b) => Some(*b),
346 _ => None,
347 }
348 }
349
350 pub fn as_list(&self) -> Option<&[Value]> {
352 match self {
353 Value::List(v) => Some(v),
354 _ => None,
355 }
356 }
357
358 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 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}