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!(
211 deficit
212 .suggested_files
213 .iter()
214 .any(|s| s.path.contains("auth"))
215 );
216 }
217
218 #[test]
219 fn no_deficit_when_loaded() {
220 let mut ledger = ContextLedger::new();
221 ledger.record("src/auth.rs", "full", 500, 500);
222 let intent = StructuredIntent::from_query("fix bug in auth.rs");
223 let known = vec!["src/auth.rs".to_string()];
224 let deficit = detect_deficit(&ledger, &intent, &known);
225 assert!(deficit.missing_targets.is_empty());
226 }
227
228 #[test]
229 fn suggests_test_files_for_fixbug() {
230 let mut ledger = ContextLedger::new();
231 ledger.record("src/auth.rs", "full", 500, 500);
232 let intent = StructuredIntent::from_query("fix bug in auth.rs");
233 let known = vec!["src/auth.rs".to_string(), "tests/auth_test.rs".to_string()];
234 let deficit = detect_deficit(&ledger, &intent, &known);
235 let test_suggestions: Vec<_> = deficit
236 .suggested_files
237 .iter()
238 .filter(|s| s.reason == DeficitReason::TestFileForTarget)
239 .collect();
240 assert!(
241 !test_suggestions.is_empty(),
242 "should suggest test files for FixBug"
243 );
244 }
245
246 #[test]
247 fn respects_budget() {
248 let mut ledger = ContextLedger::with_window_size(1000);
249 ledger.record("existing.rs", "full", 900, 900);
250 let intent = StructuredIntent::from_query("fix bug in big_file.rs");
251 let known = vec!["src/big_file.rs".to_string()];
252 let deficit = detect_deficit(&ledger, &intent, &known);
253 assert!(
254 deficit.suggested_files.is_empty() || deficit.budget_remaining < 200,
255 "should respect budget constraints"
256 );
257 }
258
259 #[test]
260 fn config_task_suggests_config_files() {
261 let ledger = ContextLedger::new();
262 let intent = StructuredIntent::from_query("configure env settings for the project");
263 let known = vec![
264 "src/main.rs".to_string(),
265 "Cargo.toml".to_string(),
266 "package.json".to_string(),
267 ];
268 let deficit = detect_deficit(&ledger, &intent, &known);
269 let config_suggestions: Vec<_> = deficit
270 .suggested_files
271 .iter()
272 .filter(|s| s.reason == DeficitReason::ConfigForTask)
273 .collect();
274 assert!(!config_suggestions.is_empty());
275 }
276
277 #[test]
278 fn mode_adapts_to_pressure() {
279 let mode_low = mode_for_pressure(&PressureAction::NoAction, &IntentScope::SingleFile);
280 let mode_high = mode_for_pressure(
281 &PressureAction::EvictLeastRelevant,
282 &IntentScope::SingleFile,
283 );
284 assert_eq!(mode_low, "full");
285 assert_eq!(mode_high, "reference");
286 }
287
288 #[test]
289 fn derive_test_paths_generates_variants() {
290 let paths = derive_test_paths("src/auth.rs");
291 assert!(paths.contains(&"auth_test".to_string()));
292 assert!(paths.contains(&"test_auth".to_string()));
293 assert!(paths.contains(&"auth.test".to_string()));
294 assert!(paths.contains(&"auth.spec".to_string()));
295 }
296}