syntext 1.1.1

Hybrid code search index for agent workflows
Documentation
//! GitHub Copilot integration installer.

use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::hook::core::{files, instructions, json as json_util};

use super::Outcome;

pub(crate) fn install(st_program: &str) -> Result<Outcome, String> {
    let paths = paths()?;
    install_at(&paths, st_program)
}

pub(crate) fn uninstall() -> Result<Outcome, String> {
    let paths = paths()?;
    uninstall_at(&paths)
}

pub(crate) fn show() -> Result<Outcome, String> {
    let paths = paths()?;
    show_at(&paths)
}

pub(crate) struct Paths {
    pub(crate) hook_config: PathBuf,
    pub(crate) instructions: PathBuf,
}

fn paths() -> Result<Paths, String> {
    let cwd = std::env::current_dir()
        .map_err(|err| format!("st: failed to read current directory: {err}"))?;
    let root = files::project_root(&cwd);
    Ok(Paths {
        hook_config: root
            .join(".github")
            .join("hooks")
            .join("syntext-rewrite.json"),
        instructions: root.join(".github").join("copilot-instructions.md"),
    })
}

pub(crate) fn install_at(paths: &Paths, st_program: &str) -> Result<Outcome, String> {
    let mut outcome = Outcome::default();
    let mut config = json_util::read_json_object(&paths.hook_config)?;
    let before = config.clone();
    remove_syntext_hooks(&mut config)?;
    ensure_copilot_hook(&mut config, &json_util::hook_command(st_program, "copilot"))?;
    if config != before && json_util::write_json_if_changed(&paths.hook_config, &config)? {
        outcome.changed.push(paths.hook_config.clone());
    }

    let block = instructions::syntext_block("copilot", "Syntext Code Search");
    if instructions::ensure_block(&paths.instructions, "copilot", &block)? {
        outcome.changed.push(paths.instructions.clone());
    }
    outcome.installed = true;
    Ok(outcome)
}

pub(crate) fn uninstall_at(paths: &Paths) -> Result<Outcome, String> {
    let mut outcome = Outcome::default();
    if paths.hook_config.exists() {
        let mut config = json_util::read_json_object(&paths.hook_config)?;
        let removed = remove_syntext_hooks(&mut config)?;
        if removed > 0 && json_util::write_json_if_changed(&paths.hook_config, &config)? {
            outcome.changed.push(paths.hook_config.clone());
        }
    }
    if instructions::remove_block(&paths.instructions, "copilot")? {
        outcome.changed.push(paths.instructions.clone());
    }
    Ok(outcome)
}

pub(crate) fn show_at(paths: &Paths) -> Result<Outcome, String> {
    let config = json_util::read_json_object(&paths.hook_config)?;
    Ok(Outcome {
        installed: has_copilot_hook(&config)? && contains_marker(&paths.instructions, "copilot")?,
        ..Outcome::default()
    })
}

fn ensure_copilot_hook(root: &mut Value, command: &str) -> Result<(), String> {
    let root_obj = root
        .as_object_mut()
        .ok_or_else(|| "st: Copilot hook config root must be an object".to_string())?;
    let hooks = root_obj.entry("hooks").or_insert_with(|| json!({}));
    let hooks = hooks
        .as_object_mut()
        .ok_or_else(|| "st: Copilot hooks must be an object".to_string())?;
    let pre_tool_use = hooks.entry("PreToolUse").or_insert_with(|| json!([]));
    let pre_tool_use = pre_tool_use
        .as_array_mut()
        .ok_or_else(|| "st: Copilot hooks.PreToolUse must be an array".to_string())?;
    let handler = json!({
        "type": "command",
        "command": command,
        "cwd": ".",
        "timeout": 5
    });
    if !pre_tool_use.contains(&handler) {
        pre_tool_use.push(handler);
    }
    Ok(())
}

fn remove_syntext_hooks(root: &mut Value) -> Result<usize, String> {
    let Some(pre_tool_use) = root
        .get_mut("hooks")
        .and_then(|hooks| hooks.get_mut("PreToolUse"))
    else {
        return Ok(0);
    };
    let pre_tool_use = pre_tool_use
        .as_array_mut()
        .ok_or_else(|| "st: Copilot hooks.PreToolUse must be an array".to_string())?;
    let before = pre_tool_use.len();
    pre_tool_use.retain(|handler| {
        !handler
            .get("command")
            .and_then(Value::as_str)
            .is_some_and(|command| json_util::is_syntext_hook_command(command, Some("copilot")))
    });
    Ok(before - pre_tool_use.len())
}

fn has_copilot_hook(root: &Value) -> Result<bool, String> {
    let Some(pre_tool_use) = root.get("hooks").and_then(|hooks| hooks.get("PreToolUse")) else {
        return Ok(false);
    };
    let pre_tool_use = pre_tool_use
        .as_array()
        .ok_or_else(|| "st: Copilot hooks.PreToolUse must be an array".to_string())?;
    Ok(pre_tool_use.iter().any(|handler| {
        handler
            .get("command")
            .and_then(Value::as_str)
            .is_some_and(|command| json_util::is_syntext_hook_command(command, Some("copilot")))
    }))
}

fn contains_marker(path: &Path, id: &str) -> Result<bool, String> {
    if !path.exists() {
        return Ok(false);
    }
    let text = std::fs::read_to_string(path)
        .map_err(|err| format!("st: failed to read {}: {err}", path.display()))?;
    Ok(text.contains(&instructions::marker_start(id)))
}