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::templates::{
11    FEDERATED_CODEGRAPH_GUIDANCE, FEDERATED_NATIVE_GUIDANCE, FEDERATED_SIGNALS, LOCAL_CODEGRAPH_GUIDANCE, LOCAL_NATIVE_GUIDANCE, LOCAL_SIGNALS
12};
13use crate::types::{HookError, RoutingIntent, RoutingRequest, RoutingResponse};
14
15#[derive(Debug, Default, Serialize, Deserialize)]
16struct DedupState {
17    entries: BTreeMap<String, u64>,
18}
19
20/// Classifies a prompt without reading source, running tools, or retaining prompt text.
21#[must_use]
22pub fn classify_prompt(prompt: &str) -> RoutingIntent {
23    let normalized = prompt.to_lowercase();
24    if contains_signal(FEDERATED_SIGNALS, &normalized) {
25        RoutingIntent::Federated
26    } else if contains_signal(LOCAL_SIGNALS, &normalized) {
27        RoutingIntent::LocalRepository
28    } else {
29        RoutingIntent::None
30    }
31}
32
33fn contains_signal(signals: &str, normalized_prompt: &str) -> bool {
34    signals
35        .lines()
36        .map(str::trim)
37        .filter(|signal| !signal.is_empty())
38        .any(|signal| normalized_prompt.contains(signal))
39}
40
41/// Classifies one host event and applies session/repository TTL deduplication.
42///
43/// Only the top-level `prompt` and optional `session_id` fields are read. Prompt text is never
44/// written to disk or included in the returned guidance.
45///
46/// # Errors
47///
48/// Returns [`HookError`] when the event has no textual prompt, the clock is invalid, or the
49/// deduplication state cannot be read or atomically written.
50pub fn route(request: &RoutingRequest) -> Result<RoutingResponse, HookError> {
51    let prompt = request
52        .event
53        .get("prompt")
54        .and_then(serde_json::Value::as_str)
55        .ok_or(HookError::MissingPrompt)?;
56    let intent = classify_prompt(prompt);
57    let guidance = guidance_for(intent, request.codegraph_enabled);
58    let Some(guidance) = guidance else {
59        return Ok(RoutingResponse {
60            intent,
61            guidance: None,
62            deduplicated: false,
63        });
64    };
65
66    let now = unix_seconds()?;
67    let state_path = dedup_state_path(request);
68    let mut state = load_state(&state_path)?;
69    state.entries.retain(|_, expires_at| *expires_at > now);
70    let session = request
71        .event
72        .get("session_id")
73        .and_then(serde_json::Value::as_str)
74        .unwrap_or("session-unavailable");
75    let key = dedup_key(request, session);
76    if state.entries.contains_key(&key) {
77        return Ok(RoutingResponse {
78            intent,
79            guidance: None,
80            deduplicated: true,
81        });
82    }
83
84    state
85        .entries
86        .insert(key, now.saturating_add(request.ttl_seconds));
87    write_state(&state_path, &state)?;
88    Ok(RoutingResponse {
89        intent,
90        guidance: Some(guidance.to_owned()),
91        deduplicated: false,
92    })
93}
94
95fn guidance_for(intent: RoutingIntent, codegraph_enabled: bool) -> Option<&'static str> {
96    match (intent, codegraph_enabled) {
97        (RoutingIntent::None, _) => None,
98        (RoutingIntent::LocalRepository, true) => Some(LOCAL_CODEGRAPH_GUIDANCE.trim_end()),
99        (RoutingIntent::Federated, true) => Some(FEDERATED_CODEGRAPH_GUIDANCE.trim_end()),
100        (RoutingIntent::LocalRepository, false) => Some(LOCAL_NATIVE_GUIDANCE.trim_end()),
101        (RoutingIntent::Federated, false) => Some(FEDERATED_NATIVE_GUIDANCE.trim_end()),
102    }
103}
104
105fn dedup_state_path(request: &RoutingRequest) -> std::path::PathBuf {
106    request
107        .root
108        .join(".code-system-graph")
109        .join("hooks")
110        .join(format!("dedup-{}.json", request.host.as_str()))
111}
112
113fn dedup_key(request: &RoutingRequest, session: &str) -> String {
114    let mut hasher = blake3::Hasher::new();
115    hasher.update(request.host.as_str().as_bytes());
116    hasher.update(&[0]);
117    hasher.update(request.root.as_os_str().as_encoded_bytes());
118    hasher.update(&[0]);
119    hasher.update(session.as_bytes());
120    hasher.finalize().to_hex().to_string()
121}
122
123fn load_state(path: &Path) -> Result<DedupState, HookError> {
124    match fs::read(path) {
125        Ok(bytes) => serde_json::from_slice(&bytes).map_err(HookError::from),
126        Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(DedupState::default()),
127        Err(source) => Err(HookError::Io {
128            path: path.to_path_buf(),
129            source,
130        }),
131    }
132}
133
134fn write_state(path: &Path, state: &DedupState) -> Result<(), HookError> {
135    let parent = path
136        .parent()
137        .ok_or_else(|| HookError::InvalidConfiguration {
138            path: path.to_path_buf(),
139            message: "state path has no parent".to_owned(),
140        })?;
141    fs::create_dir_all(parent).map_err(|source| HookError::Io {
142        path: parent.to_path_buf(),
143        source,
144    })?;
145    let bytes = serde_json::to_vec(state)?;
146    let mut destination = AtomicWriteFile::open(path).map_err(|source| HookError::Io {
147        path: path.to_path_buf(),
148        source,
149    })?;
150    destination
151        .write_all(&bytes)
152        .and_then(|()| destination.sync_all())
153        .map_err(|source| HookError::Io {
154            path: path.to_path_buf(),
155            source,
156        })?;
157    destination.commit().map_err(|source| HookError::Io {
158        path: path.to_path_buf(),
159        source,
160    })?;
161    restrict_file(path)
162}
163
164fn unix_seconds() -> Result<u64, HookError> {
165    SystemTime::now()
166        .duration_since(UNIX_EPOCH)
167        .map(|duration| duration.as_secs())
168        .map_err(|_| HookError::InvalidSystemTime)
169}
170
171#[cfg(unix)]
172fn restrict_file(path: &Path) -> Result<(), HookError> {
173    use std::os::unix::fs::PermissionsExt;
174
175    fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| HookError::Io {
176        path: path.to_path_buf(),
177        source,
178    })
179}
180
181#[cfg(not(unix))]
182fn restrict_file(_path: &Path) -> Result<(), HookError> {
183    Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{RoutingIntent, guidance_for};
189
190    #[test]
191    fn guidance_should_follow_codegraph_policy() {
192        for intent in [RoutingIntent::LocalRepository, RoutingIntent::Federated] {
193            let native = guidance_for(intent, false).expect("native guidance");
194            let enriched = guidance_for(intent, true).expect("CodeGraph guidance");
195
196            assert!(!native.contains("explore"));
197            assert!(enriched.contains("explore"));
198            assert!(native.contains("Follow the installed Code System Graph skill"));
199            assert!(enriched.contains("Follow the installed Code System Graph skill"));
200            assert!(!native.contains("routing path"));
201            assert!(!enriched.contains("routing path"));
202        }
203    }
204}