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
23struct SourceHeading {
24 index: usize,
25 level: u8,
26 title: String,
27 start: usize,
28 body_start: usize,
29}
30
31#[derive(Clone, Debug)]
33pub struct AgentIndex {
34 root: PathBuf,
35 context_path: PathBuf,
36 context_link_path: PathBuf,
37 package_descriptions: HashMap<String, String>,
38}
39
40impl AgentIndex {
41 pub fn new(root: impl Into<PathBuf>) -> Self {
42 let root = root.into();
43 Self {
44 context_path: root.join(".agents/context"),
45 root,
46 context_link_path: PathBuf::from(".agents/context"),
47 package_descriptions: HashMap::new(),
48 }
49 }
50
51 pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
53 self.package_descriptions = packages
54 .iter()
55 .filter_map(|package| {
56 package
57 .description
58 .as_ref()
59 .map(|description| (package.selector().to_owned(), description.clone()))
60 })
61 .collect();
62 self
63 }
64
65 pub fn context_path(&self) -> &Path {
66 &self.context_path
67 }
68
69 pub fn generate_context_section(&self) -> Result<String> {
70 let mut sections = vec![
71 "This section links to documentation from installed packages. It is automatically generated and can be refreshed with `cargo bake agent:context:install`.".to_owned(),
72 String::new(),
73 "**Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.**".to_owned(),
74 String::new(),
75 "If these files are missing or dependencies have changed, run `cargo bake agent:context:install` to install them.".to_owned(),
76 String::new(),
77 ];
78
79 let packages = self.collect_context_packages()?;
80 if packages.is_empty() {
81 sections.push(
82 "No context files found. Run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
83 );
84 return Ok(sections.join("\n"));
85 }
86
87 for (package_name, files) in packages {
88 sections.push(format!("### {package_name}"));
89 sections.push(String::new());
90 sections.push(
91 self.package_descriptions
92 .get(&package_name)
93 .cloned()
94 .unwrap_or_else(|| format!("Context files for {package_name}")),
95 );
96 sections.push(String::new());
97
98 for document in files {
99 append_document(
100 &mut sections,
101 &self.context_link_path.join(&package_name),
102 &document.path,
103 &document.title,
104 document.description.as_deref(),
105 );
106 }
107 }
108
109 while sections.last().is_some_and(String::is_empty) {
110 sections.pop();
111 }
112 Ok(sections.join("\n"))
113 }
114
115 pub fn update_agents_md(&self, path: impl AsRef<Path>) -> Result<()> {
116 let path = path.as_ref();
117 let path = if path.is_absolute() {
118 path.to_path_buf()
119 } else {
120 self.root.join(path)
121 };
122 let context = self.generate_context_section()?;
123
124 let existing = match fs::read_to_string(&path) {
125 Ok(contents) => Some(contents),
126 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
127 Err(error) => {
128 return Err(Error::new(format!(
129 "cannot read {}: {error}",
130 path.display()
131 )));
132 }
133 };
134
135 let updated = match existing {
136 Some(contents) => update_existing(&contents, &context),
137 None => format!("# Agent\n\n## Context\n\n{context}\n"),
138 };
139
140 if let Some(parent) = path.parent() {
141 fs::create_dir_all(parent).map_err(|error| {
142 Error::new(format!("cannot create {}: {error}", parent.display()))
143 })?;
144 }
145 fs::write(&path, updated)
146 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
147 }
148
149 fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
150 let entries = match fs::read_dir(&self.context_path) {
151 Ok(entries) => entries,
152 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
153 Err(error) => {
154 return Err(Error::new(format!(
155 "cannot read {}: {error}",
156 self.context_path.display()
157 )));
158 }
159 };
160
161 let mut packages = Vec::new();
162 for entry in entries {
163 let entry = entry?;
164 let file_type = entry.file_type()?;
165 if !file_type.is_dir() {
166 continue;
167 }
168 let package_path = entry.path();
169 let mut files = Vec::new();
170 for path in markdown_files(&package_path)? {
171 let (title, description) = extract_content(&path)?;
172 let relative_path = path.strip_prefix(&package_path).map_err(|error| {
173 Error::new(format!("cannot make context path relative: {error}"))
174 })?;
175 files.push(ContextDocument {
176 path: relative_path.to_path_buf(),
177 title,
178 description,
179 });
180 }
181 files.sort_by_key(|document| canonical_order(&document.path));
182 if !files.is_empty() {
183 packages.push((entry.file_name().to_string_lossy().into_owned(), files));
184 }
185 }
186 packages.sort_by(|left, right| left.0.cmp(&right.0));
187 Ok(packages)
188 }
189}
190
191fn update_existing(contents: &str, context: &str) -> String {
192 let Ok(root) = to_mdast(contents, &ParseOptions::default()) else {
193 return contents.to_owned();
194 };
195 let Some(children) = root.children() else {
196 return contents.to_owned();
197 };
198 let headings: Vec<_> = children
199 .iter()
200 .enumerate()
201 .filter_map(|(index, node)| {
202 let Node::Heading(heading) = node else {
203 return None;
204 };
205 let position = node.position()?;
206 Some(SourceHeading {
207 index,
208 level: heading.depth,
209 title: heading_text(node),
210 start: position.start.offset,
211 body_start: after_heading_line(contents, position.end.offset),
212 })
213 })
214 .collect();
215 let Some(agent_heading) = headings
216 .iter()
217 .find(|heading| heading.level == 1 && heading.title.eq_ignore_ascii_case("agent"))
218 else {
219 return format!("# Agent\n\n## Context\n\n{context}\n\n{contents}");
220 };
221
222 let agent_end_index = headings
223 .iter()
224 .find(|heading| heading.index > agent_heading.index && heading.level <= 1)
225 .map_or(children.len(), |heading| heading.index);
226 let context_heading = headings.iter().find(|heading| {
227 heading.index > agent_heading.index
228 && heading.index < agent_end_index
229 && heading.level == 2
230 && heading.title.eq_ignore_ascii_case("context")
231 });
232
233 let newline = if contents.contains("\r\n") {
234 "\r\n"
235 } else {
236 "\n"
237 };
238 let context = context.replace("\r\n", "\n").replace('\n', newline);
239 let mut updated = String::with_capacity(contents.len() + context.len() + 32);
240
241 if let Some(context_heading) = context_heading {
242 let end = headings
243 .iter()
244 .find(|heading| heading.index > context_heading.index && heading.level <= 2)
245 .map_or(contents.len(), |heading| heading.start);
246 updated.push_str(&contents[..context_heading.start]);
247 updated.push_str("## Context");
248 updated.push_str(newline);
249 updated.push_str(newline);
250 updated.push_str(&context);
251 if end < contents.len() {
252 updated.push_str(newline);
253 updated.push_str(newline);
254 } else if contents.ends_with('\n') || !context.is_empty() {
255 updated.push_str(newline);
256 }
257 updated.push_str(&contents[end..]);
258 } else {
259 updated.push_str(&contents[..agent_heading.body_start]);
260 updated.push_str(newline);
261 updated.push_str("## Context");
262 updated.push_str(newline);
263 updated.push_str(newline);
264 updated.push_str(&context);
265 if agent_heading.body_start < contents.len() {
266 updated.push_str(newline);
267 updated.push_str(newline);
268 } else if contents.ends_with('\n') || !context.is_empty() {
269 updated.push_str(newline);
270 }
271 updated.push_str(&contents[agent_heading.body_start..]);
272 }
273
274 updated
275}
276
277fn after_heading_line(contents: &str, offset: usize) -> usize {
278 let Some(rest) = contents.get(offset..) else {
279 return offset;
280 };
281
282 if rest.starts_with("\r\n") {
283 offset + 2
284 } else if rest.starts_with('\r') || rest.starts_with('\n') {
285 offset + 1
286 } else {
287 offset
288 }
289}
290
291fn heading_text(node: &Node) -> String {
292 let text = node.text_content();
293 text.strip_suffix("\r\n")
294 .or_else(|| text.strip_suffix('\n'))
295 .or_else(|| text.strip_suffix('\r'))
296 .unwrap_or(text.as_str())
297 .to_owned()
298}
299
300fn append_document(
301 sections: &mut Vec<String>,
302 link_root: &Path,
303 relative_path: &Path,
304 title: &str,
305 description: Option<&str>,
306) {
307 let heading = Node::Heading(Heading {
308 children: vec![Node::Link(Link {
309 children: vec![Node::Text(Text {
310 value: title.to_owned(),
311 position: None,
312 })],
313 position: None,
314 url: markdown_link(&link_root.join(relative_path)),
315 title: None,
316 })],
317 position: None,
318 depth: 4,
319 });
320 sections.push(heading.to_markdown().trim_end().to_owned());
321 sections.push(String::new());
322 if let Some(description) = description.filter(|description| !description.is_empty()) {
323 let paragraph = Node::Paragraph(Paragraph {
324 children: vec![Node::Text(Text {
325 value: description.to_owned(),
326 position: None,
327 })],
328 position: None,
329 });
330 sections.push(paragraph.to_markdown().trim_end().to_owned());
331 sections.push(String::new());
332 }
333}
334
335fn markdown_link(path: &Path) -> String {
336 path_to_string(path)
337 .replace('%', "%25")
338 .replace(' ', "%20")
339 .replace('#', "%23")
340 .replace('?', "%3F")
341 .replace('(', "%28")
342 .replace(')', "%29")
343}
344
345fn canonical_order(path: &Path) -> (usize, String, String) {
346 const CANONICAL: &[&str] = &[
347 "getting-started",
348 "overview",
349 "usage",
350 "configuration",
351 "migration",
352 "troubleshooting",
353 "debugging",
354 ];
355 let name = path
356 .file_stem()
357 .and_then(|name| name.to_str())
358 .unwrap_or_default()
359 .to_ascii_lowercase();
360 let order = CANONICAL
361 .iter()
362 .position(|canonical| *canonical == name)
363 .unwrap_or(CANONICAL.len());
364 (order, name, path_to_string(path))
365}
366
367fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
368 let content = fs::read_to_string(path)
369 .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
370 let mut options = ParseOptions::default();
371 options.constructs.frontmatter = true;
372 let root = to_mdast(&content, &options)
373 .map_err(|error| Error::new(format!("could not parse {}: {error}", path.display())))?;
374 let children = root
375 .children()
376 .ok_or_else(|| Error::new(format!("{} is not a Markdown document", path.display())))?;
377 let title = children
378 .iter()
379 .find_map(|node| match node {
380 Node::Heading(_) => {
381 let title = heading_text(node);
382 (!title.trim().is_empty()).then_some(title)
383 }
384 _ => None,
385 })
386 .unwrap_or_else(|| {
387 path.file_stem()
388 .and_then(|stem| stem.to_str())
389 .unwrap_or("Documentation")
390 .replace('-', " ")
391 });
392
393 let first_paragraph = children.iter().find_map(|node| match node {
394 Node::Paragraph(_) => Some(node.text_content()),
395 _ => None,
396 });
397 let description = frontmatter_description(&root, path)?
398 .or_else(|| first_paragraph.as_deref().and_then(first_sentence));
399
400 Ok((title, description))
401}
402
403fn first_sentence(paragraph: &str) -> Option<String> {
404 let paragraph = paragraph.trim();
405 if paragraph.is_empty() {
406 return None;
407 }
408
409 for (index, character) in paragraph.char_indices() {
410 if matches!(character, '.' | '!' | '?')
411 && paragraph[index + character.len_utf8()..]
412 .chars()
413 .next()
414 .is_none_or(char::is_whitespace)
415 {
416 return Some(paragraph[..index + character.len_utf8()].to_owned());
417 }
418 }
419
420 Some(paragraph.to_owned())
421}
422
423fn path_to_string(path: &Path) -> String {
424 path.components()
425 .map(|component| component.as_os_str().to_string_lossy())
426 .collect::<Vec<_>>()
427 .join("/")
428}