1use anyhow::Result;
7use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use serde::{Deserialize, Serialize};
10use std::cmp::Reverse;
11use std::path::{Path, PathBuf};
12
13use super::common::{current_repo_root, parameters_schema};
14
15crate::define_tool_error!(DocsError);
17
18const MAX_DOC_CHARS: usize = 20_000;
19const MAX_CONTEXT_TOTAL_CHARS: usize = 8_000;
20const MAX_CONTEXT_HEADINGS: usize = 6;
21const MAX_CONTEXT_HIGHLIGHTS: usize = 3;
22const CONTEXT_SUMMARY_CHAR_LIMIT: usize = 360;
23const CONTEXT_HIGHLIGHT_CHAR_LIMIT: usize = 420;
24
25const GENERIC_CONTEXT_KEYWORDS: &[&str] = &[
26 "overview",
27 "summary",
28 "usage",
29 "workflow",
30 "development",
31 "testing",
32 "command",
33 "config",
34 "architecture",
35 "convention",
36 "release",
37];
38
39const README_CONTEXT_KEYWORDS: &[&str] = &[
40 "feature",
41 "getting started",
42 "install",
43 "quick start",
44 "setup",
45];
46
47const AGENT_CONTEXT_KEYWORDS: &[&str] = &[
48 "project",
49 "provider",
50 "tool",
51 "instruction",
52 "style",
53 "git hygiene",
54];
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ProjectDocs;
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum ContextDocKind {
62 Readme,
63 Agents,
64}
65
66#[derive(Debug, Clone)]
67struct MarkdownSection {
68 heading: String,
69 body: String,
70 position: usize,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
75#[serde(rename_all = "lowercase")]
76pub enum DocType {
77 #[default]
79 Readme,
80 Contributing,
82 Changelog,
84 License,
86 CodeOfConduct,
88 Agents,
90 Context,
92 All,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
97pub struct ProjectDocsArgs {
98 #[serde(default)]
100 pub doc_type: DocType,
101 #[serde(default = "default_max_chars")]
104 pub max_chars: usize,
105}
106
107fn default_max_chars() -> usize {
108 MAX_DOC_CHARS
109}
110
111fn append_doc(
112 output: &mut String,
113 filename: &str,
114 content: &str,
115 max_chars: usize,
116 truncated_hint: Option<&str>,
117) {
118 output.push_str(&format!("=== {} ===\n", filename));
119
120 let char_count = content.chars().count();
121 if char_count > max_chars {
122 let truncated: String = content.chars().take(max_chars).collect();
123 output.push_str(&truncated);
124
125 if let Some(hint) = truncated_hint {
126 output.push_str(&format!(
127 "\n\n[... context snapshot truncated after {} chars; {} ...]\n",
128 max_chars, hint
129 ));
130 } else {
131 output.push_str(&format!(
132 "\n\n[... truncated, {} more chars ...]\n",
133 char_count - max_chars
134 ));
135 }
136 } else {
137 output.push_str(content);
138 }
139
140 output.push_str("\n\n");
141}
142
143fn readme_candidates() -> &'static [&'static str] {
144 &[
145 "README.md",
146 "README.rst",
147 "README.txt",
148 "README",
149 "readme.md",
150 ]
151}
152
153fn agent_doc_candidates() -> &'static [&'static str] {
154 &[
155 "AGENTS.md",
156 "CLAUDE.md",
157 ".github/copilot-instructions.md",
158 ".cursor/rules",
159 "CODING_GUIDELINES.md",
160 ]
161}
162
163fn find_first_existing_file(repo_root: &Path, candidates: &[&str]) -> Option<PathBuf> {
164 candidates
165 .iter()
166 .map(|candidate| repo_root.join(candidate))
167 .find(|path| path.exists())
168}
169
170fn is_markdown_heading(line: &str) -> bool {
171 let hashes = line.chars().take_while(|&ch| ch == '#').count();
172 hashes > 0 && hashes <= 6 && line.chars().nth(hashes) == Some(' ')
173}
174
175fn heading_title(heading: &str) -> &str {
176 heading.trim_start_matches('#').trim()
177}
178
179fn is_list_item(line: &str) -> bool {
180 line.starts_with("- ")
181 || line.starts_with("* ")
182 || line.starts_with("+ ")
183 || line
184 .chars()
185 .next()
186 .is_some_and(|ch| ch.is_ascii_digit() && line.contains(". "))
187}
188
189fn truncate_chars(text: &str, max_chars: usize) -> String {
190 let char_count = text.chars().count();
191 if char_count <= max_chars {
192 return text.to_string();
193 }
194
195 let truncated: String = text.chars().take(max_chars).collect();
196 format!("{truncated}...")
197}
198
199fn compact_excerpt(text: &str, max_chars: usize) -> String {
200 let mut output = String::new();
201 let mut in_code_block = false;
202 let mut previous_was_blank = false;
203
204 for line in text.lines() {
205 let trimmed = line.trim();
206
207 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
208 in_code_block = !in_code_block;
209 continue;
210 }
211
212 if in_code_block {
213 continue;
214 }
215
216 if trimmed.is_empty() {
217 if !output.is_empty() && !previous_was_blank {
218 output.push_str("\n\n");
219 }
220 previous_was_blank = true;
221 continue;
222 }
223
224 if output.chars().count() >= max_chars {
225 break;
226 }
227
228 if is_list_item(trimmed) {
229 if !output.is_empty() && !output.ends_with('\n') {
230 output.push('\n');
231 }
232 output.push_str(trimmed);
233 output.push('\n');
234 } else {
235 if !output.is_empty() && !output.ends_with('\n') && !output.ends_with(' ') {
236 output.push(' ');
237 }
238 output.push_str(trimmed);
239 }
240
241 previous_was_blank = false;
242 }
243
244 truncate_chars(output.trim(), max_chars)
245}
246
247fn parse_markdown_sections(content: &str) -> (String, Vec<MarkdownSection>) {
248 let mut intro_lines = Vec::new();
249 let mut sections = Vec::new();
250 let mut current_heading: Option<String> = None;
251 let mut current_lines = Vec::new();
252 let mut in_code_block = false;
253
254 for line in content.lines() {
255 let trimmed = line.trim_end();
256 let simplified = trimmed.trim();
257
258 if simplified.starts_with("```") || simplified.starts_with("~~~") {
259 in_code_block = !in_code_block;
260 continue;
261 }
262
263 if in_code_block {
264 continue;
265 }
266
267 if is_markdown_heading(simplified) {
268 if let Some(heading) = current_heading.take() {
269 sections.push(MarkdownSection {
270 heading,
271 body: current_lines.join("\n"),
272 position: sections.len(),
273 });
274 current_lines.clear();
275 }
276
277 current_heading = Some(simplified.to_string());
278 continue;
279 }
280
281 if current_heading.is_some() {
282 current_lines.push(trimmed.to_string());
283 } else {
284 intro_lines.push(trimmed.to_string());
285 }
286 }
287
288 if let Some(heading) = current_heading {
289 sections.push(MarkdownSection {
290 heading,
291 body: current_lines.join("\n"),
292 position: sections.len(),
293 });
294 }
295
296 (intro_lines.join("\n"), sections)
297}
298
299fn score_context_section(section: &MarkdownSection, kind: ContextDocKind) -> usize {
300 let lower_heading = heading_title(§ion.heading).to_ascii_lowercase();
301 let mut score = 100usize.saturating_sub(section.position * 7);
302
303 for keyword in GENERIC_CONTEXT_KEYWORDS {
304 if lower_heading.contains(keyword) {
305 score += 25;
306 }
307 }
308
309 let extra_keywords = match kind {
310 ContextDocKind::Readme => README_CONTEXT_KEYWORDS,
311 ContextDocKind::Agents => AGENT_CONTEXT_KEYWORDS,
312 };
313
314 for keyword in extra_keywords {
315 if lower_heading.contains(keyword) {
316 score += 35;
317 }
318 }
319
320 score
321}
322
323fn select_context_sections(
324 sections: &[MarkdownSection],
325 kind: ContextDocKind,
326) -> Vec<&MarkdownSection> {
327 let mut ranked = sections.iter().collect::<Vec<_>>();
328 ranked.sort_by_key(|section| {
329 (
330 Reverse(score_context_section(section, kind)),
331 section.position,
332 )
333 });
334 ranked.truncate(MAX_CONTEXT_HIGHLIGHTS);
335 ranked
336}
337
338fn render_context_doc(
339 filename: &str,
340 content: &str,
341 kind: ContextDocKind,
342 max_chars: usize,
343) -> String {
344 let (intro, sections) = parse_markdown_sections(content);
345 let summary_source = if intro.trim().is_empty() {
346 sections
347 .first()
348 .map_or(content, |section| section.body.as_str())
349 } else {
350 intro.as_str()
351 };
352 let summary = compact_excerpt(summary_source, CONTEXT_SUMMARY_CHAR_LIMIT);
353
354 let headings = sections
355 .iter()
356 .take(MAX_CONTEXT_HEADINGS)
357 .map(|section| heading_title(§ion.heading).to_string())
358 .collect::<Vec<_>>();
359
360 let highlights = select_context_sections(§ions, kind)
361 .into_iter()
362 .filter_map(|section| {
363 let snippet = compact_excerpt(§ion.body, CONTEXT_HIGHLIGHT_CHAR_LIMIT);
364 (!snippet.is_empty()).then(|| (heading_title(§ion.heading).to_string(), snippet))
365 })
366 .collect::<Vec<_>>();
367
368 let mut output = String::new();
369 output.push_str(&format!("=== {filename} ===\n"));
370
371 if !summary.is_empty() {
372 output.push_str("Summary:\n");
373 output.push_str(&summary);
374 output.push_str("\n\n");
375 }
376
377 if !headings.is_empty() {
378 output.push_str("Key sections: ");
379 output.push_str(&headings.join(" | "));
380 output.push_str("\n\n");
381 }
382
383 if !highlights.is_empty() {
384 output.push_str("Highlights:\n");
385 for (heading, snippet) in highlights {
386 output.push_str(&format!("- {heading}: {snippet}\n"));
387 }
388 }
389
390 truncate_chars(output.trim_end(), max_chars)
391}
392
393async fn build_context_output(repo_root: &Path, requested_max_chars: usize) -> Result<String> {
394 let context_budget = requested_max_chars.min(MAX_CONTEXT_TOTAL_CHARS);
395 let mut docs = Vec::new();
396
397 if let Some(path) = find_first_existing_file(repo_root, readme_candidates()) {
398 let content = tokio::fs::read_to_string(&path).await?;
399 docs.push((ContextDocKind::Readme, path, content));
400 }
401
402 if let Some(path) = find_first_existing_file(repo_root, agent_doc_candidates()) {
403 let content = tokio::fs::read_to_string(&path).await?;
404 docs.push((ContextDocKind::Agents, path, content));
405 }
406
407 if docs.is_empty() {
408 return Ok("No project context documentation found in project root.".to_string());
409 }
410
411 let mut output = String::from(
412 "Concise project context. Use `project_docs(doc_type=\"readme\")` or \
413`project_docs(doc_type=\"agents\")` for full targeted docs.\n\n",
414 );
415
416 let has_readme = docs
417 .iter()
418 .any(|(kind, _, _)| *kind == ContextDocKind::Readme);
419 let has_agents = docs
420 .iter()
421 .any(|(kind, _, _)| *kind == ContextDocKind::Agents);
422
423 let mut rendered = Vec::new();
424 for (kind, path, content) in docs {
425 let filename = path
426 .strip_prefix(repo_root)
427 .ok()
428 .and_then(|relative| relative.to_str())
429 .unwrap_or_else(|| {
430 path.file_name()
431 .and_then(|name| name.to_str())
432 .unwrap_or("doc")
433 });
434
435 let doc_budget = match (has_readme, has_agents, kind) {
436 (true, true, ContextDocKind::Readme) => context_budget * 2 / 5,
437 (true, true, ContextDocKind::Agents) => context_budget * 3 / 5,
438 _ => context_budget,
439 };
440
441 rendered.push(render_context_doc(filename, &content, kind, doc_budget));
442 }
443
444 output.push_str(&rendered.join("\n\n"));
445 Ok(truncate_chars(output.trim_end(), context_budget))
446}
447
448impl Tool for ProjectDocs {
449 const NAME: &'static str = "project_docs";
450 type Error = DocsError;
451 type Args = ProjectDocsArgs;
452 type Output = String;
453
454 async fn definition(&self, _: String) -> ToolDefinition {
455 ToolDefinition {
456 name: "project_docs".to_string(),
457 description:
458 "Fetch project documentation for context. Types: readme, contributing, changelog, license, codeofconduct, agents (AGENTS.md/CLAUDE.md), context (compact README + agent-instructions snapshot), all"
459 .to_string(),
460 parameters: parameters_schema::<ProjectDocsArgs>(),
461 }
462 }
463
464 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
465 let current_dir = current_repo_root().map_err(DocsError::from)?;
466 let max_chars = args.max_chars.min(MAX_DOC_CHARS);
467
468 if matches!(args.doc_type, DocType::Context) {
469 return build_context_output(¤t_dir, max_chars)
470 .await
471 .map_err(DocsError::from);
472 }
473
474 let files_to_check = match args.doc_type {
475 DocType::Readme => readme_candidates().to_vec(),
476 DocType::Contributing => vec!["CONTRIBUTING.md", "CONTRIBUTING", "contributing.md"],
477 DocType::Changelog => vec![
478 "CHANGELOG.md",
479 "CHANGELOG",
480 "HISTORY.md",
481 "CHANGES.md",
482 "changelog.md",
483 ],
484 DocType::License => vec!["LICENSE", "LICENSE.md", "LICENSE.txt", "license"],
485 DocType::CodeOfConduct => vec!["CODE_OF_CONDUCT.md", "code_of_conduct.md"],
486 DocType::Agents => agent_doc_candidates().to_vec(),
487 DocType::Context => Vec::new(),
488 DocType::All => vec![
489 "README.md",
490 "AGENTS.md",
491 "CLAUDE.md",
492 "CONTRIBUTING.md",
493 "CHANGELOG.md",
494 "CODE_OF_CONDUCT.md",
495 ],
496 };
497
498 let mut output = String::new();
499 let mut found_any = false;
500 let mut found_agent_doc = false;
502
503 for filename in files_to_check {
504 if filename == "CLAUDE.md" && found_agent_doc {
506 continue;
507 }
508
509 let path: PathBuf = current_dir.join(filename);
510 if path.exists() {
511 match tokio::fs::read_to_string(&path).await {
512 Ok(content) => {
513 found_any = true;
514
515 if filename == "AGENTS.md" {
517 found_agent_doc = true;
518 }
519
520 append_doc(&mut output, filename, &content, max_chars, None);
521
522 if !matches!(args.doc_type, DocType::All) {
525 break;
526 }
527 }
528 Err(e) => {
529 output.push_str(&format!("Error reading {}: {}\n", filename, e));
530 }
531 }
532 }
533 }
534
535 if !found_any {
536 output = format!(
537 "No {:?} documentation found in project root.",
538 args.doc_type
539 );
540 }
541
542 Ok(output)
543 }
544}