返回 CodeWhale
types.rs
根目录 / crates / tui / src / project_context / types.rs
1 //! Shared project-context types: the load-error enum and the
2 //! `ProjectContext` value that carries loaded instructions, rules, and the
3 //! rendered repo-constitution block into the system prompt.
4
5 use std::path::PathBuf;
6
7 use thiserror::Error;
8
9 // === Errors ===
10
11 #[derive(Debug, Error)]
12 pub(crate) enum ProjectContextError {
13 #[error("Failed to read context metadata for {path}: {source}")]
14 Metadata {
15 path: PathBuf,
16 source: std::io::Error,
17 },
18 #[error("Refusing symlinked context file {path}")]
19 Symlink { path: PathBuf },
20 #[error("Context path {path} is not a regular file")]
21 NotFile { path: PathBuf },
22 #[error("Context file {path} is too large ({size} bytes, max {max})")]
23 TooLarge {
24 path: PathBuf,
25 size: u64,
26 max: usize,
27 },
28 #[error("Failed to read context file {path}: {source}")]
29 Read {
30 path: PathBuf,
31 source: std::io::Error,
32 },
33 #[error("Context file {path} is empty")]
34 Empty { path: PathBuf },
35 }
36
37 /// Result of loading project context
38 #[derive(Debug, Clone)]
39 pub struct ProjectContext {
40 /// The loaded instructions content
41 pub instructions: Option<String>,
42 /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`.
43 /// Kept separate from `instructions` so rules alone don't block
44 /// parent-directory AGENTS.md discovery via `has_instructions()`.
45 pub rules_block: Option<String>,
46 /// Path to the loaded file (for display)
47 pub source_path: Option<PathBuf>,
48 /// Any warnings during loading
49 pub warnings: Vec<String>,
50 /// Rendered `.codewhale/constitution.json` authority block, if present.
51 /// Codewhale-specific repo authority/prioritization policy — distinct from
52 /// the cross-agent prose in `instructions`.
53 pub constitution_block: Option<String>,
54 /// Path to the repo constitution file that produced `constitution_block`.
55 pub constitution_source_path: Option<PathBuf>,
56 /// Project root directory
57 #[allow(dead_code)] // Part of ProjectContext public interface
58 pub project_root: PathBuf,
59 /// Whether this is a trusted project
60 pub is_trusted: bool,
61 }
62
63 impl ProjectContext {
64 /// Create an empty project context
65 pub fn empty(project_root: PathBuf) -> Self {
66 Self {
67 instructions: None,
68 rules_block: None,
69 source_path: None,
70 warnings: Vec::new(),
71 constitution_block: None,
72 constitution_source_path: None,
73 project_root,
74 is_trusted: false,
75 }
76 }
77
78 /// Check if any instructions were loaded
79 pub fn has_instructions(&self) -> bool {
80 self.instructions.is_some()
81 }
82
83 /// Get the instructions as a formatted block for system prompt.
84 ///
85 /// The Codewhale repo constitution (`.codewhale/constitution.json`), when
86 /// present, is emitted first as a higher-authority block, followed by the
87 /// cross-agent `<project_instructions>` prose. Either may be absent.
88 pub fn as_system_block(&self) -> Option<String> {
89 let instructions_block = 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 let mut block = format!(
96 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
97 );
98 // Append rules after instructions, inside the same logical block.
99 // Rules are kept separate from `instructions` so they don't block
100 // parent-directory AGENTS.md discovery via `has_instructions()`.
101 if let Some(rules) = &self.rules_block {
102 block.push('\n');
103 block.push_str(rules);
104 }
105 block
106 });
107
108 match (self.constitution_block.as_ref(), instructions_block) {
109 (Some(constitution), Some(instructions)) => {
110 Some(format!("{constitution}\n\n{instructions}"))
111 }
112 (Some(constitution), None) => {
113 // Constitution present but no main instructions — still emit rules if any
114 if let Some(rules) = &self.rules_block {
115 Some(format!("{constitution}\n\n{rules}"))
116 } else {
117 Some(constitution.clone())
118 }
119 }
120 (None, Some(instructions)) => Some(instructions),
121 (None, None) => {
122 // No main instructions, but rules may exist on their own
123 self.rules_block.clone()
124 }
125 }
126 }
127 }
128
129 /// Merge multiple project contexts (e.g., from nested directories)
130 #[allow(dead_code)] // Public API for monorepo context merging
131 pub fn merge_contexts(contexts: &[ProjectContext]) -> Option<String> {
132 let non_empty: Vec<_> = contexts
133 .iter()
134 .filter_map(ProjectContext::as_system_block)
135 .collect();
136
137 if non_empty.is_empty() {
138 None
139 } else {
140 Some(non_empty.join("\n\n"))
141 }
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147
148 #[test]
149 fn test_merge_contexts() {
150 let mut ctx1 = ProjectContext::empty(PathBuf::from("/a"));
151 ctx1.instructions = Some("Instructions A".to_string());
152 ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md"));
153
154 let mut ctx2 = ProjectContext::empty(PathBuf::from("/b"));
155 ctx2.instructions = Some("Instructions B".to_string());
156 ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md"));
157
158 let merged = merge_contexts(&[ctx1, ctx2]).expect("merge");
159
160 assert!(merged.contains("Instructions A"));
161 assert!(merged.contains("Instructions B"));
162 }
163 }
164
164 lines RUST