bake_agent_context/agent/context/
index.rs1use super::installer::{ContextPackage, markdown_files};
5use bake::{Error, Result};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Clone, Debug)]
11struct ContextDocument {
12 path: PathBuf,
13 title: String,
14 description: Option<String>,
15}
16
17#[derive(Clone, Debug)]
19pub struct AgentIndex {
20 root: PathBuf,
21 context_path: PathBuf,
22 context_link_path: PathBuf,
23 package_descriptions: HashMap<String, String>,
24}
25
26impl AgentIndex {
27 pub fn new(root: impl Into<PathBuf>) -> Self {
28 let root = root.into();
29 Self {
30 context_path: root.join(".agents/context"),
31 root,
32 context_link_path: PathBuf::from(".agents/context"),
33 package_descriptions: HashMap::new(),
34 }
35 }
36
37 pub fn with_packages(mut self, packages: &[ContextPackage]) -> Self {
39 self.package_descriptions = packages
40 .iter()
41 .filter_map(|package| {
42 package
43 .description
44 .as_ref()
45 .map(|description| (package.selector().to_owned(), description.clone()))
46 })
47 .collect();
48 self
49 }
50
51 pub fn context_path(&self) -> &Path {
52 &self.context_path
53 }
54
55 pub fn generate_context_section(&self) -> Result<String> {
56 let mut sections = vec![
57 "This section links to documentation from installed packages. It is automatically generated and can be refreshed with `cargo bake agent:context:install`.".to_owned(),
58 String::new(),
59 "**Before working on a package, read the relevant context files below. They contain package-specific guidance and workflows.**".to_owned(),
60 String::new(),
61 "If these files are missing or dependencies have changed, run `cargo bake agent:context:install` to install them.".to_owned(),
62 String::new(),
63 ];
64
65 let packages = self.collect_context_packages()?;
66 if packages.is_empty() {
67 sections.push(
68 "No context files found. Run `cargo bake agent:context:install` to install context from dependencies.".to_owned(),
69 );
70 return Ok(sections.join("\n"));
71 }
72
73 for (package_name, files) in packages {
74 sections.push(format!("### {package_name}"));
75 sections.push(String::new());
76 sections.push(
77 self.package_descriptions
78 .get(&package_name)
79 .cloned()
80 .unwrap_or_else(|| format!("Context files for {package_name}")),
81 );
82 sections.push(String::new());
83
84 for document in files {
85 append_document(
86 &mut sections,
87 &self.context_link_path.join(&package_name),
88 &document.path,
89 &document.title,
90 document.description.as_deref(),
91 );
92 }
93 }
94
95 while sections.last().is_some_and(String::is_empty) {
96 sections.pop();
97 }
98 Ok(sections.join("\n"))
99 }
100
101 pub fn update_agents_md(&self, path: impl AsRef<Path>) -> Result<()> {
102 let path = path.as_ref();
103 let path = if path.is_absolute() {
104 path.to_path_buf()
105 } else {
106 self.root.join(path)
107 };
108 let context = self.generate_context_section()?;
109
110 let existing = match fs::read_to_string(&path) {
111 Ok(contents) => Some(contents),
112 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
113 Err(error) => {
114 return Err(Error::new(format!(
115 "cannot read {}: {error}",
116 path.display()
117 )));
118 }
119 };
120
121 let updated = match existing {
122 Some(contents) => update_existing(&contents, &context),
123 None => format!("# Agent\n\n## Context\n\n{context}\n"),
124 };
125
126 if let Some(parent) = path.parent() {
127 fs::create_dir_all(parent).map_err(|error| {
128 Error::new(format!("cannot create {}: {error}", parent.display()))
129 })?;
130 }
131 fs::write(&path, updated)
132 .map_err(|error| Error::new(format!("cannot write {}: {error}", path.display())))
133 }
134
135 fn collect_context_packages(&self) -> Result<Vec<(String, Vec<ContextDocument>)>> {
136 let entries = match fs::read_dir(&self.context_path) {
137 Ok(entries) => entries,
138 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
139 Err(error) => {
140 return Err(Error::new(format!(
141 "cannot read {}: {error}",
142 self.context_path.display()
143 )));
144 }
145 };
146
147 let mut packages = Vec::new();
148 for entry in entries {
149 let entry = entry?;
150 let file_type = entry.file_type()?;
151 if !file_type.is_dir() {
152 continue;
153 }
154 let package_path = entry.path();
155 let mut files = Vec::new();
156 for path in markdown_files(&package_path)? {
157 let (title, description) = extract_content(&path)?;
158 let relative_path = path.strip_prefix(&package_path).map_err(|error| {
159 Error::new(format!("cannot make context path relative: {error}"))
160 })?;
161 files.push(ContextDocument {
162 path: relative_path.to_path_buf(),
163 title,
164 description,
165 });
166 }
167 files.sort_by_key(|document| canonical_order(&document.path));
168 if !files.is_empty() {
169 packages.push((entry.file_name().to_string_lossy().into_owned(), files));
170 }
171 }
172 packages.sort_by(|left, right| left.0.cmp(&right.0));
173 Ok(packages)
174 }
175}
176
177fn update_existing(contents: &str, context: &str) -> String {
178 let mut lines: Vec<String> = contents.lines().map(str::to_owned).collect();
179 let had_trailing_newline = contents.ends_with('\n');
180 let agent_heading = lines
181 .iter()
182 .position(|line| line.trim().eq_ignore_ascii_case("# agent"));
183
184 let Some(agent_heading) = agent_heading else {
185 return format!("# Agent\n\n## Context\n\n{context}\n\n{contents}");
186 };
187
188 let mut context_heading = None;
189 for (index, line) in lines.iter().enumerate().skip(agent_heading + 1) {
190 let trimmed = line.trim();
191 if heading_level(trimmed) == Some(1) {
192 break;
193 }
194 if trimmed.eq_ignore_ascii_case("## context") {
195 context_heading = Some(index);
196 break;
197 }
198 }
199
200 let replacement: Vec<String> = std::iter::once("## Context".to_owned())
201 .chain(std::iter::once(String::new()))
202 .chain(context.lines().map(str::to_owned))
203 .collect();
204
205 if let Some(context_heading) = context_heading {
206 let end = (context_heading + 1..lines.len())
207 .find(|index| heading_level(lines[*index].trim()).is_some_and(|level| level <= 2))
208 .unwrap_or(lines.len());
209 lines.splice(context_heading..end, replacement);
210 } else {
211 lines.splice(
212 agent_heading + 1..agent_heading + 1,
213 std::iter::once(String::new()).chain(replacement),
214 );
215 }
216
217 let mut updated = lines.join("\n");
218 if had_trailing_newline || !updated.is_empty() {
219 updated.push('\n');
220 }
221 updated
222}
223
224fn heading_level(line: &str) -> Option<usize> {
225 let hashes = line
226 .chars()
227 .take_while(|character| *character == '#')
228 .count();
229 if (1..=6).contains(&hashes) && line.chars().nth(hashes).is_some_and(char::is_whitespace) {
230 Some(hashes)
231 } else {
232 None
233 }
234}
235
236fn append_document(
237 sections: &mut Vec<String>,
238 link_root: &Path,
239 relative_path: &Path,
240 title: &str,
241 description: Option<&str>,
242) {
243 sections.push(format!(
244 "#### [{}]({})",
245 title.replace(']', "\\]"),
246 markdown_link(&link_root.join(relative_path))
247 ));
248 sections.push(String::new());
249 if let Some(description) = description.filter(|description| !description.is_empty()) {
250 sections.push(description.to_owned());
251 sections.push(String::new());
252 }
253}
254
255fn markdown_link(path: &Path) -> String {
256 path_to_string(path)
257 .replace('%', "%25")
258 .replace(' ', "%20")
259 .replace('#', "%23")
260 .replace('?', "%3F")
261 .replace('(', "%28")
262 .replace(')', "%29")
263}
264
265fn canonical_order(path: &Path) -> (usize, String, String) {
266 const CANONICAL: &[&str] = &[
267 "getting-started",
268 "overview",
269 "usage",
270 "configuration",
271 "migration",
272 "troubleshooting",
273 "debugging",
274 ];
275 let name = path
276 .file_stem()
277 .and_then(|name| name.to_str())
278 .unwrap_or_default()
279 .to_ascii_lowercase();
280 let order = CANONICAL
281 .iter()
282 .position(|canonical| *canonical == name)
283 .unwrap_or(CANONICAL.len());
284 (order, name, path_to_string(path))
285}
286
287fn extract_content(path: &Path) -> Result<(String, Option<String>)> {
288 let content = fs::read_to_string(path)
289 .map_err(|error| Error::new(format!("cannot read {}: {error}", path.display())))?;
290 let lines: Vec<_> = content.lines().map(str::trim).collect();
291 let title = lines
292 .iter()
293 .find_map(|line| {
294 heading_level(line).map(|_| line.trim_start_matches('#').trim().to_owned())
295 })
296 .filter(|title| !title.is_empty())
297 .unwrap_or_else(|| {
298 path.file_stem()
299 .and_then(|stem| stem.to_str())
300 .unwrap_or("Documentation")
301 .replace('-', " ")
302 });
303
304 let first_paragraph = lines
305 .iter()
306 .copied()
307 .filter(|line| heading_level(line).is_none())
308 .skip_while(|line| line.is_empty())
309 .take_while(|line| !line.is_empty())
310 .collect::<Vec<_>>()
311 .join(" ");
312
313 let description = first_sentence(&first_paragraph);
314
315 Ok((title, description))
316}
317
318fn first_sentence(paragraph: &str) -> Option<String> {
319 let paragraph = paragraph.trim();
320 if paragraph.is_empty() {
321 return None;
322 }
323
324 for (index, character) in paragraph.char_indices() {
325 if matches!(character, '.' | '!' | '?')
326 && paragraph[index + character.len_utf8()..]
327 .chars()
328 .next()
329 .is_none_or(char::is_whitespace)
330 {
331 return Some(paragraph[..index + character.len_utf8()].to_owned());
332 }
333 }
334
335 Some(paragraph.to_owned())
336}
337
338fn path_to_string(path: &Path) -> String {
339 path.components()
340 .map(|component| component.as_os_str().to_string_lossy())
341 .collect::<Vec<_>>()
342 .join("/")
343}