Skip to main content

okf_core/
index.rs

1//! Generation of `index.md` directory listings.
2//!
3//! Index files support **progressive disclosure**: they let a human or agent
4//! see what a directory holds before opening individual documents. Grouping is
5//! by concept `type`, which is also how an `Attested Computation` becomes
6//! discoverable from an index.
7//!
8//! This is a port of the reference `bundle/index.py`'s `regenerate_indexes` and
9//! `_build_index_text`. The reference synthesizes subdirectory descriptions
10//! with an LLM; since OKF tooling must not require any particular model or
11//! network access, the description synthesizer here is a pluggable closure with
12//! a deterministic default ([`default_synthesize`]). Ported to
13//! Rust and modified from the original Apache-2.0 Python source; see the NOTICE
14//! file.
15
16use crate::document::Document;
17use crate::yaml::Value;
18use std::collections::{BTreeMap, HashMap};
19use std::ffi::OsStr;
20use std::fs;
21use std::io;
22use std::path::{Path, PathBuf};
23
24const INDEX_FILE: &str = "index.md";
25
26/// One row in a generated index, mirroring the reference's
27/// `(type, title, relative_link, description)` tuple.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct IndexEntry {
30    /// The concept type, or `"Subdirectories"` for a child directory.
31    pub type_: String,
32    /// Display title.
33    pub title: String,
34    /// Relative link target.
35    pub link: String,
36    /// One-line description (may be empty).
37    pub description: String,
38}
39
40/// Builds the markdown text of an `index.md` from a set of entries: entries are
41/// grouped by type under `#`-headings (types sorted ascending), and within each
42/// group sorted by title (case-insensitive).
43#[must_use]
44pub fn build_index_text(entries: &[IndexEntry]) -> String {
45    build_index_text_impl(entries, encode_link_destination)
46}
47
48/// Builds index text when entry links are already percent-encoded. The index
49/// generator uses this for filenames obtained as raw `OsStr` bytes; the public
50/// [`build_index_text`] function continues to accept ordinary path strings.
51fn build_index_text_with_encoded_links(entries: &[IndexEntry]) -> String {
52    build_index_text_impl(entries, str::to_owned)
53}
54
55fn build_index_text_impl<F>(entries: &[IndexEntry], encode_link: F) -> String
56where
57    F: Fn(&str) -> String,
58{
59    let mut grouped: BTreeMap<String, Vec<(&str, &str, &str)>> = BTreeMap::new();
60    for e in entries {
61        let key = if e.type_.is_empty() {
62            "Other".to_string()
63        } else {
64            e.type_.clone()
65        };
66        grouped
67            .entry(key)
68            .or_default()
69            .push((&e.title, &e.link, &e.description));
70    }
71
72    let mut sections: Vec<String> = Vec::new();
73    for (typ, mut items) in grouped {
74        items.sort_by_key(|a| a.0.to_lowercase());
75        let mut lines = vec![format!("# {}", escape_markdown_text(&typ)), String::new()];
76        for (title, link, desc) in items {
77            let title = escape_markdown_text(title);
78            let link = encode_link(link);
79            let desc = escape_markdown_text(desc);
80            let suffix = if desc.is_empty() {
81                String::new()
82            } else {
83                format!(" - {desc}")
84            };
85            lines.push(format!("* [{title}]({link}){suffix}"));
86        }
87        sections.push(lines.join("\n"));
88    }
89    format!("{}\n", sections.join("\n\n"))
90}
91
92/// Escapes Markdown delimiters before placing arbitrary text in an index. This
93/// keeps titles and descriptions from creating links, images, or raw HTML while
94/// retaining ordinary punctuation such as `*` and `.` in the rendered text;
95/// line breaks become spaces because index descriptions are one-line values.
96fn escape_markdown_text(text: &str) -> String {
97    let mut escaped = String::with_capacity(text.len());
98    for c in text.chars() {
99        match c {
100            '\n' | '\r' => escaped.push(' '),
101            '\\' | '[' | ']' | '<' | '>' | '&' => {
102                escaped.push('\\');
103                escaped.push(c);
104            }
105            c => escaped.push(c),
106        }
107    }
108    escaped
109}
110
111/// Percent-encodes a relative Markdown destination. Keeping only unreserved
112/// URI bytes and path separators makes brackets, parentheses, quotes, spaces,
113/// `#`, and `%` unable to alter the link syntax while preserving the filename
114/// that the destination addresses.
115fn encode_link_destination(link: &str) -> String {
116    percent_encode_path(link.as_bytes())
117}
118
119fn percent_encode_path(bytes: &[u8]) -> String {
120    const HEX: &[u8; 16] = b"0123456789ABCDEF";
121    let mut encoded = String::with_capacity(bytes.len());
122    for &byte in bytes {
123        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
124            encoded.push(byte as char);
125        } else {
126            encoded.push('%');
127            encoded.push(HEX[(byte >> 4) as usize] as char);
128            encoded.push(HEX[(byte & 0x0f) as usize] as char);
129        }
130    }
131    encoded
132}
133
134/// Encodes one filesystem component without first converting it through a
135/// lossy UTF-8 representation. This keeps an index destination usable even
136/// when the filesystem permits a non-UTF-8 filename.
137fn encoded_component(name: &OsStr) -> String {
138    #[cfg(unix)]
139    {
140        use std::os::unix::ffi::OsStrExt;
141        percent_encode_path(name.as_bytes())
142    }
143    #[cfg(not(unix))]
144    {
145        percent_encode_path(name.to_string_lossy().as_bytes())
146    }
147}
148
149/// A synthesizer for subdirectory descriptions: given the directory's path
150/// (relative to the bundle root) and its child `(title, description)` pairs,
151/// returns a one-line description.
152pub type Synthesize<'a> = dyn Fn(&str, &[(String, String)]) -> String + 'a;
153
154/// The default, deterministic synthesizer: lists the child titles.
155///
156/// Used when no custom (for example LLM-backed) synthesizer is supplied. The
157/// wording is the reference `synthesize_description`'s own `_fallback`, which is
158/// what it writes when its model call fails, so an index generated here reads
159/// the same as one generated there without a model.
160#[must_use]
161pub fn default_synthesize(_rel: &str, children: &[(String, String)]) -> String {
162    if children.is_empty() {
163        return String::new();
164    }
165    let titles: Vec<&str> = children
166        .iter()
167        .map(|(title, _)| title.as_str())
168        .filter(|title| !title.is_empty())
169        .collect();
170    let titles = if titles.is_empty() {
171        "no titled entries".to_string()
172    } else {
173        titles.join(", ")
174    };
175    format!("Contains {} entries: {titles}.", children.len())
176}
177
178/// Regenerates every `index.md` in the bundle using [`default_synthesize`].
179///
180/// # Errors
181///
182/// Returns the underlying [`io::Error`] from any directory walk or file write.
183pub fn regenerate_indexes(bundle_root: impl AsRef<Path>) -> io::Result<Vec<PathBuf>> {
184    regenerate_indexes_with(bundle_root, &default_synthesize)
185}
186
187/// Regenerates every `index.md` in the bundle, deriving each subdirectory's
188/// description with the supplied synthesizer.
189///
190/// Directories are processed deepest-first so a parent index can reuse the
191/// descriptions computed for its children. Empty directories are skipped.
192/// Returns the paths of the index files written.
193///
194/// # Errors
195///
196/// Returns the underlying [`io::Error`] from any directory walk or file write.
197pub fn regenerate_indexes_with(
198    bundle_root: impl AsRef<Path>,
199    synthesize: &Synthesize,
200) -> io::Result<Vec<PathBuf>> {
201    let bundle_root = bundle_root.as_ref();
202    let mut written = Vec::new();
203    if !bundle_root.exists() {
204        return Ok(written);
205    }
206
207    let mut directories = directories_to_index(bundle_root)?;
208    // Deepest-first; ties broken by path for determinism.
209    directories.sort_by(|a, b| {
210        let da = depth(bundle_root, a);
211        let db = depth(bundle_root, b);
212        db.cmp(&da).then_with(|| a.cmp(b))
213    });
214
215    let mut dir_descriptions: HashMap<PathBuf, String> = HashMap::new();
216
217    for directory in &directories {
218        let mut entries: Vec<IndexEntry> = Vec::new();
219
220        let mut children: Vec<PathBuf> = fs::read_dir(directory)?
221            .filter_map(Result::ok)
222            .map(|e| e.path())
223            .collect();
224        children.sort();
225
226        for child in children {
227            let name = child
228                .file_name()
229                .map(|n| n.to_string_lossy().to_string())
230                .unwrap_or_default();
231            if crate::bundle::RESERVED_FILENAMES.contains(&name.as_str()) {
232                continue;
233            }
234            if child.is_file() && child.extension().is_some_and(|e| e == "md") {
235                let Some(doc) = load_doc(&child) else {
236                    continue;
237                };
238                let stem = child
239                    .file_stem()
240                    .map(|s| s.to_string_lossy().to_string())
241                    .unwrap_or_default();
242                // An empty `title` falls back to the filename, as the spec permits
243                // and the reference's `fm.get("title") or child.stem` does.
244                let title = doc
245                    .frontmatter
246                    .title()
247                    .filter(|t| !t.is_empty())
248                    .map_or(stem, std::borrow::Cow::into_owned);
249                let description = doc
250                    .frontmatter
251                    .description()
252                    .map(std::borrow::Cow::into_owned)
253                    .unwrap_or_default();
254                let type_ = doc
255                    .frontmatter
256                    .type_()
257                    .map(std::borrow::Cow::into_owned)
258                    .unwrap_or_default();
259                entries.push(IndexEntry {
260                    type_,
261                    title,
262                    link: encoded_component(child.file_name().unwrap_or_default()),
263                    description,
264                });
265            } else if child.is_dir() {
266                let description = dir_descriptions.get(&child).cloned().unwrap_or_default();
267                let encoded_name = encoded_component(child.file_name().unwrap_or_default());
268                entries.push(IndexEntry {
269                    type_: "Subdirectories".to_string(),
270                    title: name.clone(),
271                    link: format!("{encoded_name}/{INDEX_FILE}"),
272                    description,
273                });
274            }
275        }
276
277        if entries.is_empty() {
278            continue;
279        }
280
281        written.push(write_index(directory, bundle_root, &entries)?);
282
283        if directory == bundle_root {
284            continue;
285        }
286
287        let pairs: Vec<(String, String)> = entries
288            .iter()
289            .map(|e| (e.title.clone(), e.description.clone()))
290            .collect();
291        let desc = if pairs.len() == 1 && !pairs[0].1.is_empty() {
292            pairs[0].1.clone()
293        } else {
294            let rel = directory
295                .strip_prefix(bundle_root)
296                .unwrap_or(directory)
297                .to_string_lossy()
298                .to_string();
299            synthesize(&rel, &pairs)
300        };
301        dir_descriptions.insert(directory.clone(), desc);
302    }
303
304    Ok(written)
305}
306
307fn load_doc(path: &Path) -> Option<Document> {
308    let text = fs::read_to_string(path).ok()?;
309    Document::parse(&text).ok()
310}
311
312fn write_index(
313    directory: &Path,
314    bundle_root: &Path,
315    entries: &[IndexEntry],
316) -> io::Result<PathBuf> {
317    let index_path = directory.join(INDEX_FILE);
318    let body = build_index_text_with_encoded_links(entries);
319    let text = if directory == bundle_root {
320        match preserved_frontmatter(&index_path) {
321            Some(fm) => format!("---\n{fm}---\n\n{body}"),
322            None => body,
323        }
324    } else {
325        body
326    };
327    fs::write(&index_path, text)?;
328    Ok(index_path)
329}
330
331/// The `okf_version` declaration to carry over when rewriting an `index.md`.
332///
333/// A bundle-root `index.md` is the one place frontmatter is permitted in an
334/// index, and the only key it may hold is `okf_version`. Regenerating the
335/// listing must not silently drop the bundle's declared version, so the key is
336/// read back and re-emitted; anything else in the block is discarded, since it
337/// does not belong there.
338fn preserved_frontmatter(index_path: &Path) -> Option<String> {
339    let doc = load_doc(index_path)?;
340    let version = doc.frontmatter.get("okf_version")?;
341    let mut kept = crate::yaml::Mapping::new();
342    kept.insert("okf_version", version.clone());
343    Some(Value::Mapping(kept).to_yaml_string())
344}
345
346fn depth(root: &Path, dir: &Path) -> usize {
347    dir.strip_prefix(root).map_or(0, |r| r.components().count())
348}
349
350/// All directories that contain at least one `.md` file at any depth, including
351/// the bundle root (matching the reference `_directories_to_index`).
352fn directories_to_index(bundle_root: &Path) -> io::Result<Vec<PathBuf>> {
353    let mut md_files = Vec::new();
354    collect_markdown(bundle_root, &mut md_files)?;
355
356    let mut dirs: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
357    let root_parent = bundle_root.parent();
358    for md in &md_files {
359        let mut cur = md.parent();
360        while let Some(dir) = cur {
361            if Some(dir) == root_parent {
362                break;
363            }
364            dirs.insert(dir.to_path_buf());
365            if dir == bundle_root {
366                break;
367            }
368            cur = dir.parent();
369        }
370    }
371    Ok(dirs.into_iter().collect())
372}
373
374fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
375    for entry in fs::read_dir(dir)? {
376        let entry = entry?;
377        let path = entry.path();
378        if entry.file_type()?.is_dir() {
379            collect_markdown(&path, out)?;
380        } else if path.extension().is_some_and(|e| e == "md") {
381            out.push(path);
382        }
383    }
384    Ok(())
385}