use super::installer::{ContextPackage, markdown_files};
use super::skill::frontmatter_description;
use bake::{Error, Result};
use socketry_markdown::{
ParseOptions,
mdast::{Heading, Link, Node, Paragraph, Text},
to_mdast,
};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Clone, Debug)]
struct ContextDocument {
path: PathBuf,
title: String,
description: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ContextIndex {
root: PathBuf,
context_path: PathBuf,
package_descriptions: HashMap<String, String>,
}
impl ContextIndex {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
Self {
context_path: root.join(".agents/context"),
root,
package_descriptions: HashMap::new(),
}
}
pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
self.package_descriptions = packages
.iter()
.filter_map(|package| {
package
.description
.as_ref()
.map(|description| (package.selector().to_owned(), description.clone()))
})
.collect();
self
}
pub fn context_path(&self) -> &Path {
&self.context_path
}
pub fn generate_index(&self) -> Result<String> {
let mut sections = vec![
"# Context Index".to_owned(),
String::new(),
"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(),
String::new(),
"Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.".to_owned(),
String::new(),
"If this index or the linked files are missing, run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
String::new(),
];
let packages = self.collect_context_packages()?;
if packages.is_empty() {
sections.push("No context files are installed.".to_owned());
return Ok(sections.join("\n"));
}
for (package_name, files) in packages {
sections.push(format!("## {package_name}"));
sections.push(String::new());
sections.push(
self.package_descriptions
.get(&package_name)
.cloned()
.unwrap_or_else(|| format!("Context files for {package_name}")),
);
sections.push(String::new());
for document in files {
append_document(
&mut sections,
Path::new(&package_name),
&document.path,
&document.title,
document.description.as_deref(),
);
}
}
while sections.last().is_some_and(String::is_empty) {
sections.pop();
}
Ok(sections.join("\n"))
}
pub fn update_index(&self) -> Result<()> {
let index = self.generate_index()?;
create_context_directory(&self.context_path)?;
let path = self.context_path.join("index.md");
fs::write(&path, index)
.map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))?;
let installed_skills = super::skill::installed_skill_names(&self.root)?;
super::exclude::prepare(&self.root, installed_skills)?
.map(|update| update.apply())
.transpose()?;
Ok(())
}
fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
let entries = match fs::read_dir(&self.context_path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(Error::new(format!(
"cannot read {}: {error}",
self.context_path.display()
)));
}
};
let mut packages = Vec::new();
for entry in entries {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let package_path = entry.path();
let mut files = Vec::new();
for path in markdown_files(&package_path)? {
let (title, description) = extract_content(&path)?;
let relative_path = relative_context_path(&package_path, &path)?;
files.push(ContextDocument {
path: relative_path.to_path_buf(),
title,
description,
});
}
files.sort_by_key(|document| canonical_order(&document.path));
if !files.is_empty() {
packages.push((entry.file_name().to_string_lossy().into_owned(), files));
}
}
packages.sort_by(|left, right| left.0.cmp(&right.0));
Ok(packages)
}
}
fn create_context_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path)
.map_err(|error| Error::new(format!("cannot create {}: {error}", path.display())))
}
fn relative_context_path(package_path: &Path, path: &Path) -> Result<PathBuf> {
path.strip_prefix(package_path)
.map(Path::to_path_buf)
.map_err(|error| Error::new(format!("cannot make context path relative: {error}")))
}
fn append_document(
sections: &mut Vec<String>,
link_root: &Path,
relative_path: &Path,
title: &str,
description: Option<&str>,
) {
let heading = Node::Heading(Heading {
children: vec![Node::Link(Link {
children: vec![Node::Text(Text {
value: title.to_owned(),
position: None,
})],
position: None,
url: markdown_link(&link_root.join(relative_path)),
title: None,
})],
position: None,
depth: 3,
});
sections.push(heading.to_markdown().trim_end().to_owned());
sections.push(String::new());
if let Some(description) = description.filter(|description| !description.is_empty()) {
let paragraph = Node::Paragraph(Paragraph {
children: vec![Node::Text(Text {
value: description.to_owned(),
position: None,
})],
position: None,
});
sections.push(paragraph.to_markdown().trim_end().to_owned());
sections.push(String::new());
}
}
fn markdown_link(path: &Path) -> String {
path_to_string(path)
.replace('%', "%25")
.replace(' ', "%20")
.replace('#', "%23")
.replace('?', "%3F")
.replace('(', "%28")
.replace(')', "%29")
}
fn canonical_order(path: &Path) -> (usize, String, String) {
const CANONICAL: &[&str] = &[
"getting-started",
"overview",
"usage",
"configuration",
"migration",
"troubleshooting",
"debugging",
];
let name = path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let order = CANONICAL
.iter()
.position(|canonical| *canonical == name)
.unwrap_or(CANONICAL.len());
(order, name, path_to_string(path))
}
fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
let content = fs::read_to_string(path)
.map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
let mut options = ParseOptions::default();
options.constructs.frontmatter = true;
let root =
to_mdast(&content, &options).expect("Markdown parsing without MDX support is infallible");
let children = root
.children()
.expect("a Markdown document root always has children");
let title = children
.iter()
.find_map(|node| match node {
Node::Heading(_) => {
let title = heading_text(node);
(!title.trim().is_empty()).then_some(title)
}
_ => None,
})
.unwrap_or_else(|| {
path.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("Documentation")
.replace('-', " ")
});
let first_paragraph = children.iter().find_map(|node| match node {
Node::Paragraph(_) => Some(node.text_content()),
_ => None,
});
let description = frontmatter_description(&root, path)?
.or_else(|| first_paragraph.as_deref().and_then(first_sentence));
Ok((title, description))
}
fn heading_text(node: &Node) -> String {
let text = node.text_content();
text.strip_suffix("\r\n")
.or_else(|| text.strip_suffix('\n'))
.or_else(|| text.strip_suffix('\r'))
.unwrap_or(text.as_str())
.to_owned()
}
fn first_sentence(paragraph: &str) -> Option<String> {
let paragraph = paragraph.trim();
if paragraph.is_empty() {
return None;
}
for (index, character) in paragraph.char_indices() {
if matches!(character, '.' | '!' | '?')
&& paragraph[index + character.len_utf8()..]
.chars()
.next()
.is_none_or(char::is_whitespace)
{
return Some(paragraph[..index + character.len_utf8()].to_owned());
}
}
Some(paragraph.to_owned())
}
fn path_to_string(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write(root: &Path, relative: &str, contents: &str) {
let path = root.join(relative);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
#[test]
fn exposes_context_path_and_renders_an_empty_index() {
let directory = tempdir().unwrap();
let index = ContextIndex::new(directory.path());
assert_eq!(
index.context_path(),
directory.path().join(".agents/context")
);
let rendered = index.generate_index().unwrap();
assert!(rendered.contains("No context files are installed."));
assert!(!rendered.ends_with('\n'));
}
#[test]
fn context_documents_use_fallback_titles_and_optional_descriptions() {
let directory = tempdir().unwrap();
write(
directory.path(),
".agents/context/provider/no-heading.md",
"Text without a heading or punctuation\n",
);
write(
directory.path(),
".agents/context/provider/blank.md",
"# \n\n \n",
);
write(
directory.path(),
".agents/context/provider/metadata.md",
"---\ndescription: \n---\n\n# Metadata\n\nBody sentence. More body.\n",
);
let rendered = ContextIndex::new(directory.path())
.generate_index()
.unwrap();
assert!(rendered.contains("[no heading](provider/no-heading.md)"));
assert!(rendered.contains("[blank](provider/blank.md)"));
assert!(rendered.contains("[Metadata](provider/metadata.md)"));
assert!(rendered.contains("Body sentence."));
assert!(!rendered.contains("More body."));
}
#[test]
fn package_descriptions_are_optional_and_packages_are_sorted() {
let directory = tempdir().unwrap();
let root = directory.path();
write(
root,
".agents/context/zeta@1.0.0/guide.md",
"# Zeta\n\nZeta guidance.\n",
);
write(
root,
".agents/context/alpha@1.0.0/guide.md",
"# Alpha\n\nAlpha guidance.\n",
);
let mut alpha = ContextPackage::for_test("alpha", "1.0.0", PathBuf::new());
alpha.description = None;
let zeta = ContextPackage::for_test("zeta", "1.0.0", PathBuf::new());
let rendered = ContextIndex::new(root)
.with_packages(&[zeta, alpha])
.generate_index()
.unwrap();
assert!(rendered.find("## alpha@1.0.0").unwrap() < rendered.find("## zeta@1.0.0").unwrap());
assert!(rendered.contains("Context files for alpha@1.0.0"));
assert!(rendered.contains("zeta documentation"));
}
#[test]
fn reports_context_read_and_markdown_parse_errors() {
let directory = tempdir().unwrap();
let error = extract_content(&directory.path().join("missing.md"))
.unwrap_err()
.to_string();
assert!(error.contains("cannot read"));
let directory = tempdir().unwrap();
let context_path = directory.path().join(".agents/context");
fs::create_dir_all(directory.path().join(".agents")).unwrap();
fs::write(&context_path, "not a directory").unwrap();
assert!(
ContextIndex::new(directory.path())
.generate_index()
.is_err()
);
let directory = tempdir().unwrap();
write(
directory.path(),
".agents/context/provider/broken.md",
"---\ndescription: [unterminated\n---\n",
);
assert!(
ContextIndex::new(directory.path())
.generate_index()
.is_err()
);
}
#[test]
fn reports_index_directory_and_registry_errors() {
let directory = tempdir().unwrap();
let index = ContextIndex::new(directory.path());
fs::write(directory.path().join(".agents"), "not a directory").unwrap();
assert!(index.update_index().is_err());
let directory = tempdir().unwrap();
fs::create_dir_all(directory.path().join(".agents/context/index.md")).unwrap();
assert!(ContextIndex::new(directory.path()).update_index().is_err());
let directory = tempdir().unwrap();
fs::write(directory.path().join("blocker"), "not a directory").unwrap();
assert!(create_context_directory(&directory.path().join("blocker/context")).is_err());
let directory = tempdir().unwrap();
write(
directory.path(),
".agents/skills/.agent-context-skills.json",
"{invalid json}",
);
assert!(ContextIndex::new(directory.path()).update_index().is_err());
}
#[test]
fn updates_local_git_excludes_with_the_generated_index() {
let directory = tempdir().unwrap();
let output = std::process::Command::new("git")
.args(["init", "--quiet"])
.current_dir(directory.path())
.output()
.unwrap();
assert!(output.status.success());
fs::write(directory.path().join(".git/info/exclude"), "# user rule\n").unwrap();
ContextIndex::new(directory.path()).update_index().unwrap();
let exclude = fs::read_to_string(directory.path().join(".git/info/exclude")).unwrap();
assert!(exclude.starts_with("# user rule\n"));
assert!(exclude.contains("# BEGIN bake-agent-context\n"));
}
#[test]
fn formats_links_and_orders_canonical_documents() {
assert_eq!(
markdown_link(Path::new("package/a% b#c?d(e).md")),
"package/a%25%20b%23c%3Fd%28e%29.md"
);
assert!(
canonical_order(Path::new("getting-started.md"))
< canonical_order(Path::new("unknown.md"))
);
assert!(
canonical_order(Path::new("other/usage.md"))
< canonical_order(Path::new("other/zeta.md"))
);
assert!(
canonical_order(Path::new("../Usage.md")) < canonical_order(Path::new("../zeta.md"))
);
assert_eq!(
path_to_string(Path::new("provider/reference/guide.md")),
"provider/reference/guide.md"
);
}
#[test]
fn extracts_sentences_and_handles_markdown_heading_newlines() {
assert_eq!(
first_sentence(" Start here! Later "),
Some("Start here!".to_owned())
);
assert_eq!(
first_sentence("Question? Then answer."),
Some("Question?".to_owned())
);
assert_eq!(
first_sentence("No punctuation"),
Some("No punctuation".to_owned())
);
assert_eq!(first_sentence(" \n"), None);
for (value, expected) in [
("title\r\n", "title"),
("title\r", "title"),
("title\n", "title"),
] {
let heading = Node::Heading(Heading {
children: vec![Node::Text(Text {
value: value.to_owned(),
position: None,
})],
position: None,
depth: 1,
});
assert_eq!(heading_text(&heading), expected);
}
}
#[test]
fn reports_relative_path_invariant_violations() {
let directory = tempdir().unwrap();
let package = directory.path().join("provider");
assert!(relative_context_path(&package, &directory.path().join("outside.md")).is_err());
assert_eq!(
relative_context_path(&package, &package.join("guide.md")).unwrap(),
PathBuf::from("guide.md")
);
}
}