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
243pub 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
256pub fn now_string() -> String {
261 format_system_time(SystemTime::now())
262}
263
264pub 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 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 pub fn as_str(&self) -> Option<&str> {
291 match self {
292 Value::String(s) => Some(s),
293 _ => None,
294 }
295 }
296
297 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 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 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 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 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 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 pub fn as_bool(&self) -> Option<bool> {
374 match self {
375 Value::Bool(b) => Some(*b),
376 _ => None,
377 }
378 }
379
380 pub fn as_list(&self) -> Option<&[Value]> {
382 match self {
383 Value::List(v) => Some(v),
384 _ => None,
385 }
386 }
387
388 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 pub fn is_null(&self) -> bool {
398 matches!(self, Value::Null)
399 }
400
401 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}