1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_overlay::{OverlayOp, OverlayStore};
3
4#[derive(Debug, Clone)]
5pub struct PreDispatchResult {
6 pub overridden_mode: Option<String>,
7 pub reason: Option<&'static str>,
8}
9
10#[derive(Debug, Clone)]
11pub struct PostDispatchResult {
12 pub eviction_hint: Option<String>,
13 pub elicitation_hint: Option<String>,
14}
15
16pub fn pre_dispatch_read(
17 path: &str,
18 requested_mode: &str,
19 task: Option<&str>,
20 project_root: Option<&str>,
21) -> PreDispatchResult {
22 if requested_mode == "diff" {
23 return PreDispatchResult {
24 overridden_mode: None,
25 reason: None,
26 };
27 }
28
29 if let Some(root) = project_root {
30 let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
31 if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
32 return result;
33 }
34 }
35
36 if requested_mode == "full" {
37 return PreDispatchResult {
38 overridden_mode: None,
39 reason: None,
40 };
41 }
42
43 if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
44 if bt.should_force_full(path) {
45 return PreDispatchResult {
46 overridden_mode: Some("full".to_string()),
47 reason: Some("bounce-prevention"),
48 };
49 }
50 }
51
52 if let Some(task_str) = task {
53 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
54 let norm = crate::core::pathutil::normalize_tool_path(path);
55 let is_target = intent
56 .targets
57 .iter()
58 .any(|t| norm.ends_with(t) || norm.contains(t));
59 if is_target {
60 return PreDispatchResult {
61 overridden_mode: Some("full".to_string()),
62 reason: Some("intent-target"),
63 };
64 }
65 }
66
67 if let Some(root) = project_root {
68 if let Some(index) = try_load_graph(root) {
69 let related = index.get_related(path, 1);
70 if let Some(task_str) = task {
71 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
72 for target in &intent.targets {
73 let target_related = index.get_related(target, 1);
74 let norm = crate::core::pathutil::normalize_tool_path(path);
75 if target_related
76 .iter()
77 .any(|r| r.contains(&norm) || norm.contains(r))
78 {
79 return PreDispatchResult {
80 overridden_mode: Some("map".to_string()),
81 reason: Some("graph-direct-import"),
82 };
83 }
84 }
85 }
86 if !related.is_empty() && requested_mode == "auto" {
87 let reverse_deps = index.get_reverse_deps(path, 1);
88 if reverse_deps.len() > 3 {
89 return PreDispatchResult {
90 overridden_mode: Some("map".to_string()),
91 reason: Some("graph-hub-file"),
92 };
93 }
94 }
95 }
96 }
97
98 if let Some(root) = project_root {
99 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root) {
100 let norm = crate::core::pathutil::normalize_tool_path(path);
101 let mentions = knowledge
102 .facts
103 .iter()
104 .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
105 .count();
106 if mentions >= 3 {
107 return PreDispatchResult {
108 overridden_mode: Some("map".to_string()),
109 reason: Some("knowledge-high-relevance"),
110 };
111 }
112 }
113 }
114
115 PreDispatchResult {
116 overridden_mode: None,
117 reason: None,
118 }
119}
120
121fn check_overlay_mode_override(
122 path: &str,
123 requested_mode: &str,
124 overlay: &OverlayStore,
125) -> Option<PreDispatchResult> {
126 let item_id = ContextItemId::from_file(path);
127 let overlays = overlay.for_item(&item_id);
128
129 for ov in overlays.iter().rev() {
130 match &ov.operation {
131 OverlayOp::SetView(view) => {
132 let mode_str = view.as_str();
133 if mode_str != requested_mode {
134 return Some(PreDispatchResult {
135 overridden_mode: Some(mode_str.to_string()),
136 reason: Some("overlay-set-view"),
137 });
138 }
139 }
140 OverlayOp::Pin { .. } if requested_mode != "full" => {
141 return Some(PreDispatchResult {
142 overridden_mode: Some("full".to_string()),
143 reason: Some("pinned"),
144 });
145 }
146 OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
147 return Some(PreDispatchResult {
148 overridden_mode: Some("signatures".to_string()),
149 reason: Some("excluded"),
150 });
151 }
152 _ => {}
153 }
154 }
155 None
156}
157
158pub fn post_dispatch_record(
159 path: &str,
160 mode: &str,
161 original_tokens: usize,
162 sent_tokens: usize,
163 ledger: &mut crate::core::context_ledger::ContextLedger,
164 overlay: &crate::core::context_overlay::OverlayStore,
165) -> PostDispatchResult {
166 ledger.record(path, mode, original_tokens, sent_tokens);
167
168 let item_id = ContextItemId::from_file(path);
169 let state = overlay.apply_to_state(&item_id, ContextState::Included);
170
171 if state == ContextState::Excluded {
172 return PostDispatchResult {
173 eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
174 elicitation_hint: None,
175 };
176 }
177
178 let elicitation =
179 super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
180 .map(|s| s.format_fallback_hint());
181
182 let pressure = ledger.pressure();
183 if pressure.utilization > 0.9 {
184 let candidates = ledger.eviction_candidates_by_phi(3);
185 if !candidates.is_empty() {
186 let names: Vec<_> = candidates
187 .iter()
188 .take(3)
189 .map(|p| crate::core::protocol::shorten_path(p))
190 .collect();
191 return PostDispatchResult {
192 eviction_hint: Some(format!(
193 "Context pressure {:.0}%. Consider evicting: {}",
194 pressure.utilization * 100.0,
195 names.join(", ")
196 )),
197 elicitation_hint: elicitation,
198 };
199 }
200 }
201
202 PostDispatchResult {
203 eviction_hint: None,
204 elicitation_hint: elicitation,
205 }
206}
207
208fn try_load_graph(project_root: &str) -> Option<crate::core::graph_index::ProjectIndex> {
209 crate::core::graph_index::ProjectIndex::load(project_root)
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn pre_dispatch_passthrough_for_full() {
218 let result = pre_dispatch_read("src/main.rs", "full", None, None);
219 assert!(result.overridden_mode.is_none());
220 }
221
222 #[test]
223 fn pre_dispatch_passthrough_for_diff() {
224 let result = pre_dispatch_read("src/main.rs", "diff", None, None);
225 assert!(result.overridden_mode.is_none());
226 }
227
228 #[test]
229 fn pre_dispatch_no_override_without_signals() {
230 let result = pre_dispatch_read("src/unknown.rs", "auto", None, None);
231 assert!(result.overridden_mode.is_none());
232 }
233
234 #[test]
235 fn pre_dispatch_bounce_prevention_forces_full() {
236 {
237 let mut bt = crate::core::bounce_tracker::global().lock().unwrap();
238 bt.set_seq(1);
239 bt.record_read("src/bouncy.yml", "map", 30, 400);
240 bt.set_seq(2);
241 bt.record_read("src/bouncy.yml", "full", 400, 400);
242 bt.set_seq(3);
243 bt.record_read("a2.yml", "map", 30, 400);
244 bt.set_seq(4);
245 bt.record_read("a2.yml", "full", 400, 400);
246 bt.set_seq(5);
247 bt.record_read("a3.yml", "map", 30, 400);
248 bt.set_seq(6);
249 bt.record_read("a3.yml", "full", 400, 400);
250 }
251 let result = pre_dispatch_read("new.yml", "auto", None, None);
252 assert_eq!(result.overridden_mode, Some("full".to_string()));
253 assert_eq!(result.reason, Some("bounce-prevention"));
254 }
255
256 #[test]
257 fn overlay_pin_forces_full_mode() {
258 let dir = tempfile::tempdir().expect("tmp dir");
259 let root = dir.path();
260 let mut store = OverlayStore::new();
261 let target = ContextItemId::from_file("src/important.rs");
262 store.add(crate::core::context_overlay::ContextOverlay::new(
263 target,
264 OverlayOp::Pin { verbatim: false },
265 crate::core::context_overlay::OverlayScope::Project,
266 String::new(),
267 crate::core::context_overlay::OverlayAuthor::User,
268 ));
269 store.save_project(root).unwrap();
270
271 let result = pre_dispatch_read(
272 "src/important.rs",
273 "auto",
274 None,
275 Some(root.to_str().unwrap()),
276 );
277 assert_eq!(result.overridden_mode, Some("full".to_string()));
278 assert_eq!(result.reason, Some("pinned"));
279 }
280
281 #[test]
282 fn overlay_exclude_forces_signatures_mode() {
283 let dir = tempfile::tempdir().expect("tmp dir");
284 let root = dir.path();
285 let mut store = OverlayStore::new();
286 let target = ContextItemId::from_file("src/noisy.rs");
287 store.add(crate::core::context_overlay::ContextOverlay::new(
288 target,
289 OverlayOp::Exclude {
290 reason: "noise".to_string(),
291 },
292 crate::core::context_overlay::OverlayScope::Project,
293 String::new(),
294 crate::core::context_overlay::OverlayAuthor::User,
295 ));
296 store.save_project(root).unwrap();
297
298 let result = pre_dispatch_read("src/noisy.rs", "auto", None, Some(root.to_str().unwrap()));
299 assert_eq!(result.overridden_mode, Some("signatures".to_string()));
300 assert_eq!(result.reason, Some("excluded"));
301 }
302
303 #[test]
304 fn overlay_set_view_forces_specified_mode() {
305 let dir = tempfile::tempdir().expect("tmp dir");
306 let root = dir.path();
307 let mut store = OverlayStore::new();
308 let target = ContextItemId::from_file("src/big.rs");
309 store.add(crate::core::context_overlay::ContextOverlay::new(
310 target,
311 OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
312 crate::core::context_overlay::OverlayScope::Project,
313 String::new(),
314 crate::core::context_overlay::OverlayAuthor::User,
315 ));
316 store.save_project(root).unwrap();
317
318 let result = pre_dispatch_read("src/big.rs", "auto", None, Some(root.to_str().unwrap()));
319 assert_eq!(result.overridden_mode, Some("map".to_string()));
320 assert_eq!(result.reason, Some("overlay-set-view"));
321 }
322}