1use super::installer::{ContextPackage, markdown_files};
5use super::skill::frontmatter_description;
6#[cfg(test)]
7use super::test_filesystem as fs;
8use bake::{Error, Result};
9use socketry_markdown::{
10 ParseOptions,
11 mdast::{Heading, Link, Node, Paragraph, Text},
12 to_mdast,
13};
14use std::collections::HashMap;
15#[cfg(not(test))]
16use std::fs;
17use std::path::{Path, PathBuf};
18
19#[derive(Clone, Debug)]
20struct ContextDocument {
21 path: PathBuf,
22 title: String,
23 description: Option<String>,
24}
25
26#[derive(Clone, Debug)]
28pub struct ContextIndex {
29 root: PathBuf,
30 context_path: PathBuf,
31 package_descriptions: HashMap<String, String>,
32}
33
34impl ContextIndex {
35 pub fn new(root: impl Into<PathBuf>) -> Self {
36 let root = root.into();
37 Self {
38 context_path: root.join(".agents/context"),
39 root,
40 package_descriptions: HashMap::new(),
41 }
42 }
43
44 pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
46 self.package_descriptions = packages
47 .iter()
48 .filter_map(|package| {
49 package
50 .description
51 .as_ref()
52 .map(|description| (package.selector().to_owned(), description.clone()))
53 })
54 .collect();
55 self
56 }
57
58 pub fn context_path(&self) -> &Path {
59 &self.context_path
60 }
61
62 pub fn generate_index(&self) -> Result<String> {
64 let mut sections = vec![
65 "# Context Index".to_owned(),
66 String::new(),
67 "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(),
68 String::new(),
69 "Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.".to_owned(),
70 String::new(),
71 "If this index or the linked files are missing, run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
72 String::new(),
73 ];
74
75 let packages = self.collect_context_packages()?;
76 if packages.is_empty() {
77 sections.push("No context files are installed.".to_owned());
78 return Ok(sections.join("\n"));
79 }
80
81 for (package_name, files) in packages {
82 sections.push(format!("## {package_name}"));
83 sections.push(String::new());
84 sections.push(
85 self.package_descriptions
86 .get(&package_name)
87 .cloned()
88 .unwrap_or_else(|| format!("Context files for {package_name}")),
89 );
90 sections.push(String::new());
91
92 for document in files {
93 append_document(
94 &mut sections,
95 Path::new(&package_name),
96 &document.path,
97 &document.title,
98 document.description.as_deref(),
99 );
100 }
101 }
102
103 while sections.last().is_some_and(String::is_empty) {
104 sections.pop();
105 }
106 Ok(sections.join("\n"))
107 }
108
109 pub fn update_index(&self) -> Result<()> {
111 let index = self.generate_index()?;
112 create_context_directory(&self.context_path)?;
113
114 let path = self.context_path.join("index.md");
115 fs::write(&path, index)
116 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))?;
117
118 let installed_skills = super::skill::installed_skill_names(&self.root)?;
119 super::exclude::prepare(&self.root, installed_skills)?
120 .map(|update| update.apply())
121 .transpose()?;
122
123 Ok(())
124 }
125
126 fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
127 let entries = match fs::read_dir(&self.context_path) {
128 Ok(entries) => entries,
129 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
130 Err(error) => {
131 return Err(Error::new(format!(
132 "cannot read {}: {error}",
133 self.context_path.display()
134 )));
135 }
136 };
137
138 let mut packages = Vec::new();
139 for entry in entries {
140 let entry = entry?;
141 let file_type = entry.file_type()?;
142 if !file_type.is_dir() {
143 continue;
144 }
145 let package_path = entry.path();
146 let mut files = Vec::new();
147 for path in markdown_files(&package_path)? {
148 let (title, description) = extract_content(&path)?;
149 let relative_path = path
150 .strip_prefix(&package_path)
151 .expect("markdown_files only returns paths within its requested directory");
152 files.push(ContextDocument {
153 path: relative_path.to_path_buf(),
154 title,
155 description,
156 });
157 }
158 files.sort_by_key(|document| canonical_order(&document.path));
159 if !files.is_empty() {
160 packages.push((entry.file_name().to_string_lossy().into_owned(), files));
161 }
162 }
163 packages.sort_by(|left, right| left.0.cmp(&right.0));
164 Ok(packages)
165 }
166}
167
168fn create_context_directory(path: &Path) -> Result<()> {
169 fs::create_dir_all(path)
170 .map_err(|error| Error::new(format!("cannot create {}: {error}", path.display())))
171}
172
173fn append_document(
174 sections: &mut Vec<String>,
175 link_root: &Path,
176 relative_path: &Path,
177 title: &str,
178 description: Option<&str>,
179) {
180 let heading = Node::Heading(Heading {
181 children: vec![Node::Link(Link {
182 children: vec![Node::Text(Text {
183 value: title.to_owned(),
184 position: None,
185 })],
186 position: None,
187 url: markdown_link(&link_root.join(relative_path)),
188 title: None,
189 })],
190 position: None,
191 depth: 3,
192 });
193 sections.push(heading.to_markdown().trim_end().to_owned());
194 sections.push(String::new());
195 if let Some(description) = description.filter(|description| !description.is_empty()) {
196 let paragraph = Node::Paragraph(Paragraph {
197 children: vec![Node::Text(Text {
198 value: description.to_owned(),
199 position: None,
200 })],
201 position: None,
202 });
203 sections.push(paragraph.to_markdown().trim_end().to_owned());
204 sections.push(String::new());
205 }
206}
207
208fn markdown_link(path: &Path) -> String {
209 path_to_string(path)
210 .replace('%', "%25")
211 .replace(' ', "%20")
212 .replace('#', "%23")
213 .replace('?', "%3F")
214 .replace('(', "%28")
215 .replace(')', "%29")
216}
217
218fn canonical_order(path: &Path) -> (usize, String, String) {
219 const CANONICAL: &[&str] = &[
220 "getting-started",
221 "overview",
222 "usage",
223 "configuration",
224 "migration",
225 "troubleshooting",
226 "debugging",
227 ];
228 let name = path
229 .file_stem()
230 .and_then(|name| name.to_str())
231 .unwrap_or_default()
232 .to_ascii_lowercase();
233 let order = CANONICAL
234 .iter()
235 .position(|canonical| *canonical == name)
236 .unwrap_or(CANONICAL.len());
237 (order, name, path_to_string(path))
238}
239
240fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
241 let content = fs::read_to_string(path)
242 .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
243 let mut options = ParseOptions::default();
244 options.constructs.frontmatter = true;
245 let root =
247 to_mdast(&content, &options).expect("Markdown parsing without MDX support is infallible");
248 let children = root
249 .children()
250 .expect("a Markdown document root always has children");
251 let title = children
252 .iter()
253 .find_map(|node| match node {
254 Node::Heading(_) => {
255 let title = heading_text(node);
256 (!title.trim().is_empty()).then_some(title)
257 }
258 _ => None,
259 })
260 .unwrap_or_else(|| {
261 path.file_stem()
262 .and_then(|stem| stem.to_str())
263 .unwrap_or("Documentation")
264 .replace('-', " ")
265 });
266
267 let first_paragraph = children.iter().find_map(|node| match node {
268 Node::Paragraph(_) => Some(node.text_content()),
269 _ => None,
270 });
271 let description = frontmatter_description(&root, path)?
272 .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
273
274 Ok((title, description))
275}
276
277fn heading_text(node: &Node) -> String {
278 let text = node.text_content();
279 text.strip_suffix("\r\n")
280 .or_else(|| text.strip_suffix('\n'))
281 .or_else(|| text.strip_suffix('\r'))
282 .unwrap_or(text.as_str())
283 .to_owned()
284}
285
286fn first_sentence(paragraph: &str) -> Option<String> {
287 let paragraph = paragraph.trim();
288 if paragraph.is_empty() {
289 return None;
290 }
291
292 for (index, character) in paragraph.char_indices() {
293 if matches!(character, '.' | '!' | '?')
294 && paragraph[index + character.len_utf8()..]
295 .chars()
296 .next()
297 .is_none_or(char::is_whitespace)
298 {
299 return Some(paragraph[..index + character.len_utf8()].to_owned());
300 }
301 }
302
303 Some(paragraph.to_owned())
304}
305
306fn path_to_string(path: &Path) -> String {
307 path.components()
308 .map(|component| component.as_os_str().to_string_lossy())
309 .collect::<Vec<_>>()
310 .join("/")
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use tempfile::tempdir;
317
318 fn write(root: &Path, relative: &str, contents: &str) {
319 let path = root.join(relative);
320 fs::create_dir_all(path.parent().unwrap()).unwrap();
321 fs::write(path, contents).unwrap();
322 }
323
324 #[test]
325 fn exposes_context_path_and_renders_an_empty_index() {
326 let directory = tempdir().unwrap();
327 let index = ContextIndex::new(directory.path());
328
329 assert_eq!(
330 index.context_path(),
331 directory.path().join(".agents/context")
332 );
333 let rendered = index.generate_index().unwrap();
334 assert!(rendered.contains("No context files are installed."));
335 assert!(!rendered.ends_with('\n'));
336 }
337
338 #[test]
339 fn context_documents_use_fallback_titles_and_optional_descriptions() {
340 let directory = tempdir().unwrap();
341 write(
342 directory.path(),
343 ".agents/context/provider/no-heading.md",
344 "Text without a heading or punctuation\n",
345 );
346 write(
347 directory.path(),
348 ".agents/context/provider/blank.md",
349 "# \n\n \n",
350 );
351 write(
352 directory.path(),
353 ".agents/context/provider/metadata.md",
354 "---\ndescription: \n---\n\n# Metadata\n\nBody sentence. More body.\n",
355 );
356
357 let rendered = ContextIndex::new(directory.path())
358 .generate_index()
359 .unwrap();
360 assert!(rendered.contains("[no heading](provider/no-heading.md)"));
361 assert!(rendered.contains("[blank](provider/blank.md)"));
362 assert!(rendered.contains("[Metadata](provider/metadata.md)"));
363 assert!(rendered.contains("Body sentence."));
364 assert!(!rendered.contains("More body."));
365 }
366
367 #[test]
368 fn package_descriptions_are_optional_and_packages_are_sorted() {
369 let directory = tempdir().unwrap();
370 let root = directory.path();
371 write(
372 root,
373 ".agents/context/zeta@1.0.0/guide.md",
374 "# Zeta\n\nZeta guidance.\n",
375 );
376 write(
377 root,
378 ".agents/context/alpha@1.0.0/guide.md",
379 "# Alpha\n\nAlpha guidance.\n",
380 );
381
382 let mut alpha = ContextPackage::for_test("alpha", "1.0.0", PathBuf::new());
383 alpha.description = None;
384 let zeta = ContextPackage::for_test("zeta", "1.0.0", PathBuf::new());
385 let rendered = ContextIndex::new(root)
386 .with_packages(&[zeta, alpha])
387 .generate_index()
388 .unwrap();
389
390 assert!(rendered.find("## alpha@1.0.0").unwrap() < rendered.find("## zeta@1.0.0").unwrap());
391 assert!(rendered.contains("Context files for alpha@1.0.0"));
392 assert!(rendered.contains("zeta documentation"));
393 }
394
395 #[test]
396 fn reports_context_read_and_markdown_parse_errors() {
397 let directory = tempdir().unwrap();
398 let error = extract_content(&directory.path().join("missing.md"))
399 .unwrap_err()
400 .to_string();
401 assert!(error.contains("cannot read"));
402
403 let directory = tempdir().unwrap();
404 let context_path = directory.path().join(".agents/context");
405 fs::create_dir_all(directory.path().join(".agents")).unwrap();
406 fs::write(&context_path, "not a directory").unwrap();
407 assert!(
408 ContextIndex::new(directory.path())
409 .generate_index()
410 .is_err()
411 );
412
413 let directory = tempdir().unwrap();
414 write(
415 directory.path(),
416 ".agents/context/provider/broken.md",
417 "---\ndescription: [unterminated\n---\n",
418 );
419 assert!(
420 ContextIndex::new(directory.path())
421 .generate_index()
422 .is_err()
423 );
424 }
425
426 #[test]
427 fn reports_index_directory_and_registry_errors() {
428 let directory = tempdir().unwrap();
429 let index = ContextIndex::new(directory.path());
430 fs::write(directory.path().join(".agents"), "not a directory").unwrap();
431 assert!(index.update_index().is_err());
432
433 let directory = tempdir().unwrap();
434 fs::create_dir_all(directory.path().join(".agents/context/index.md")).unwrap();
435 assert!(ContextIndex::new(directory.path()).update_index().is_err());
436
437 let directory = tempdir().unwrap();
438 fs::write(directory.path().join("blocker"), "not a directory").unwrap();
439 assert!(create_context_directory(&directory.path().join("blocker/context")).is_err());
440
441 let directory = tempdir().unwrap();
442 write(
443 directory.path(),
444 ".agents/skills/.agent-context-skills.json",
445 "{invalid json}",
446 );
447 assert!(ContextIndex::new(directory.path()).update_index().is_err());
448 }
449
450 #[test]
451 fn propagates_index_and_exclude_write_errors() {
452 let directory = tempdir().unwrap();
453 let context_path = directory.path().join(".agents/context");
454 let failure_path = context_path.clone();
455 let _failure = fs::fail_once(fs::Operation::CreateDirectoryTree, move |path| {
456 path == failure_path
457 });
458 assert!(ContextIndex::new(directory.path()).update_index().is_err());
459 drop(_failure);
460
461 let directory = tempdir().unwrap();
462 let index_path = directory.path().join(".agents/context/index.md");
463 let failure_path = index_path.clone();
464 let _failure = fs::fail_once(fs::Operation::Write, move |path| path == failure_path);
465 assert!(ContextIndex::new(directory.path()).update_index().is_err());
466 drop(_failure);
467
468 let directory = tempdir().unwrap();
469 let output = std::process::Command::new("git")
470 .args(["init", "--quiet"])
471 .current_dir(directory.path())
472 .output()
473 .unwrap();
474 assert!(output.status.success());
475 fs::write(
476 directory.path().join(".git/info/exclude"),
477 "# BEGIN bake-agent-context",
478 )
479 .unwrap();
480 assert!(ContextIndex::new(directory.path()).update_index().is_err());
481
482 let directory = tempdir().unwrap();
483 let output = std::process::Command::new("git")
484 .args(["init", "--quiet"])
485 .current_dir(directory.path())
486 .output()
487 .unwrap();
488 assert!(output.status.success());
489 let exclude_path = directory.path().join(".git/info/exclude");
490 let failure_path = exclude_path.clone();
491 let _failure = fs::fail_once(fs::Operation::Write, move |path| path == failure_path);
492 assert!(ContextIndex::new(directory.path()).update_index().is_err());
493 }
494
495 #[test]
496 fn propagates_context_entry_read_and_markdown_errors() {
497 let directory = tempdir().unwrap();
498 let context_path = directory.path().join(".agents/context");
499 fs::create_dir_all(&context_path).unwrap();
500 let failure_path = context_path.clone();
501 let _failure = fs::fail_once(fs::Operation::ReadDirectoryEntry, move |path| {
502 path == failure_path
503 });
504 assert!(
505 ContextIndex::new(directory.path())
506 .generate_index()
507 .is_err()
508 );
509 drop(_failure);
510
511 let package_path = context_path.join("provider");
512 fs::create_dir_all(&package_path).unwrap();
513 let failure_path = package_path.clone();
514 let _failure = fs::fail_once(fs::Operation::FileType, move |path| path == failure_path);
515 assert!(
516 ContextIndex::new(directory.path())
517 .generate_index()
518 .is_err()
519 );
520 drop(_failure);
521
522 let guide = package_path.join("guide.md");
523 fs::write(&guide, "# Guide\n").unwrap();
524 let failure_path = package_path.clone();
525 let _failure = fs::fail_once(fs::Operation::ReadDirectory, move |path| {
526 path == failure_path
527 });
528 assert!(
529 ContextIndex::new(directory.path())
530 .generate_index()
531 .is_err()
532 );
533 drop(_failure);
534
535 let failure_path = guide.clone();
536 let _failure = fs::fail_once(fs::Operation::Read, move |path| path == failure_path);
537 assert!(
538 ContextIndex::new(directory.path())
539 .generate_index()
540 .is_err()
541 );
542 }
543
544 #[test]
545 fn updates_local_git_excludes_with_the_generated_index() {
546 let directory = tempdir().unwrap();
547 let output = std::process::Command::new("git")
548 .args(["init", "--quiet"])
549 .current_dir(directory.path())
550 .output()
551 .unwrap();
552 assert!(output.status.success());
553
554 fs::write(directory.path().join(".git/info/exclude"), "# user rule\n").unwrap();
555 ContextIndex::new(directory.path()).update_index().unwrap();
556 let exclude = fs::read_to_string(directory.path().join(".git/info/exclude")).unwrap();
557 assert!(exclude.starts_with("# user rule\n"));
558 assert!(exclude.contains("# BEGIN bake-agent-context\n"));
559 }
560
561 #[test]
562 fn formats_links_and_orders_canonical_documents() {
563 assert_eq!(
564 markdown_link(Path::new("package/a% b#c?d(e).md")),
565 "package/a%25%20b%23c%3Fd%28e%29.md"
566 );
567 assert!(
568 canonical_order(Path::new("getting-started.md"))
569 < canonical_order(Path::new("unknown.md"))
570 );
571 assert!(
572 canonical_order(Path::new("other/usage.md"))
573 < canonical_order(Path::new("other/zeta.md"))
574 );
575 assert!(
576 canonical_order(Path::new("../Usage.md")) < canonical_order(Path::new("../zeta.md"))
577 );
578 assert_eq!(
579 path_to_string(Path::new("provider/reference/guide.md")),
580 "provider/reference/guide.md"
581 );
582 }
583
584 #[test]
585 fn extracts_sentences_and_handles_markdown_heading_newlines() {
586 assert_eq!(
587 first_sentence(" Start here! Later "),
588 Some("Start here!".to_owned())
589 );
590 assert_eq!(
591 first_sentence("Question? Then answer."),
592 Some("Question?".to_owned())
593 );
594 assert_eq!(
595 first_sentence("No punctuation"),
596 Some("No punctuation".to_owned())
597 );
598 assert_eq!(first_sentence(" \n"), None);
599
600 for (value, expected) in [
601 ("title\r\n", "title"),
602 ("title\r", "title"),
603 ("title\n", "title"),
604 ] {
605 let heading = Node::Heading(Heading {
606 children: vec![Node::Text(Text {
607 value: value.to_owned(),
608 position: None,
609 })],
610 position: None,
611 depth: 1,
612 });
613 assert_eq!(heading_text(&heading), expected);
614 }
615 }
616}