bake-agent-context 0.2.0

Install context files from Cargo dependencies for coding agents
Documentation
// Released under the MIT License.
// Copyright, 2026, by Samuel Williams.

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>,
}

/// Generates an index of installed package context in `.agents/context/index.md`.
#[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(),
        }
    }

    /// Add Cargo package descriptions to the generated context index.
    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
    }

    /// Render a Markdown index from context files already installed in the project.
    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"))
    }

    /// Write the generated index without creating or modifying the project's `agents.md`.
    pub fn update_index(&self) -> Result<()> {
        let index = self.generate_index()?;
        fs::create_dir_all(&self.context_path).map_err(|error| {
            Error::new(format!(
                "cannot create {}: {error}",
                self.context_path.display()
            ))
        })?;

        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)?;
        if let Some(exclude_update) = super::exclude::prepare(&self.root, installed_skills)? {
            exclude_update.apply()?;
        }

        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 = path.strip_prefix(&package_path).map_err(|error| {
                    Error::new(format!("cannot make context path relative: {error}"))
                })?;
                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 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)
        .map_err(|error| Error::new(format!("could not parse {}: {error}", path.display())))?;
    let children = root
        .children()
        .ok_or_else(|| Error::new(format!("{} is not a Markdown document", path.display())))?;
    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("/")
}