Skip to main content

bake_agent_context/
index.rs

1// Released under the MIT License.
2// Copyright, 2026, by Samuel Williams.
3
4use super::installer::{ContextPackage, markdown_files};
5use super::skill::frontmatter_description;
6use bake::{Error, Result};
7use socketry_markdown::{
8    ParseOptions,
9    mdast::{Heading, Link, Node, Paragraph, Text},
10    to_mdast,
11};
12use std::collections::HashMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16#[derive(Clone, Debug)]
17struct ContextDocument {
18    path: PathBuf,
19    title: String,
20    description: Option<String>,
21}
22
23/// Generates an index of installed package context in `.agents/context/index.md`.
24#[derive(Clone, Debug)]
25pub struct ContextIndex {
26    root: PathBuf,
27    context_path: PathBuf,
28    package_descriptions: HashMap<String, String>,
29}
30
31impl ContextIndex {
32    pub fn new(root: impl Into<PathBuf>) -> Self {
33        let root = root.into();
34        Self {
35            context_path: root.join(".agents/context"),
36            root,
37            package_descriptions: HashMap::new(),
38        }
39    }
40
41    /// Add Cargo package descriptions to the generated context index.
42    pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
43        self.package_descriptions = packages
44            .iter()
45            .filter_map(|package| {
46                package
47                    .description
48                    .as_ref()
49                    .map(|description| (package.selector().to_owned(), description.clone()))
50            })
51            .collect();
52        self
53    }
54
55    pub fn context_path(&self) -> &Path {
56        &self.context_path
57    }
58
59    /// Render a Markdown index from context files already installed in the project.
60    pub fn generate_index(&self) -> Result<String> {
61        let mut sections = vec![
62            "# Context Index".to_owned(),
63            String::new(),
64            "This index links to guidance installed from resolved Cargo dependencies. It is generated by `cargo bake agent:context:install` and can be refreshed with `cargo bake agent:context:index`.".to_owned(),
65            String::new(),
66            "Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.".to_owned(),
67            String::new(),
68            "If this index or the linked files are missing, run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
69            String::new(),
70        ];
71
72        let packages = self.collect_context_packages()?;
73        if packages.is_empty() {
74            sections.push("No context files are installed.".to_owned());
75            return Ok(sections.join("\n"));
76        }
77
78        for (package_name, files) in packages {
79            sections.push(format!("## {package_name}"));
80            sections.push(String::new());
81            sections.push(
82                self.package_descriptions
83                    .get(&package_name)
84                    .cloned()
85                    .unwrap_or_else(|| format!("Context files for {package_name}")),
86            );
87            sections.push(String::new());
88
89            for document in files {
90                append_document(
91                    &mut sections,
92                    Path::new(&package_name),
93                    &document.path,
94                    &document.title,
95                    document.description.as_deref(),
96                );
97            }
98        }
99
100        while sections.last().is_some_and(String::is_empty) {
101            sections.pop();
102        }
103        Ok(sections.join("\n"))
104    }
105
106    /// Write the generated index without creating or modifying the project's `agents.md`.
107    pub fn update_index(&self) -> Result<()> {
108        let index = self.generate_index()?;
109        create_context_directory(&self.context_path)?;
110
111        let path = self.context_path.join("index.md");
112        fs::write(&path, index)
113            .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))?;
114
115        let installed_skills = super::skill::installed_skill_names(&self.root)?;
116        super::exclude::prepare(&self.root, installed_skills)?
117            .map(|update| update.apply())
118            .transpose()?;
119
120        Ok(())
121    }
122
123    fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
124        let entries = match fs::read_dir(&self.context_path) {
125            Ok(entries) => entries,
126            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
127            Err(error) => {
128                return Err(Error::new(format!(
129                    "cannot read {}: {error}",
130                    self.context_path.display()
131                )));
132            }
133        };
134
135        let mut packages = Vec::new();
136        for entry in entries {
137            let entry = entry?;
138            let file_type = entry.file_type()?;
139            if !file_type.is_dir() {
140                continue;
141            }
142            let package_path = entry.path();
143            let mut files = Vec::new();
144            for path in markdown_files(&package_path)? {
145                let (title, description) = extract_content(&path)?;
146                let relative_path = relative_context_path(&package_path, &path)?;
147                files.push(ContextDocument {
148                    path: relative_path.to_path_buf(),
149                    title,
150                    description,
151                });
152            }
153            files.sort_by_key(|document| canonical_order(&document.path));
154            if !files.is_empty() {
155                packages.push((entry.file_name().to_string_lossy().into_owned(), files));
156            }
157        }
158        packages.sort_by(|left, right| left.0.cmp(&right.0));
159        Ok(packages)
160    }
161}
162
163fn create_context_directory(path: &Path) -> Result<()> {
164    fs::create_dir_all(path)
165        .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display())))
166}
167
168fn relative_context_path(package_path: &Path, path: &Path) -> Result<PathBuf> {
169    path.strip_prefix(package_path)
170        .map(Path::to_path_buf)
171        .map_err(|error| Error::new(format!("cannot make context path relative: {error}")))
172}
173
174fn append_document(
175    sections: &mut Vec<String>,
176    link_root: &Path,
177    relative_path: &Path,
178    title: &str,
179    description: Option<&str>,
180) {
181    let heading = Node::Heading(Heading {
182        children: vec![Node::Link(Link {
183            children: vec![Node::Text(Text {
184                value: title.to_owned(),
185                position: None,
186            })],
187            position: None,
188            url: markdown_link(&link_root.join(relative_path)),
189            title: None,
190        })],
191        position: None,
192        depth: 3,
193    });
194    sections.push(heading.to_markdown().trim_end().to_owned());
195    sections.push(String::new());
196    if let Some(description) = description.filter(|description| !description.is_empty()) {
197        let paragraph = Node::Paragraph(Paragraph {
198            children: vec![Node::Text(Text {
199                value: description.to_owned(),
200                position: None,
201            })],
202            position: None,
203        });
204        sections.push(paragraph.to_markdown().trim_end().to_owned());
205        sections.push(String::new());
206    }
207}
208
209fn markdown_link(path: &Path) -> String {
210    path_to_string(path)
211        .replace('%', "%25")
212        .replace(' ', "%20")
213        .replace('#', "%23")
214        .replace('?', "%3F")
215        .replace('(', "%28")
216        .replace(')', "%29")
217}
218
219fn canonical_order(path: &Path) -> (usize, String, String) {
220    const CANONICAL: &[&str] = &[
221        "getting-started",
222        "overview",
223        "usage",
224        "configuration",
225        "migration",
226        "troubleshooting",
227        "debugging",
228    ];
229    let name = path
230        .file_stem()
231        .and_then(|name| name.to_str())
232        .unwrap_or_default()
233        .to_ascii_lowercase();
234    let order = CANONICAL
235        .iter()
236        .position(|canonical| *canonical == name)
237        .unwrap_or(CANONICAL.len());
238    (order, name, path_to_string(path))
239}
240
241fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
242    let content = fs::read_to_string(path)
243        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
244    let mut options = ParseOptions::default();
245    options.constructs.frontmatter = true;
246    // MDX parsing is disabled, so Markdown syntax cannot fail to parse.
247    let root =
248        to_mdast(&content, &options).expect("Markdown parsing without MDX support is infallible");
249    let children = root
250        .children()
251        .expect("a Markdown document root always has children");
252    let title = children
253        .iter()
254        .find_map(|node| match node {
255            Node::Heading(_) => {
256                let title = heading_text(node);
257                (!title.trim().is_empty()).then_some(title)
258            }
259            _ => None,
260        })
261        .unwrap_or_else(|| {
262            path.file_stem()
263                .and_then(|stem| stem.to_str())
264                .unwrap_or("Documentation")
265                .replace('-', " ")
266        });
267
268    let first_paragraph = children.iter().find_map(|node| match node {
269        Node::Paragraph(_) => Some(node.text_content()),
270        _ => None,
271    });
272    let description = frontmatter_description(&root, path)?
273        .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
274
275    Ok((title, description))
276}
277
278fn heading_text(node: &Node) -> String {
279    let text = node.text_content();
280    text.strip_suffix("\r\n")
281        .or_else(|| text.strip_suffix('\n'))
282        .or_else(|| text.strip_suffix('\r'))
283        .unwrap_or(text.as_str())
284        .to_owned()
285}
286
287fn first_sentence(paragraph: &str) -> Option<String> {
288    let paragraph = paragraph.trim();
289    if paragraph.is_empty() {
290        return None;
291    }
292
293    for (index, character) in paragraph.char_indices() {
294        if matches!(character, '.' | '!' | '?')
295            && paragraph[index + character.len_utf8()..]
296                .chars()
297                .next()
298                .is_none_or(char::is_whitespace)
299        {
300            return Some(paragraph[..index + character.len_utf8()].to_owned());
301        }
302    }
303
304    Some(paragraph.to_owned())
305}
306
307fn path_to_string(path: &Path) -> String {
308    path.components()
309        .map(|component| component.as_os_str().to_string_lossy())
310        .collect::<Vec<_>>()
311        .join("/")
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use tempfile::tempdir;
318
319    fn write(root: &Path, relative: &str, contents: &str) {
320        let path = root.join(relative);
321        fs::create_dir_all(path.parent().unwrap()).unwrap();
322        fs::write(path, contents).unwrap();
323    }
324
325    #[test]
326    fn exposes_context_path_and_renders_an_empty_index() {
327        let directory = tempdir().unwrap();
328        let index = ContextIndex::new(directory.path());
329
330        assert_eq!(
331            index.context_path(),
332            directory.path().join(".agents/context")
333        );
334        let rendered = index.generate_index().unwrap();
335        assert!(rendered.contains("No context files are installed."));
336        assert!(!rendered.ends_with('\n'));
337    }
338
339    #[test]
340    fn context_documents_use_fallback_titles_and_optional_descriptions() {
341        let directory = tempdir().unwrap();
342        write(
343            directory.path(),
344            ".agents/context/provider/no-heading.md",
345            "Text without a heading or punctuation\n",
346        );
347        write(
348            directory.path(),
349            ".agents/context/provider/blank.md",
350            "#   \n\n   \n",
351        );
352        write(
353            directory.path(),
354            ".agents/context/provider/metadata.md",
355            "---\ndescription:   \n---\n\n# Metadata\n\nBody sentence. More body.\n",
356        );
357
358        let rendered = ContextIndex::new(directory.path())
359            .generate_index()
360            .unwrap();
361        assert!(rendered.contains("[no heading](provider/no-heading.md)"));
362        assert!(rendered.contains("[blank](provider/blank.md)"));
363        assert!(rendered.contains("[Metadata](provider/metadata.md)"));
364        assert!(rendered.contains("Body sentence."));
365        assert!(!rendered.contains("More body."));
366    }
367
368    #[test]
369    fn package_descriptions_are_optional_and_packages_are_sorted() {
370        let directory = tempdir().unwrap();
371        let root = directory.path();
372        write(
373            root,
374            ".agents/context/zeta@1.0.0/guide.md",
375            "# Zeta\n\nZeta guidance.\n",
376        );
377        write(
378            root,
379            ".agents/context/alpha@1.0.0/guide.md",
380            "# Alpha\n\nAlpha guidance.\n",
381        );
382
383        let mut alpha = ContextPackage::for_test("alpha", "1.0.0", PathBuf::new());
384        alpha.description = None;
385        let zeta = ContextPackage::for_test("zeta", "1.0.0", PathBuf::new());
386        let rendered = ContextIndex::new(root)
387            .with_packages(&[zeta, alpha])
388            .generate_index()
389            .unwrap();
390
391        assert!(rendered.find("## alpha@1.0.0").unwrap() < rendered.find("## zeta@1.0.0").unwrap());
392        assert!(rendered.contains("Context files for alpha@1.0.0"));
393        assert!(rendered.contains("zeta documentation"));
394    }
395
396    #[test]
397    fn reports_context_read_and_markdown_parse_errors() {
398        let directory = tempdir().unwrap();
399        let error = extract_content(&directory.path().join("missing.md"))
400            .unwrap_err()
401            .to_string();
402        assert!(error.contains("cannot read"));
403
404        let directory = tempdir().unwrap();
405        let context_path = directory.path().join(".agents/context");
406        fs::create_dir_all(directory.path().join(".agents")).unwrap();
407        fs::write(&context_path, "not a directory").unwrap();
408        assert!(
409            ContextIndex::new(directory.path())
410                .generate_index()
411                .is_err()
412        );
413
414        let directory = tempdir().unwrap();
415        write(
416            directory.path(),
417            ".agents/context/provider/broken.md",
418            "---\ndescription: [unterminated\n---\n",
419        );
420        assert!(
421            ContextIndex::new(directory.path())
422                .generate_index()
423                .is_err()
424        );
425    }
426
427    #[test]
428    fn reports_index_directory_and_registry_errors() {
429        let directory = tempdir().unwrap();
430        let index = ContextIndex::new(directory.path());
431        fs::write(directory.path().join(".agents"), "not a directory").unwrap();
432        assert!(index.update_index().is_err());
433
434        let directory = tempdir().unwrap();
435        fs::create_dir_all(directory.path().join(".agents/context/index.md")).unwrap();
436        assert!(ContextIndex::new(directory.path()).update_index().is_err());
437
438        let directory = tempdir().unwrap();
439        fs::write(directory.path().join("blocker"), "not a directory").unwrap();
440        assert!(create_context_directory(&directory.path().join("blocker/context")).is_err());
441
442        let directory = tempdir().unwrap();
443        write(
444            directory.path(),
445            ".agents/skills/.agent-context-skills.json",
446            "{invalid json}",
447        );
448        assert!(ContextIndex::new(directory.path()).update_index().is_err());
449    }
450
451    #[test]
452    fn updates_local_git_excludes_with_the_generated_index() {
453        let directory = tempdir().unwrap();
454        let output = std::process::Command::new("git")
455            .args(["init", "--quiet"])
456            .current_dir(directory.path())
457            .output()
458            .unwrap();
459        assert!(output.status.success());
460
461        fs::write(directory.path().join(".git/info/exclude"), "# user rule\n").unwrap();
462        ContextIndex::new(directory.path()).update_index().unwrap();
463        let exclude = fs::read_to_string(directory.path().join(".git/info/exclude")).unwrap();
464        assert!(exclude.starts_with("# user rule\n"));
465        assert!(exclude.contains("# BEGIN bake-agent-context\n"));
466    }
467
468    #[test]
469    fn formats_links_and_orders_canonical_documents() {
470        assert_eq!(
471            markdown_link(Path::new("package/a% b#c?d(e).md")),
472            "package/a%25%20b%23c%3Fd%28e%29.md"
473        );
474        assert!(
475            canonical_order(Path::new("getting-started.md"))
476                < canonical_order(Path::new("unknown.md"))
477        );
478        assert!(
479            canonical_order(Path::new("other/usage.md"))
480                < canonical_order(Path::new("other/zeta.md"))
481        );
482        assert!(
483            canonical_order(Path::new("../Usage.md")) < canonical_order(Path::new("../zeta.md"))
484        );
485        assert_eq!(
486            path_to_string(Path::new("provider/reference/guide.md")),
487            "provider/reference/guide.md"
488        );
489    }
490
491    #[test]
492    fn extracts_sentences_and_handles_markdown_heading_newlines() {
493        assert_eq!(
494            first_sentence("  Start here! Later  "),
495            Some("Start here!".to_owned())
496        );
497        assert_eq!(
498            first_sentence("Question? Then answer."),
499            Some("Question?".to_owned())
500        );
501        assert_eq!(
502            first_sentence("No punctuation"),
503            Some("No punctuation".to_owned())
504        );
505        assert_eq!(first_sentence("  \n"), None);
506
507        for (value, expected) in [
508            ("title\r\n", "title"),
509            ("title\r", "title"),
510            ("title\n", "title"),
511        ] {
512            let heading = Node::Heading(Heading {
513                children: vec![Node::Text(Text {
514                    value: value.to_owned(),
515                    position: None,
516                })],
517                position: None,
518                depth: 1,
519            });
520            assert_eq!(heading_text(&heading), expected);
521        }
522    }
523
524    #[test]
525    fn reports_relative_path_invariant_violations() {
526        let directory = tempdir().unwrap();
527        let package = directory.path().join("provider");
528        assert!(relative_context_path(&package, &directory.path().join("outside.md")).is_err());
529        assert_eq!(
530            relative_context_path(&package, &package.join("guide.md")).unwrap(),
531            PathBuf::from("guide.md")
532        );
533    }
534}