返回 DeepSeek-TUI-2026
project_context.rs
根目录 / crates / tui / src / project_context.rs
1 //! Project context loading for DeepSeek TUI.
2 //!
3 //! This module handles loading project-specific context files that provide
4 //! instructions and context to the AI agent. These include:
5 //!
6 //! - `AGENTS.md` - Project-level agent instructions (primary)
7 //! - `.claude/instructions.md` - Claude-style hidden instructions
8 //! - `CLAUDE.md` - Claude-style instructions
9 //! - `.deepseek/instructions.md` - Hidden instructions file (legacy)
10 //!
11 //! The loaded content is injected into the system prompt to give the agent
12 //! context about the project's conventions, structure, and requirements.
13
14 use std::fs;
15 use std::path::{Path, PathBuf};
16
17 use thiserror::Error;
18
19 /// Names of project context files to look for, in priority order.
20 const PROJECT_CONTEXT_FILES: &[&str] = &[
21 "AGENTS.md",
22 ".claude/instructions.md",
23 "CLAUDE.md",
24 ".deepseek/instructions.md",
25 ];
26
27 /// Maximum size for project context files (to prevent loading huge files)
28 const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
29
30 // === Errors ===
31
32 #[derive(Debug, Error)]
33 enum ProjectContextError {
34 #[error("Failed to read context metadata for {path}: {source}")]
35 Metadata {
36 path: PathBuf,
37 source: std::io::Error,
38 },
39 #[error("Context file {path} is too large ({size} bytes, max {max})")]
40 TooLarge {
41 path: PathBuf,
42 size: u64,
43 max: usize,
44 },
45 #[error("Failed to read context file {path}: {source}")]
46 Read {
47 path: PathBuf,
48 source: std::io::Error,
49 },
50 #[error("Context file {path} is empty")]
51 Empty { path: PathBuf },
52 }
53
54 /// Result of loading project context
55 #[derive(Debug, Clone)]
56 pub struct ProjectContext {
57 /// The loaded instructions content
58 pub instructions: Option<String>,
59 /// Path to the loaded file (for display)
60 pub source_path: Option<PathBuf>,
61 /// Any warnings during loading
62 pub warnings: Vec<String>,
63 /// Project root directory
64 #[allow(dead_code)] // Part of ProjectContext public interface
65 pub project_root: PathBuf,
66 /// Whether this is a trusted project
67 pub is_trusted: bool,
68 }
69
70 impl ProjectContext {
71 /// Create an empty project context
72 pub fn empty(project_root: PathBuf) -> Self {
73 Self {
74 instructions: None,
75 source_path: None,
76 warnings: Vec::new(),
77 project_root,
78 is_trusted: false,
79 }
80 }
81
82 /// Check if any instructions were loaded
83 pub fn has_instructions(&self) -> bool {
84 self.instructions.is_some()
85 }
86
87 /// Get the instructions as a formatted block for system prompt
88 pub fn as_system_block(&self) -> Option<String> {
89 self.instructions.as_ref().map(|content| {
90 let source = self
91 .source_path
92 .as_ref()
93 .map_or_else(|| "project".to_string(), |p| p.display().to_string());
94
95 format!(
96 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
97 )
98 })
99 }
100 }
101
102 /// Load project context from the workspace directory.
103 ///
104 /// This searches for known project context files and loads the first one found.
105 pub fn load_project_context(workspace: &Path) -> ProjectContext {
106 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
107
108 // Search for project context files
109 for filename in PROJECT_CONTEXT_FILES {
110 let file_path = workspace.join(filename);
111
112 if file_path.exists() && file_path.is_file() {
113 match load_context_file(&file_path) {
114 Ok(content) => {
115 ctx.instructions = Some(content);
116 ctx.source_path = Some(file_path);
117 break;
118 }
119 Err(error) => {
120 ctx.warnings.push(error.to_string());
121 }
122 }
123 }
124 }
125
126 // Check for trust file
127 ctx.is_trusted = check_trust_status(workspace);
128
129 ctx
130 }
131
132 /// Load project context from parent directories as well.
133 ///
134 /// This allows for monorepo setups where a root AGENTS.md applies to all subdirectories.
135 pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
136 let mut ctx = load_project_context(workspace);
137
138 // If no context found in workspace, check parent directories
139 if !ctx.has_instructions() {
140 let mut current = workspace.parent();
141
142 while let Some(parent) = current {
143 let parent_ctx = load_project_context(parent);
144 ctx.warnings.extend(parent_ctx.warnings.iter().cloned());
145 if parent_ctx.has_instructions() {
146 ctx.instructions = parent_ctx.instructions;
147 ctx.source_path = parent_ctx.source_path;
148 break;
149 }
150
151 current = parent.parent();
152 }
153 }
154
155 ctx
156 }
157
158 /// Load a context file with size checking
159 fn load_context_file(path: &Path) -> Result<String, ProjectContextError> {
160 // Check file size first
161 let metadata = fs::metadata(path).map_err(|source| ProjectContextError::Metadata {
162 path: path.to_path_buf(),
163 source,
164 })?;
165
166 if metadata.len() > MAX_CONTEXT_SIZE as u64 {
167 return Err(ProjectContextError::TooLarge {
168 path: path.to_path_buf(),
169 size: metadata.len(),
170 max: MAX_CONTEXT_SIZE,
171 });
172 }
173
174 // Read the file
175 let content = fs::read_to_string(path).map_err(|source| ProjectContextError::Read {
176 path: path.to_path_buf(),
177 source,
178 })?;
179
180 // Basic validation
181 if content.trim().is_empty() {
182 return Err(ProjectContextError::Empty {
183 path: path.to_path_buf(),
184 });
185 }
186
187 Ok(content)
188 }
189
190 /// Check if this project is marked as trusted
191 fn check_trust_status(workspace: &Path) -> bool {
192 if crate::config::is_workspace_trusted(workspace) {
193 return true;
194 }
195
196 // Check for trust markers
197 let trust_markers = [
198 workspace.join(".deepseek").join("trusted"),
199 workspace.join(".deepseek").join("trust.json"),
200 ];
201
202 for marker in &trust_markers {
203 if marker.exists() {
204 return true;
205 }
206 }
207
208 false
209 }
210
211 /// Create a default AGENTS.md file for a project
212 pub fn create_default_agents_md(workspace: &Path) -> std::io::Result<PathBuf> {
213 let agents_path = workspace.join("AGENTS.md");
214
215 let default_content = r#"# Project Agent Instructions
216
217 This file provides guidance to AI agents (DeepSeek TUI, Claude Code, etc.) when working with code in this repository.
218
219 ## File Location
220
221 Save this file as `AGENTS.md` in your project root so the CLI can load it automatically.
222
223 ## Build and Development Commands
224
225 ```bash
226 # Build
227 # cargo build # Rust projects
228 # npm run build # Node.js projects
229 # python -m build # Python projects
230
231 # Test
232 # cargo test # Rust
233 # npm test # Node.js
234 # pytest # Python
235
236 # Lint and Format
237 # cargo fmt && cargo clippy # Rust
238 # npm run lint # Node.js
239 # ruff check . # Python
240 ```
241
242 ## Architecture Overview
243
244 <!-- Describe your project's high-level architecture here -->
245 <!-- Focus on the "big picture" that requires reading multiple files to understand -->
246
247 ### Key Components
248
249 <!-- List and describe the main components/modules -->
250
251 ### Data Flow
252
253 <!-- Describe how data flows through the system -->
254
255 ## Configuration Files
256
257 <!-- List important configuration files and their purposes -->
258
259 ## Extension Points
260
261 <!-- Describe how to extend the codebase (add new features, tools, etc.) -->
262
263 ## Commit Messages
264
265 Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`
266 "#;
267
268 fs::write(&agents_path, default_content)?;
269 Ok(agents_path)
270 }
271
272 /// Merge multiple project contexts (e.g., from nested directories)
273 #[allow(dead_code)] // Public API for monorepo context merging
274 pub fn merge_contexts(contexts: &[ProjectContext]) -> Option<String> {
275 let non_empty: Vec<_> = contexts
276 .iter()
277 .filter_map(ProjectContext::as_system_block)
278 .collect();
279
280 if non_empty.is_empty() {
281 None
282 } else {
283 Some(non_empty.join("\n\n"))
284 }
285 }
286
287 // === Unit Tests ===
288
289 #[cfg(test)]
290 mod tests {
291 use super::*;
292 use tempfile::tempdir;
293
294 #[test]
295 fn test_load_project_context_empty() {
296 let tmp = tempdir().expect("tempdir");
297 let ctx = load_project_context(tmp.path());
298
299 assert!(!ctx.has_instructions());
300 assert!(ctx.source_path.is_none());
301 }
302
303 #[test]
304 fn test_load_project_context_agents_md() {
305 let tmp = tempdir().expect("tempdir");
306 let agents_path = tmp.path().join("AGENTS.md");
307 fs::write(&agents_path, "# Test Instructions\n\nFollow these rules.").expect("write");
308
309 let ctx = load_project_context(tmp.path());
310
311 assert!(ctx.has_instructions());
312 assert!(
313 ctx.instructions
314 .as_ref()
315 .unwrap()
316 .contains("Test Instructions")
317 );
318 assert_eq!(ctx.source_path, Some(agents_path));
319 }
320
321 #[test]
322 fn test_load_project_context_priority() {
323 let tmp = tempdir().expect("tempdir");
324
325 // Create both files - AGENTS.md should take priority
326 fs::write(tmp.path().join("AGENTS.md"), "AGENTS content").expect("write");
327 let claude_dir = tmp.path().join(".claude");
328 fs::create_dir(&claude_dir).expect("mkdir");
329 fs::write(claude_dir.join("instructions.md"), "CLAUDE content").expect("write");
330
331 let ctx = load_project_context(tmp.path());
332
333 assert!(ctx.has_instructions());
334 assert!(
335 ctx.instructions
336 .as_ref()
337 .unwrap()
338 .contains("AGENTS content")
339 );
340 }
341
342 #[test]
343 fn test_load_project_context_hidden_dir() {
344 let tmp = tempdir().expect("tempdir");
345 let hidden_dir = tmp.path().join(".deepseek");
346 fs::create_dir(&hidden_dir).expect("mkdir");
347 fs::write(hidden_dir.join("instructions.md"), "Hidden instructions").expect("write");
348
349 let ctx = load_project_context(tmp.path());
350
351 assert!(ctx.has_instructions());
352 assert!(
353 ctx.instructions
354 .as_ref()
355 .unwrap()
356 .contains("Hidden instructions")
357 );
358 }
359
360 #[test]
361 fn test_as_system_block() {
362 let tmp = tempdir().expect("tempdir");
363 let agents_path = tmp.path().join("AGENTS.md");
364 fs::write(&agents_path, "Test content").expect("write");
365
366 let ctx = load_project_context(tmp.path());
367 let block = ctx.as_system_block().expect("block");
368
369 assert!(block.contains("<project_instructions"));
370 assert!(block.contains("Test content"));
371 assert!(block.contains("</project_instructions>"));
372 }
373
374 #[test]
375 fn test_empty_file_warning() {
376 let tmp = tempdir().expect("tempdir");
377 let agents_path = tmp.path().join("AGENTS.md");
378 fs::write(&agents_path, " \n \n ").expect("write"); // Only whitespace
379
380 let ctx = load_project_context(tmp.path());
381
382 assert!(!ctx.has_instructions());
383 assert!(!ctx.warnings.is_empty());
384 }
385
386 #[test]
387 fn test_check_trust_status() {
388 let tmp = tempdir().expect("tempdir");
389
390 // Not trusted by default
391 assert!(!check_trust_status(tmp.path()));
392
393 // Create trust marker
394 let deepseek_dir = tmp.path().join(".deepseek");
395 fs::create_dir(&deepseek_dir).expect("mkdir");
396 fs::write(deepseek_dir.join("trusted"), "").expect("write");
397
398 assert!(check_trust_status(tmp.path()));
399 }
400
401 #[test]
402 fn test_create_default_agents_md() {
403 let tmp = tempdir().expect("tempdir");
404 let path = create_default_agents_md(tmp.path()).expect("create");
405
406 assert!(path.exists());
407 let content = fs::read_to_string(&path).expect("read");
408 assert!(content.contains("Project Agent Instructions"));
409 }
410
411 #[test]
412 fn test_load_with_parents() {
413 let tmp = tempdir().expect("tempdir");
414
415 // Create a nested structure
416 let subdir = tmp.path().join("subproject");
417 fs::create_dir(&subdir).expect("mkdir");
418
419 // Put AGENTS.md in parent
420 fs::write(tmp.path().join("AGENTS.md"), "Parent instructions").expect("write");
421 // Also create .git to mark as repo root
422 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
423
424 // Load from subdir should find parent's AGENTS.md
425 let ctx = load_project_context_with_parents(&subdir);
426
427 assert!(ctx.has_instructions());
428 assert!(
429 ctx.instructions
430 .as_ref()
431 .unwrap()
432 .contains("Parent instructions")
433 );
434 }
435
436 #[test]
437 fn test_merge_contexts() {
438 let mut ctx1 = ProjectContext::empty(PathBuf::from("/a"));
439 ctx1.instructions = Some("Instructions A".to_string());
440 ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md"));
441
442 let mut ctx2 = ProjectContext::empty(PathBuf::from("/b"));
443 ctx2.instructions = Some("Instructions B".to_string());
444 ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md"));
445
446 let merged = merge_contexts(&[ctx1, ctx2]).expect("merge");
447
448 assert!(merged.contains("Instructions A"));
449 assert!(merged.contains("Instructions B"));
450 }
451
452 #[test]
453 fn test_load_with_parents_searches_above_git_root_when_needed() {
454 let tmp = tempdir().expect("tempdir");
455
456 // AGENTS.md exists above repository root.
457 fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
458
459 // Mark repository root one level below.
460 let repo_root = tmp.path().join("repo");
461 fs::create_dir(&repo_root).expect("mkdir repo");
462 fs::create_dir(repo_root.join(".git")).expect("mkdir .git");
463
464 let workspace = repo_root.join("apps").join("client");
465 fs::create_dir_all(&workspace).expect("mkdir workspace");
466
467 let ctx = load_project_context_with_parents(&workspace);
468 assert!(ctx.has_instructions());
469 assert!(
470 ctx.instructions
471 .as_ref()
472 .unwrap()
473 .contains("Organization instructions")
474 );
475 }
476 }
477
477 lines RUST