Skip to main content

git_iris/agents/tools/
docs.rs

1//! Project documentation tool for Rig-based agents
2//!
3//! This tool fetches documentation files like README.md, CONTRIBUTING.md,
4//! CHANGELOG.md, etc. from the project root.
5
6use 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
15// Use standard tool error macro for consistency
16crate::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/// Tool for fetching project documentation files
57#[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/// Type of documentation to fetch
74#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
75#[serde(rename_all = "lowercase")]
76pub enum DocType {
77    /// README file (README.md, README.rst, README.txt)
78    #[default]
79    Readme,
80    /// Contributing guidelines (CONTRIBUTING.md)
81    Contributing,
82    /// Changelog (CHANGELOG.md, HISTORY.md)
83    Changelog,
84    /// License file (LICENSE, LICENSE.md)
85    License,
86    /// Code of conduct (`CODE_OF_CONDUCT.md`)
87    CodeOfConduct,
88    /// Agent/AI instructions (AGENTS.md, CLAUDE.md, .github/copilot-instructions.md)
89    Agents,
90    /// Project context: concise README + agent instructions summary
91    Context,
92    /// All documentation files
93    All,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
97pub struct ProjectDocsArgs {
98    /// Type of documentation to fetch
99    #[serde(default)]
100    pub doc_type: DocType,
101    /// Maximum characters to return (default: 20000, max: 20000).
102    /// For `context`, this is a total shared budget across the returned snapshot.
103    #[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(&section.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(&section.heading).to_string())
358        .collect::<Vec<_>>();
359
360    let highlights = select_context_sections(&sections, kind)
361        .into_iter()
362        .filter_map(|section| {
363            let snippet = compact_excerpt(&section.body, CONTEXT_HIGHLIGHT_CHAR_LIMIT);
364            (!snippet.is_empty()).then(|| (heading_title(&section.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(&current_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        // Track if we found an agent instructions file (AGENTS.md often symlinks to CLAUDE.md)
501        let mut found_agent_doc = false;
502
503        for filename in files_to_check {
504            // Skip CLAUDE.md if we already found AGENTS.md (avoid duplicate from symlink)
505            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                        // Mark that we found an agent doc file
516                        if filename == "AGENTS.md" {
517                            found_agent_doc = true;
518                        }
519
520                        append_doc(&mut output, filename, &content, max_chars, None);
521
522                        // For single doc types, return after finding first match
523                        // Context and All gather multiple files
524                        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}