Skip to main content

code_system_graph_hooks/
install.rs

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
42/// Installs or updates one host integration with atomic, marker-scoped writes.
43///
44/// Existing host configuration is merged rather than replaced. A timestamped backup is created
45/// before each changed existing file. Repeating an identical installation is a no-op.
46///
47/// # Errors
48///
49/// Returns [`HookError`] for invalid roots or host configuration and for failed filesystem,
50/// serialization, backup, or atomic-write operations.
51pub 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
98/// Inspects marker-owned host and strict-gate content without changing files.
99///
100/// # Errors
101///
102/// Returns [`HookError`] when existing host configuration cannot be read or parsed.
103pub 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
155/// Removes only marker-owned content and merges around unrelated host configuration.
156///
157/// Existing files are backed up immediately before a changed merge. Backups are not blindly
158/// restored because doing so could discard edits made after installation.
159///
160/// # Errors
161///
162/// Returns [`HookError`] when host configuration cannot be parsed or a backup, merge, deletion,
163/// permission change, or atomic write fails.
164pub 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}