Skip to main content

bake_agent_context/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        fs::create_dir_all(&self.context_path).map_err(|error| {
110            Error::new(format!(
111                "cannot create {}: {error}",
112                self.context_path.display()
113            ))
114        })?;
115
116        let path = self.context_path.join("index.md");
117        fs::write(&path, index)
118            .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))?;
119
120        let installed_skills = super::skill::installed_skill_names(&self.root)?;
121        if let Some(exclude_update) = super::exclude::prepare(&self.root, installed_skills)? {
122            exclude_update.apply()?;
123        }
124
125        Ok(())
126    }
127
128    fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
129        let entries = match fs::read_dir(&self.context_path) {
130            Ok(entries) => entries,
131            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
132            Err(error) => {
133                return Err(Error::new(format!(
134                    "cannot read {}: {error}",
135                    self.context_path.display()
136                )));
137            }
138        };
139
140        let mut packages = Vec::new();
141        for entry in entries {
142            let entry = entry?;
143            let file_type = entry.file_type()?;
144            if !file_type.is_dir() {
145                continue;
146            }
147            let package_path = entry.path();
148            let mut files = Vec::new();
149            for path in markdown_files(&package_path)? {
150                let (title, description) = extract_content(&path)?;
151                let relative_path = path.strip_prefix(&package_path).map_err(|error| {
152                    Error::new(format!("cannot make context path relative: {error}"))
153                })?;
154                files.push(ContextDocument {
155                    path: relative_path.to_path_buf(),
156                    title,
157                    description,
158                });
159            }
160            files.sort_by_key(|document| canonical_order(&document.path));
161            if !files.is_empty() {
162                packages.push((entry.file_name().to_string_lossy().into_owned(), files));
163            }
164        }
165        packages.sort_by(|left, right| left.0.cmp(&right.0));
166        Ok(packages)
167    }
168}
169
170fn append_document(
171    sections: &mut Vec<String>,
172    link_root: &Path,
173    relative_path: &Path,
174    title: &str,
175    description: Option<&str>,
176) {
177    let heading = Node::Heading(Heading {
178        children: vec![Node::Link(Link {
179            children: vec![Node::Text(Text {
180                value: title.to_owned(),
181                position: None,
182            })],
183            position: None,
184            url: markdown_link(&link_root.join(relative_path)),
185            title: None,
186        })],
187        position: None,
188        depth: 3,
189    });
190    sections.push(heading.to_markdown().trim_end().to_owned());
191    sections.push(String::new());
192    if let Some(description) = description.filter(|description| !description.is_empty()) {
193        let paragraph = Node::Paragraph(Paragraph {
194            children: vec![Node::Text(Text {
195                value: description.to_owned(),
196                position: None,
197            })],
198            position: None,
199        });
200        sections.push(paragraph.to_markdown().trim_end().to_owned());
201        sections.push(String::new());
202    }
203}
204
205fn markdown_link(path: &Path) -> String {
206    path_to_string(path)
207        .replace('%', "%25")
208        .replace(' ', "%20")
209        .replace('#', "%23")
210        .replace('?', "%3F")
211        .replace('(', "%28")
212        .replace(')', "%29")
213}
214
215fn canonical_order(path: &Path) -> (usize, String, String) {
216    const CANONICAL: &[&str] = &[
217        "getting-started",
218        "overview",
219        "usage",
220        "configuration",
221        "migration",
222        "troubleshooting",
223        "debugging",
224    ];
225    let name = path
226        .file_stem()
227        .and_then(|name| name.to_str())
228        .unwrap_or_default()
229        .to_ascii_lowercase();
230    let order = CANONICAL
231        .iter()
232        .position(|canonical| *canonical == name)
233        .unwrap_or(CANONICAL.len());
234    (order, name, path_to_string(path))
235}
236
237fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
238    let content = fs::read_to_string(path)
239        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
240    let mut options = ParseOptions::default();
241    options.constructs.frontmatter = true;
242    let root = to_mdast(&content, &options)
243        .map_err(|error| Error::new(format!("could not parse {}: {error}", path.display())))?;
244    let children = root
245        .children()
246        .ok_or_else(|| Error::new(format!("{} is not a Markdown document", path.display())))?;
247    let title = children
248        .iter()
249        .find_map(|node| match node {
250            Node::Heading(_) => {
251                let title = heading_text(node);
252                (!title.trim().is_empty()).then_some(title)
253            }
254            _ => None,
255        })
256        .unwrap_or_else(|| {
257            path.file_stem()
258                .and_then(|stem| stem.to_str())
259                .unwrap_or("Documentation")
260                .replace('-', " ")
261        });
262
263    let first_paragraph = children.iter().find_map(|node| match node {
264        Node::Paragraph(_) => Some(node.text_content()),
265        _ => None,
266    });
267    let description = frontmatter_description(&root, path)?
268        .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
269
270    Ok((title, description))
271}
272
273fn heading_text(node: &Node) -> String {
274    let text = node.text_content();
275    text.strip_suffix("\r\n")
276        .or_else(|| text.strip_suffix('\n'))
277        .or_else(|| text.strip_suffix('\r'))
278        .unwrap_or(text.as_str())
279        .to_owned()
280}
281
282fn first_sentence(paragraph: &str) -> Option<String> {
283    let paragraph = paragraph.trim();
284    if paragraph.is_empty() {
285        return None;
286    }
287
288    for (index, character) in paragraph.char_indices() {
289        if matches!(character, '.' | '!' | '?')
290            && paragraph[index + character.len_utf8()..]
291                .chars()
292                .next()
293                .is_none_or(char::is_whitespace)
294        {
295            return Some(paragraph[..index + character.len_utf8()].to_owned());
296        }
297    }
298
299    Some(paragraph.to_owned())
300}
301
302fn path_to_string(path: &Path) -> String {
303    path.components()
304        .map(|component| component.as_os_str().to_string_lossy())
305        .collect::<Vec<_>>()
306        .join("/")
307}