1use std::collections::{BTreeMap, BTreeSet, HashMap};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22use serde::{Deserialize, Serialize};
23use turbovault_core::Result;
24use turbovault_core::okf::{self, ReservedFile};
25use turbovault_parser::parse_citations;
26use turbovault_vault::VaultManager;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OkfConceptInfo {
31 pub path: String,
33 pub concept_id: String,
35 pub conformant: bool,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub reserved: Option<ReservedFile>,
40 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
42 pub type_: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub title: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub resource: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub timestamp: Option<String>,
51 pub citation_count: usize,
53 #[serde(skip_serializing_if = "Vec::is_empty")]
55 pub issues: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct OkfValidateReport {
61 pub total: usize,
63 pub conformant: usize,
65 pub non_conformant: usize,
67 pub concepts: usize,
69 pub reserved_files: usize,
71 pub type_distribution: BTreeMap<String, usize>,
73 #[serde(skip_serializing_if = "Vec::is_empty")]
75 pub non_conformant_paths: Vec<String>,
76 pub files: Vec<OkfConceptInfo>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GeneratedIndex {
83 pub path: String,
85 pub entries: usize,
87 pub written: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct GenerateIndexReport {
94 pub indexes: Vec<GeneratedIndex>,
96 pub total_entries: usize,
98 pub dry_run: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct LogEntryResult {
105 pub path: String,
107 pub date: String,
109 pub created_file: bool,
111 pub created_section: bool,
113}
114
115pub struct OkfTools {
117 manager: Arc<VaultManager>,
118}
119
120impl OkfTools {
121 pub fn new(manager: Arc<VaultManager>) -> Self {
122 Self { manager }
123 }
124
125 fn rel(&self, path: &Path) -> String {
126 self.manager.relative_path(path)
127 }
128
129 pub async fn bundle_info(&self) -> okf::BundleInfo {
135 let files = self.manager.vault_files_validated().await;
136 okf::detect_bundle(self.manager.vault_path().as_path(), &files)
137 }
138
139 pub async fn validate(&self, subtree: Option<&str>) -> Result<OkfValidateReport> {
144 let files = self.manager.vault_files_validated().await;
146 let root = self.manager.vault_path();
147 let filter_prefix = subtree.map(|s| root.join(s));
148
149 let mut infos: Vec<OkfConceptInfo> = Vec::new();
150 let mut type_distribution: BTreeMap<String, usize> = BTreeMap::new();
151
152 for vault_file in &files {
153 let path = &vault_file.path;
154 if let Some(prefix) = &filter_prefix
155 && !path.starts_with(prefix)
156 {
157 continue;
158 }
159
160 let fm = vault_file.frontmatter.as_ref();
161 let conformance = okf::check_concept(fm, path);
162
163 let type_ = fm.and_then(|f| f.okf_type());
164 if let (Some(t), None) = (&type_, conformance.reserved) {
165 *type_distribution.entry(t.clone()).or_insert(0) += 1;
166 }
167
168 infos.push(OkfConceptInfo {
169 path: self.rel(path),
170 concept_id: okf::concept_id(root, path),
171 conformant: conformance.conformant,
172 reserved: conformance.reserved,
173 type_,
174 title: fm.and_then(|f| f.okf_title()),
175 description: fm.and_then(|f| f.okf_description()),
176 resource: fm.and_then(|f| f.okf_resource()),
177 timestamp: fm.and_then(|f| f.okf_timestamp()),
178 citation_count: parse_citations(&vault_file.content).len(),
179 issues: conformance.issues,
180 });
181 }
182
183 infos.sort_by(|a, b| a.path.cmp(&b.path));
185
186 let total = infos.len();
187 let conformant = infos.iter().filter(|i| i.conformant).count();
188 let reserved_files = infos.iter().filter(|i| i.reserved.is_some()).count();
189 let non_conformant_paths: Vec<String> = infos
190 .iter()
191 .filter(|i| !i.conformant)
192 .map(|i| i.path.clone())
193 .collect();
194
195 Ok(OkfValidateReport {
196 total,
197 conformant,
198 non_conformant: total - conformant,
199 concepts: total - reserved_files,
200 reserved_files,
201 type_distribution,
202 non_conformant_paths,
203 files: infos,
204 })
205 }
206
207 pub async fn generate_index(
214 &self,
215 directory: Option<&str>,
216 recursive: bool,
217 dry_run: bool,
218 ) -> Result<GenerateIndexReport> {
219 let validated = self.manager.vault_files_validated().await;
222 let root = self.manager.vault_path().clone();
223 let base = match directory {
224 Some(d) => root.join(d),
225 None => root.clone(),
226 };
227
228 let meta: HashMap<PathBuf, ConceptMeta> = validated
231 .iter()
232 .map(|vf| {
233 let fm = vf.frontmatter.as_ref();
234 (
235 vf.path.clone(),
236 ConceptMeta {
237 title: fm.and_then(|f| f.okf_title()),
238 description: fm.and_then(|f| f.okf_description()),
239 },
240 )
241 })
242 .collect();
243
244 let mut dir_concepts: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
247 let mut dir_subdirs: BTreeMap<PathBuf, BTreeSet<PathBuf>> = BTreeMap::new();
248
249 for vf in &validated {
250 let path = &vf.path;
251 let Some(parent) = path.parent() else {
252 continue;
253 };
254 if okf::reserved_file(path).is_none() {
255 dir_concepts
256 .entry(parent.to_path_buf())
257 .or_default()
258 .push(path.clone());
259 }
260 let mut cur = parent.to_path_buf();
262 while cur.starts_with(&root) && cur != root {
263 let Some(grandparent) = cur.parent() else {
264 break;
265 };
266 dir_subdirs
267 .entry(grandparent.to_path_buf())
268 .or_default()
269 .insert(cur.clone());
270 if grandparent == root {
271 break;
272 }
273 cur = grandparent.to_path_buf();
274 }
275 }
276
277 let mut target_dirs: BTreeSet<PathBuf> = BTreeSet::new();
279 let all_dirs: BTreeSet<PathBuf> = dir_concepts
280 .keys()
281 .chain(dir_subdirs.keys())
282 .chain(dir_subdirs.values().flatten())
283 .cloned()
284 .collect();
285 for dir in &all_dirs {
286 let include = if recursive {
287 dir.starts_with(&base)
288 } else {
289 *dir == base
290 };
291 if include {
292 target_dirs.insert(dir.clone());
293 }
294 }
295 if recursive || target_dirs.is_empty() {
297 target_dirs.insert(base.clone());
298 }
299
300 let mut indexes = Vec::new();
301 let mut total_entries = 0usize;
302
303 for dir in &target_dirs {
304 let concepts = dir_concepts.get(dir).cloned().unwrap_or_default();
305 let subdirs = dir_subdirs.get(dir).cloned().unwrap_or_default();
306 if concepts.is_empty() && subdirs.is_empty() {
307 continue;
308 }
309
310 let content = Self::render_index(dir, &concepts, &subdirs, &meta);
311 let entries = concepts.len() + subdirs.len();
312 total_entries += entries;
313
314 let index_abs = dir.join("index.md");
315 let index_rel = self.rel(&index_abs);
316
317 let mut written = false;
318 if !dry_run {
319 let existing = self.manager.read_file(&index_abs).await.ok();
320 if existing.as_deref() != Some(content.as_str()) {
321 self.manager.write_file(&index_abs, &content, None).await?;
322 written = true;
323 }
324 }
325
326 indexes.push(GeneratedIndex {
327 path: index_rel,
328 entries,
329 written,
330 });
331 }
332
333 indexes.sort_by(|a, b| a.path.cmp(&b.path));
334
335 Ok(GenerateIndexReport {
336 indexes,
337 total_entries,
338 dry_run,
339 })
340 }
341
342 fn render_index(
345 dir: &Path,
346 concepts: &[PathBuf],
347 subdirs: &BTreeSet<PathBuf>,
348 meta: &HashMap<PathBuf, ConceptMeta>,
349 ) -> String {
350 let heading = dir.file_name().and_then(|n| n.to_str()).unwrap_or("Index");
352
353 let mut out = format!("# {}\n", heading);
354
355 let mut concept_entries: Vec<(String, String, Option<String>)> = Vec::new();
357 for path in concepts {
358 let file_name = path
359 .file_name()
360 .and_then(|n| n.to_str())
361 .unwrap_or_default()
362 .to_string();
363 let stem_title = || {
364 path.file_stem()
365 .and_then(|s| s.to_str())
366 .unwrap_or(&file_name)
367 .to_string()
368 };
369 let (title, description) = match meta.get(path) {
370 Some(m) => (
371 m.title.clone().unwrap_or_else(stem_title),
372 m.description.clone(),
373 ),
374 None => (stem_title(), None),
375 };
376 concept_entries.push((title, file_name, description));
377 }
378 concept_entries.sort_by_key(|e| e.0.to_lowercase());
379
380 if !concept_entries.is_empty() {
381 out.push_str("\n## Notes\n\n");
382 for (title, link, description) in &concept_entries {
383 let title = escape_link_text(title);
384 match description {
385 Some(d) => {
386 out.push_str(&format!("* [{}]({}) - {}\n", title, link, one_line(d)))
387 }
388 None => out.push_str(&format!("* [{}]({})\n", title, link)),
389 }
390 }
391 }
392
393 if !subdirs.is_empty() {
395 let mut sub_entries: Vec<(String, String)> = subdirs
396 .iter()
397 .filter_map(|s| {
398 s.file_name()
399 .and_then(|n| n.to_str())
400 .map(|n| (n.to_string(), format!("{}/", n)))
401 })
402 .collect();
403 sub_entries.sort_by_key(|e| e.0.to_lowercase());
404
405 out.push_str("\n## Subdirectories\n\n");
406 for (name, link) in &sub_entries {
407 out.push_str(&format!("* [{}]({})\n", name, link));
408 }
409 }
410
411 out
412 }
413
414 pub async fn append_log_entry(
426 &self,
427 directory: Option<&str>,
428 kind: Option<&str>,
429 text: &str,
430 date: Option<&str>,
431 ) -> Result<LogEntryResult> {
432 let date = match date {
433 Some(d) => {
434 chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| {
435 turbovault_core::Error::parse_error(format!(
436 "invalid date '{d}' — expected ISO YYYY-MM-DD"
437 ))
438 })?;
439 d.to_string()
440 }
441 None => chrono::Local::now().format("%Y-%m-%d").to_string(),
442 };
443 let kind = kind.unwrap_or("Update");
444
445 let log_rel = match directory {
446 Some(d) if !d.is_empty() && d != "." => format!("{}/log.md", d.trim_end_matches('/')),
447 _ => "log.md".to_string(),
448 };
449 let log_path = std::path::PathBuf::from(&log_rel);
450
451 let resolved = self.manager.resolve_path(&log_path)?;
455 let existing = match tokio::fs::read_to_string(&resolved).await {
456 Ok(c) => c,
457 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
458 Err(e) => return Err(turbovault_core::Error::io(e)),
459 };
460 let (content, created_file, created_section) =
461 build_log_content(&existing, &date, kind, text);
462 self.manager.write_file(&log_path, &content, None).await?;
463
464 Ok(LogEntryResult {
465 path: log_rel,
466 date,
467 created_file,
468 created_section,
469 })
470 }
471}
472
473struct ConceptMeta {
475 title: Option<String>,
476 description: Option<String>,
477}
478
479fn escape_link_text(s: &str) -> String {
482 s.replace('\\', "\\\\")
483 .replace('[', "\\[")
484 .replace(']', "\\]")
485}
486
487fn one_line(s: &str) -> String {
490 s.split_whitespace().collect::<Vec<_>>().join(" ")
491}
492
493fn build_log_content(existing: &str, date: &str, kind: &str, text: &str) -> (String, bool, bool) {
496 let entry = format!("* **{}**: {}", kind, text);
497
498 if existing.trim().is_empty() {
499 let content = format!("# Update Log\n\n## {}\n\n{}\n", date, entry);
500 return (content, true, true);
501 }
502
503 let date_heading = format!("## {}", date);
504 let mut out: Vec<String> = existing.lines().map(|s| s.to_string()).collect();
505 let trailing_newline = existing.ends_with('\n');
506
507 if let Some(idx) = out.iter().position(|l| l.trim() == date_heading) {
508 let mut end = out.len();
511 for (j, line) in out.iter().enumerate().skip(idx + 1) {
512 if line.trim_start().starts_with("# ") || line.trim_start().starts_with("## ") {
513 end = j;
514 break;
515 }
516 }
517 let mut insert_at = end;
518 while insert_at > idx + 1 && out[insert_at - 1].trim().is_empty() {
519 insert_at -= 1;
520 }
521 out.insert(insert_at, entry);
522 return (join_lines(&out, trailing_newline), false, false);
523 }
524
525 let title_idx = out
528 .iter()
529 .position(|l| l.trim_start().starts_with("# ") && !l.trim_start().starts_with("## "));
530 let insert_pos = match title_idx {
531 Some(t) => {
532 let mut p = t + 1;
533 if out.get(p).map(|l| l.trim().is_empty()).unwrap_or(false) {
534 p += 1;
535 }
536 p
537 }
538 None => 0,
539 };
540 for (k, line) in [date_heading, String::new(), entry, String::new()]
541 .into_iter()
542 .enumerate()
543 {
544 out.insert(insert_pos + k, line);
545 }
546 (join_lines(&out, trailing_newline), false, true)
547}
548
549fn join_lines(lines: &[String], trailing_newline: bool) -> String {
550 let mut s = lines.join("\n");
551 if trailing_newline {
552 s.push('\n');
553 }
554 s
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn make_manager(vault_dir: &Path) -> Arc<VaultManager> {
562 use turbovault_core::{ServerConfig, VaultConfig};
563 let mut config = ServerConfig::new();
564 config
565 .vaults
566 .push(VaultConfig::builder("test", vault_dir).build().unwrap());
567 Arc::new(VaultManager::new(config).unwrap())
568 }
569
570 #[tokio::test]
571 async fn validate_flags_missing_type() {
572 let temp = tempfile::TempDir::new().unwrap();
573 std::fs::create_dir_all(temp.path().join("tables")).unwrap();
574 std::fs::write(
575 temp.path().join("tables/orders.md"),
576 "---\ntype: BigQuery Table\ntitle: Orders\ndescription: One row per order.\n---\n# Schema\n\n# Citations\n\n[1] [src](https://x.example)\n",
577 )
578 .unwrap();
579 std::fs::write(
580 temp.path().join("loose.md"),
581 "---\ntitle: No type here\n---\n# Body\n",
582 )
583 .unwrap();
584 std::fs::write(temp.path().join("index.md"), "# Index\n").unwrap();
585
586 let manager = make_manager(temp.path());
587 manager.initialize().await.unwrap();
588 let tools = OkfTools::new(manager);
589
590 let report = tools.validate(None).await.unwrap();
591 assert_eq!(report.total, 3);
592 assert_eq!(report.non_conformant, 1);
593 assert_eq!(report.non_conformant_paths, vec!["loose.md".to_string()]);
594 assert_eq!(report.reserved_files, 1); assert_eq!(
596 report.type_distribution.get("BigQuery Table").copied(),
597 Some(1)
598 );
599
600 let orders = report
601 .files
602 .iter()
603 .find(|f| f.path == "tables/orders.md")
604 .unwrap();
605 assert!(orders.conformant);
606 assert_eq!(orders.concept_id, "tables/orders");
607 assert_eq!(orders.type_.as_deref(), Some("BigQuery Table"));
608 assert_eq!(orders.citation_count, 1);
609 }
610
611 #[tokio::test]
612 async fn validate_subtree_filter() {
613 let temp = tempfile::TempDir::new().unwrap();
614 std::fs::create_dir_all(temp.path().join("tables")).unwrap();
615 std::fs::write(
616 temp.path().join("tables/orders.md"),
617 "---\ntype: Table\n---\n# x\n",
618 )
619 .unwrap();
620 std::fs::write(temp.path().join("root.md"), "---\ntype: Note\n---\n# y\n").unwrap();
621
622 let manager = make_manager(temp.path());
623 manager.initialize().await.unwrap();
624 let tools = OkfTools::new(manager);
625
626 let report = tools.validate(Some("tables")).await.unwrap();
627 assert_eq!(report.total, 1);
628 assert_eq!(report.files[0].path, "tables/orders.md");
629 }
630
631 #[tokio::test]
632 async fn generate_index_dry_run_lists_entries() {
633 let temp = tempfile::TempDir::new().unwrap();
634 std::fs::create_dir_all(temp.path().join("tables")).unwrap();
635 std::fs::write(
636 temp.path().join("tables/orders.md"),
637 "---\ntype: Table\ntitle: Orders\ndescription: One per order.\n---\n# x\n",
638 )
639 .unwrap();
640 std::fs::write(
641 temp.path().join("tables/customers.md"),
642 "---\ntype: Table\ntitle: Customers\n---\n# y\n",
643 )
644 .unwrap();
645
646 let manager = make_manager(temp.path());
647 manager.initialize().await.unwrap();
648 let tools = OkfTools::new(manager);
649
650 let report = tools.generate_index(None, false, true).await.unwrap();
652 assert!(report.dry_run);
653 let root_index = report
654 .indexes
655 .iter()
656 .find(|i| i.path == "index.md")
657 .unwrap();
658 assert_eq!(root_index.entries, 1); assert!(!root_index.written);
660
661 assert!(!temp.path().join("index.md").exists());
663 }
664
665 #[tokio::test]
666 async fn generate_index_recursive_writes_files() {
667 let temp = tempfile::TempDir::new().unwrap();
668 std::fs::create_dir_all(temp.path().join("tables")).unwrap();
669 std::fs::write(
670 temp.path().join("tables/orders.md"),
671 "---\ntype: Table\ntitle: Orders\ndescription: One per order.\n---\n# x\n",
672 )
673 .unwrap();
674
675 let manager = make_manager(temp.path());
676 manager.initialize().await.unwrap();
677 let tools = OkfTools::new(manager);
678
679 let report = tools.generate_index(None, true, false).await.unwrap();
680 assert!(!report.dry_run);
681
682 let tables_index = std::fs::read_to_string(temp.path().join("tables/index.md")).unwrap();
684 assert!(tables_index.contains("# tables"));
685 assert!(tables_index.contains("* [Orders](orders.md) - One per order."));
686
687 let rerun = tools.generate_index(None, true, false).await.unwrap();
689 let tables = rerun
690 .indexes
691 .iter()
692 .find(|i| i.path == "tables/index.md")
693 .unwrap();
694 assert!(!tables.written);
695 }
696
697 #[test]
698 fn index_entry_escapes_title_and_flattens_description() {
699 assert_eq!(
700 escape_link_text("Orders [archived]"),
701 "Orders \\[archived\\]"
702 );
703 assert_eq!(
704 one_line("line one\n line two\t three"),
705 "line one line two three"
706 );
707 }
708
709 #[test]
710 fn build_log_creates_file_when_empty() {
711 let (content, created_file, created_section) =
712 build_log_content("", "2026-06-13", "Creation", "Established the bundle.");
713 assert!(created_file);
714 assert!(created_section);
715 assert!(content.starts_with("# Update Log\n"));
716 assert!(content.contains("## 2026-06-13"));
717 assert!(content.contains("* **Creation**: Established the bundle."));
718 }
719
720 #[test]
721 fn build_log_appends_to_existing_date_section() {
722 let existing = "# Update Log\n\n## 2026-06-13\n\n* **Update**: First.\n";
723 let (content, created_file, created_section) =
724 build_log_content(existing, "2026-06-13", "Update", "Second.");
725 assert!(!created_file);
726 assert!(!created_section);
727 let first = content.find("First.").unwrap();
729 let second = content.find("Second.").unwrap();
730 assert!(first < second);
731 assert_eq!(content.matches("## 2026-06-13").count(), 1);
732 }
733
734 #[test]
735 fn build_log_inserts_new_date_newest_first() {
736 let existing = "# Update Log\n\n## 2026-06-10\n\n* **Update**: Old.\n";
737 let (content, _, created_section) =
738 build_log_content(existing, "2026-06-13", "Update", "New.");
739 assert!(created_section);
740 let new_pos = content.find("## 2026-06-13").unwrap();
742 let old_pos = content.find("## 2026-06-10").unwrap();
743 assert!(new_pos < old_pos);
744 }
745
746 #[tokio::test]
747 async fn append_log_entry_writes_file() {
748 let temp = tempfile::TempDir::new().unwrap();
749 let manager = make_manager(temp.path());
750 manager.initialize().await.unwrap();
751 let tools = OkfTools::new(manager);
752
753 let result = tools
754 .append_log_entry(None, Some("Creation"), "Bootstrapped.", Some("2026-06-13"))
755 .await
756 .unwrap();
757 assert_eq!(result.path, "log.md");
758 assert!(result.created_file);
759
760 let written = std::fs::read_to_string(temp.path().join("log.md")).unwrap();
761 assert!(written.contains("## 2026-06-13"));
762 assert!(written.contains("* **Creation**: Bootstrapped."));
763
764 tools
766 .append_log_entry(None, None, "Refined.", Some("2026-06-13"))
767 .await
768 .unwrap();
769 let written = std::fs::read_to_string(temp.path().join("log.md")).unwrap();
770 assert_eq!(written.matches("## 2026-06-13").count(), 1);
771 assert!(written.contains("* **Update**: Refined."));
772 }
773
774 #[tokio::test]
775 async fn bundle_info_detects_okf_bundle_end_to_end() {
776 let temp = tempfile::TempDir::new().unwrap();
777 std::fs::create_dir_all(temp.path().join("tables")).unwrap();
778 std::fs::write(
779 temp.path().join("tables/orders.md"),
780 "---\ntype: BigQuery Table\n---\n# x\n",
781 )
782 .unwrap();
783 std::fs::write(
784 temp.path().join("tables/customers.md"),
785 "---\ntype: BigQuery Table\n---\n# y\n",
786 )
787 .unwrap();
788 std::fs::write(temp.path().join("index.md"), "# Index\n").unwrap();
789
790 let manager = make_manager(temp.path());
791 manager.initialize().await.unwrap();
792 let tools = OkfTools::new(manager);
793
794 let info = tools.bundle_info().await;
795 assert!(info.is_okf_bundle);
796 assert_eq!(info.concept_docs, 2);
797 assert!(info.has_root_index);
798 assert_eq!(info.top_types, vec![("BigQuery Table".to_string(), 2)]);
799 }
800
801 #[tokio::test]
802 async fn append_log_entry_rejects_bad_date() {
803 let temp = tempfile::TempDir::new().unwrap();
804 let manager = make_manager(temp.path());
805 manager.initialize().await.unwrap();
806 let tools = OkfTools::new(manager);
807
808 let err = tools
809 .append_log_entry(None, None, "x", Some("June 13"))
810 .await;
811 assert!(err.is_err());
812 }
813}