1use super::context_field::ContextItemId;
2use super::context_ledger::{ContextLedger, PressureAction};
3use super::intent_engine::{IntentScope, StructuredIntent, TaskType};
4
5#[derive(Debug, Clone)]
6pub struct ContextDeficit {
7 pub missing_targets: Vec<String>,
8 pub suggested_files: Vec<SuggestedFile>,
9 pub pressure_action: PressureAction,
10 pub budget_remaining: usize,
11}
12
13#[derive(Debug, Clone)]
14pub struct SuggestedFile {
15 pub path: String,
16 pub item_id: ContextItemId,
17 pub reason: DeficitReason,
18 pub estimated_tokens: usize,
19 pub recommended_mode: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum DeficitReason {
24 TargetNotLoaded,
25 DependencyOfTarget,
26 TestFileForTarget,
27 ConfigForTask,
28}
29
30impl DeficitReason {
31 fn priority(&self) -> u8 {
32 match self {
33 Self::TargetNotLoaded => 0,
34 Self::DependencyOfTarget => 1,
35 Self::TestFileForTarget => 2,
36 Self::ConfigForTask => 3,
37 }
38 }
39}
40
41pub fn detect_deficit(
42 ledger: &ContextLedger,
43 intent: &StructuredIntent,
44 known_files: &[String],
45) -> ContextDeficit {
46 let loaded_paths: Vec<&str> = ledger.entries.iter().map(|e| e.path.as_str()).collect();
47 let pressure = ledger.pressure();
48
49 let mut missing_targets = Vec::new();
50 let mut suggestions = Vec::new();
51
52 for target in &intent.targets {
53 if target.contains('.') || target.contains('/') {
54 let is_loaded = loaded_paths
55 .iter()
56 .any(|p| p.ends_with(target) || p.contains(target));
57 if !is_loaded {
58 missing_targets.push(target.clone());
59
60 let matching: Vec<&String> = known_files
61 .iter()
62 .filter(|f| f.ends_with(target) || f.contains(target))
63 .collect();
64
65 for file in matching {
66 let mode = mode_for_pressure(&pressure.recommendation, &intent.scope);
67 suggestions.push(SuggestedFile {
68 path: file.clone(),
69 item_id: ContextItemId::from_file(file),
70 reason: DeficitReason::TargetNotLoaded,
71 estimated_tokens: estimate_tokens_for_mode(&mode),
72 recommended_mode: mode,
73 });
74 }
75 }
76 }
77 }
78
79 if intent.task_type == TaskType::FixBug || intent.task_type == TaskType::Test {
80 for target in &intent.targets {
81 if target.contains('.') || target.contains('/') {
82 let test_patterns = derive_test_paths(target);
83 for test_path in &test_patterns {
84 let matching: Vec<&String> = known_files
85 .iter()
86 .filter(|f| f.contains(test_path))
87 .collect();
88 for file in matching {
89 if !loaded_paths.contains(&file.as_str())
90 && !suggestions.iter().any(|s| s.path == *file)
91 {
92 let mode = mode_for_pressure(&pressure.recommendation, &intent.scope);
93 suggestions.push(SuggestedFile {
94 path: file.clone(),
95 item_id: ContextItemId::from_file(file),
96 reason: DeficitReason::TestFileForTarget,
97 estimated_tokens: estimate_tokens_for_mode(&mode),
98 recommended_mode: mode,
99 });
100 }
101 }
102 }
103 }
104 }
105 }
106
107 if intent.task_type == TaskType::Config || intent.task_type == TaskType::Deploy {
108 let config_patterns = [
109 "Cargo.toml",
110 "package.json",
111 "tsconfig.json",
112 "pyproject.toml",
113 ".env",
114 "Dockerfile",
115 ];
116 for pattern in &config_patterns {
117 let matching: Vec<&String> = known_files
118 .iter()
119 .filter(|f| f.ends_with(pattern))
120 .collect();
121 for file in matching {
122 if !loaded_paths.contains(&file.as_str())
123 && !suggestions.iter().any(|s| s.path == *file)
124 {
125 suggestions.push(SuggestedFile {
126 path: file.clone(),
127 item_id: ContextItemId::from_file(file),
128 reason: DeficitReason::ConfigForTask,
129 estimated_tokens: estimate_tokens_for_mode("full"),
130 recommended_mode: "full".to_string(),
131 });
132 }
133 }
134 }
135 }
136
137 suggestions.sort_by_key(|s| s.reason.priority());
138
139 let budget_remaining = pressure.remaining_tokens;
140 let mut cumulative = 0usize;
141 suggestions.retain(|s| {
142 cumulative += s.estimated_tokens;
143 cumulative <= budget_remaining
144 });
145
146 ContextDeficit {
147 missing_targets,
148 suggested_files: suggestions,
149 pressure_action: pressure.recommendation,
150 budget_remaining,
151 }
152}
153
154fn mode_for_pressure(action: &PressureAction, scope: &IntentScope) -> String {
155 match action {
156 PressureAction::EvictLeastRelevant => "reference".to_string(),
157 PressureAction::ForceCompression => "signatures".to_string(),
158 PressureAction::SuggestCompression => match scope {
159 IntentScope::SingleFile => "full".to_string(),
160 IntentScope::MultiFile => "signatures".to_string(),
161 IntentScope::CrossModule | IntentScope::ProjectWide => "map".to_string(),
162 },
163 PressureAction::NoAction => match scope {
164 IntentScope::SingleFile | IntentScope::MultiFile => "full".to_string(),
165 IntentScope::CrossModule => "signatures".to_string(),
166 IntentScope::ProjectWide => "map".to_string(),
167 },
168 }
169}
170
171fn estimate_tokens_for_mode(mode: &str) -> usize {
172 match mode {
173 "full" => 2000,
174 "signatures" => 400,
175 "map" => 200,
176 "reference" => 50,
177 "aggressive" => 800,
178 _ => 1000,
179 }
180}
181
182fn derive_test_paths(file_path: &str) -> Vec<String> {
183 let stem = std::path::Path::new(file_path)
184 .file_stem()
185 .and_then(|s| s.to_str())
186 .unwrap_or("");
187 if stem.is_empty() {
188 return Vec::new();
189 }
190 vec![
191 format!("{stem}_test"),
192 format!("test_{stem}"),
193 format!("{stem}.test"),
194 format!("{stem}.spec"),
195 format!("{stem}_spec"),
196 ]
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn detects_missing_target() {
205 let ledger = ContextLedger::new();
206 let intent = StructuredIntent::from_query("fix bug in auth.rs");
207 let known = vec!["src/auth.rs".to_string(), "src/db.rs".to_string()];
208 let deficit = detect_deficit(&ledger, &intent, &known);
209 assert!(!deficit.missing_targets.is_empty());
210 assert!(deficit
211 .suggested_files
212 .iter()
213 .any(|s| s.path.contains("auth")));
214 }
215
216 #[test]
217 fn no_deficit_when_loaded() {
218 let mut ledger = ContextLedger::new();
219 ledger.record("src/auth.rs", "full", 500, 500);
220 let intent = StructuredIntent::from_query("fix bug in auth.rs");
221 let known = vec!["src/auth.rs".to_string()];
222 let deficit = detect_deficit(&ledger, &intent, &known);
223 assert!(deficit.missing_targets.is_empty());
224 }
225
226 #[test]
227 fn suggests_test_files_for_fixbug() {
228 let mut ledger = ContextLedger::new();
229 ledger.record("src/auth.rs", "full", 500, 500);
230 let intent = StructuredIntent::from_query("fix bug in auth.rs");
231 let known = vec!["src/auth.rs".to_string(), "tests/auth_test.rs".to_string()];
232 let deficit = detect_deficit(&ledger, &intent, &known);
233 let test_suggestions: Vec<_> = deficit
234 .suggested_files
235 .iter()
236 .filter(|s| s.reason == DeficitReason::TestFileForTarget)
237 .collect();
238 assert!(
239 !test_suggestions.is_empty(),
240 "should suggest test files for FixBug"
241 );
242 }
243
244 #[test]
245 fn respects_budget() {
246 let mut ledger = ContextLedger::with_window_size(1000);
247 ledger.record("existing.rs", "full", 900, 900);
248 let intent = StructuredIntent::from_query("fix bug in big_file.rs");
249 let known = vec!["src/big_file.rs".to_string()];
250 let deficit = detect_deficit(&ledger, &intent, &known);
251 assert!(
252 deficit.suggested_files.is_empty() || deficit.budget_remaining < 200,
253 "should respect budget constraints"
254 );
255 }
256
257 #[test]
258 fn config_task_suggests_config_files() {
259 let ledger = ContextLedger::new();
260 let intent = StructuredIntent::from_query("configure env settings for the project");
261 let known = vec![
262 "src/main.rs".to_string(),
263 "Cargo.toml".to_string(),
264 "package.json".to_string(),
265 ];
266 let deficit = detect_deficit(&ledger, &intent, &known);
267 let config_suggestions: Vec<_> = deficit
268 .suggested_files
269 .iter()
270 .filter(|s| s.reason == DeficitReason::ConfigForTask)
271 .collect();
272 assert!(!config_suggestions.is_empty());
273 }
274
275 #[test]
276 fn mode_adapts_to_pressure() {
277 let mode_low = mode_for_pressure(&PressureAction::NoAction, &IntentScope::SingleFile);
278 let mode_high = mode_for_pressure(
279 &PressureAction::EvictLeastRelevant,
280 &IntentScope::SingleFile,
281 );
282 assert_eq!(mode_low, "full");
283 assert_eq!(mode_high, "reference");
284 }
285
286 #[test]
287 fn derive_test_paths_generates_variants() {
288 let paths = derive_test_paths("src/auth.rs");
289 assert!(paths.contains(&"auth_test".to_string()));
290 assert!(paths.contains(&"test_auth".to_string()));
291 assert!(paths.contains(&"auth.test".to_string()));
292 assert!(paths.contains(&"auth.spec".to_string()));
293 }
294}