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
23struct SourceHeading {
24    index: usize,
25    level: u8,
26    title: String,
27    start: usize,
28    body_start: usize,
29}
30
31/// Manages the generated Context section in a project's agents.md file.
32#[derive(Clone, Debug)]
33pub struct AgentIndex {
34    root: PathBuf,
35    context_path: PathBuf,
36    context_link_path: PathBuf,
37    package_descriptions: HashMap<String, String>,
38}
39
40impl AgentIndex {
41    pub fn new(root: impl Into<PathBuf>) -> Self {
42        let root = root.into();
43        Self {
44            context_path: root.join(".agents/context"),
45            root,
46            context_link_path: PathBuf::from(".agents/context"),
47            package_descriptions: HashMap::new(),
48        }
49    }
50
51    /// Add Cargo package descriptions to the generated context section.
52    pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
53        self.package_descriptions = packages
54            .iter()
55            .filter_map(|package| {
56                package
57                    .description
58                    .as_ref()
59                    .map(|description| (package.selector().to_owned(), description.clone()))
60            })
61            .collect();
62        self
63    }
64
65    pub fn context_path(&self) -> &Path {
66        &self.context_path
67    }
68
69    pub fn generate_context_section(&self) -> Result<String> {
70        let mut sections = vec![
71            "This section links to documentation from installed packages. It is automatically generated and can be refreshed with `cargo bake agent:context:install`.".to_owned(),
72            String::new(),
73            "**Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.**".to_owned(),
74            String::new(),
75            "If these files are missing or dependencies have changed, run `cargo bake agent:context:install` to install them.".to_owned(),
76            String::new(),
77        ];
78
79        let packages = self.collect_context_packages()?;
80        if packages.is_empty() {
81            sections.push(
82                "No context files found. Run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
83            );
84            return Ok(sections.join("\n"));
85        }
86
87        for (package_name, files) in packages {
88            sections.push(format!("### {package_name}"));
89            sections.push(String::new());
90            sections.push(
91                self.package_descriptions
92                    .get(&package_name)
93                    .cloned()
94                    .unwrap_or_else(|| format!("Context files for {package_name}")),
95            );
96            sections.push(String::new());
97
98            for document in files {
99                append_document(
100                    &mut sections,
101                    &self.context_link_path.join(&package_name),
102                    &document.path,
103                    &document.title,
104                    document.description.as_deref(),
105                );
106            }
107        }
108
109        while sections.last().is_some_and(String::is_empty) {
110            sections.pop();
111        }
112        Ok(sections.join("\n"))
113    }
114
115    pub fn update_agents_md(&self, path: impl AsRef<Path>) -> Result<()> {
116        let path = path.as_ref();
117        let path = if path.is_absolute() {
118            path.to_path_buf()
119        } else {
120            self.root.join(path)
121        };
122        let context = self.generate_context_section()?;
123
124        let existing = match fs::read_to_string(&path) {
125            Ok(contents) => Some(contents),
126            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
127            Err(error) => {
128                return Err(Error::new(format!(
129                    "cannot read {}: {error}",
130                    path.display()
131                )));
132            }
133        };
134
135        let updated = match existing {
136            Some(contents) => update_existing(&contents, &context),
137            None => format!("# Agent\n\n## Context\n\n{context}\n"),
138        };
139
140        if let Some(parent) = path.parent() {
141            fs::create_dir_all(parent).map_err(|error| {
142                Error::new(format!("cannot create {}: {error}", parent.display()))
143            })?;
144        }
145        fs::write(&path, updated)
146            .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
147    }
148
149    fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
150        let entries = match fs::read_dir(&self.context_path) {
151            Ok(entries) => entries,
152            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
153            Err(error) => {
154                return Err(Error::new(format!(
155                    "cannot read {}: {error}",
156                    self.context_path.display()
157                )));
158            }
159        };
160
161        let mut packages = Vec::new();
162        for entry in entries {
163            let entry = entry?;
164            let file_type = entry.file_type()?;
165            if !file_type.is_dir() {
166                continue;
167            }
168            let package_path = entry.path();
169            let mut files = Vec::new();
170            for path in markdown_files(&package_path)? {
171                let (title, description) = extract_content(&path)?;
172                let relative_path = path.strip_prefix(&package_path).map_err(|error| {
173                    Error::new(format!("cannot make context path relative: {error}"))
174                })?;
175                files.push(ContextDocument {
176                    path: relative_path.to_path_buf(),
177                    title,
178                    description,
179                });
180            }
181            files.sort_by_key(|document| canonical_order(&document.path));
182            if !files.is_empty() {
183                packages.push((entry.file_name().to_string_lossy().into_owned(), files));
184            }
185        }
186        packages.sort_by(|left, right| left.0.cmp(&right.0));
187        Ok(packages)
188    }
189}
190
191fn update_existing(contents: &str, context: &str) -> String {
192    let Ok(root) = to_mdast(contents, &ParseOptions::default()) else {
193        return contents.to_owned();
194    };
195    let Some(children) = root.children() else {
196        return contents.to_owned();
197    };
198    let headings: Vec<_> = children
199        .iter()
200        .enumerate()
201        .filter_map(|(index, node)| {
202            let Node::Heading(heading) = node else {
203                return None;
204            };
205            let position = node.position()?;
206            Some(SourceHeading {
207                index,
208                level: heading.depth,
209                title: heading_text(node),
210                start: position.start.offset,
211                body_start: after_heading_line(contents, position.end.offset),
212            })
213        })
214        .collect();
215    let Some(agent_heading) = headings
216        .iter()
217        .find(|heading| heading.level == 1 && heading.title.eq_ignore_ascii_case("agent"))
218    else {
219        return format!("# Agent\n\n## Context\n\n{context}\n\n{contents}");
220    };
221
222    let agent_end_index = headings
223        .iter()
224        .find(|heading| heading.index > agent_heading.index && heading.level <= 1)
225        .map_or(children.len(), |heading| heading.index);
226    let context_heading = headings.iter().find(|heading| {
227        heading.index > agent_heading.index
228            && heading.index < agent_end_index
229            && heading.level == 2
230            && heading.title.eq_ignore_ascii_case("context")
231    });
232
233    let newline = if contents.contains("\r\n") {
234        "\r\n"
235    } else {
236        "\n"
237    };
238    let context = context.replace("\r\n", "\n").replace('\n', newline);
239    let mut updated = String::with_capacity(contents.len() + context.len() + 32);
240
241    if let Some(context_heading) = context_heading {
242        let end = headings
243            .iter()
244            .find(|heading| heading.index > context_heading.index && heading.level <= 2)
245            .map_or(contents.len(), |heading| heading.start);
246        updated.push_str(&contents[..context_heading.start]);
247        updated.push_str("## Context");
248        updated.push_str(newline);
249        updated.push_str(newline);
250        updated.push_str(&context);
251        if end < contents.len() {
252            updated.push_str(newline);
253            updated.push_str(newline);
254        } else if contents.ends_with('\n') || !context.is_empty() {
255            updated.push_str(newline);
256        }
257        updated.push_str(&contents[end..]);
258    } else {
259        updated.push_str(&contents[..agent_heading.body_start]);
260        updated.push_str(newline);
261        updated.push_str("## Context");
262        updated.push_str(newline);
263        updated.push_str(newline);
264        updated.push_str(&context);
265        if agent_heading.body_start < contents.len() {
266            updated.push_str(newline);
267            updated.push_str(newline);
268        } else if contents.ends_with('\n') || !context.is_empty() {
269            updated.push_str(newline);
270        }
271        updated.push_str(&contents[agent_heading.body_start..]);
272    }
273
274    updated
275}
276
277fn after_heading_line(contents: &str, offset: usize) -> usize {
278    let Some(rest) = contents.get(offset..) else {
279        return offset;
280    };
281
282    if rest.starts_with("\r\n") {
283        offset + 2
284    } else if rest.starts_with('\r') || rest.starts_with('\n') {
285        offset + 1
286    } else {
287        offset
288    }
289}
290
291fn heading_text(node: &Node) -> String {
292    let text = node.text_content();
293    text.strip_suffix("\r\n")
294        .or_else(|| text.strip_suffix('\n'))
295        .or_else(|| text.strip_suffix('\r'))
296        .unwrap_or(text.as_str())
297        .to_owned()
298}
299
300fn append_document(
301    sections: &mut Vec<String>,
302    link_root: &Path,
303    relative_path: &Path,
304    title: &str,
305    description: Option<&str>,
306) {
307    let heading = Node::Heading(Heading {
308        children: vec![Node::Link(Link {
309            children: vec![Node::Text(Text {
310                value: title.to_owned(),
311                position: None,
312            })],
313            position: None,
314            url: markdown_link(&link_root.join(relative_path)),
315            title: None,
316        })],
317        position: None,
318        depth: 4,
319    });
320    sections.push(heading.to_markdown().trim_end().to_owned());
321    sections.push(String::new());
322    if let Some(description) = description.filter(|description| !description.is_empty()) {
323        let paragraph = Node::Paragraph(Paragraph {
324            children: vec![Node::Text(Text {
325                value: description.to_owned(),
326                position: None,
327            })],
328            position: None,
329        });
330        sections.push(paragraph.to_markdown().trim_end().to_owned());
331        sections.push(String::new());
332    }
333}
334
335fn markdown_link(path: &Path) -> String {
336    path_to_string(path)
337        .replace('%', "%25")
338        .replace(' ', "%20")
339        .replace('#', "%23")
340        .replace('?', "%3F")
341        .replace('(', "%28")
342        .replace(')', "%29")
343}
344
345fn canonical_order(path: &Path) -> (usize, String, String) {
346    const CANONICAL: &[&str] = &[
347        "getting-started",
348        "overview",
349        "usage",
350        "configuration",
351        "migration",
352        "troubleshooting",
353        "debugging",
354    ];
355    let name = path
356        .file_stem()
357        .and_then(|name| name.to_str())
358        .unwrap_or_default()
359        .to_ascii_lowercase();
360    let order = CANONICAL
361        .iter()
362        .position(|canonical| *canonical == name)
363        .unwrap_or(CANONICAL.len());
364    (order, name, path_to_string(path))
365}
366
367fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
368    let content = fs::read_to_string(path)
369        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
370    let mut options = ParseOptions::default();
371    options.constructs.frontmatter = true;
372    let root = to_mdast(&content, &options)
373        .map_err(|error| Error::new(format!("could not parse {}: {error}", path.display())))?;
374    let children = root
375        .children()
376        .ok_or_else(|| Error::new(format!("{} is not a Markdown document", path.display())))?;
377    let title = children
378        .iter()
379        .find_map(|node| match node {
380            Node::Heading(_) => {
381                let title = heading_text(node);
382                (!title.trim().is_empty()).then_some(title)
383            }
384            _ => None,
385        })
386        .unwrap_or_else(|| {
387            path.file_stem()
388                .and_then(|stem| stem.to_str())
389                .unwrap_or("Documentation")
390                .replace('-', " ")
391        });
392
393    let first_paragraph = children.iter().find_map(|node| match node {
394        Node::Paragraph(_) => Some(node.text_content()),
395        _ => None,
396    });
397    let description = frontmatter_description(&root, path)?
398        .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
399
400    Ok((title, description))
401}
402
403fn first_sentence(paragraph: &str) -> Option<String> {
404    let paragraph = paragraph.trim();
405    if paragraph.is_empty() {
406        return None;
407    }
408
409    for (index, character) in paragraph.char_indices() {
410        if matches!(character, '.' | '!' | '?')
411            && paragraph[index + character.len_utf8()..]
412                .chars()
413                .next()
414                .is_none_or(char::is_whitespace)
415        {
416            return Some(paragraph[..index + character.len_utf8()].to_owned());
417        }
418    }
419
420    Some(paragraph.to_owned())
421}
422
423fn path_to_string(path: &Path) -> String {
424    path.components()
425        .map(|component| component.as_os_str().to_string_lossy())
426        .collect::<Vec<_>>()
427        .join("/")
428}