zagens_core/
project_context.rs1use std::fs;
16use std::path::{Path, PathBuf};
17
18use thiserror::Error;
19
20const PROJECT_CONTEXT_FILES: &[&str] = &[
22 "AGENTS.md",
23 ".claude/instructions.md",
24 "CLAUDE.md",
25 ".zagens/instructions.md",
26 ".deepseek/instructions.md",
27];
28
29const MAX_CONTEXT_SIZE: usize = 100 * 1024; #[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#[derive(Debug, Clone)]
58pub struct ProjectContext {
59 pub instructions: Option<String>,
61 pub source_path: Option<PathBuf>,
63 pub warnings: Vec<String>,
65 #[allow(dead_code)] pub project_root: PathBuf,
68 pub is_trusted: bool,
70}
71
72impl ProjectContext {
73 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 pub fn has_instructions(&self) -> bool {
86 self.instructions.is_some()
87 }
88
89 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
104pub fn load_project_context(workspace: &Path) -> ProjectContext {
108 let mut ctx = ProjectContext::empty(workspace.to_path_buf());
109
110 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 ctx.is_trusted = check_trust_status(workspace);
130
131 ctx
132}
133
134pub fn load_project_context_with_parents(workspace: &Path) -> ProjectContext {
138 let mut ctx = load_project_context(workspace);
139
140 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
160fn load_context_file(path: &Path) -> Result<String, ProjectContextError> {
162 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 let content = fs::read_to_string(path).map_err(|source| ProjectContextError::Read {
178 path: path.to_path_buf(),
179 source,
180 })?;
181
182 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
194fn check_trust_status(workspace: &Path) -> bool {
196 if is_workspace_trusted_from_config(workspace) {
197 return true;
198 }
199
200 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
215pub 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#[allow(dead_code)] pub 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
291fn 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 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"); 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 assert!(!check_trust_status(tmp.path()));
457
458 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 let subdir = tmp.path().join("subproject");
482 fs::create_dir(&subdir).expect("mkdir");
483
484 fs::write(tmp.path().join("AGENTS.md"), "Parent instructions").expect("write");
486 fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
488
489 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 fs::write(tmp.path().join("AGENTS.md"), "Organization instructions").expect("write");
523
524 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}