| 1 | //! Project document discovery and loading |
| 2 | //! |
| 3 | //! Supports auto-discovery of project instructions like Claude Code. |
| 4 | //! Priority: AGENTS.md > .claude/instructions.md > CLAUDE.md > .deepseek/instructions.md |
| 5 | |
| 6 | use std::path::{Path, PathBuf}; |
| 7 | |
| 8 | /// Document filenames to search for (in priority order) |
| 9 | pub const DOC_FILENAMES: &[&str] = &[ |
| 10 | "AGENTS.md", |
| 11 | ".claude/instructions.md", |
| 12 | "CLAUDE.md", |
| 13 | ".deepseek/instructions.md", |
| 14 | ]; |
| 15 | |
| 16 | /// Maximum bytes to read from project docs (default: 32KB) |
| 17 | #[allow(dead_code)] // Used by read_project_docs |
| 18 | pub const DEFAULT_MAX_BYTES: usize = 32768; |
| 19 | |
| 20 | /// A discovered project document |
| 21 | #[derive(Debug, Clone)] |
| 22 | #[allow(dead_code)] |
| 23 | pub struct ProjectDoc { |
| 24 | pub path: PathBuf, |
| 25 | pub content: String, |
| 26 | } |
| 27 | |
| 28 | /// Walk from cwd up to git root, collecting all project docs |
| 29 | pub fn discover_paths(cwd: &Path) -> Vec<PathBuf> { |
| 30 | let mut paths = Vec::new(); |
| 31 | let git_root = find_git_root(cwd); |
| 32 | |
| 33 | let mut current = cwd.to_path_buf(); |
| 34 | loop { |
| 35 | for filename in DOC_FILENAMES { |
| 36 | let doc_path = current.join(filename); |
| 37 | if doc_path.exists() && doc_path.is_file() { |
| 38 | paths.push(doc_path); |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // Stop at git root or filesystem root |
| 43 | if let Some(ref root) = git_root |
| 44 | && current == *root |
| 45 | { |
| 46 | break; |
| 47 | } |
| 48 | |
| 49 | match current.parent() { |
| 50 | Some(parent) if parent != current => { |
| 51 | current = parent.to_path_buf(); |
| 52 | } |
| 53 | _ => break, |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // Reverse so parent docs come first (will be overridden by child docs) |
| 58 | paths.reverse(); |
| 59 | paths |
| 60 | } |
| 61 | |
| 62 | /// Find the git root directory from cwd |
| 63 | fn find_git_root(cwd: &Path) -> Option<PathBuf> { |
| 64 | let mut current = cwd.to_path_buf(); |
| 65 | loop { |
| 66 | if current.join(".git").exists() { |
| 67 | return Some(current); |
| 68 | } |
| 69 | match current.parent() { |
| 70 | Some(parent) if parent != current => { |
| 71 | current = parent.to_path_buf(); |
| 72 | } |
| 73 | _ => return None, |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// Read and concatenate project docs with byte limit |
| 79 | #[allow(dead_code)] // Public API; project_context.rs provides the active code path |
| 80 | pub fn read_project_docs(paths: &[PathBuf], max_bytes: usize) -> Option<String> { |
| 81 | if paths.is_empty() { |
| 82 | return None; |
| 83 | } |
| 84 | |
| 85 | let mut combined = String::new(); |
| 86 | let mut total_bytes = 0; |
| 87 | |
| 88 | for path in paths { |
| 89 | if total_bytes >= max_bytes { |
| 90 | break; |
| 91 | } |
| 92 | |
| 93 | if let Ok(content) = std::fs::read_to_string(path) { |
| 94 | let remaining = max_bytes.saturating_sub(total_bytes); |
| 95 | let content = if content.len() > remaining { |
| 96 | // Truncate to remaining bytes at a word boundary if possible |
| 97 | let truncated: String = content.chars().take(remaining).collect(); |
| 98 | format!("{truncated}\n\n[...truncated...]") |
| 99 | } else { |
| 100 | content |
| 101 | }; |
| 102 | |
| 103 | if !combined.is_empty() { |
| 104 | combined.push_str("\n\n---\n\n"); |
| 105 | } |
| 106 | combined.push_str(&format_instructions(path, &content)); |
| 107 | total_bytes += content.len(); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | if combined.is_empty() { |
| 112 | None |
| 113 | } else { |
| 114 | Some(combined) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// Format project instructions for injection into system prompt |
| 119 | #[allow(dead_code)] // Used by read_project_docs |
| 120 | pub fn format_instructions(path: &Path, content: &str) -> String { |
| 121 | format!( |
| 122 | "# Project instructions from {}\n\n<INSTRUCTIONS>\n{}\n</INSTRUCTIONS>", |
| 123 | path.display(), |
| 124 | content.trim() |
| 125 | ) |
| 126 | } |
| 127 | |
| 128 | /// Load project docs from workspace with default settings |
| 129 | #[allow(dead_code)] // Convenience function; project_context.rs provides the active code path |
| 130 | pub fn load_from_workspace(workspace: &Path) -> Option<String> { |
| 131 | let paths = discover_paths(workspace); |
| 132 | read_project_docs(&paths, DEFAULT_MAX_BYTES) |
| 133 | } |
| 134 |