1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value, json};
7
8use crate::managed_root::{MAX_HOST_FILE_BYTES, ManagedRoot};
9use crate::types::{
10 HookError, HookMode, HookStatus, HostKind, InstallReport, InstallRequest, UninstallReport
11};
12
13const PRODUCT_MARKER: &str = "code-system-graph-hooks:v1";
14const STATE_DIRECTORY: &str = ".code-system-graph/hooks";
15const GENERATED_STATE_IGNORE_RULE: &[u8] = b".code-system-graph/";
16const CURSOR_LIMITATION: &str = "Cursor beforeSubmitPrompt can allow or block but cannot inject advisory routing context; installed an always-on project rule instead.";
17const ANTIGRAVITY_LIMITATION: &str = "Antigravity IDE does not document a stable project-file prompt hook protocol; installed a workspace rule instead.";
18
19#[derive(Debug, Clone, Copy)]
20enum HostProtocol {
21 Json { event: &'static str },
22 Guidance,
23}
24
25#[derive(Debug)]
26struct HostSpec {
27 path: PathBuf,
28 protocol: HostProtocol,
29 limitation: Option<&'static str>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33struct InstallState {
34 marker: String,
35 host: HostKind,
36 mode: HookMode,
37 host_file: PathBuf,
38 #[serde(default)]
39 codegraph_enabled: bool,
40}
41
42pub fn install(request: &InstallRequest) -> Result<InstallReport, HookError> {
52 validate_request(request)?;
53 let managed = ManagedRoot::open(&request.root)?;
54 let spec = host_spec(request);
55 let state_relative = state_relative_path(request);
56 let mut backups = Vec::new();
57 let mut warnings = duplicate_warnings(&managed, request, &spec)?;
58 let runtime = request.code_system_graph_binary.with_file_name(format!(
59 "code-system-graph-hooks{}",
60 std::env::consts::EXE_SUFFIX
61 ));
62
63 let routing_changed = match spec.protocol {
64 HostProtocol::Json { event } => {
65 install_json_hook(&managed, request, &spec.path, event, &runtime, &mut backups)?
66 }
67 HostProtocol::Guidance => install_guidance(&managed, request, &spec.path, &mut backups)?,
68 };
69 let strict_changed = if request.mode == HookMode::Strict {
70 install_strict_gate(&managed, request, &mut backups, &mut warnings)?
71 } else {
72 remove_strict_gate(&managed, request, &mut backups, &mut Vec::new())?
73 };
74 let state = InstallState {
75 marker: PRODUCT_MARKER.to_owned(),
76 host: request.host,
77 mode: request.mode,
78 host_file: managed.absolute(&spec.path),
79 codegraph_enabled: request.codegraph_enabled,
80 };
81 let state_changed =
82 write_json_if_changed(&managed, &state_relative, &state, false, &mut backups)?;
83 restrict_file(&managed.absolute(&state_relative))?;
84 let (gitignore_path, gitignore_updated) = configure_generated_state_ignore(&managed)?;
85
86 Ok(InstallReport {
87 changed: routing_changed || strict_changed || state_changed || gitignore_updated,
88 host_file: managed.absolute(&spec.path),
89 state_file: managed.absolute(&state_relative),
90 gitignore_path,
91 gitignore_updated,
92 backups,
93 warnings,
94 limitation: spec.limitation.map(str::to_owned),
95 })
96}
97
98pub fn status(request: &InstallRequest) -> Result<HookStatus, HookError> {
104 validate_request(request)?;
105 let managed = ManagedRoot::open(&request.root)?;
106 let spec = host_spec(request);
107 let state_relative = state_relative_path(request);
108 let installed_state = read_install_state(&managed, &state_relative)?;
109 let policy = installed_state
110 .as_ref()
111 .map_or(request.codegraph_enabled, |state| state.codegraph_enabled);
112 let runtime = request.code_system_graph_binary.with_file_name(format!(
113 "code-system-graph-hooks{}",
114 std::env::consts::EXE_SUFFIX
115 ));
116 let routing_installed = match spec.protocol {
117 HostProtocol::Json { event } => {
118 json_hook_installed(&managed, request, &spec.path, event, &runtime, policy)?
119 }
120 HostProtocol::Guidance => {
121 file_contains(&managed, &spec.path, &guidance_block(request.host, policy))?
122 }
123 };
124 let strict_gate_installed = if request.mode == HookMode::Strict {
125 file_contains(
126 &managed,
127 &git_pre_commit(&managed)?,
128 &begin_marker(request.host),
129 )?
130 } else if let Some(path) = optional_git_pre_commit(&managed)? {
131 file_contains(&managed, &path, &begin_marker(request.host))?
132 } else {
133 false
134 };
135 let policy_matches = installed_state
136 .as_ref()
137 .is_none_or(|state| state.codegraph_enabled == request.codegraph_enabled);
138 let installed = routing_installed
139 && (request.mode == HookMode::Advisory || strict_gate_installed)
140 && installed_state.is_some()
141 && policy_matches;
142 let warnings = duplicate_warnings(&managed, request, &spec)?;
143
144 Ok(HookStatus {
145 installed,
146 routing_installed,
147 strict_gate_installed,
148 host_file: managed.absolute(&spec.path),
149 state_file: managed.absolute(&state_relative),
150 warnings,
151 limitation: spec.limitation.map(str::to_owned),
152 })
153}
154
155pub fn uninstall(request: &InstallRequest) -> Result<UninstallReport, HookError> {
165 validate_request(request)?;
166 let managed = ManagedRoot::open(&request.root)?;
167 let spec = host_spec(request);
168 let state_relative = state_relative_path(request);
169 let mut backups = Vec::new();
170 let mut removed_files = Vec::new();
171 let mut warnings = Vec::new();
172
173 let routing_changed = match spec.protocol {
174 HostProtocol::Json { event } => {
175 uninstall_json_hook(&managed, &spec.path, event, &mut backups)?
176 }
177 HostProtocol::Guidance => remove_guidance(
178 &managed,
179 request,
180 &spec.path,
181 &mut backups,
182 &mut removed_files,
183 )?,
184 };
185 let strict_changed = remove_strict_gate(&managed, request, &mut backups, &mut removed_files)?;
186 let state_changed = if managed.remove_file_if_exists(&state_relative)? {
187 removed_files.push(managed.absolute(&state_relative));
188 true
189 } else {
190 false
191 };
192
193 warnings.extend(duplicate_warnings(&managed, request, &spec)?);
194 Ok(UninstallReport {
195 changed: routing_changed || strict_changed || state_changed,
196 backups,
197 removed_files,
198 warnings,
199 })
200}
201
202fn validate_request(request: &InstallRequest) -> Result<(), HookError> {
203 if request.workspace.trim().is_empty() {
204 return Err(HookError::InvalidConfiguration {
205 path: request.root.clone(),
206 message: "workspace name must not be empty".to_owned(),
207 });
208 }
209 if request.repository.trim().is_empty() {
210 return Err(HookError::InvalidConfiguration {
211 path: request.root.clone(),
212 message: "repository alias must not be empty".to_owned(),
213 });
214 }
215 Ok(())
216}
217
218fn host_spec(request: &InstallRequest) -> HostSpec {
219 let (relative, protocol, limitation) = match request.host {
220 HostKind::ClaudeCode => (
221 ".claude/settings.local.json",
222 HostProtocol::Json {
223 event: "UserPromptSubmit",
224 },
225 None,
226 ),
227 HostKind::Codex => (
228 ".codex/hooks.json",
229 HostProtocol::Json {
230 event: "UserPromptSubmit",
231 },
232 None,
233 ),
234 HostKind::Gemini => (
235 ".gemini/settings.json",
236 HostProtocol::Json {
237 event: "BeforeAgent",
238 },
239 None,
240 ),
241 HostKind::Antigravity => (
242 ".agents/rules/code-system-graph-routing.md",
243 HostProtocol::Guidance,
244 Some(ANTIGRAVITY_LIMITATION),
245 ),
246 HostKind::Cursor => (
247 ".cursor/rules/code-system-graph-routing.mdc",
248 HostProtocol::Guidance,
249 Some(CURSOR_LIMITATION),
250 ),
251 };
252 HostSpec {
253 path: PathBuf::from(relative),
254 protocol,
255 limitation,
256 }
257}
258
259fn state_relative_path(request: &InstallRequest) -> PathBuf {
260 PathBuf::from(STATE_DIRECTORY).join(format!("install-{}.json", request.host.as_str()))
261}
262
263fn read_install_state(
264 managed: &ManagedRoot,
265 relative: &Path,
266) -> Result<Option<InstallState>, HookError> {
267 let Some(content) = managed.read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)? else {
268 return Ok(None);
269 };
270 let state =
271 serde_json::from_str(&content).map_err(|error| HookError::InvalidConfiguration {
272 path: managed.absolute(relative),
273 message: error.to_string(),
274 })?;
275 Ok(Some(state))
276}
277
278fn configure_generated_state_ignore(
279 managed: &ManagedRoot,
280) -> Result<(Option<PathBuf>, bool), HookError> {
281 let canonical_root = managed.root();
282 if !belongs_to_git_worktree(canonical_root) {
283 return Ok((None, false));
284 }
285 let (relative, updated) = ensure_generated_state_ignored(managed)?;
286 Ok((Some(managed.absolute(&relative)), updated))
287}
288
289fn belongs_to_git_worktree(root: &Path) -> bool {
290 root.ancestors()
291 .any(|ancestor| valid_git_worktree_marker(&ancestor.join(".git")))
292}
293
294fn valid_git_worktree_marker(marker: &Path) -> bool {
295 let Ok(metadata) = fs::symlink_metadata(marker) else {
296 return false;
297 };
298 if metadata.file_type().is_dir() {
299 return fs::symlink_metadata(marker.join("HEAD"))
300 .is_ok_and(|head| head.file_type().is_file());
301 }
302 if !metadata.file_type().is_file() || metadata.len() > 4_096 {
303 return false;
304 }
305 let Ok(source) = fs::read_to_string(marker) else {
306 return false;
307 };
308 source
309 .lines()
310 .next()
311 .is_some_and(|line| line.trim_start().starts_with("gitdir:"))
312}
313
314fn ensure_generated_state_ignored(managed: &ManagedRoot) -> Result<(PathBuf, bool), HookError> {
315 let relative = PathBuf::from(".gitignore");
316 let content = managed
317 .read_optional_bytes_bounded(&relative, MAX_HOST_FILE_BYTES)?
318 .unwrap_or_default();
319 if generated_state_is_ignored(&content) {
320 return Ok((relative, false));
321 }
322 let mut updated = content;
323 if !updated.is_empty() && !updated.ends_with(b"\n") {
324 updated.push(b'\n');
325 }
326 updated.extend_from_slice(GENERATED_STATE_IGNORE_RULE);
327 updated.push(b'\n');
328 managed.atomic_write(&relative, &updated)?;
329 Ok((relative, true))
330}
331
332fn generated_state_is_ignored(content: &[u8]) -> bool {
333 content
334 .split(|byte| *byte == b'\n')
335 .map(|line| line.strip_suffix(b"\r").map_or(line, |trimmed| trimmed))
336 .fold(None, |state, line| match line {
337 b".code-system-graph/"
338 | b"/.code-system-graph/"
339 | b".code-system-graph"
340 | b"/.code-system-graph" => Some(true),
341 b"!.code-system-graph/"
342 | b"!/.code-system-graph/"
343 | b"!.code-system-graph"
344 | b"!/.code-system-graph" => Some(false),
345 _ => state,
346 })
347 .unwrap_or(false)
348}
349
350fn install_json_hook(
351 managed: &ManagedRoot,
352 request: &InstallRequest,
353 relative: &Path,
354 event: &str,
355 runtime: &Path,
356 backups: &mut Vec<PathBuf>,
357) -> Result<bool, HookError> {
358 let (mut root, existed) = read_json_object(managed, relative)?;
359 let hooks = object_field_mut(&mut root, "hooks", managed, relative)?;
360 let entries = array_field_mut(hooks, event, managed, relative)?;
361 let owned = owned_json_entry(request, runtime);
362 if entries.iter().any(is_owned_json) {
363 if entries.iter().any(|entry| entry == &owned) {
364 return Ok(false);
365 }
366 entries.retain(|entry| !is_owned_json(entry));
367 }
368 entries.push(owned);
369 write_value(managed, relative, &root, existed, backups)
370}
371
372fn owned_json_entry(request: &InstallRequest, runtime: &Path) -> Value {
373 owned_json_entry_with_policy(request, runtime, request.codegraph_enabled)
374}
375
376fn owned_json_entry_with_policy(
377 request: &InstallRequest,
378 runtime: &Path,
379 codegraph_enabled: bool,
380) -> Value {
381 let command = format!(
382 "{} route --host {} --root {} --codegraph-enabled {} --marker {}",
383 shell_quote(runtime.as_os_str().to_string_lossy().as_ref()),
384 request.host.as_str(),
385 shell_quote(request.root.as_os_str().to_string_lossy().as_ref()),
386 codegraph_enabled,
387 PRODUCT_MARKER
388 );
389 match request.host {
390 HostKind::ClaudeCode | HostKind::Codex => json!({
391 "hooks": [{
392 "type": "command",
393 "command": command,
394 "timeout": 5,
395 "statusMessage": "Selecting repository intelligence"
396 }]
397 }),
398 HostKind::Gemini => json!({
399 "matcher": "*",
400 "hooks": [{
401 "name": PRODUCT_MARKER,
402 "type": "command",
403 "command": command,
404 "timeout": 5000,
405 "description": "Select Code System Graph or CodeGraph from prompt intent"
406 }]
407 }),
408 HostKind::Antigravity | HostKind::Cursor => Value::Null,
409 }
410}
411
412fn uninstall_json_hook(
413 managed: &ManagedRoot,
414 relative: &Path,
415 event: &str,
416 backups: &mut Vec<PathBuf>,
417) -> Result<bool, HookError> {
418 if !managed.regular_file_exists(relative)? {
419 return Ok(false);
420 }
421 let (mut root, _) = read_json_object(managed, relative)?;
422 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
423 return Ok(false);
424 };
425 let Some(entries) = hooks.get_mut(event).and_then(Value::as_array_mut) else {
426 return Ok(false);
427 };
428 let original_len = entries.len();
429 entries.retain(|entry| !is_owned_json(entry));
430 if entries.len() == original_len {
431 return Ok(false);
432 }
433 if entries.is_empty() {
434 hooks.remove(event);
435 }
436 if hooks.is_empty() {
437 root.as_object_mut().map(|object| object.remove("hooks"));
438 }
439 write_value(managed, relative, &root, true, backups)
440}
441
442fn json_hook_installed(
443 managed: &ManagedRoot,
444 request: &InstallRequest,
445 relative: &Path,
446 event: &str,
447 runtime: &Path,
448 policy: bool,
449) -> Result<bool, HookError> {
450 if !managed.regular_file_exists(relative)? {
451 return Ok(false);
452 }
453 let owned = owned_json_entry_with_policy(request, runtime, policy);
454 let (root, _) = read_json_object(managed, relative)?;
455 Ok(root
456 .get("hooks")
457 .and_then(|hooks| hooks.get(event))
458 .and_then(Value::as_array)
459 .is_some_and(|entries| entries.iter().any(|entry| entry == &owned)))
460}
461
462fn install_guidance(
463 managed: &ManagedRoot,
464 request: &InstallRequest,
465 relative: &Path,
466 backups: &mut Vec<PathBuf>,
467) -> Result<bool, HookError> {
468 let existing = read_optional_string(managed, relative)?;
469 let marker = begin_marker(request.host);
470 let block = guidance_block(request.host, request.codegraph_enabled);
471 if existing
472 .as_deref()
473 .is_some_and(|content| content.contains(&block))
474 {
475 return Ok(false);
476 }
477 let existing = match existing {
478 Some(content) if content.contains(&marker) => {
479 let without_owned = remove_marked_block(&content, request.host).ok_or_else(|| {
480 HookError::InvalidConfiguration {
481 path: managed.absolute(relative),
482 message: "managed guidance has an incomplete marker block".to_owned(),
483 }
484 })?;
485 (!without_owned.trim().is_empty()).then_some(without_owned)
486 }
487 other => other,
488 };
489 let updated = match existing {
490 Some(mut content) => {
491 if !content.ends_with('\n') {
492 content.push('\n');
493 }
494 content.push('\n');
495 content.push_str(&block);
496 content
497 }
498 None => guidance_scaffold(request.host, &block),
499 };
500 write_string(
501 managed,
502 relative,
503 &updated,
504 managed.regular_file_exists(relative)?,
505 backups,
506 )
507}
508
509fn guidance_scaffold(host: HostKind, block: &str) -> String {
510 if host == HostKind::Cursor {
511 format!(
512 "---\ndescription: Code System Graph routing guidance ({PRODUCT_MARKER})\nalwaysApply: true\n---\n\n{block}"
513 )
514 } else {
515 block.to_owned()
516 }
517}
518
519fn guidance_block(host: HostKind, codegraph_enabled: bool) -> String {
520 let routing = if codegraph_enabled {
521 "- For work local to this repository, use Code System Graph explore first and use CodeGraph directly only if the provider is degraded.\n- For cross-repository work, contracts, architecture, impact, diffs, or pull-request overlap, use Code System Graph first and explore for local symbol detail."
522 } else {
523 "- For repository-local work, use Code System Graph only for persisted entities, relationships, and source-free evidence; local source and symbol detail is unavailable in the native-only profile.\n- For cross-repository work, contracts, architecture, impact, diffs, or pull-request overlap, use Code System Graph first."
524 };
525 format!(
526 "{begin}\n# Code System Graph intelligence routing\n\nClassify only the user's submitted prompt. Do not quote, copy, or inject the prompt itself.\n\n{routing}\n- Never automatically run scans, CodeGraph init or sync, source queries, or mutations because of this rule.\n- Keep routing guidance brief and advisory.\n{end}\n",
527 begin = begin_marker(host),
528 end = end_marker(host)
529 )
530}
531
532fn remove_guidance(
533 managed: &ManagedRoot,
534 request: &InstallRequest,
535 relative: &Path,
536 backups: &mut Vec<PathBuf>,
537 removed_files: &mut Vec<PathBuf>,
538) -> Result<bool, HookError> {
539 let Some(content) = read_optional_string(managed, relative)? else {
540 return Ok(false);
541 };
542 let Some(updated) = remove_marked_block(&content, request.host) else {
543 return Ok(false);
544 };
545 let generated_cursor_scaffold = request.host == HostKind::Cursor
546 && updated.trim()
547 == format!(
548 "---\ndescription: Code System Graph routing guidance ({PRODUCT_MARKER})\nalwaysApply: true\n---"
549 );
550 backup(managed, relative, backups)?;
551 if updated.trim().is_empty() || generated_cursor_scaffold {
552 managed.remove_file_if_exists(relative)?;
553 removed_files.push(managed.absolute(relative));
554 } else {
555 managed.atomic_write(relative, updated.as_bytes())?;
556 }
557 Ok(true)
558}
559
560fn install_strict_gate(
561 managed: &ManagedRoot,
562 request: &InstallRequest,
563 backups: &mut Vec<PathBuf>,
564 warnings: &mut Vec<String>,
565) -> Result<bool, HookError> {
566 let relative = git_pre_commit(managed)?;
567 let existing = read_optional_string(managed, &relative)?;
568 let marker = begin_marker(request.host);
569 if existing
570 .as_deref()
571 .is_some_and(|content| content.contains(&marker))
572 {
573 return Ok(false);
574 }
575 if existing.as_deref().is_some_and(contains_product_reference) {
576 warnings.push(format!(
577 "another Code System Graph pre-commit hook exists in `{}`; it was preserved",
578 managed.absolute(&relative).display()
579 ));
580 }
581 let block = strict_gate_block(request);
582 let updated = match existing {
583 Some(mut content) => {
584 if !content.ends_with('\n') {
585 content.push('\n');
586 }
587 content.push('\n');
588 content.push_str(&block);
589 content
590 }
591 None => format!("#!/bin/sh\n\n{block}"),
592 };
593 let changed = write_string(
594 managed,
595 &relative,
596 &updated,
597 managed.regular_file_exists(&relative)?,
598 backups,
599 )?;
600 if changed {
601 make_executable(&managed.absolute(&relative))?;
602 }
603 Ok(changed)
604}
605
606fn strict_gate_block(request: &InstallRequest) -> String {
607 format!(
608 "{begin}\nCODE_SYSTEM_GRAPH_RESULT=\"$({binary} changes --scope staged --database {database} --workspace {workspace} --repository {repository})\" || {{\n echo \"Code System Graph staged-change analysis failed; commit blocked by strict mode.\" >&2\n exit 1\n}}\nCODE_SYSTEM_GRAPH_FINGERPRINT=\"$(printf '%s' \"$CODE_SYSTEM_GRAPH_RESULT\" | tr -d '\\n' | sed -n 's/.*\"exact_diff_fingerprint\":\"\\([^\"]*\\)\".*/\\1/p')\"\nif [ -z \"$CODE_SYSTEM_GRAPH_FINGERPRINT\" ]; then\n echo \"Code System Graph returned no exact staged fingerprint; commit blocked by strict mode.\" >&2\n exit 1\nfi\nunset CODE_SYSTEM_GRAPH_RESULT CODE_SYSTEM_GRAPH_FINGERPRINT\n{end}\n",
609 begin = begin_marker(request.host),
610 binary = shell_quote(
611 request
612 .code_system_graph_binary
613 .as_os_str()
614 .to_string_lossy()
615 .as_ref()
616 ),
617 database = shell_quote(request.database.as_os_str().to_string_lossy().as_ref()),
618 workspace = shell_quote(&request.workspace),
619 repository = shell_quote(&request.repository),
620 end = end_marker(request.host)
621 )
622}
623
624fn remove_strict_gate(
625 managed: &ManagedRoot,
626 request: &InstallRequest,
627 backups: &mut Vec<PathBuf>,
628 removed_files: &mut Vec<PathBuf>,
629) -> Result<bool, HookError> {
630 let Some(relative) = optional_git_pre_commit(managed)? else {
631 return Ok(false);
632 };
633 let Some(content) = read_optional_string(managed, &relative)? else {
634 return Ok(false);
635 };
636 let Some(updated) = remove_marked_block(&content, request.host) else {
637 return Ok(false);
638 };
639 backup(managed, &relative, backups)?;
640 if updated.trim() == "#!/bin/sh" || updated.trim().is_empty() {
641 managed.remove_file_if_exists(&relative)?;
642 removed_files.push(managed.absolute(&relative));
643 } else {
644 managed.atomic_write(&relative, updated.as_bytes())?;
645 make_executable(&managed.absolute(&relative))?;
646 }
647 Ok(true)
648}
649
650fn git_pre_commit(managed: &ManagedRoot) -> Result<PathBuf, HookError> {
651 optional_git_pre_commit(managed)?.ok_or_else(|| HookError::InvalidConfiguration {
652 path: managed.root().to_path_buf(),
653 message: "strict mode requires a Git repository worktree".to_owned(),
654 })
655}
656
657fn optional_git_pre_commit(managed: &ManagedRoot) -> Result<Option<PathBuf>, HookError> {
658 let dot_git = Path::new(".git");
659 if managed.is_directory(dot_git)? {
660 return Ok(Some(PathBuf::from(".git/hooks/pre-commit")));
661 }
662 if !managed.entry_exists(dot_git)? {
663 return Ok(None);
664 }
665 let content = managed.read_utf8_bounded(dot_git, 4_096)?;
666 let relative = content
667 .trim()
668 .strip_prefix("gitdir:")
669 .map(str::trim)
670 .ok_or_else(|| HookError::InvalidConfiguration {
671 path: managed.absolute(dot_git),
672 message: "expected a Git directory or `gitdir:` pointer".to_owned(),
673 })?;
674 let git_dir = Path::new(relative);
675 let git_dir = if git_dir.is_absolute() {
676 let canonical = fs::canonicalize(git_dir).map_err(|source| HookError::Io {
677 path: git_dir.to_path_buf(),
678 source,
679 })?;
680 if !canonical.starts_with(managed.root()) {
681 return Err(HookError::InvalidConfiguration {
682 path: canonical,
683 message: "Git metadata escapes the authorized repository root".to_owned(),
684 });
685 }
686 match canonical.strip_prefix(managed.root()) {
687 Ok(path) => path.to_path_buf(),
688 Err(_) => {
689 return Err(HookError::InvalidConfiguration {
690 path: canonical,
691 message: "Git metadata escapes the authorized repository root".to_owned(),
692 });
693 }
694 }
695 } else {
696 git_dir.to_path_buf()
697 };
698 Ok(Some(git_dir.join("hooks/pre-commit")))
699}
700
701fn duplicate_warnings(
702 managed: &ManagedRoot,
703 request: &InstallRequest,
704 spec: &HostSpec,
705) -> Result<Vec<String>, HookError> {
706 let mut warnings = Vec::new();
707 match spec.protocol {
708 HostProtocol::Json { event } if managed.regular_file_exists(&spec.path)? => {
709 let (root, _) = read_json_object(managed, &spec.path)?;
710 if root
711 .get("hooks")
712 .and_then(|hooks| hooks.get(event))
713 .and_then(Value::as_array)
714 .is_some_and(|entries| {
715 entries
716 .iter()
717 .any(|entry| !is_owned_json(entry) && json_mentions_product(entry))
718 })
719 {
720 warnings.push(format!(
721 "another Code System Graph hook exists in `{}` and was preserved",
722 managed.absolute(&spec.path).display()
723 ));
724 }
725 }
726 HostProtocol::Guidance if managed.regular_file_exists(&spec.path)? => {
727 if let Some(content) = read_optional_string(managed, &spec.path)?
728 && !content.contains(&begin_marker(request.host))
729 && contains_product_reference(&content)
730 {
731 warnings.push(format!(
732 "another Code System Graph guidance file exists in `{}` and was preserved",
733 managed.absolute(&spec.path).display()
734 ));
735 }
736 }
737 HostProtocol::Json { .. } | HostProtocol::Guidance => {}
738 }
739 Ok(warnings)
740}
741
742fn read_json_object(managed: &ManagedRoot, relative: &Path) -> Result<(Value, bool), HookError> {
743 if !managed.regular_file_exists(relative)? {
744 return Ok((Value::Object(Map::new()), false));
745 }
746 let content = managed.read_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?;
747 let value: Value =
748 serde_json::from_str(&content).map_err(|source| HookError::InvalidConfiguration {
749 path: managed.absolute(relative),
750 message: source.to_string(),
751 })?;
752 if !value.is_object() {
753 return Err(HookError::InvalidConfiguration {
754 path: managed.absolute(relative),
755 message: "top-level JSON value must be an object".to_owned(),
756 });
757 }
758 Ok((value, true))
759}
760
761fn object_field_mut<'a>(
762 root: &'a mut Value,
763 key: &str,
764 managed: &ManagedRoot,
765 relative: &Path,
766) -> Result<&'a mut Map<String, Value>, HookError> {
767 let object = root
768 .as_object_mut()
769 .ok_or_else(|| HookError::InvalidConfiguration {
770 path: managed.absolute(relative),
771 message: "top-level JSON value must be an object".to_owned(),
772 })?;
773 let value = object
774 .entry(key.to_owned())
775 .or_insert_with(|| Value::Object(Map::new()));
776 value
777 .as_object_mut()
778 .ok_or_else(|| HookError::InvalidConfiguration {
779 path: managed.absolute(relative),
780 message: format!("`{key}` must be an object"),
781 })
782}
783
784fn array_field_mut<'a>(
785 object: &'a mut Map<String, Value>,
786 key: &str,
787 managed: &ManagedRoot,
788 relative: &Path,
789) -> Result<&'a mut Vec<Value>, HookError> {
790 let value = object
791 .entry(key.to_owned())
792 .or_insert_with(|| Value::Array(Vec::new()));
793 value
794 .as_array_mut()
795 .ok_or_else(|| HookError::InvalidConfiguration {
796 path: managed.absolute(relative),
797 message: format!("hook event `{key}` must be an array"),
798 })
799}
800
801fn is_owned_json(value: &Value) -> bool {
802 json_strings(value).any(|text| text.contains(PRODUCT_MARKER))
803}
804
805fn json_mentions_product(value: &Value) -> bool {
806 json_strings(value).any(contains_product_reference)
807}
808
809fn json_strings(value: &Value) -> Box<dyn Iterator<Item = &str> + '_> {
810 match value {
811 Value::String(text) => Box::new(std::iter::once(text.as_str())),
812 Value::Array(values) => Box::new(values.iter().flat_map(json_strings)),
813 Value::Object(values) => Box::new(values.values().flat_map(json_strings)),
814 Value::Null | Value::Bool(_) | Value::Number(_) => Box::new(std::iter::empty()),
815 }
816}
817
818fn contains_product_reference(value: &str) -> bool {
819 let value = value.to_ascii_lowercase();
820 value.contains("code-system-graph") || value.contains("code system graph")
821}
822
823fn begin_marker(host: HostKind) -> String {
824 format!("# BEGIN {PRODUCT_MARKER}:{}", host.as_str())
825}
826
827fn end_marker(host: HostKind) -> String {
828 format!("# END {PRODUCT_MARKER}:{}", host.as_str())
829}
830
831fn remove_marked_block(content: &str, host: HostKind) -> Option<String> {
832 let begin = begin_marker(host);
833 let end = end_marker(host);
834 let start = content.find(&begin)?;
835 let relative_end = content[start..].find(&end)?;
836 let mut finish = start + relative_end + end.len();
837 if content.as_bytes().get(finish) == Some(&b'\n') {
838 finish += 1;
839 }
840 let mut updated = String::with_capacity(content.len() - (finish - start));
841 updated.push_str(&content[..start]);
842 updated.push_str(&content[finish..]);
843 Some(updated.trim_end().to_owned() + "\n")
844}
845
846fn file_contains(managed: &ManagedRoot, relative: &Path, needle: &str) -> Result<bool, HookError> {
847 Ok(read_optional_string(managed, relative)?
848 .as_deref()
849 .is_some_and(|content| content.contains(needle)))
850}
851
852fn read_optional_string(
853 managed: &ManagedRoot,
854 relative: &Path,
855) -> Result<Option<String>, HookError> {
856 managed.read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)
857}
858
859fn write_json_if_changed<T: Serialize>(
860 managed: &ManagedRoot,
861 relative: &Path,
862 value: &T,
863 backup_existing: bool,
864 backups: &mut Vec<PathBuf>,
865) -> Result<bool, HookError> {
866 let bytes = serde_json::to_vec_pretty(value)?;
867 write_bytes_if_changed(managed, relative, &bytes, backup_existing, backups)
868}
869
870fn write_value(
871 managed: &ManagedRoot,
872 relative: &Path,
873 value: &Value,
874 existed: bool,
875 backups: &mut Vec<PathBuf>,
876) -> Result<bool, HookError> {
877 let mut bytes = serde_json::to_vec_pretty(value)?;
878 bytes.push(b'\n');
879 write_bytes_if_changed(managed, relative, &bytes, existed, backups)
880}
881
882fn write_string(
883 managed: &ManagedRoot,
884 relative: &Path,
885 content: &str,
886 existed: bool,
887 backups: &mut Vec<PathBuf>,
888) -> Result<bool, HookError> {
889 write_bytes_if_changed(managed, relative, content.as_bytes(), existed, backups)
890}
891
892fn write_bytes_if_changed(
893 managed: &ManagedRoot,
894 relative: &Path,
895 bytes: &[u8],
896 backup_existing: bool,
897 backups: &mut Vec<PathBuf>,
898) -> Result<bool, HookError> {
899 if managed
900 .read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?
901 .is_some_and(|existing| existing.as_bytes() == bytes)
902 {
903 return Ok(false);
904 }
905 if backup_existing && managed.regular_file_exists(relative)? {
906 backup(managed, relative, backups)?;
907 }
908 managed.atomic_write(relative, bytes)?;
909 Ok(true)
910}
911
912fn backup(
913 managed: &ManagedRoot,
914 relative: &Path,
915 backups: &mut Vec<PathBuf>,
916) -> Result<(), HookError> {
917 let stamp = SystemTime::now()
918 .duration_since(UNIX_EPOCH)
919 .map_err(|_| HookError::InvalidSystemTime)?;
920 let file_name = relative
921 .file_name()
922 .ok_or_else(|| HookError::InvalidConfiguration {
923 path: managed.absolute(relative),
924 message: "backup source has no file name".to_owned(),
925 })?
926 .to_string_lossy();
927 let backup_relative = relative.with_file_name(format!(
928 "{file_name}.bak.code-system-graph.{}-{}",
929 stamp.as_secs(),
930 stamp.subsec_nanos()
931 ));
932 let content = managed.read_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?;
933 managed.atomic_write(&backup_relative, content.as_bytes())?;
934 backups.push(managed.absolute(&backup_relative));
935 Ok(())
936}
937
938fn shell_quote(value: &str) -> String {
939 format!("'{}'", value.replace('\'', "'\"'\"'"))
940}
941
942#[cfg(unix)]
943fn restrict_file(path: &Path) -> Result<(), HookError> {
944 use std::os::unix::fs::PermissionsExt;
945
946 fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| HookError::Io {
947 path: path.to_path_buf(),
948 source,
949 })
950}
951
952#[cfg(not(unix))]
953fn restrict_file(_path: &Path) -> Result<(), HookError> {
954 Ok(())
955}
956
957#[cfg(unix)]
958fn make_executable(path: &Path) -> Result<(), HookError> {
959 use std::os::unix::fs::PermissionsExt;
960
961 let mut permissions = fs::metadata(path)
962 .map_err(|source| HookError::Io {
963 path: path.to_path_buf(),
964 source,
965 })?
966 .permissions();
967 permissions.set_mode(permissions.mode() | 0o700);
968 fs::set_permissions(path, permissions).map_err(|source| HookError::Io {
969 path: path.to_path_buf(),
970 source,
971 })
972}
973
974#[cfg(not(unix))]
975fn make_executable(_path: &Path) -> Result<(), HookError> {
976 Ok(())
977}
978
979#[cfg(test)]
980mod tests {
981 use std::path::PathBuf;
982
983 use super::{guidance_block, owned_json_entry};
984 use crate::types::{HookMode, HostKind, InstallRequest};
985
986 fn request(host: HostKind, codegraph_enabled: bool) -> InstallRequest {
987 InstallRequest {
988 root: PathBuf::from("/workspace/api"),
989 host,
990 mode: HookMode::Advisory,
991 code_system_graph_binary: PathBuf::from("/bin/csgraph"),
992 database: PathBuf::from("/workspace/graph.db"),
993 workspace: "commerce".to_owned(),
994 repository: "api".to_owned(),
995 codegraph_enabled,
996 }
997 }
998
999 #[test]
1000 fn generated_routing_should_follow_codegraph_policy() {
1001 let native = guidance_block(HostKind::Cursor, false);
1002 let enriched = guidance_block(HostKind::Cursor, true);
1003 assert!(!native.contains("explore"));
1004 assert!(enriched.contains("explore"));
1005
1006 let runtime = PathBuf::from("/bin/code-system-graph-hooks");
1007 let native_hook = owned_json_entry(&request(HostKind::Codex, false), &runtime).to_string();
1008 let enriched_hook = owned_json_entry(&request(HostKind::Codex, true), &runtime).to_string();
1009 assert!(native_hook.contains("--codegraph-enabled false"));
1010 assert!(enriched_hook.contains("--codegraph-enabled true"));
1011 }
1012}