aster/context/
agents_md_parser.rs1use crate::context::types::{AgentsMdConfig, ContextError};
23use regex::Regex;
24use std::path::{Path, PathBuf};
25use tokio::fs;
26
27const AGENTS_MD_FILENAMES: &[&str] = &["AGENTS.md", "agents.md", "AGENT.md", "agent.md"];
29
30const AGENTS_MD_SUBDIRS: &[&str] = &[".kiro", ".claude", ".github"];
32
33pub struct AgentsMdParser;
39
40impl AgentsMdParser {
41 pub async fn parse(cwd: &Path) -> Result<Option<AgentsMdConfig>, ContextError> {
61 let possible_paths = Self::get_possible_paths(cwd);
62
63 for path in possible_paths {
64 if path.exists() && path.is_file() {
65 match fs::read_to_string(&path).await {
66 Ok(content) => {
67 let files = Self::extract_file_references(&content, cwd);
68 return Ok(Some(AgentsMdConfig::new(content, files)));
69 }
70 Err(e) => {
71 tracing::warn!("Failed to read AGENTS.md at {}: {}", path.display(), e);
72 }
74 }
75 }
76 }
77
78 Ok(None)
79 }
80
81 pub fn parse_sync(cwd: &Path) -> Result<Option<AgentsMdConfig>, ContextError> {
93 let possible_paths = Self::get_possible_paths(cwd);
94
95 for path in possible_paths {
96 if path.exists() && path.is_file() {
97 match std::fs::read_to_string(&path) {
98 Ok(content) => {
99 let files = Self::extract_file_references(&content, cwd);
100 return Ok(Some(AgentsMdConfig::new(content, files)));
101 }
102 Err(e) => {
103 tracing::warn!("Failed to read AGENTS.md at {}: {}", path.display(), e);
104 }
105 }
106 }
107 }
108
109 Ok(None)
110 }
111
112 pub fn get_possible_paths(cwd: &Path) -> Vec<PathBuf> {
126 let mut paths = Vec::new();
127
128 for filename in AGENTS_MD_FILENAMES {
130 paths.push(cwd.join(filename));
131 }
132
133 for subdir in AGENTS_MD_SUBDIRS {
135 for filename in AGENTS_MD_FILENAMES {
136 paths.push(cwd.join(subdir).join(filename));
137 }
138 }
139
140 paths
141 }
142
143 pub fn extract_file_references(text: &str, cwd: &Path) -> Vec<PathBuf> {
159 let mut files = Vec::new();
160
161 let link_pattern = Regex::new(r"\[([^\]]*)\]\(([^)]+)\)").unwrap();
164 for cap in link_pattern.captures_iter(text) {
165 if let Some(path_match) = cap.get(2) {
166 let path_str = path_match.as_str();
167 if !path_str.starts_with("http")
169 && !path_str.starts_with('#')
170 && !path_str.starts_with("mailto:")
171 {
172 let path = Self::resolve_path(path_str, cwd);
173 if path.exists() && !files.contains(&path) {
174 files.push(path);
175 }
176 }
177 }
178 }
179
180 let code_block_pattern = Regex::new(r"```\w+:([^\s`]+)").unwrap();
182 for cap in code_block_pattern.captures_iter(text) {
183 if let Some(path_match) = cap.get(1) {
184 let path = Self::resolve_path(path_match.as_str(), cwd);
185 if path.exists() && !files.contains(&path) {
186 files.push(path);
187 }
188 }
189 }
190
191 let inline_code_pattern = Regex::new(r"`([^`]+\.[a-zA-Z0-9]+)`").unwrap();
194 for cap in inline_code_pattern.captures_iter(text) {
195 if let Some(path_match) = cap.get(1) {
196 let path_str = path_match.as_str();
197 if !path_str.contains(' ')
199 && !path_str.starts_with('-')
200 && !path_str.starts_with('$')
201 {
202 let path = Self::resolve_path(path_str, cwd);
203 if path.exists() && !files.contains(&path) {
204 files.push(path);
205 }
206 }
207 }
208 }
209
210 files
211 }
212
213 fn resolve_path(path_str: &str, cwd: &Path) -> PathBuf {
217 let path = Path::new(path_str);
218 if path.is_absolute() {
219 path.to_path_buf()
220 } else {
221 cwd.join(path)
222 }
223 }
224
225 pub async fn inject_to_system_prompt(
250 system_prompt: &str,
251 cwd: &Path,
252 ) -> Result<String, ContextError> {
253 match Self::parse(cwd).await? {
254 Some(config) => {
255 let injected = format!(
256 "{}\n\n## Project Instructions (from AGENTS.md)\n\n{}",
257 system_prompt, config.content
258 );
259 Ok(injected)
260 }
261 None => Ok(system_prompt.to_string()),
262 }
263 }
264
265 pub fn inject_to_system_prompt_sync(
269 system_prompt: &str,
270 cwd: &Path,
271 ) -> Result<String, ContextError> {
272 match Self::parse_sync(cwd)? {
273 Some(config) => {
274 let injected = format!(
275 "{}\n\n## Project Instructions (from AGENTS.md)\n\n{}",
276 system_prompt, config.content
277 );
278 Ok(injected)
279 }
280 None => Ok(system_prompt.to_string()),
281 }
282 }
283
284 pub fn exists(cwd: &Path) -> bool {
294 Self::get_possible_paths(cwd)
295 .iter()
296 .any(|p| p.exists() && p.is_file())
297 }
298
299 pub fn find_path(cwd: &Path) -> Option<PathBuf> {
309 Self::get_possible_paths(cwd)
310 .into_iter()
311 .find(|p| p.exists() && p.is_file())
312 }
313}
314
315#[cfg(test)]
320mod tests {
321 use super::*;
322 use std::fs;
323 use tempfile::TempDir;
324
325 #[test]
326 fn test_get_possible_paths() {
327 let cwd = Path::new("/test/project");
328 let paths = AgentsMdParser::get_possible_paths(cwd);
329
330 assert!(paths.contains(&PathBuf::from("/test/project/AGENTS.md")));
332 assert!(paths.contains(&PathBuf::from("/test/project/agents.md")));
333
334 assert!(paths.contains(&PathBuf::from("/test/project/.kiro/AGENTS.md")));
336
337 assert!(paths.contains(&PathBuf::from("/test/project/.claude/AGENTS.md")));
339
340 assert!(paths.contains(&PathBuf::from("/test/project/.github/AGENTS.md")));
342 }
343
344 #[test]
345 fn test_extract_file_references_markdown_links() {
346 let temp_dir = TempDir::new().unwrap();
347 let file_path = temp_dir.path().join("src/main.rs");
348 fs::create_dir_all(temp_dir.path().join("src")).unwrap();
349 fs::write(&file_path, "fn main() {}").unwrap();
350
351 let content = "Check [main file](src/main.rs) for details";
352 let files = AgentsMdParser::extract_file_references(content, temp_dir.path());
353
354 assert_eq!(files.len(), 1);
355 assert_eq!(files[0], file_path);
356 }
357
358 #[test]
359 fn test_extract_file_references_ignores_urls() {
360 let temp_dir = TempDir::new().unwrap();
361 let content = "See [docs](https://example.com) and [anchor](#section)";
362 let files = AgentsMdParser::extract_file_references(content, temp_dir.path());
363
364 assert!(files.is_empty());
365 }
366
367 #[test]
368 fn test_extract_file_references_inline_code() {
369 let temp_dir = TempDir::new().unwrap();
370 let file_path = temp_dir.path().join("config.json");
371 fs::write(&file_path, "{}").unwrap();
372
373 let content = "Edit `config.json` to configure";
374 let files = AgentsMdParser::extract_file_references(content, temp_dir.path());
375
376 assert_eq!(files.len(), 1);
377 assert_eq!(files[0], file_path);
378 }
379
380 #[test]
381 fn test_extract_file_references_no_duplicates() {
382 let temp_dir = TempDir::new().unwrap();
383 let file_path = temp_dir.path().join("main.rs");
384 fs::write(&file_path, "fn main() {}").unwrap();
385
386 let content = "See [main](main.rs) and also `main.rs` for details";
387 let files = AgentsMdParser::extract_file_references(content, temp_dir.path());
388
389 assert_eq!(files.len(), 1);
390 }
391
392 #[test]
393 fn test_extract_file_references_nonexistent_files() {
394 let temp_dir = TempDir::new().unwrap();
395 let content = "See [missing](nonexistent.rs) file";
396 let files = AgentsMdParser::extract_file_references(content, temp_dir.path());
397
398 assert!(files.is_empty());
399 }
400
401 #[tokio::test]
402 async fn test_parse_root_agents_md() {
403 let temp_dir = TempDir::new().unwrap();
404 let agents_path = temp_dir.path().join("AGENTS.md");
405 let content = "# Project Instructions\n\nBuild with `cargo build`";
406 fs::write(&agents_path, content).unwrap();
407
408 let result = AgentsMdParser::parse(temp_dir.path()).await.unwrap();
409
410 assert!(result.is_some());
411 let config = result.unwrap();
412 assert_eq!(config.content, content);
413 }
414
415 #[tokio::test]
416 async fn test_parse_kiro_agents_md() {
417 let temp_dir = TempDir::new().unwrap();
418 let kiro_dir = temp_dir.path().join(".kiro");
419 fs::create_dir(&kiro_dir).unwrap();
420 let agents_path = kiro_dir.join("AGENTS.md");
421 let content = "# Kiro Instructions";
422 fs::write(&agents_path, content).unwrap();
423
424 let result = AgentsMdParser::parse(temp_dir.path()).await.unwrap();
425
426 assert!(result.is_some());
427 let config = result.unwrap();
428 assert_eq!(config.content, content);
429 }
430
431 #[tokio::test]
432 async fn test_parse_prefers_root_over_subdir() {
433 let temp_dir = TempDir::new().unwrap();
434
435 let root_agents = temp_dir.path().join("AGENTS.md");
437 fs::write(&root_agents, "Root instructions").unwrap();
438
439 let kiro_dir = temp_dir.path().join(".kiro");
441 fs::create_dir(&kiro_dir).unwrap();
442 let kiro_agents = kiro_dir.join("AGENTS.md");
443 fs::write(&kiro_agents, "Kiro instructions").unwrap();
444
445 let result = AgentsMdParser::parse(temp_dir.path()).await.unwrap();
446
447 assert!(result.is_some());
448 let config = result.unwrap();
449 assert_eq!(config.content, "Root instructions");
451 }
452
453 #[tokio::test]
454 async fn test_parse_not_found() {
455 let temp_dir = TempDir::new().unwrap();
456 let result = AgentsMdParser::parse(temp_dir.path()).await.unwrap();
457
458 assert!(result.is_none());
459 }
460
461 #[tokio::test]
462 async fn test_parse_with_file_references() {
463 let temp_dir = TempDir::new().unwrap();
464
465 let src_dir = temp_dir.path().join("src");
467 fs::create_dir(&src_dir).unwrap();
468 let main_rs = src_dir.join("main.rs");
469 fs::write(&main_rs, "fn main() {}").unwrap();
470
471 let agents_path = temp_dir.path().join("AGENTS.md");
473 let content = "# Instructions\n\nSee [main](src/main.rs) for entry point";
474 fs::write(&agents_path, content).unwrap();
475
476 let result = AgentsMdParser::parse(temp_dir.path()).await.unwrap();
477
478 assert!(result.is_some());
479 let config = result.unwrap();
480 assert_eq!(config.files.len(), 1);
481 assert_eq!(config.files[0], main_rs);
482 }
483
484 #[test]
485 fn test_parse_sync() {
486 let temp_dir = TempDir::new().unwrap();
487 let agents_path = temp_dir.path().join("AGENTS.md");
488 let content = "# Sync Test";
489 fs::write(&agents_path, content).unwrap();
490
491 let result = AgentsMdParser::parse_sync(temp_dir.path()).unwrap();
492
493 assert!(result.is_some());
494 assert_eq!(result.unwrap().content, content);
495 }
496
497 #[tokio::test]
498 async fn test_inject_to_system_prompt_with_agents() {
499 let temp_dir = TempDir::new().unwrap();
500 let agents_path = temp_dir.path().join("AGENTS.md");
501 let agents_content = "Build with cargo";
502 fs::write(&agents_path, agents_content).unwrap();
503
504 let system_prompt = "You are a helpful assistant.";
505 let result = AgentsMdParser::inject_to_system_prompt(system_prompt, temp_dir.path())
506 .await
507 .unwrap();
508
509 assert!(result.contains(system_prompt));
510 assert!(result.contains(agents_content));
511 assert!(result.contains("Project Instructions"));
512 }
513
514 #[tokio::test]
515 async fn test_inject_to_system_prompt_without_agents() {
516 let temp_dir = TempDir::new().unwrap();
517 let system_prompt = "You are a helpful assistant.";
518
519 let result = AgentsMdParser::inject_to_system_prompt(system_prompt, temp_dir.path())
520 .await
521 .unwrap();
522
523 assert_eq!(result, system_prompt);
524 }
525
526 #[test]
527 fn test_inject_to_system_prompt_sync() {
528 let temp_dir = TempDir::new().unwrap();
529 let agents_path = temp_dir.path().join("AGENTS.md");
530 fs::write(&agents_path, "Sync instructions").unwrap();
531
532 let system_prompt = "Base prompt";
533 let result =
534 AgentsMdParser::inject_to_system_prompt_sync(system_prompt, temp_dir.path()).unwrap();
535
536 assert!(result.contains(system_prompt));
537 assert!(result.contains("Sync instructions"));
538 }
539
540 #[test]
541 fn test_exists() {
542 let temp_dir = TempDir::new().unwrap();
543
544 assert!(!AgentsMdParser::exists(temp_dir.path()));
546
547 let agents_path = temp_dir.path().join("AGENTS.md");
549 fs::write(&agents_path, "test").unwrap();
550
551 assert!(AgentsMdParser::exists(temp_dir.path()));
553 }
554
555 #[test]
556 fn test_find_path() {
557 let temp_dir = TempDir::new().unwrap();
558
559 assert!(AgentsMdParser::find_path(temp_dir.path()).is_none());
561
562 let agents_path = temp_dir.path().join("AGENTS.md");
564 fs::write(&agents_path, "test").unwrap();
565
566 let found = AgentsMdParser::find_path(temp_dir.path());
568 assert!(found.is_some());
569 assert_eq!(found.unwrap(), agents_path);
570 }
571
572 #[test]
573 fn test_lowercase_agents_md() {
574 let temp_dir = TempDir::new().unwrap();
575 let agents_path = temp_dir.path().join("agents.md");
576 fs::write(&agents_path, "lowercase").unwrap();
577
578 let result = AgentsMdParser::parse_sync(temp_dir.path()).unwrap();
579
580 assert!(result.is_some());
581 assert_eq!(result.unwrap().content, "lowercase");
582 }
583
584 #[test]
585 fn test_claude_subdir() {
586 let temp_dir = TempDir::new().unwrap();
587 let claude_dir = temp_dir.path().join(".claude");
588 fs::create_dir(&claude_dir).unwrap();
589 let agents_path = claude_dir.join("AGENTS.md");
590 fs::write(&agents_path, "claude instructions").unwrap();
591
592 let result = AgentsMdParser::parse_sync(temp_dir.path()).unwrap();
593
594 assert!(result.is_some());
595 assert_eq!(result.unwrap().content, "claude instructions");
596 }
597}