An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat(context): add content line counts to AGENTS.md heading extraction

Replace extract_headings with extract_heading_summaries that returns Vec<(String, usize)>
with line counts for each top-level heading. Update resolve_agents_md to format summaries
as 'Heading (N lines), Heading2 (M lines)' format.

Verifies v2-phase5.AC1.1

+69 -17
+69 -17
src/context/agents_md.rs
··· 46 46 let agents_md_path = dir.join("AGENTS.md"); 47 47 if agents_md_path.exists() && !seen_paths.contains(&agents_md_path) { 48 48 seen_paths.insert(agents_md_path.clone()); 49 - let headings = extract_headings(&agents_md_path)?; 50 - let heading_summary = headings.join(", "); 49 + let heading_summaries = extract_heading_summaries(&agents_md_path)?; 50 + let heading_summary = heading_summaries 51 + .iter() 52 + .map(|(heading, count)| format!("{} ({} lines)", heading, count)) 53 + .collect::<Vec<_>>() 54 + .join(", "); 51 55 let path_str = agents_md_path.to_string_lossy().to_string(); 52 56 summaries.push((path_str, heading_summary)); 53 57 } ··· 57 61 Ok(summaries) 58 62 } 59 63 60 - /// Extract top-level headings (lines starting with "# ") from a markdown file 61 - fn extract_headings(path: &Path) -> Result<Vec<String>> { 64 + /// Extract top-level headings with content line counts from a markdown file 65 + /// 66 + /// Returns a vector of (heading_text, line_count) tuples, where line_count is the number 67 + /// of non-empty content lines under that heading (until the next heading or EOF). 68 + fn extract_heading_summaries(path: &Path) -> Result<Vec<(String, usize)>> { 62 69 let content = std::fs::read_to_string(path)?; 63 - let mut headings = Vec::new(); 70 + let lines: Vec<&str> = content.lines().collect(); 71 + let mut summaries = Vec::new(); 72 + 73 + let mut i = 0; 74 + while i < lines.len() { 75 + let line = lines[i]; 64 76 65 - for line in content.lines() { 77 + // Check if this is a top-level heading (# followed by space) 66 78 if let Some(heading) = line.strip_prefix("# ") { 67 - headings.push(heading.trim().to_string()); 79 + let heading_text = heading.trim().to_string(); 80 + 81 + // Count non-empty lines until the next top-level heading or EOF 82 + let mut line_count = 0; 83 + let mut j = i + 1; 84 + while j < lines.len() { 85 + let next_line = lines[j]; 86 + // Stop at the next top-level heading 87 + if next_line.starts_with("# ") { 88 + break; 89 + } 90 + // Count non-empty lines 91 + if !next_line.trim().is_empty() { 92 + line_count += 1; 93 + } 94 + j += 1; 95 + } 96 + 97 + summaries.push((heading_text, line_count)); 68 98 } 99 + 100 + i += 1; 69 101 } 70 102 71 - Ok(headings) 103 + Ok(summaries) 72 104 } 73 105 74 106 #[cfg(test)] ··· 78 110 use tempfile::TempDir; 79 111 80 112 #[test] 81 - fn test_extract_headings() -> Result<()> { 113 + fn test_extract_heading_summaries() -> Result<()> { 114 + let tmpdir = TempDir::new()?; 115 + let agents_md_path = tmpdir.path().join("AGENTS.md"); 116 + fs::write( 117 + &agents_md_path, 118 + "# Introduction\nSome content here\nMore content\n# Getting Started\n\n## Subsection\ndetails\n# Advanced", 119 + )?; 120 + 121 + let summaries = extract_heading_summaries(&agents_md_path)?; 122 + assert_eq!(summaries.len(), 3); 123 + assert_eq!(summaries[0].0, "Introduction"); 124 + assert_eq!(summaries[0].1, 2); // "Some content here" and "More content" 125 + assert_eq!(summaries[1].0, "Getting Started"); 126 + assert_eq!(summaries[1].1, 2); // "## Subsection" and "details" (both non-empty content lines) 127 + assert_eq!(summaries[2].0, "Advanced"); 128 + assert_eq!(summaries[2].1, 0); // no content after 129 + Ok(()) 130 + } 131 + 132 + #[test] 133 + fn test_extract_heading_summaries_empty_sections() -> Result<()> { 82 134 let tmpdir = TempDir::new()?; 83 135 let agents_md_path = tmpdir.path().join("AGENTS.md"); 84 136 fs::write( 85 137 &agents_md_path, 86 - "# Introduction\n# Getting Started\n## Subsection\n# Advanced", 138 + "# First\n\n\n# Second\nContent\n# Third\n", 87 139 )?; 88 140 89 - let headings = extract_headings(&agents_md_path)?; 90 - assert_eq!( 91 - headings, 92 - vec!["Introduction", "Getting Started", "Advanced"] 93 - ); 141 + let summaries = extract_heading_summaries(&agents_md_path)?; 142 + assert_eq!(summaries.len(), 3); 143 + assert_eq!(summaries[0].1, 0); // First has no content 144 + assert_eq!(summaries[1].1, 1); // Second has one line 145 + assert_eq!(summaries[2].1, 0); // Third has no content 94 146 Ok(()) 95 147 } 96 148 ··· 100 152 let project_root = tmpdir.path(); 101 153 102 154 // Create AGENTS.md at root 103 - fs::write(project_root.join("AGENTS.md"), "# Root\n# Guidelines")?; 155 + fs::write(project_root.join("AGENTS.md"), "# Root\nroot content\n# Guidelines")?; 104 156 105 157 // Create a file to scope 106 158 fs::write(project_root.join("main.rs"), "fn main() {}")?; 107 159 108 160 let summaries = resolve_agents_md(project_root, &[PathBuf::from("main.rs")])?; 109 161 assert_eq!(summaries.len(), 1); 110 - assert_eq!(summaries[0].1, "Root, Guidelines"); 162 + assert_eq!(summaries[0].1, "Root (1 lines), Guidelines (0 lines)"); 111 163 Ok(()) 112 164 } 113 165