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 create_context_directory(&self.context_path)?;
110
111 let path = self.context_path.join("index.md");
112 fs::write(&path, index)
113 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))?;
114
115 let installed_skills = super::skill::installed_skill_names(&self.root)?;
116 super::exclude::prepare(&self.root, installed_skills)?
117 .map(|update| update.apply())
118 .transpose()?;
119
120 Ok(())
121 }
122
123 fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
124 let entries = match fs::read_dir(&self.context_path) {
125 Ok(entries) => entries,
126 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
127 Err(error) => {
128 return Err(Error::new(format!(
129 "cannot read {}: {error}",
130 self.context_path.display()
131 )));
132 }
133 };
134
135 let mut packages = Vec::new();
136 for entry in entries {
137 let entry = entry?;
138 let file_type = entry.file_type()?;
139 if !file_type.is_dir() {
140 continue;
141 }
142 let package_path = entry.path();
143 let mut files = Vec::new();
144 for path in markdown_files(&package_path)? {
145 let (title, description) = extract_content(&path)?;
146 let relative_path = relative_context_path(&package_path, &path)?;
147 files.push(ContextDocument {
148 path: relative_path.to_path_buf(),
149 title,
150 description,
151 });
152 }
153 files.sort_by_key(|document| canonical_order(&document.path));
154 if !files.is_empty() {
155 packages.push((entry.file_name().to_string_lossy().into_owned(), files));
156 }
157 }
158 packages.sort_by(|left, right| left.0.cmp(&right.0));
159 Ok(packages)
160 }
161}
162
163fn create_context_directory(path: &Path) -> Result<()> {
164 fs::create_dir_all(path)
165 .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display())))
166}
167
168fn relative_context_path(package_path: &Path, path: &Path) -> Result<PathBuf> {
169 path.strip_prefix(package_path)
170 .map(Path::to_path_buf)
171 .map_err(|error| Error::new(format!("cannot make context path relative: {error}")))
172}
173
174fn append_document(
175 sections: &mut Vec<String>,
176 link_root: &Path,
177 relative_path: &Path,
178 title: &str,
179 description: Option<&str>,
180) {
181 let heading = Node::Heading(Heading {
182 children: vec![Node::Link(Link {
183 children: vec![Node::Text(Text {
184 value: title.to_owned(),
185 position: None,
186 })],
187 position: None,
188 url: markdown_link(&link_root.join(relative_path)),
189 title: None,
190 })],
191 position: None,
192 depth: 3,
193 });
194 sections.push(heading.to_markdown().trim_end().to_owned());
195 sections.push(String::new());
196 if let Some(description) = description.filter(|description| !description.is_empty()) {
197 let paragraph = Node::Paragraph(Paragraph {
198 children: vec![Node::Text(Text {
199 value: description.to_owned(),
200 position: None,
201 })],
202 position: None,
203 });
204 sections.push(paragraph.to_markdown().trim_end().to_owned());
205 sections.push(String::new());
206 }
207}
208
209fn markdown_link(path: &Path) -> String {
210 path_to_string(path)
211 .replace('%', "%25")
212 .replace(' ', "%20")
213 .replace('#', "%23")
214 .replace('?', "%3F")
215 .replace('(', "%28")
216 .replace(')', "%29")
217}
218
219fn canonical_order(path: &Path) -> (usize, String, String) {
220 const CANONICAL: &[&str] = &[
221 "getting-started",
222 "overview",
223 "usage",
224 "configuration",
225 "migration",
226 "troubleshooting",
227 "debugging",
228 ];
229 let name = path
230 .file_stem()
231 .and_then(|name| name.to_str())
232 .unwrap_or_default()
233 .to_ascii_lowercase();
234 let order = CANONICAL
235 .iter()
236 .position(|canonical| *canonical == name)
237 .unwrap_or(CANONICAL.len());
238 (order, name, path_to_string(path))
239}
240
241fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
242 let content = fs::read_to_string(path)
243 .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
244 let mut options = ParseOptions::default();
245 options.constructs.frontmatter = true;
246 let root =
248 to_mdast(&content, &options).expect("Markdown parsing without MDX support is infallible");
249 let children = root
250 .children()
251 .expect("a Markdown document root always has children");
252 let title = children
253 .iter()
254 .find_map(|node| match node {
255 Node::Heading(_) => {
256 let title = heading_text(node);
257 (!title.trim().is_empty()).then_some(title)
258 }
259 _ => None,
260 })
261 .unwrap_or_else(|| {
262 path.file_stem()
263 .and_then(|stem| stem.to_str())
264 .unwrap_or("Documentation")
265 .replace('-', " ")
266 });
267
268 let first_paragraph = children.iter().find_map(|node| match node {
269 Node::Paragraph(_) => Some(node.text_content()),
270 _ => None,
271 });
272 let description = frontmatter_description(&root, path)?
273 .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
274
275 Ok((title, description))
276}
277
278fn heading_text(node: &Node) -> String {
279 let text = node.text_content();
280 text.strip_suffix("\r\n")
281 .or_else(|| text.strip_suffix('\n'))
282 .or_else(|| text.strip_suffix('\r'))
283 .unwrap_or(text.as_str())
284 .to_owned()
285}
286
287fn first_sentence(paragraph: &str) -> Option<String> {
288 let paragraph = paragraph.trim();
289 if paragraph.is_empty() {
290 return None;
291 }
292
293 for (index, character) in paragraph.char_indices() {
294 if matches!(character, '.' | '!' | '?')
295 && paragraph[index + character.len_utf8()..]
296 .chars()
297 .next()
298 .is_none_or(char::is_whitespace)
299 {
300 return Some(paragraph[..index + character.len_utf8()].to_owned());
301 }
302 }
303
304 Some(paragraph.to_owned())
305}
306
307fn path_to_string(path: &Path) -> String {
308 path.components()
309 .map(|component| component.as_os_str().to_string_lossy())
310 .collect::<Vec<_>>()
311 .join("/")
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use tempfile::tempdir;
318
319 fn write(root: &Path, relative: &str, contents: &str) {
320 let path = root.join(relative);
321 fs::create_dir_all(path.parent().unwrap()).unwrap();
322 fs::write(path, contents).unwrap();
323 }
324
325 #[test]
326 fn exposes_context_path_and_renders_an_empty_index() {
327 let directory = tempdir().unwrap();
328 let index = ContextIndex::new(directory.path());
329
330 assert_eq!(
331 index.context_path(),
332 directory.path().join(".agents/context")
333 );
334 let rendered = index.generate_index().unwrap();
335 assert!(rendered.contains("No context files are installed."));
336 assert!(!rendered.ends_with('\n'));
337 }
338
339 #[test]
340 fn context_documents_use_fallback_titles_and_optional_descriptions() {
341 let directory = tempdir().unwrap();
342 write(
343 directory.path(),
344 ".agents/context/provider/no-heading.md",
345 "Text without a heading or punctuation\n",
346 );
347 write(
348 directory.path(),
349 ".agents/context/provider/blank.md",
350 "# \n\n \n",
351 );
352 write(
353 directory.path(),
354 ".agents/context/provider/metadata.md",
355 "---\ndescription: \n---\n\n# Metadata\n\nBody sentence. More body.\n",
356 );
357
358 let rendered = ContextIndex::new(directory.path())
359 .generate_index()
360 .unwrap();
361 assert!(rendered.contains("[no heading](provider/no-heading.md)"));
362 assert!(rendered.contains("[blank](provider/blank.md)"));
363 assert!(rendered.contains("[Metadata](provider/metadata.md)"));
364 assert!(rendered.contains("Body sentence."));
365 assert!(!rendered.contains("More body."));
366 }
367
368 #[test]
369 fn package_descriptions_are_optional_and_packages_are_sorted() {
370 let directory = tempdir().unwrap();
371 let root = directory.path();
372 write(
373 root,
374 ".agents/context/zeta@1.0.0/guide.md",
375 "# Zeta\n\nZeta guidance.\n",
376 );
377 write(
378 root,
379 ".agents/context/alpha@1.0.0/guide.md",
380 "# Alpha\n\nAlpha guidance.\n",
381 );
382
383 let mut alpha = ContextPackage::for_test("alpha", "1.0.0", PathBuf::new());
384 alpha.description = None;
385 let zeta = ContextPackage::for_test("zeta", "1.0.0", PathBuf::new());
386 let rendered = ContextIndex::new(root)
387 .with_packages(&[zeta, alpha])
388 .generate_index()
389 .unwrap();
390
391 assert!(rendered.find("## alpha@1.0.0").unwrap() < rendered.find("## zeta@1.0.0").unwrap());
392 assert!(rendered.contains("Context files for alpha@1.0.0"));
393 assert!(rendered.contains("zeta documentation"));
394 }
395
396 #[test]
397 fn reports_context_read_and_markdown_parse_errors() {
398 let directory = tempdir().unwrap();
399 let error = extract_content(&directory.path().join("missing.md"))
400 .unwrap_err()
401 .to_string();
402 assert!(error.contains("cannot read"));
403
404 let directory = tempdir().unwrap();
405 let context_path = directory.path().join(".agents/context");
406 fs::create_dir_all(directory.path().join(".agents")).unwrap();
407 fs::write(&context_path, "not a directory").unwrap();
408 assert!(
409 ContextIndex::new(directory.path())
410 .generate_index()
411 .is_err()
412 );
413
414 let directory = tempdir().unwrap();
415 write(
416 directory.path(),
417 ".agents/context/provider/broken.md",
418 "---\ndescription: [unterminated\n---\n",
419 );
420 assert!(
421 ContextIndex::new(directory.path())
422 .generate_index()
423 .is_err()
424 );
425 }
426
427 #[test]
428 fn reports_index_directory_and_registry_errors() {
429 let directory = tempdir().unwrap();
430 let index = ContextIndex::new(directory.path());
431 fs::write(directory.path().join(".agents"), "not a directory").unwrap();
432 assert!(index.update_index().is_err());
433
434 let directory = tempdir().unwrap();
435 fs::create_dir_all(directory.path().join(".agents/context/index.md")).unwrap();
436 assert!(ContextIndex::new(directory.path()).update_index().is_err());
437
438 let directory = tempdir().unwrap();
439 fs::write(directory.path().join("blocker"), "not a directory").unwrap();
440 assert!(create_context_directory(&directory.path().join("blocker/context")).is_err());
441
442 let directory = tempdir().unwrap();
443 write(
444 directory.path(),
445 ".agents/skills/.agent-context-skills.json",
446 "{invalid json}",
447 );
448 assert!(ContextIndex::new(directory.path()).update_index().is_err());
449 }
450
451 #[test]
452 fn updates_local_git_excludes_with_the_generated_index() {
453 let directory = tempdir().unwrap();
454 let output = std::process::Command::new("git")
455 .args(["init", "--quiet"])
456 .current_dir(directory.path())
457 .output()
458 .unwrap();
459 assert!(output.status.success());
460
461 fs::write(directory.path().join(".git/info/exclude"), "# user rule\n").unwrap();
462 ContextIndex::new(directory.path()).update_index().unwrap();
463 let exclude = fs::read_to_string(directory.path().join(".git/info/exclude")).unwrap();
464 assert!(exclude.starts_with("# user rule\n"));
465 assert!(exclude.contains("# BEGIN bake-agent-context\n"));
466 }
467
468 #[test]
469 fn formats_links_and_orders_canonical_documents() {
470 assert_eq!(
471 markdown_link(Path::new("package/a% b#c?d(e).md")),
472 "package/a%25%20b%23c%3Fd%28e%29.md"
473 );
474 assert!(
475 canonical_order(Path::new("getting-started.md"))
476 < canonical_order(Path::new("unknown.md"))
477 );
478 assert!(
479 canonical_order(Path::new("other/usage.md"))
480 < canonical_order(Path::new("other/zeta.md"))
481 );
482 assert!(
483 canonical_order(Path::new("../Usage.md")) < canonical_order(Path::new("../zeta.md"))
484 );
485 assert_eq!(
486 path_to_string(Path::new("provider/reference/guide.md")),
487 "provider/reference/guide.md"
488 );
489 }
490
491 #[test]
492 fn extracts_sentences_and_handles_markdown_heading_newlines() {
493 assert_eq!(
494 first_sentence(" Start here! Later "),
495 Some("Start here!".to_owned())
496 );
497 assert_eq!(
498 first_sentence("Question? Then answer."),
499 Some("Question?".to_owned())
500 );
501 assert_eq!(
502 first_sentence("No punctuation"),
503 Some("No punctuation".to_owned())
504 );
505 assert_eq!(first_sentence(" \n"), None);
506
507 for (value, expected) in [
508 ("title\r\n", "title"),
509 ("title\r", "title"),
510 ("title\n", "title"),
511 ] {
512 let heading = Node::Heading(Heading {
513 children: vec![Node::Text(Text {
514 value: value.to_owned(),
515 position: None,
516 })],
517 position: None,
518 depth: 1,
519 });
520 assert_eq!(heading_text(&heading), expected);
521 }
522 }
523
524 #[test]
525 fn reports_relative_path_invariant_violations() {
526 let directory = tempdir().unwrap();
527 let package = directory.path().join("provider");
528 assert!(relative_context_path(&package, &directory.path().join("outside.md")).is_err());
529 assert_eq!(
530 relative_context_path(&package, &package.join("guide.md")).unwrap(),
531 PathBuf::from("guide.md")
532 );
533 }
534}