1use std::collections::BTreeMap;
2use std::fs;
3use std::io::Write;
4use std::path::Path;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use atomic_write_file::AtomicWriteFile;
8use serde::{Deserialize, Serialize};
9
10use crate::types::{HookError, RoutingIntent, RoutingRequest, RoutingResponse};
11
12const FEDERATED_CODEGRAPH_GUIDANCE: &str = "Use Code System Graph first for federated contracts, architecture, impact, diff, or PR-overlap context; use explore for repository-local source and symbol detail.";
13const LOCAL_CODEGRAPH_GUIDANCE: &str = "Use Code System Graph explore first for repository-local symbols, callers, tests, and implementation detail; use CodeGraph directly only if the provider is degraded.";
14const FEDERATED_NATIVE_GUIDANCE: &str = "Use Code System Graph first for federated contracts, architecture, impact, diff, or PR-overlap context. Repository-local source and symbol detail is unavailable in the native-only profile.";
15const LOCAL_NATIVE_GUIDANCE: &str = "Use Code System Graph for persisted repository entities, relationships, and source-free evidence. Repository-local source and symbol detail is unavailable in the native-only profile.";
16
17const FEDERATED_SIGNALS: &[&str] = &[
18 "cross-repo",
19 "cross repo",
20 "multiple repos",
21 "across repos",
22 "contract",
23 "api boundary",
24 "architecture",
25 "architectural",
26 "impact",
27 "blast radius",
28 "diff",
29 "pull request",
30 "pull-request",
31 "pr overlap",
32 "overlapping pr",
33 "dependency graph",
34 "service boundary",
35 "federated",
36];
37
38const LOCAL_SIGNALS: &[&str] = &[
39 "repository",
40 "repo",
41 "code",
42 "symbol",
43 "function",
44 "method",
45 "class",
46 "module",
47 "file",
48 "test",
49 "bug",
50 "refactor",
51 "implement",
52 "caller",
53 "call site",
54];
55
56#[derive(Debug, Default, Serialize, Deserialize)]
57struct DedupState {
58 entries: BTreeMap<String, u64>,
59}
60
61#[must_use]
63pub fn classify_prompt(prompt: &str) -> RoutingIntent {
64 let normalized = prompt.to_lowercase();
65 if FEDERATED_SIGNALS
66 .iter()
67 .any(|signal| normalized.contains(signal))
68 {
69 RoutingIntent::Federated
70 } else if LOCAL_SIGNALS
71 .iter()
72 .any(|signal| normalized.contains(signal))
73 {
74 RoutingIntent::LocalRepository
75 } else {
76 RoutingIntent::None
77 }
78}
79
80pub fn route(request: &RoutingRequest) -> Result<RoutingResponse, HookError> {
90 let prompt = request
91 .event
92 .get("prompt")
93 .and_then(serde_json::Value::as_str)
94 .ok_or(HookError::MissingPrompt)?;
95 let intent = classify_prompt(prompt);
96 let guidance = guidance_for(intent, request.codegraph_enabled);
97 let Some(guidance) = guidance else {
98 return Ok(RoutingResponse {
99 intent,
100 guidance: None,
101 deduplicated: false,
102 });
103 };
104
105 let now = unix_seconds()?;
106 let state_path = dedup_state_path(request);
107 let mut state = load_state(&state_path)?;
108 state.entries.retain(|_, expires_at| *expires_at > now);
109 let session = request
110 .event
111 .get("session_id")
112 .and_then(serde_json::Value::as_str)
113 .unwrap_or("session-unavailable");
114 let key = dedup_key(request, session);
115 if state.entries.contains_key(&key) {
116 return Ok(RoutingResponse {
117 intent,
118 guidance: None,
119 deduplicated: true,
120 });
121 }
122
123 state
124 .entries
125 .insert(key, now.saturating_add(request.ttl_seconds));
126 write_state(&state_path, &state)?;
127 Ok(RoutingResponse {
128 intent,
129 guidance: Some(guidance.to_owned()),
130 deduplicated: false,
131 })
132}
133
134fn guidance_for(intent: RoutingIntent, codegraph_enabled: bool) -> Option<&'static str> {
135 match (intent, codegraph_enabled) {
136 (RoutingIntent::None, _) => None,
137 (RoutingIntent::LocalRepository, true) => Some(LOCAL_CODEGRAPH_GUIDANCE),
138 (RoutingIntent::Federated, true) => Some(FEDERATED_CODEGRAPH_GUIDANCE),
139 (RoutingIntent::LocalRepository, false) => Some(LOCAL_NATIVE_GUIDANCE),
140 (RoutingIntent::Federated, false) => Some(FEDERATED_NATIVE_GUIDANCE),
141 }
142}
143
144fn dedup_state_path(request: &RoutingRequest) -> std::path::PathBuf {
145 request
146 .root
147 .join(".code-system-graph")
148 .join("hooks")
149 .join(format!("dedup-{}.json", request.host.as_str()))
150}
151
152fn dedup_key(request: &RoutingRequest, session: &str) -> String {
153 let mut hasher = blake3::Hasher::new();
154 hasher.update(request.host.as_str().as_bytes());
155 hasher.update(&[0]);
156 hasher.update(request.root.as_os_str().as_encoded_bytes());
157 hasher.update(&[0]);
158 hasher.update(session.as_bytes());
159 hasher.finalize().to_hex().to_string()
160}
161
162fn load_state(path: &Path) -> Result<DedupState, HookError> {
163 match fs::read(path) {
164 Ok(bytes) => serde_json::from_slice(&bytes).map_err(HookError::from),
165 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(DedupState::default()),
166 Err(source) => Err(HookError::Io {
167 path: path.to_path_buf(),
168 source,
169 }),
170 }
171}
172
173fn write_state(path: &Path, state: &DedupState) -> Result<(), HookError> {
174 let parent = path
175 .parent()
176 .ok_or_else(|| HookError::InvalidConfiguration {
177 path: path.to_path_buf(),
178 message: "state path has no parent".to_owned(),
179 })?;
180 fs::create_dir_all(parent).map_err(|source| HookError::Io {
181 path: parent.to_path_buf(),
182 source,
183 })?;
184 let bytes = serde_json::to_vec(state)?;
185 let mut destination = AtomicWriteFile::open(path).map_err(|source| HookError::Io {
186 path: path.to_path_buf(),
187 source,
188 })?;
189 destination
190 .write_all(&bytes)
191 .and_then(|()| destination.sync_all())
192 .map_err(|source| HookError::Io {
193 path: path.to_path_buf(),
194 source,
195 })?;
196 destination.commit().map_err(|source| HookError::Io {
197 path: path.to_path_buf(),
198 source,
199 })?;
200 restrict_file(path)
201}
202
203fn unix_seconds() -> Result<u64, HookError> {
204 SystemTime::now()
205 .duration_since(UNIX_EPOCH)
206 .map(|duration| duration.as_secs())
207 .map_err(|_| HookError::InvalidSystemTime)
208}
209
210#[cfg(unix)]
211fn restrict_file(path: &Path) -> Result<(), HookError> {
212 use std::os::unix::fs::PermissionsExt;
213
214 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| HookError::Io {
215 path: path.to_path_buf(),
216 source,
217 })
218}
219
220#[cfg(not(unix))]
221fn restrict_file(_path: &Path) -> Result<(), HookError> {
222 Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227 use super::{RoutingIntent, guidance_for};
228
229 #[test]
230 fn guidance_should_follow_codegraph_policy() {
231 for intent in [RoutingIntent::LocalRepository, RoutingIntent::Federated] {
232 let native = guidance_for(intent, false).expect("native guidance");
233 let enriched = guidance_for(intent, true).expect("CodeGraph guidance");
234
235 assert!(!native.contains("explore"));
236 assert!(enriched.contains("explore"));
237 }
238 }
239}