返回 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::{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("Context file {path} is too large ({size} bytes, max {max})")]
19 TooLarge {
20 path: PathBuf,
21 size: u64,
22 max: usize,
23 },
24 #[error("Failed to read context file {path}: {source}")]
25 Read {
26 path: PathBuf,
27 source: std::io::Error,
28 },
29 #[error("Context file {path} is empty")]
30 Empty { path: PathBuf },
31 }
32
33 /// Result of loading project context
34 #[derive(Debug, Clone)]
35 pub struct ProjectContext {
36 /// The loaded instructions content
37 pub instructions: Option<String>,
38 /// Auto-discovered rules from `.codewhale/rules/` / `.claude/rules/`.
39 /// Kept separate from `instructions` so rules alone don't block
40 /// parent-directory AGENTS.md discovery via `has_instructions()`.
41 pub rules_block: Option<String>,
42 /// Path to the loaded file (for display)
43 pub source_path: Option<PathBuf>,
44 /// Any warnings during loading
45 pub warnings: Vec<String>,
46 /// Rendered `.codewhale/constitution.json` authority block, if present.
47 /// Codewhale-specific repo authority/prioritization policy — distinct from
48 /// the cross-agent prose in `instructions`.
49 pub constitution_block: Option<String>,
50 /// Path to the repo constitution file that produced `constitution_block`.
51 pub constitution_source_path: Option<PathBuf>,
52 /// Project root directory
53 #[allow(dead_code)] // Part of ProjectContext public interface
54 pub project_root: PathBuf,
55 /// Whether this is a trusted project
56 pub is_trusted: bool,
57 }
58
59 impl ProjectContext {
60 /// Create an empty project context
61 pub fn empty(project_root: PathBuf) -> Self {
62 Self {
63 instructions: None,
64 rules_block: None,
65 source_path: None,
66 warnings: Vec::new(),
67 constitution_block: None,
68 constitution_source_path: None,
69 project_root,
70 is_trusted: false,
71 }
72 }
73
74 /// Check if any instructions were loaded
75 pub fn has_instructions(&self) -> bool {
76 self.instructions.is_some()
77 }
78
79 /// Get the instructions as a formatted block for system prompt.
80 ///
81 /// The Codewhale repo constitution (`.codewhale/constitution.json`), when
82 /// present, is emitted first as a higher-authority block, followed by the
83 /// cross-agent `<project_instructions>` prose. Either may be absent.
84 pub fn as_system_block(&self) -> Option<String> {
85 let instructions_block = self.instructions.as_ref().map(|content| {
86 let source = project_instructions_source_label(self.source_path.as_deref());
87
88 let mut block = format!(
89 "<project_instructions source=\"{source}\">\n{content}\n</project_instructions>"
90 );
91 // Append rules after instructions, inside the same logical block.
92 // Rules are kept separate from `instructions` so they don't block
93 // parent-directory AGENTS.md discovery via `has_instructions()`.
94 if let Some(rules) = &self.rules_block {
95 block.push('\n');
96 block.push_str(rules);
97 }
98 block
99 });
100
101 match (self.constitution_block.as_ref(), instructions_block) {
102 (Some(constitution), Some(instructions)) => {
103 Some(format!("{constitution}\n\n{instructions}"))
104 }
105 (Some(constitution), None) => {
106 // Constitution present but no main instructions — still emit rules if any
107 if let Some(rules) = &self.rules_block {
108 Some(format!("{constitution}\n\n{rules}"))
109 } else {
110 Some(constitution.clone())
111 }
112 }
113 (None, Some(instructions)) => Some(instructions),
114 (None, None) => {
115 // No main instructions, but rules may exist on their own
116 self.rules_block.clone()
117 }
118 }
119 }
120 }
121
122 /// The `source` label for `<project_instructions>` (and repo constitution)
123 /// blocks: the context file's name only, never its absolute path. The label
124 /// sits inside the pinned system prompt, so keeping it stable across
125 /// directory moves and recasings means an unchanged file does not emit a
126 /// spurious `<context_update>` history append after a move, and absolute
127 /// project paths stay out of provider-bound prompt labels. Directory
128 /// identity stays discoverable via the shell; the label names the origin,
129 /// it is not a locator.
130 pub(crate) fn project_instructions_source_label(source_path: Option<&Path>) -> String {
131 source_path
132 .and_then(|path| path.file_name())
133 .map(|name| name.to_string_lossy().into_owned())
134 .unwrap_or_else(|| "project".to_string())
135 }
136
137 /// The `source` label for prompt blocks that must keep sibling scopes
138 /// distinguishable (ancestor instruction-chain segments, `<project_rule>`
139 /// blocks): the file's path relative to `root` — the containing checkout —
140 /// rendered with forward slashes. When `root` is `None` or `path` is not
141 /// inside it, falls back to the absolute spelling rather than guessing.
142 ///
143 /// Like [`project_instructions_source_label`], the label sits inside the
144 /// pinned system prompt, so keeping it stable across directory moves and
145 /// recasings means an unchanged file does not emit a spurious
146 /// `<context_update>` history append after a move, and absolute project
147 /// paths stay out of provider-bound prompt labels. Root-relative spelling
148 /// (rather than the bare file name) is deliberate: chain segments and rule
149 /// files legitimately share basenames across scopes, and the label exists
150 /// precisely to disambiguate them.
151 pub(crate) fn repo_relative_source_label(path: &Path, root: Option<&Path>) -> String {
152 if let Some(root) = root
153 && let Ok(relative) = path.strip_prefix(root)
154 {
155 return relative.to_string_lossy().replace('\\', "/");
156 }
157 path.display().to_string()
158 }
159
160 /// Merge multiple project contexts (e.g., from nested directories)
161 #[allow(dead_code)] // Public API for monorepo context merging
162 pub fn merge_contexts(contexts: &[ProjectContext]) -> Option<String> {
163 let non_empty: Vec<_> = contexts
164 .iter()
165 .filter_map(ProjectContext::as_system_block)
166 .collect();
167
168 if non_empty.is_empty() {
169 None
170 } else {
171 Some(non_empty.join("\n\n"))
172 }
173 }
174
175 #[cfg(test)]
176 mod tests {
177 use super::*;
178
179 #[test]
180 fn test_merge_contexts() {
181 let mut ctx1 = ProjectContext::empty(PathBuf::from("/a"));
182 ctx1.instructions = Some("Instructions A".to_string());
183 ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md"));
184
185 let mut ctx2 = ProjectContext::empty(PathBuf::from("/b"));
186 ctx2.instructions = Some("Instructions B".to_string());
187 ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md"));
188
189 let merged = merge_contexts(&[ctx1, ctx2]).expect("merge");
190
191 assert!(merged.contains("Instructions A"));
192 assert!(merged.contains("Instructions B"));
193 }
194 }
195
195 lines RUST