Skip to main content

lean_ctx/core/
context_deficit.rs

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}