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