Skip to main content

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