bake_agent_context/agent/context/
index.rs1use 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#[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 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 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 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}