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 bake::{Error, Result};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Clone, Debug)]
11struct ContextDocument {
12    path: PathBuf,
13    title: String,
14    description: Option<String>,
15}
16
17/// Manages the generated Context section in a project's agents.md file.
18#[derive(Clone, Debug)]
19pub struct AgentIndex {
20    root: PathBuf,
21    context_path: PathBuf,
22    context_link_path: PathBuf,
23    package_descriptions: HashMap<String, String>,
24}
25
26impl AgentIndex {
27    pub fn new(root: impl Into<PathBuf>) -> Self {
28        let root = root.into();
29        Self {
30            context_path: root.join(".agents/context"),
31            root,
32            context_link_path: PathBuf::from(".agents/context"),
33            package_descriptions: HashMap::new(),
34        }
35    }
36
37    /// Add Cargo package descriptions to the generated context section.
38    pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
39        self.package_descriptions = packages
40            .iter()
41            .filter_map(|package| {
42                package
43                    .description
44                    .as_ref()
45                    .map(|description| (package.selector().to_owned(), description.clone()))
46            })
47            .collect();
48        self
49    }
50
51    pub fn context_path(&self) -> &Path {
52        &self.context_path
53    }
54
55    pub fn generate_context_section(&self) -> Result<String> {
56        let mut sections = vec![
57            "This section links to documentation from installed packages. It is automatically generated and can be refreshed with `cargo bake agent:context:install`.".to_owned(),
58            String::new(),
59            "**Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.**".to_owned(),
60            String::new(),
61            "If these files are missing or dependencies have changed, run `cargo bake agent:context:install` to install them.".to_owned(),
62            String::new(),
63        ];
64
65        let packages = self.collect_context_packages()?;
66        if packages.is_empty() {
67            sections.push(
68                "No context files found. Run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
69            );
70            return Ok(sections.join("\n"));
71        }
72
73        for (package_name, files) in packages {
74            sections.push(format!("### {package_name}"));
75            sections.push(String::new());
76            sections.push(
77                self.package_descriptions
78                    .get(&package_name)
79                    .cloned()
80                    .unwrap_or_else(|| format!("Context files for {package_name}")),
81            );
82            sections.push(String::new());
83
84            for document in files {
85                append_document(
86                    &mut sections,
87                    &self.context_link_path.join(&package_name),
88                    &document.path,
89                    &document.title,
90                    document.description.as_deref(),
91                );
92            }
93        }
94
95        while sections.last().is_some_and(String::is_empty) {
96            sections.pop();
97        }
98        Ok(sections.join("\n"))
99    }
100
101    pub fn update_agents_md(&self, path: impl AsRef<Path>) -> Result<()> {
102        let path = path.as_ref();
103        let path = if path.is_absolute() {
104            path.to_path_buf()
105        } else {
106            self.root.join(path)
107        };
108        let context = self.generate_context_section()?;
109
110        let existing = match fs::read_to_string(&path) {
111            Ok(contents) => Some(contents),
112            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
113            Err(error) => {
114                return Err(Error::new(format!(
115                    "cannot read {}: {error}",
116                    path.display()
117                )));
118            }
119        };
120
121        let updated = match existing {
122            Some(contents) => update_existing(&contents, &context),
123            None => format!("# Agent\n\n## Context\n\n{context}\n"),
124        };
125
126        if let Some(parent) = path.parent() {
127            fs::create_dir_all(parent).map_err(|error| {
128                Error::new(format!("cannot create {}: {error}", parent.display()))
129            })?;
130        }
131        fs::write(&path, updated)
132            .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
133    }
134
135    fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
136        let entries = match fs::read_dir(&self.context_path) {
137            Ok(entries) => entries,
138            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
139            Err(error) => {
140                return Err(Error::new(format!(
141                    "cannot read {}: {error}",
142                    self.context_path.display()
143                )));
144            }
145        };
146
147        let mut packages = Vec::new();
148        for entry in entries {
149            let entry = entry?;
150            let file_type = entry.file_type()?;
151            if !file_type.is_dir() {
152                continue;
153            }
154            let package_path = entry.path();
155            let mut files = Vec::new();
156            for path in markdown_files(&package_path)? {
157                let (title, description) = extract_content(&path)?;
158                let relative_path = path.strip_prefix(&package_path).map_err(|error| {
159                    Error::new(format!("cannot make context path relative: {error}"))
160                })?;
161                files.push(ContextDocument {
162                    path: relative_path.to_path_buf(),
163                    title,
164                    description,
165                });
166            }
167            files.sort_by_key(|document| canonical_order(&document.path));
168            if !files.is_empty() {
169                packages.push((entry.file_name().to_string_lossy().into_owned(), files));
170            }
171        }
172        packages.sort_by(|left, right| left.0.cmp(&right.0));
173        Ok(packages)
174    }
175}
176
177fn update_existing(contents: &str, context: &str) -> String {
178    let mut lines: Vec<String> = contents.lines().map(str::to_owned).collect();
179    let had_trailing_newline = contents.ends_with('\n');
180    let agent_heading = lines
181        .iter()
182        .position(|line| line.trim().eq_ignore_ascii_case("# agent"));
183
184    let Some(agent_heading) = agent_heading else {
185        return format!("# Agent\n\n## Context\n\n{context}\n\n{contents}");
186    };
187
188    let mut context_heading = None;
189    for (index, line) in lines.iter().enumerate().skip(agent_heading + 1) {
190        let trimmed = line.trim();
191        if heading_level(trimmed) == Some(1) {
192            break;
193        }
194        if trimmed.eq_ignore_ascii_case("## context") {
195            context_heading = Some(index);
196            break;
197        }
198    }
199
200    let replacement: Vec<String> = std::iter::once("## Context".to_owned())
201        .chain(std::iter::once(String::new()))
202        .chain(context.lines().map(str::to_owned))
203        .collect();
204
205    if let Some(context_heading) = context_heading {
206        let end = (context_heading + 1..lines.len())
207            .find(|index| heading_level(lines[*index].trim()).is_some_and(|level| level <= 2))
208            .unwrap_or(lines.len());
209        lines.splice(context_heading..end, replacement);
210    } else {
211        lines.splice(
212            agent_heading + 1..agent_heading + 1,
213            std::iter::once(String::new()).chain(replacement),
214        );
215    }
216
217    let mut updated = lines.join("\n");
218    if had_trailing_newline || !updated.is_empty() {
219        updated.push('\n');
220    }
221    updated
222}
223
224fn heading_level(line: &str) -> Option<usize> {
225    let hashes = line
226        .chars()
227        .take_while(|character| *character == '#')
228        .count();
229    if (1..=6).contains(&hashes) && line.chars().nth(hashes).is_some_and(char::is_whitespace) {
230        Some(hashes)
231    } else {
232        None
233    }
234}
235
236fn append_document(
237    sections: &mut Vec<String>,
238    link_root: &Path,
239    relative_path: &Path,
240    title: &str,
241    description: Option<&str>,
242) {
243    sections.push(format!(
244        "#### [{}]({})",
245        title.replace(']', "\\]"),
246        markdown_link(&link_root.join(relative_path))
247    ));
248    sections.push(String::new());
249    if let Some(description) = description.filter(|description| !description.is_empty()) {
250        sections.push(description.to_owned());
251        sections.push(String::new());
252    }
253}
254
255fn markdown_link(path: &Path) -> String {
256    path_to_string(path)
257        .replace('%', "%25")
258        .replace(' ', "%20")
259        .replace('#', "%23")
260        .replace('?', "%3F")
261        .replace('(', "%28")
262        .replace(')', "%29")
263}
264
265fn canonical_order(path: &Path) -> (usize, String, String) {
266    const CANONICAL: &[&str] = &[
267        "getting-started",
268        "overview",
269        "usage",
270        "configuration",
271        "migration",
272        "troubleshooting",
273        "debugging",
274    ];
275    let name = path
276        .file_stem()
277        .and_then(|name| name.to_str())
278        .unwrap_or_default()
279        .to_ascii_lowercase();
280    let order = CANONICAL
281        .iter()
282        .position(|canonical| *canonical == name)
283        .unwrap_or(CANONICAL.len());
284    (order, name, path_to_string(path))
285}
286
287fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
288    let content = fs::read_to_string(path)
289        .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
290    let lines: Vec<_> = content.lines().map(str::trim).collect();
291    let title = lines
292        .iter()
293        .find_map(|line| {
294            heading_level(line).map(|_| line.trim_start_matches('#').trim().to_owned())
295        })
296        .filter(|title| !title.is_empty())
297        .unwrap_or_else(|| {
298            path.file_stem()
299                .and_then(|stem| stem.to_str())
300                .unwrap_or("Documentation")
301                .replace('-', " ")
302        });
303
304    let first_paragraph = lines
305        .iter()
306        .copied()
307        .filter(|line| heading_level(line).is_none())
308        .skip_while(|line| line.is_empty())
309        .take_while(|line| !line.is_empty())
310        .collect::<Vec<_>>()
311        .join(" ");
312
313    let description = first_sentence(&first_paragraph);
314
315    Ok((title, description))
316}
317
318fn first_sentence(paragraph: &str) -> Option<String> {
319    let paragraph = paragraph.trim();
320    if paragraph.is_empty() {
321        return None;
322    }
323
324    for (index, character) in paragraph.char_indices() {
325        if matches!(character, '.' | '!' | '?')
326            && paragraph[index + character.len_utf8()..]
327                .chars()
328                .next()
329                .is_none_or(char::is_whitespace)
330        {
331            return Some(paragraph[..index + character.len_utf8()].to_owned());
332        }
333    }
334
335    Some(paragraph.to_owned())
336}
337
338fn path_to_string(path: &Path) -> String {
339    path.components()
340        .map(|component| component.as_os_str().to_string_lossy())
341        .collect::<Vec<_>>()
342        .join("/")
343}