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