use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::models::{Frontmatter, VaultFile};
impl Frontmatter {
fn okf_str_field(&self, key: &str) -> Option<String> {
let s = self.data.get(key)?.as_str()?.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
pub fn okf_type(&self) -> Option<String> {
self.okf_str_field("type")
}
pub fn okf_title(&self) -> Option<String> {
self.okf_str_field("title")
}
pub fn okf_description(&self) -> Option<String> {
self.okf_str_field("description")
}
pub fn okf_resource(&self) -> Option<String> {
self.okf_str_field("resource")
}
pub fn okf_timestamp(&self) -> Option<String> {
self.okf_str_field("timestamp")
}
pub fn is_okf_concept(&self) -> bool {
self.okf_type().is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReservedFile {
Index,
Log,
}
impl ReservedFile {
pub fn filename(self) -> &'static str {
match self {
ReservedFile::Index => "index.md",
ReservedFile::Log => "log.md",
}
}
}
pub fn reserved_file(path: &Path) -> Option<ReservedFile> {
let name = path.file_name()?.to_str()?.to_ascii_lowercase();
match name.as_str() {
"index.md" => Some(ReservedFile::Index),
"log.md" => Some(ReservedFile::Log),
_ => None,
}
}
pub fn concept_id(bundle_root: &Path, path: &Path) -> String {
let rel = path.strip_prefix(bundle_root).unwrap_or(path);
let s = rel.to_string_lossy().replace('\\', "/");
let s = s.trim_start_matches('/');
s.strip_suffix(".md").unwrap_or(s).to_string()
}
pub fn normalize_link_target(target: &str) -> Option<Vec<String>> {
if target.starts_with("http://")
|| target.starts_with("https://")
|| target.starts_with("mailto:")
|| target.starts_with('#')
{
return None;
}
let path_part = target.split('#').next().unwrap_or("").trim();
if path_part.is_empty() {
return None;
}
let parts: Vec<String> = path_part
.split(['/', '\\'])
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
.map(|seg| {
let lower = seg.to_lowercase();
lower.strip_suffix(".md").unwrap_or(&lower).to_string()
})
.collect();
if parts.is_empty() { None } else { Some(parts) }
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Citation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<u32>,
pub text: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConceptConformance {
pub conformant: bool,
pub has_frontmatter: bool,
pub has_type: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reserved: Option<ReservedFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub issues: Vec<String>,
}
pub fn check_concept(frontmatter: Option<&Frontmatter>, path: &Path) -> ConceptConformance {
let reserved = reserved_file(path);
let has_frontmatter = frontmatter.is_some();
let has_type = frontmatter.is_some_and(Frontmatter::is_okf_concept);
let mut issues = Vec::new();
if reserved.is_some() {
return ConceptConformance {
conformant: true,
has_frontmatter,
has_type,
reserved,
issues,
};
}
if !has_frontmatter {
issues.push("missing parseable YAML frontmatter block".to_string());
} else if !has_type {
issues.push("frontmatter is missing a non-empty `type` field".to_string());
}
ConceptConformance {
conformant: issues.is_empty(),
has_frontmatter,
has_type,
reserved,
issues,
}
}
const BUNDLE_CONCEPT_RATIO_THRESHOLD: f64 = 0.5;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BundleInfo {
pub is_okf_bundle: bool,
pub total_docs: usize,
pub concept_docs: usize,
pub reserved_files: usize,
pub concept_ratio: f64,
pub has_root_index: bool,
pub has_root_log: bool,
pub top_types: Vec<(String, usize)>,
}
pub fn detect_bundle(root: &Path, files: &[VaultFile]) -> BundleInfo {
let total_docs = files.len();
let mut reserved_files = 0usize;
let mut non_reserved = 0usize;
let mut concept_docs = 0usize;
let mut has_root_index = false;
let mut has_root_log = false;
let mut type_counts: std::collections::BTreeMap<String, usize> =
std::collections::BTreeMap::new();
for vf in files {
match reserved_file(&vf.path) {
Some(kind) => {
reserved_files += 1;
if vf.path.parent() == Some(root) {
match kind {
ReservedFile::Index => has_root_index = true,
ReservedFile::Log => has_root_log = true,
}
}
}
None => {
non_reserved += 1;
if let Some(t) = vf.frontmatter.as_ref().and_then(Frontmatter::okf_type) {
concept_docs += 1;
*type_counts.entry(t).or_insert(0) += 1;
}
}
}
}
let concept_ratio = if non_reserved == 0 {
0.0
} else {
concept_docs as f64 / non_reserved as f64
};
let is_okf_bundle =
concept_docs >= 1 && (concept_ratio >= BUNDLE_CONCEPT_RATIO_THRESHOLD || has_root_index);
let mut top_types: Vec<(String, usize)> = type_counts.into_iter().collect();
top_types.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
BundleInfo {
is_okf_bundle,
total_docs,
concept_docs,
reserved_files,
concept_ratio,
has_root_index,
has_root_log,
top_types,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::SourcePosition;
use std::collections::HashMap;
use std::path::PathBuf;
fn fm(pairs: &[(&str, serde_json::Value)]) -> Frontmatter {
let mut data = HashMap::new();
for (k, v) in pairs {
data.insert((*k).to_string(), v.clone());
}
Frontmatter {
data,
position: SourcePosition::start(),
}
}
#[test]
fn accessors_read_recommended_fields() {
let f = fm(&[
("type", serde_json::json!("BigQuery Table")),
("title", serde_json::json!("Customer Orders")),
("description", serde_json::json!("One row per order.")),
(
"resource",
serde_json::json!("https://console.cloud.google.com/x"),
),
("timestamp", serde_json::json!("2026-05-28T14:30:00Z")),
]);
assert_eq!(f.okf_type().as_deref(), Some("BigQuery Table"));
assert_eq!(f.okf_title().as_deref(), Some("Customer Orders"));
assert_eq!(f.okf_description().as_deref(), Some("One row per order."));
assert_eq!(
f.okf_resource().as_deref(),
Some("https://console.cloud.google.com/x")
);
assert_eq!(f.okf_timestamp().as_deref(), Some("2026-05-28T14:30:00Z"));
assert!(f.is_okf_concept());
}
#[test]
fn empty_and_missing_fields_are_none() {
let f = fm(&[("type", serde_json::json!(" "))]);
assert_eq!(f.okf_type(), None);
assert!(!f.is_okf_concept());
let g = fm(&[]);
assert_eq!(g.okf_title(), None);
}
#[test]
fn reserved_files_detected_case_insensitively() {
assert_eq!(
reserved_file(Path::new("/v/index.md")),
Some(ReservedFile::Index)
);
assert_eq!(
reserved_file(Path::new("/v/sub/LOG.md")),
Some(ReservedFile::Log)
);
assert_eq!(reserved_file(Path::new("/v/orders.md")), None);
}
#[test]
fn concept_id_strips_root_and_suffix() {
let root = PathBuf::from("/vault");
assert_eq!(
concept_id(&root, &PathBuf::from("/vault/tables/users.md")),
"tables/users"
);
assert_eq!(
concept_id(&root, &PathBuf::from("tables/users.md")),
"tables/users"
);
}
#[test]
fn normalize_targets() {
assert_eq!(
normalize_link_target("/tables/orders.md"),
Some(vec!["tables".to_string(), "orders".to_string()])
);
assert_eq!(
normalize_link_target("./customers.md#schema"),
Some(vec!["customers".to_string()])
);
assert_eq!(
normalize_link_target("../shared/glossary.md"),
Some(vec!["shared".to_string(), "glossary".to_string()])
);
assert_eq!(normalize_link_target("https://example.com"), None);
assert_eq!(normalize_link_target("#anchor"), None);
assert_eq!(normalize_link_target(""), None);
assert_eq!(
normalize_link_target("\\tables\\orders.md"),
Some(vec!["tables".to_string(), "orders".to_string()])
);
}
#[test]
fn conformance_requires_type_for_concepts() {
let ok = check_concept(
Some(&fm(&[("type", serde_json::json!("Playbook"))])),
Path::new("/v/playbooks/x.md"),
);
assert!(ok.conformant);
let no_type = check_concept(Some(&fm(&[])), Path::new("/v/x.md"));
assert!(!no_type.conformant);
assert!(no_type.has_frontmatter);
assert!(!no_type.has_type);
assert_eq!(no_type.issues.len(), 1);
let no_fm = check_concept(None, Path::new("/v/x.md"));
assert!(!no_fm.conformant);
assert!(!no_fm.has_frontmatter);
}
#[test]
fn reserved_files_are_conformant_without_type() {
let idx = check_concept(None, Path::new("/v/index.md"));
assert!(idx.conformant);
assert_eq!(idx.reserved, Some(ReservedFile::Index));
assert!(idx.issues.is_empty());
}
fn vfile(path: &str, type_: Option<&str>) -> VaultFile {
use crate::models::FileMetadata;
let p = PathBuf::from(path);
let meta = FileMetadata {
path: p.clone(),
size: 0,
created_at: 0.0,
modified_at: 0.0,
checksum: String::new(),
is_attachment: false,
};
let mut vf = VaultFile::new(p, String::new(), meta);
vf.frontmatter = type_.map(|t| fm(&[("type", serde_json::json!(t))]));
vf
}
#[test]
fn detect_bundle_flags_a_typed_vault() {
let root = PathBuf::from("/v");
let files = vec![
vfile("/v/tables/orders.md", Some("BigQuery Table")),
vfile("/v/tables/customers.md", Some("BigQuery Table")),
vfile("/v/playbooks/etl.md", Some("Playbook")),
vfile("/v/index.md", None),
vfile("/v/log.md", None),
];
let info = detect_bundle(&root, &files);
assert!(info.is_okf_bundle);
assert_eq!(info.total_docs, 5);
assert_eq!(info.concept_docs, 3);
assert_eq!(info.reserved_files, 2);
assert_eq!(info.concept_ratio, 1.0);
assert!(info.has_root_index);
assert!(info.has_root_log);
assert_eq!(
info.top_types,
vec![
("BigQuery Table".to_string(), 2),
("Playbook".to_string(), 1)
]
);
}
#[test]
fn detect_bundle_ignores_plain_obsidian_vault() {
let root = PathBuf::from("/v");
let files = vec![
vfile("/v/daily/monday.md", None),
vfile("/v/ideas.md", None),
vfile("/v/index.md", None),
];
let info = detect_bundle(&root, &files);
assert!(!info.is_okf_bundle);
assert_eq!(info.concept_docs, 0);
assert_eq!(info.concept_ratio, 0.0);
assert!(info.has_root_index);
assert!(info.top_types.is_empty());
}
#[test]
fn detect_bundle_root_index_qualifies_below_ratio() {
let root = PathBuf::from("/v");
let files = vec![
vfile("/v/orders.md", Some("Table")),
vfile("/v/notes.md", None),
vfile("/v/scratch.md", None),
vfile("/v/index.md", None),
];
let info = detect_bundle(&root, &files);
assert!(info.concept_ratio < BUNDLE_CONCEPT_RATIO_THRESHOLD);
assert!(info.has_root_index);
assert!(info.is_okf_bundle);
}
#[test]
fn detect_bundle_nested_reserved_not_counted_as_root() {
let root = PathBuf::from("/v");
let files = vec![
vfile("/v/tables/orders.md", Some("Table")),
vfile("/v/tables/index.md", None), ];
let info = detect_bundle(&root, &files);
assert!(!info.has_root_index);
assert_eq!(info.reserved_files, 1);
assert!(info.is_okf_bundle);
}
}