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