Skip to main content

turbovault_tools/
okf_tools.rs

1//! Open Knowledge Format (OKF) tools.
2//!
3//! Two capabilities that make TurboVault a first-class OKF *consumer* and
4//! *maintainer*:
5//!
6//! - [`OkfTools::validate`] — checks a vault (or subtree) for OKF v0.1
7//!   conformance (spec §9) and surfaces each concept's OKF metadata (`type`,
8//!   `title`, `description`, `resource`, `timestamp`, citation count). Designed
9//!   to double as a CI gate: the report is non-conformant if any concept lacks
10//!   a parseable frontmatter `type`.
11//! - [`OkfTools::generate_index`] — synthesizes/refreshes `index.md` files for
12//!   progressive disclosure (spec §6), enumerating each directory's concepts
13//!   and subdirectories using their frontmatter `description`.
14//!
15//! Both operate on the existing vault model — OKF is layered semantics over
16//! markdown + frontmatter, not a separate store.
17
18use 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/// OKF metadata and conformance for a single document.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct OkfConceptInfo {
31    /// Vault-relative path.
32    pub path: String,
33    /// OKF concept ID (bundle-relative path minus `.md`).
34    pub concept_id: String,
35    /// Whether the document is OKF-conformant.
36    pub conformant: bool,
37    /// Reserved-file kind (`index`/`log`), if any.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub reserved: Option<ReservedFile>,
40    /// OKF `type` — the only required field.
41    #[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    /// Number of citations under a `# Citations` heading (spec §8).
52    pub citation_count: usize,
53    /// Conformance issues, if any.
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub issues: Vec<String>,
56}
57
58/// Vault-wide OKF conformance report.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct OkfValidateReport {
61    /// Total markdown documents examined.
62    pub total: usize,
63    /// Documents that are OKF-conformant.
64    pub conformant: usize,
65    /// Documents that are not conformant (the CI gate fails when > 0).
66    pub non_conformant: usize,
67    /// Non-reserved concept documents.
68    pub concepts: usize,
69    /// Reserved files (`index.md` / `log.md`).
70    pub reserved_files: usize,
71    /// Count of concepts per `type` value (bundle's type vocabulary).
72    pub type_distribution: BTreeMap<String, usize>,
73    /// Vault-relative paths of non-conformant documents (quick CI summary).
74    #[serde(skip_serializing_if = "Vec::is_empty")]
75    pub non_conformant_paths: Vec<String>,
76    /// Per-document detail.
77    pub files: Vec<OkfConceptInfo>,
78}
79
80/// One generated/previewed index file.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct GeneratedIndex {
83    /// Vault-relative path of the `index.md`.
84    pub path: String,
85    /// Number of entries (concepts + subdirectories) listed.
86    pub entries: usize,
87    /// Whether the file was written (false in dry-run, or if unchanged).
88    pub written: bool,
89}
90
91/// Result of an index-generation run.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct GenerateIndexReport {
94    /// Indexes generated or previewed, one per directory.
95    pub indexes: Vec<GeneratedIndex>,
96    /// Total entries across all generated indexes.
97    pub total_entries: usize,
98    /// Whether this was a dry run (nothing written).
99    pub dry_run: bool,
100}
101
102/// Result of appending an entry to a `log.md` (spec §7).
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct LogEntryResult {
105    /// Vault-relative path of the `log.md`.
106    pub path: String,
107    /// The date section the entry was filed under (`YYYY-MM-DD`).
108    pub date: String,
109    /// Whether the `log.md` file was newly created.
110    pub created_file: bool,
111    /// Whether a new date section was created for this entry.
112    pub created_section: bool,
113}
114
115/// OKF tooling over a vault.
116pub 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    /// Bundle-level OKF orientation signals: is this an OKF bundle, where is the
130    /// progressive-disclosure entry point, and what is its type vocabulary.
131    ///
132    /// Cache-first (parsed notes validated against disk mtime, no re-scan) so it
133    /// is cheap enough to fold into a first-contact discovery call.
134    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    /// Validate the vault (or a subtree) for OKF v0.1 conformance.
140    ///
141    /// `subtree`, when given, is a vault-relative directory; only documents
142    /// under it are examined.
143    pub async fn validate(&self, subtree: Option<&str>) -> Result<OkfValidateReport> {
144        // Cache-first: parsed notes validated against disk mtime, no re-scan.
145        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        // Stable, path-sorted output (cache iteration order is unspecified).
184        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    /// Generate or refresh `index.md` files for progressive disclosure.
208    ///
209    /// - `directory`: vault-relative directory to index. `None` = the bundle
210    ///   root.
211    /// - `recursive`: also index every subdirectory.
212    /// - `dry_run`: compute the indexes but do not write them.
213    pub async fn generate_index(
214        &self,
215        directory: Option<&str>,
216        recursive: bool,
217        dry_run: bool,
218    ) -> Result<GenerateIndexReport> {
219        // Cache-first: parsed notes (validated against disk mtime) instead of a
220        // fresh scan + re-parse of every file.
221        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        // Prefetch (title, description) per concept so index rendering needs no
229        // further I/O.
230        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        // Map each directory to its direct concept files (non-reserved .md) and
245        // the set of its direct subdirectories.
246        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            // Register every ancestor directory up to root as a subdir of its parent.
261            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        // Which directories to index.
278        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        // Ensure the base directory is considered even if it has no entries yet.
296        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    /// Render an `index.md` body for a directory (spec §6 — no frontmatter),
343    /// using prefetched concept metadata so no per-file I/O is needed.
344    fn render_index(
345        dir: &Path,
346        concepts: &[PathBuf],
347        subdirs: &BTreeSet<PathBuf>,
348        meta: &HashMap<PathBuf, ConceptMeta>,
349    ) -> String {
350        // Heading is the directory's own name (the bundle's folder name at root).
351        let heading = dir.file_name().and_then(|n| n.to_str()).unwrap_or("Index");
352
353        let mut out = format!("# {}\n", heading);
354
355        // Concept entries, sorted by display title.
356        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        // Subdirectory entries.
394        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    /// Append an entry to a directory's `log.md` (spec §7).
415    ///
416    /// - `directory`: vault-relative directory whose `log.md` to update. `None`
417    ///   = the bundle root.
418    /// - `kind`: the leading bold word (`Update`, `Creation`, `Deprecation`, …).
419    ///   Defaults to `Update`.
420    /// - `text`: the entry prose.
421    /// - `date`: ISO `YYYY-MM-DD`. Defaults to today (local time).
422    ///
423    /// Entries are filed newest-first: a new date becomes the first `##`
424    /// section; an existing date gains another bullet.
425    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        // Read the existing log, distinguishing "absent" (create fresh) from
452        // "present but unreadable" (propagate — never clobber a log we failed to
453        // read). resolve_path enforces the vault boundary.
454        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
473/// Title + description prefetched for a concept, used to render index entries.
474struct ConceptMeta {
475    title: Option<String>,
476    description: Option<String>,
477}
478
479/// Escape a string for use as markdown link text, so a `]`/`[` in a title
480/// can't break the generated `[title](link)` entry.
481fn escape_link_text(s: &str) -> String {
482    s.replace('\\', "\\\\")
483        .replace('[', "\\[")
484        .replace(']', "\\]")
485}
486
487/// Collapse a (possibly multi-line) description to a single line, so it can't
488/// break the `* [title](link) - description` bullet.
489fn one_line(s: &str) -> String {
490    s.split_whitespace().collect::<Vec<_>>().join(" ")
491}
492
493/// Pure builder for `log.md` content: insert `entry` under a `## {date}`
494/// section, newest-first. Returns `(content, created_file, created_section)`.
495fn 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        // Existing date section: append the bullet at its end (before the next
509        // heading), skipping trailing blank lines.
510        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    // No section for this date: insert it as the first `##` section, right after
526    // the document title (and its blank line), so newest sits on top.
527    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); // index.md
595        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        // Root index (non-recursive): one subdirectory entry, no concepts.
651        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); // the `tables/` subdirectory
659        assert!(!root_index.written);
660
661        // Nothing should have been written in dry-run.
662        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        // tables/index.md should now list Orders with its description.
683        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        // Re-running should be idempotent (no rewrite when content is unchanged).
688        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        // Both bullets under the same date, in order.
728        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        // The new date section comes before the old one (newest-first).
741        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        // A second entry on the same day appends under the same section.
765        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}