code_system_graph_hooks/
routing.rs1use 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#[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
41pub 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}