use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use turbovault_core::Result;
use turbovault_core::okf::{self, ReservedFile};
use turbovault_parser::parse_citations;
use turbovault_vault::VaultManager;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OkfConceptInfo {
pub path: String,
pub concept_id: String,
pub conformant: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reserved: Option<ReservedFile>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
pub citation_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OkfValidateReport {
pub total: usize,
pub conformant: usize,
pub non_conformant: usize,
pub concepts: usize,
pub reserved_files: usize,
pub type_distribution: BTreeMap<String, usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub non_conformant_paths: Vec<String>,
pub files: Vec<OkfConceptInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneratedIndex {
pub path: String,
pub entries: usize,
pub written: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateIndexReport {
pub indexes: Vec<GeneratedIndex>,
pub total_entries: usize,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntryResult {
pub path: String,
pub date: String,
pub created_file: bool,
pub created_section: bool,
}
pub struct OkfTools {
manager: Arc<VaultManager>,
}
impl OkfTools {
pub fn new(manager: Arc<VaultManager>) -> Self {
Self { manager }
}
fn rel(&self, path: &Path) -> String {
self.manager.relative_path(path)
}
pub async fn bundle_info(&self) -> okf::BundleInfo {
let files = self.manager.vault_files_validated().await;
okf::detect_bundle(self.manager.vault_path().as_path(), &files)
}
pub async fn validate(&self, subtree: Option<&str>) -> Result<OkfValidateReport> {
let files = self.manager.vault_files_validated().await;
let root = self.manager.vault_path();
let filter_prefix = subtree.map(|s| root.join(s));
let mut infos: Vec<OkfConceptInfo> = Vec::new();
let mut type_distribution: BTreeMap<String, usize> = BTreeMap::new();
for vault_file in &files {
let path = &vault_file.path;
if let Some(prefix) = &filter_prefix
&& !path.starts_with(prefix)
{
continue;
}
let fm = vault_file.frontmatter.as_ref();
let conformance = okf::check_concept(fm, path);
let type_ = fm.and_then(|f| f.okf_type());
if let (Some(t), None) = (&type_, conformance.reserved) {
*type_distribution.entry(t.clone()).or_insert(0) += 1;
}
infos.push(OkfConceptInfo {
path: self.rel(path),
concept_id: okf::concept_id(root, path),
conformant: conformance.conformant,
reserved: conformance.reserved,
type_,
title: fm.and_then(|f| f.okf_title()),
description: fm.and_then(|f| f.okf_description()),
resource: fm.and_then(|f| f.okf_resource()),
timestamp: fm.and_then(|f| f.okf_timestamp()),
citation_count: parse_citations(&vault_file.content).len(),
issues: conformance.issues,
});
}
infos.sort_by(|a, b| a.path.cmp(&b.path));
let total = infos.len();
let conformant = infos.iter().filter(|i| i.conformant).count();
let reserved_files = infos.iter().filter(|i| i.reserved.is_some()).count();
let non_conformant_paths: Vec<String> = infos
.iter()
.filter(|i| !i.conformant)
.map(|i| i.path.clone())
.collect();
Ok(OkfValidateReport {
total,
conformant,
non_conformant: total - conformant,
concepts: total - reserved_files,
reserved_files,
type_distribution,
non_conformant_paths,
files: infos,
})
}
pub async fn generate_index(
&self,
directory: Option<&str>,
recursive: bool,
dry_run: bool,
) -> Result<GenerateIndexReport> {
let validated = self.manager.vault_files_validated().await;
let root = self.manager.vault_path().clone();
let base = match directory {
Some(d) => root.join(d),
None => root.clone(),
};
let meta: HashMap<PathBuf, ConceptMeta> = validated
.iter()
.map(|vf| {
let fm = vf.frontmatter.as_ref();
(
vf.path.clone(),
ConceptMeta {
title: fm.and_then(|f| f.okf_title()),
description: fm.and_then(|f| f.okf_description()),
},
)
})
.collect();
let mut dir_concepts: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut dir_subdirs: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
for vf in &validated {
let path = &vf.path;
let Some(parent) = path.parent() else {
continue;
};
if okf::reserved_file(path).is_none() {
dir_concepts
.entry(parent.to_path_buf())
.or_default()
.push(path.clone());
}
let mut cur = parent.to_path_buf();
while cur.starts_with(&root) && cur != root {
let Some(grandparent) = cur.parent() else {
break;
};
dir_subdirs
.entry(grandparent.to_path_buf())
.or_default()
.insert(cur.clone());
if grandparent == root {
break;
}
cur = grandparent.to_path_buf();
}
}
let mut target_dirs: BTreeSet<PathBuf> = BTreeSet::new();
let all_dirs: BTreeSet<PathBuf> = dir_concepts
.keys()
.chain(dir_subdirs.keys())
.chain(dir_subdirs.values().flatten())
.cloned()
.collect();
for dir in &all_dirs {
let include = if recursive {
dir.starts_with(&base)
} else {
*dir == base
};
if include {
target_dirs.insert(dir.clone());
}
}
if recursive || target_dirs.is_empty() {
target_dirs.insert(base.clone());
}
let mut indexes = Vec::new();
let mut total_entries = 0usize;
for dir in &target_dirs {
let concepts = dir_concepts.get(dir).cloned().unwrap_or_default();
let subdirs = dir_subdirs.get(dir).cloned().unwrap_or_default();
if concepts.is_empty() && subdirs.is_empty() {
continue;
}
let content = Self::render_index(dir, &concepts, &subdirs, &meta);
let entries = concepts.len() + subdirs.len();
total_entries += entries;
let index_abs = dir.join("index.md");
let index_rel = self.rel(&index_abs);
let mut written = false;
if !dry_run {
let existing = self.manager.read_file(&index_abs).await.ok();
if existing.as_deref() != Some(content.as_str()) {
self.manager.write_file(&index_abs, &content, None).await?;
written = true;
}
}
indexes.push(GeneratedIndex {
path: index_rel,
entries,
written,
});
}
indexes.sort_by(|a, b| a.path.cmp(&b.path));
Ok(GenerateIndexReport {
indexes,
total_entries,
dry_run,
})
}
fn render_index(
dir: &Path,
concepts: &[PathBuf],
subdirs: &BTreeSet<PathBuf>,
meta: &HashMap<PathBuf, ConceptMeta>,
) -> String {
let heading = dir.file_name().and_then(|n| n.to_str()).unwrap_or("Index");
let mut out = format!("# {}\n", heading);
let mut concept_entries: Vec<(String, String, Option<String>)> = Vec::new();
for path in concepts {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default()
.to_string();
let stem_title = || {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&file_name)
.to_string()
};
let (title, description) = match meta.get(path) {
Some(m) => (
m.title.clone().unwrap_or_else(stem_title),
m.description.clone(),
),
None => (stem_title(), None),
};
concept_entries.push((title, file_name, description));
}
concept_entries.sort_by_key(|e| e.0.to_lowercase());
if !concept_entries.is_empty() {
out.push_str("\n## Notes\n\n");
for (title, link, description) in &concept_entries {
let title = escape_link_text(title);
match description {
Some(d) => {
out.push_str(&format!("* [{}]({}) - {}\n", title, link, one_line(d)))
}
None => out.push_str(&format!("* [{}]({})\n", title, link)),
}
}
}
if !subdirs.is_empty() {
let mut sub_entries: Vec<(String, String)> = subdirs
.iter()
.filter_map(|s| {
s.file_name()
.and_then(|n| n.to_str())
.map(|n| (n.to_string(), format!("{}/", n)))
})
.collect();
sub_entries.sort_by_key(|e| e.0.to_lowercase());
out.push_str("\n## Subdirectories\n\n");
for (name, link) in &sub_entries {
out.push_str(&format!("* [{}]({})\n", name, link));
}
}
out
}
pub async fn append_log_entry(
&self,
directory: Option<&str>,
kind: Option<&str>,
text: &str,
date: Option<&str>,
) -> Result<LogEntryResult> {
let date = match date {
Some(d) => {
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| {
turbovault_core::Error::parse_error(format!(
"invalid date '{d}' — expected ISO YYYY-MM-DD"
))
})?;
d.to_string()
}
None => chrono::Local::now().format("%Y-%m-%d").to_string(),
};
let kind = kind.unwrap_or("Update");
let log_rel = match directory {
Some(d) if !d.is_empty() && d != "." => format!("{}/log.md", d.trim_end_matches('/')),
_ => "log.md".to_string(),
};
let log_path = std::path::PathBuf::from(&log_rel);
let resolved = self.manager.resolve_path(&log_path)?;
let existing = match tokio::fs::read_to_string(&resolved).await {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(turbovault_core::Error::io(e)),
};
let (content, created_file, created_section) =
build_log_content(&existing, &date, kind, text);
self.manager.write_file(&log_path, &content, None).await?;
Ok(LogEntryResult {
path: log_rel,
date,
created_file,
created_section,
})
}
}
struct ConceptMeta {
title: Option<String>,
description: Option<String>,
}
fn escape_link_text(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn one_line(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn build_log_content(existing: &str, date: &str, kind: &str, text: &str) -> (String, bool, bool) {
let entry = format!("* **{}**: {}", kind, text);
if existing.trim().is_empty() {
let content = format!("# Update Log\n\n## {}\n\n{}\n", date, entry);
return (content, true, true);
}
let date_heading = format!("## {}", date);
let mut out: Vec<String> = existing.lines().map(|s| s.to_string()).collect();
let trailing_newline = existing.ends_with('\n');
if let Some(idx) = out.iter().position(|l| l.trim() == date_heading) {
let mut end = out.len();
for (j, line) in out.iter().enumerate().skip(idx + 1) {
if line.trim_start().starts_with("# ") || line.trim_start().starts_with("## ") {
end = j;
break;
}
}
let mut insert_at = end;
while insert_at > idx + 1 && out[insert_at - 1].trim().is_empty() {
insert_at -= 1;
}
out.insert(insert_at, entry);
return (join_lines(&out, trailing_newline), false, false);
}
let title_idx = out
.iter()
.position(|l| l.trim_start().starts_with("# ") && !l.trim_start().starts_with("## "));
let insert_pos = match title_idx {
Some(t) => {
let mut p = t + 1;
if out.get(p).map(|l| l.trim().is_empty()).unwrap_or(false) {
p += 1;
}
p
}
None => 0,
};
for (k, line) in [date_heading, String::new(), entry, String::new()]
.into_iter()
.enumerate()
{
out.insert(insert_pos + k, line);
}
(join_lines(&out, trailing_newline), false, true)
}
fn join_lines(lines: &[String], trailing_newline: bool) -> String {
let mut s = lines.join("\n");
if trailing_newline {
s.push('\n');
}
s
}
#[cfg(test)]
mod tests {
use super::*;
fn make_manager(vault_dir: &Path) -> Arc<VaultManager> {
use turbovault_core::{ServerConfig, VaultConfig};
let mut config = ServerConfig::new();
config
.vaults
.push(VaultConfig::builder("test", vault_dir).build().unwrap());
Arc::new(VaultManager::new(config).unwrap())
}
#[tokio::test]
async fn validate_flags_missing_type() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("tables")).unwrap();
std::fs::write(
temp.path().join("tables/orders.md"),
"---\ntype: BigQuery Table\ntitle: Orders\ndescription: One row per order.\n---\n# Schema\n\n# Citations\n\n[1] [src](https://x.example)\n",
)
.unwrap();
std::fs::write(
temp.path().join("loose.md"),
"---\ntitle: No type here\n---\n# Body\n",
)
.unwrap();
std::fs::write(temp.path().join("index.md"), "# Index\n").unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let report = tools.validate(None).await.unwrap();
assert_eq!(report.total, 3);
assert_eq!(report.non_conformant, 1);
assert_eq!(report.non_conformant_paths, vec!["loose.md".to_string()]);
assert_eq!(report.reserved_files, 1); assert_eq!(
report.type_distribution.get("BigQuery Table").copied(),
Some(1)
);
let orders = report
.files
.iter()
.find(|f| f.path == "tables/orders.md")
.unwrap();
assert!(orders.conformant);
assert_eq!(orders.concept_id, "tables/orders");
assert_eq!(orders.type_.as_deref(), Some("BigQuery Table"));
assert_eq!(orders.citation_count, 1);
}
#[tokio::test]
async fn validate_subtree_filter() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("tables")).unwrap();
std::fs::write(
temp.path().join("tables/orders.md"),
"---\ntype: Table\n---\n# x\n",
)
.unwrap();
std::fs::write(temp.path().join("root.md"), "---\ntype: Note\n---\n# y\n").unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let report = tools.validate(Some("tables")).await.unwrap();
assert_eq!(report.total, 1);
assert_eq!(report.files[0].path, "tables/orders.md");
}
#[tokio::test]
async fn generate_index_dry_run_lists_entries() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("tables")).unwrap();
std::fs::write(
temp.path().join("tables/orders.md"),
"---\ntype: Table\ntitle: Orders\ndescription: One per order.\n---\n# x\n",
)
.unwrap();
std::fs::write(
temp.path().join("tables/customers.md"),
"---\ntype: Table\ntitle: Customers\n---\n# y\n",
)
.unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let report = tools.generate_index(None, false, true).await.unwrap();
assert!(report.dry_run);
let root_index = report
.indexes
.iter()
.find(|i| i.path == "index.md")
.unwrap();
assert_eq!(root_index.entries, 1); assert!(!root_index.written);
assert!(!temp.path().join("index.md").exists());
}
#[tokio::test]
async fn generate_index_recursive_writes_files() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("tables")).unwrap();
std::fs::write(
temp.path().join("tables/orders.md"),
"---\ntype: Table\ntitle: Orders\ndescription: One per order.\n---\n# x\n",
)
.unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let report = tools.generate_index(None, true, false).await.unwrap();
assert!(!report.dry_run);
let tables_index = std::fs::read_to_string(temp.path().join("tables/index.md")).unwrap();
assert!(tables_index.contains("# tables"));
assert!(tables_index.contains("* [Orders](orders.md) - One per order."));
let rerun = tools.generate_index(None, true, false).await.unwrap();
let tables = rerun
.indexes
.iter()
.find(|i| i.path == "tables/index.md")
.unwrap();
assert!(!tables.written);
}
#[test]
fn index_entry_escapes_title_and_flattens_description() {
assert_eq!(
escape_link_text("Orders [archived]"),
"Orders \\[archived\\]"
);
assert_eq!(
one_line("line one\n line two\t three"),
"line one line two three"
);
}
#[test]
fn build_log_creates_file_when_empty() {
let (content, created_file, created_section) =
build_log_content("", "2026-06-13", "Creation", "Established the bundle.");
assert!(created_file);
assert!(created_section);
assert!(content.starts_with("# Update Log\n"));
assert!(content.contains("## 2026-06-13"));
assert!(content.contains("* **Creation**: Established the bundle."));
}
#[test]
fn build_log_appends_to_existing_date_section() {
let existing = "# Update Log\n\n## 2026-06-13\n\n* **Update**: First.\n";
let (content, created_file, created_section) =
build_log_content(existing, "2026-06-13", "Update", "Second.");
assert!(!created_file);
assert!(!created_section);
let first = content.find("First.").unwrap();
let second = content.find("Second.").unwrap();
assert!(first < second);
assert_eq!(content.matches("## 2026-06-13").count(), 1);
}
#[test]
fn build_log_inserts_new_date_newest_first() {
let existing = "# Update Log\n\n## 2026-06-10\n\n* **Update**: Old.\n";
let (content, _, created_section) =
build_log_content(existing, "2026-06-13", "Update", "New.");
assert!(created_section);
let new_pos = content.find("## 2026-06-13").unwrap();
let old_pos = content.find("## 2026-06-10").unwrap();
assert!(new_pos < old_pos);
}
#[tokio::test]
async fn append_log_entry_writes_file() {
let temp = tempfile::TempDir::new().unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let result = tools
.append_log_entry(None, Some("Creation"), "Bootstrapped.", Some("2026-06-13"))
.await
.unwrap();
assert_eq!(result.path, "log.md");
assert!(result.created_file);
let written = std::fs::read_to_string(temp.path().join("log.md")).unwrap();
assert!(written.contains("## 2026-06-13"));
assert!(written.contains("* **Creation**: Bootstrapped."));
tools
.append_log_entry(None, None, "Refined.", Some("2026-06-13"))
.await
.unwrap();
let written = std::fs::read_to_string(temp.path().join("log.md")).unwrap();
assert_eq!(written.matches("## 2026-06-13").count(), 1);
assert!(written.contains("* **Update**: Refined."));
}
#[tokio::test]
async fn bundle_info_detects_okf_bundle_end_to_end() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(temp.path().join("tables")).unwrap();
std::fs::write(
temp.path().join("tables/orders.md"),
"---\ntype: BigQuery Table\n---\n# x\n",
)
.unwrap();
std::fs::write(
temp.path().join("tables/customers.md"),
"---\ntype: BigQuery Table\n---\n# y\n",
)
.unwrap();
std::fs::write(temp.path().join("index.md"), "# Index\n").unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let info = tools.bundle_info().await;
assert!(info.is_okf_bundle);
assert_eq!(info.concept_docs, 2);
assert!(info.has_root_index);
assert_eq!(info.top_types, vec![("BigQuery Table".to_string(), 2)]);
}
#[tokio::test]
async fn append_log_entry_rejects_bad_date() {
let temp = tempfile::TempDir::new().unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = OkfTools::new(manager);
let err = tools
.append_log_entry(None, None, "x", Some("June 13"))
.await;
assert!(err.is_err());
}
}