1use std::path::Path;
27
28use serde::{Deserialize, Serialize};
29
30use crate::models::{Frontmatter, VaultFile};
31
32impl Frontmatter {
37 fn okf_str_field(&self, key: &str) -> Option<String> {
41 let s = self.data.get(key)?.as_str()?.trim();
42 if s.is_empty() {
43 None
44 } else {
45 Some(s.to_string())
46 }
47 }
48
49 pub fn okf_type(&self) -> Option<String> {
53 self.okf_str_field("type")
54 }
55
56 pub fn okf_title(&self) -> Option<String> {
58 self.okf_str_field("title")
59 }
60
61 pub fn okf_description(&self) -> Option<String> {
64 self.okf_str_field("description")
65 }
66
67 pub fn okf_resource(&self) -> Option<String> {
69 self.okf_str_field("resource")
70 }
71
72 pub fn okf_timestamp(&self) -> Option<String> {
75 self.okf_str_field("timestamp")
76 }
77
78 pub fn is_okf_concept(&self) -> bool {
81 self.okf_type().is_some()
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ReservedFile {
91 Index,
93 Log,
95}
96
97impl ReservedFile {
98 pub fn filename(self) -> &'static str {
100 match self {
101 ReservedFile::Index => "index.md",
102 ReservedFile::Log => "log.md",
103 }
104 }
105}
106
107pub fn reserved_file(path: &Path) -> Option<ReservedFile> {
111 let name = path.file_name()?.to_str()?.to_ascii_lowercase();
112 match name.as_str() {
113 "index.md" => Some(ReservedFile::Index),
114 "log.md" => Some(ReservedFile::Log),
115 _ => None,
116 }
117}
118
119pub fn concept_id(bundle_root: &Path, path: &Path) -> String {
128 let rel = path.strip_prefix(bundle_root).unwrap_or(path);
129 let s = rel.to_string_lossy().replace('\\', "/");
130 let s = s.trim_start_matches('/');
131 s.strip_suffix(".md").unwrap_or(s).to_string()
132}
133
134pub fn normalize_link_target(target: &str) -> Option<Vec<String>> {
156 if target.starts_with("http://")
158 || target.starts_with("https://")
159 || target.starts_with("mailto:")
160 || target.starts_with('#')
161 {
162 return None;
163 }
164
165 let path_part = target.split('#').next().unwrap_or("").trim();
167 if path_part.is_empty() {
168 return None;
169 }
170
171 let parts: Vec<String> = path_part
172 .split(['/', '\\'])
173 .filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
174 .map(|seg| {
175 let lower = seg.to_lowercase();
176 lower.strip_suffix(".md").unwrap_or(&lower).to_string()
177 })
178 .collect();
179
180 if parts.is_empty() { None } else { Some(parts) }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct Citation {
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub index: Option<u32>,
191 pub text: String,
193 pub url: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct ConceptConformance {
204 pub conformant: bool,
206 pub has_frontmatter: bool,
208 pub has_type: bool,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub reserved: Option<ReservedFile>,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub issues: Vec<String>,
216}
217
218pub fn check_concept(frontmatter: Option<&Frontmatter>, path: &Path) -> ConceptConformance {
223 let reserved = reserved_file(path);
224 let has_frontmatter = frontmatter.is_some();
225 let has_type = frontmatter.is_some_and(Frontmatter::is_okf_concept);
226 let mut issues = Vec::new();
227
228 if reserved.is_some() {
230 return ConceptConformance {
231 conformant: true,
232 has_frontmatter,
233 has_type,
234 reserved,
235 issues,
236 };
237 }
238
239 if !has_frontmatter {
240 issues.push("missing parseable YAML frontmatter block".to_string());
241 } else if !has_type {
242 issues.push("frontmatter is missing a non-empty `type` field".to_string());
243 }
244
245 ConceptConformance {
246 conformant: issues.is_empty(),
247 has_frontmatter,
248 has_type,
249 reserved,
250 issues,
251 }
252}
253
254const BUNDLE_CONCEPT_RATIO_THRESHOLD: f64 = 0.5;
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct BundleInfo {
267 pub is_okf_bundle: bool,
271 pub total_docs: usize,
273 pub concept_docs: usize,
275 pub reserved_files: usize,
277 pub concept_ratio: f64,
279 pub has_root_index: bool,
282 pub has_root_log: bool,
284 pub top_types: Vec<(String, usize)>,
286}
287
288pub fn detect_bundle(root: &Path, files: &[VaultFile]) -> BundleInfo {
294 let total_docs = files.len();
295 let mut reserved_files = 0usize;
296 let mut non_reserved = 0usize;
297 let mut concept_docs = 0usize;
298 let mut has_root_index = false;
299 let mut has_root_log = false;
300 let mut type_counts: std::collections::BTreeMap<String, usize> =
301 std::collections::BTreeMap::new();
302
303 for vf in files {
304 match reserved_file(&vf.path) {
305 Some(kind) => {
306 reserved_files += 1;
307 if vf.path.parent() == Some(root) {
308 match kind {
309 ReservedFile::Index => has_root_index = true,
310 ReservedFile::Log => has_root_log = true,
311 }
312 }
313 }
314 None => {
315 non_reserved += 1;
316 if let Some(t) = vf.frontmatter.as_ref().and_then(Frontmatter::okf_type) {
317 concept_docs += 1;
318 *type_counts.entry(t).or_insert(0) += 1;
319 }
320 }
321 }
322 }
323
324 let concept_ratio = if non_reserved == 0 {
325 0.0
326 } else {
327 concept_docs as f64 / non_reserved as f64
328 };
329
330 let is_okf_bundle =
331 concept_docs >= 1 && (concept_ratio >= BUNDLE_CONCEPT_RATIO_THRESHOLD || has_root_index);
332
333 let mut top_types: Vec<(String, usize)> = type_counts.into_iter().collect();
335 top_types.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
336
337 BundleInfo {
338 is_okf_bundle,
339 total_docs,
340 concept_docs,
341 reserved_files,
342 concept_ratio,
343 has_root_index,
344 has_root_log,
345 top_types,
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::models::SourcePosition;
353 use std::collections::HashMap;
354 use std::path::PathBuf;
355
356 fn fm(pairs: &[(&str, serde_json::Value)]) -> Frontmatter {
357 let mut data = HashMap::new();
358 for (k, v) in pairs {
359 data.insert((*k).to_string(), v.clone());
360 }
361 Frontmatter {
362 data,
363 position: SourcePosition::start(),
364 }
365 }
366
367 #[test]
368 fn accessors_read_recommended_fields() {
369 let f = fm(&[
370 ("type", serde_json::json!("BigQuery Table")),
371 ("title", serde_json::json!("Customer Orders")),
372 ("description", serde_json::json!("One row per order.")),
373 (
374 "resource",
375 serde_json::json!("https://console.cloud.google.com/x"),
376 ),
377 ("timestamp", serde_json::json!("2026-05-28T14:30:00Z")),
378 ]);
379 assert_eq!(f.okf_type().as_deref(), Some("BigQuery Table"));
380 assert_eq!(f.okf_title().as_deref(), Some("Customer Orders"));
381 assert_eq!(f.okf_description().as_deref(), Some("One row per order."));
382 assert_eq!(
383 f.okf_resource().as_deref(),
384 Some("https://console.cloud.google.com/x")
385 );
386 assert_eq!(f.okf_timestamp().as_deref(), Some("2026-05-28T14:30:00Z"));
387 assert!(f.is_okf_concept());
388 }
389
390 #[test]
391 fn empty_and_missing_fields_are_none() {
392 let f = fm(&[("type", serde_json::json!(" "))]);
393 assert_eq!(f.okf_type(), None);
394 assert!(!f.is_okf_concept());
395 let g = fm(&[]);
396 assert_eq!(g.okf_title(), None);
397 }
398
399 #[test]
400 fn reserved_files_detected_case_insensitively() {
401 assert_eq!(
402 reserved_file(Path::new("/v/index.md")),
403 Some(ReservedFile::Index)
404 );
405 assert_eq!(
406 reserved_file(Path::new("/v/sub/LOG.md")),
407 Some(ReservedFile::Log)
408 );
409 assert_eq!(reserved_file(Path::new("/v/orders.md")), None);
410 }
411
412 #[test]
413 fn concept_id_strips_root_and_suffix() {
414 let root = PathBuf::from("/vault");
415 assert_eq!(
416 concept_id(&root, &PathBuf::from("/vault/tables/users.md")),
417 "tables/users"
418 );
419 assert_eq!(
420 concept_id(&root, &PathBuf::from("tables/users.md")),
421 "tables/users"
422 );
423 }
424
425 #[test]
426 fn normalize_targets() {
427 assert_eq!(
428 normalize_link_target("/tables/orders.md"),
429 Some(vec!["tables".to_string(), "orders".to_string()])
430 );
431 assert_eq!(
432 normalize_link_target("./customers.md#schema"),
433 Some(vec!["customers".to_string()])
434 );
435 assert_eq!(
436 normalize_link_target("../shared/glossary.md"),
437 Some(vec!["shared".to_string(), "glossary".to_string()])
438 );
439 assert_eq!(normalize_link_target("https://example.com"), None);
440 assert_eq!(normalize_link_target("#anchor"), None);
441 assert_eq!(normalize_link_target(""), None);
442 assert_eq!(
444 normalize_link_target("\\tables\\orders.md"),
445 Some(vec!["tables".to_string(), "orders".to_string()])
446 );
447 }
448
449 #[test]
450 fn conformance_requires_type_for_concepts() {
451 let ok = check_concept(
452 Some(&fm(&[("type", serde_json::json!("Playbook"))])),
453 Path::new("/v/playbooks/x.md"),
454 );
455 assert!(ok.conformant);
456
457 let no_type = check_concept(Some(&fm(&[])), Path::new("/v/x.md"));
458 assert!(!no_type.conformant);
459 assert!(no_type.has_frontmatter);
460 assert!(!no_type.has_type);
461 assert_eq!(no_type.issues.len(), 1);
462
463 let no_fm = check_concept(None, Path::new("/v/x.md"));
464 assert!(!no_fm.conformant);
465 assert!(!no_fm.has_frontmatter);
466 }
467
468 #[test]
469 fn reserved_files_are_conformant_without_type() {
470 let idx = check_concept(None, Path::new("/v/index.md"));
471 assert!(idx.conformant);
472 assert_eq!(idx.reserved, Some(ReservedFile::Index));
473 assert!(idx.issues.is_empty());
474 }
475
476 fn vfile(path: &str, type_: Option<&str>) -> VaultFile {
477 use crate::models::FileMetadata;
478 let p = PathBuf::from(path);
479 let meta = FileMetadata {
480 path: p.clone(),
481 size: 0,
482 created_at: 0.0,
483 modified_at: 0.0,
484 checksum: String::new(),
485 is_attachment: false,
486 };
487 let mut vf = VaultFile::new(p, String::new(), meta);
488 vf.frontmatter = type_.map(|t| fm(&[("type", serde_json::json!(t))]));
489 vf
490 }
491
492 #[test]
493 fn detect_bundle_flags_a_typed_vault() {
494 let root = PathBuf::from("/v");
495 let files = vec![
496 vfile("/v/tables/orders.md", Some("BigQuery Table")),
497 vfile("/v/tables/customers.md", Some("BigQuery Table")),
498 vfile("/v/playbooks/etl.md", Some("Playbook")),
499 vfile("/v/index.md", None),
500 vfile("/v/log.md", None),
501 ];
502 let info = detect_bundle(&root, &files);
503 assert!(info.is_okf_bundle);
504 assert_eq!(info.total_docs, 5);
505 assert_eq!(info.concept_docs, 3);
506 assert_eq!(info.reserved_files, 2);
507 assert_eq!(info.concept_ratio, 1.0);
508 assert!(info.has_root_index);
509 assert!(info.has_root_log);
510 assert_eq!(
512 info.top_types,
513 vec![
514 ("BigQuery Table".to_string(), 2),
515 ("Playbook".to_string(), 1)
516 ]
517 );
518 }
519
520 #[test]
521 fn detect_bundle_ignores_plain_obsidian_vault() {
522 let root = PathBuf::from("/v");
523 let files = vec![
525 vfile("/v/daily/monday.md", None),
526 vfile("/v/ideas.md", None),
527 vfile("/v/index.md", None),
528 ];
529 let info = detect_bundle(&root, &files);
530 assert!(!info.is_okf_bundle);
531 assert_eq!(info.concept_docs, 0);
532 assert_eq!(info.concept_ratio, 0.0);
533 assert!(info.has_root_index);
534 assert!(info.top_types.is_empty());
535 }
536
537 #[test]
538 fn detect_bundle_root_index_qualifies_below_ratio() {
539 let root = PathBuf::from("/v");
540 let files = vec![
543 vfile("/v/orders.md", Some("Table")),
544 vfile("/v/notes.md", None),
545 vfile("/v/scratch.md", None),
546 vfile("/v/index.md", None),
547 ];
548 let info = detect_bundle(&root, &files);
549 assert!(info.concept_ratio < BUNDLE_CONCEPT_RATIO_THRESHOLD);
550 assert!(info.has_root_index);
551 assert!(info.is_okf_bundle);
552 }
553
554 #[test]
555 fn detect_bundle_nested_reserved_not_counted_as_root() {
556 let root = PathBuf::from("/v");
557 let files = vec![
558 vfile("/v/tables/orders.md", Some("Table")),
559 vfile("/v/tables/index.md", None), ];
561 let info = detect_bundle(&root, &files);
562 assert!(!info.has_root_index);
563 assert_eq!(info.reserved_files, 1);
564 assert!(info.is_okf_bundle);
566 }
567}