Skip to main content

code_system_graph_hooks/
routing.rs

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/// Classifies a prompt without reading source, running tools, or retaining prompt text.
62#[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
80/// Classifies one host event and applies session/repository TTL deduplication.
81///
82/// Only the top-level `prompt` and optional `session_id` fields are read. Prompt text is never
83/// written to disk or included in the returned guidance.
84///
85/// # Errors
86///
87/// Returns [`HookError`] when the event has no textual prompt, the clock is invalid, or the
88/// deduplication state cannot be read or atomically written.
89pub 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}