gobby-code 0.9.9

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
Documentation
use crate::config::Context;
use crate::db;
use crate::graph::code_graph::{self, GraphLifecycleAction, GraphLifecycleOutput};
use crate::output::{self, Format};
use crate::projection::sync::ProjectionSyncReport;
use serde_json::{Value, json};

pub const GRAPH_SYNC_CONTRACT_EXIT_CODE: u8 = 2;

#[derive(Debug)]
pub struct GraphSyncContractError {
    payload: Value,
}

impl GraphSyncContractError {
    pub(super) fn project_not_indexed(ctx: &Context, file_path: &str) -> Self {
        Self {
            payload: json!({
                "success": false,
                "project_id": ctx.project_id,
                "file_path": file_path,
                "status": "error",
                "reason": "project_not_indexed",
                "error": format!("project {} is not indexed", ctx.project_id),
            }),
        }
    }

    pub(super) fn indexed_file_not_found(ctx: &Context, file_path: &str) -> Self {
        Self {
            payload: json!({
                "success": false,
                "project_id": ctx.project_id,
                "file_path": file_path,
                "status": "error",
                "reason": "indexed_file_not_found",
                "error": format!("indexed file `{file_path}` was not found for project {}", ctx.project_id),
            }),
        }
    }

    pub fn exit_code(&self) -> u8 {
        GRAPH_SYNC_CONTRACT_EXIT_CODE
    }

    pub fn print(&self) -> anyhow::Result<()> {
        output::print_json(&self.payload)
    }

    pub fn payload(&self) -> &Value {
        &self.payload
    }
}

impl std::fmt::Display for GraphSyncContractError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let reason = self
            .payload
            .get("reason")
            .and_then(Value::as_str)
            .unwrap_or("graph_sync_contract_error");
        write!(f, "graph sync-file contract error: {reason}")
    }
}

impl std::error::Error for GraphSyncContractError {}

pub(super) fn format_success_text(output: &GraphLifecycleOutput) -> String {
    format!(
        "{} for project {}: {}",
        output.action.success_prefix(),
        output.project_id,
        output.summary
    )
}

pub(super) trait LifecycleBackend {
    fn run(
        &self,
        ctx: &Context,
        action: GraphLifecycleAction,
    ) -> anyhow::Result<GraphLifecycleOutput>;
}

struct CodeGraphLifecycleBackend;

impl LifecycleBackend for CodeGraphLifecycleBackend {
    fn run(
        &self,
        ctx: &Context,
        action: GraphLifecycleAction,
    ) -> anyhow::Result<GraphLifecycleOutput> {
        match action {
            GraphLifecycleAction::Clear => clear_project_graph(ctx),
            GraphLifecycleAction::Rebuild => rebuild_project_graph(ctx),
        }
    }
}

pub(super) fn run_lifecycle_action_with_backend(
    ctx: &Context,
    action: GraphLifecycleAction,
    format: Format,
    backend: &impl LifecycleBackend,
) -> anyhow::Result<()> {
    let output = backend.run(ctx, action)?;
    match format {
        Format::Json => output::print_json(&output.payload),
        Format::Text => {
            output::print_text(&format_success_text(&output))?;
            output::print_json_compact(&output.payload)
        }
    }
}

fn lifecycle_output(
    action: GraphLifecycleAction,
    ctx: &Context,
    payload: Value,
) -> GraphLifecycleOutput {
    let summary = code_graph::extract_summary_text(&payload).unwrap_or_else(|| payload.to_string());
    GraphLifecycleOutput {
        project_id: ctx.project_id.clone(),
        action,
        summary,
        payload,
    }
}

enum GraphFileSyncOutcome {
    Synced {
        relationships_written: usize,
        symbols_synced: usize,
    },
    SkippedMissingIndexedFile,
}

pub(super) fn skipped_missing_indexed_file_payload(ctx: &Context, file_path: &str) -> Value {
    json!({
        "project_id": ctx.project_id,
        "file_path": file_path,
        "status": "skipped",
        "reason": "indexed_file_not_found",
    })
}

fn sync_file_graph(
    ctx: &Context,
    file_path: &str,
    allow_missing_indexed_file: bool,
) -> anyhow::Result<GraphFileSyncOutcome> {
    let mut conn = db::connect_readwrite(&ctx.database_url)?;
    if !db::indexed_project_exists(&mut conn, &ctx.project_id)? {
        return Err(GraphSyncContractError::project_not_indexed(ctx, file_path).into());
    }
    code_graph::require_graph_reads(ctx)?;
    if !db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)? {
        if allow_missing_indexed_file {
            return Ok(GraphFileSyncOutcome::SkippedMissingIndexedFile);
        }
        return Err(GraphSyncContractError::indexed_file_not_found(ctx, file_path).into());
    }
    let facts = db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
    let relationships_written = code_graph::sync_file_graph(
        ctx,
        &facts.file_path,
        &facts.imports,
        &facts.definitions,
        &facts.calls,
        true,
    )?;
    db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
    Ok(GraphFileSyncOutcome::Synced {
        relationships_written,
        symbols_synced: facts.definitions.len(),
    })
}

fn clear_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
    code_graph::require_graph_reads(ctx)?;
    let mut conn = db::connect_readwrite(&ctx.database_url)?;
    let files_marked_pending = db::reset_graph_sync_for_project(&mut conn, &ctx.project_id)?;
    code_graph::clear_project(ctx)?;
    let report = ProjectionSyncReport::ok(0, 0);
    Ok(lifecycle_output(
        GraphLifecycleAction::Clear,
        ctx,
        json!({
            "success": true,
            "project_id": ctx.project_id,
            "status": report.status,
            "synced_files": report.synced_files,
            "synced_symbols": report.synced_symbols,
            "degraded": report.degraded,
            "error": report.error,
            "files_marked_pending": files_marked_pending,
            "summary": format!("marked {files_marked_pending} files pending and cleared graph projection"),
        }),
    ))
}

fn rebuild_project_graph(ctx: &Context) -> anyhow::Result<GraphLifecycleOutput> {
    code_graph::require_graph_reads(ctx)?;
    let mut conn = db::connect_readwrite(&ctx.database_url)?;
    let file_paths = db::list_indexed_file_paths(&mut conn, &ctx.project_id)?;

    let mut files_synced = 0usize;
    let mut symbols_synced = 0usize;
    let mut errors = Vec::new();
    code_graph::with_code_graph(ctx, |graph| {
        for file_path in &file_paths {
            let synced_symbols =
                match db::mark_graph_sync_attempted(&mut conn, &ctx.project_id, file_path)
                    .and_then(|updated| {
                        if updated {
                            Ok(())
                        } else {
                            anyhow::bail!("indexed file no longer exists")
                        }
                    })
                    .and_then(|_| {
                        let facts =
                            db::read_graph_file_facts(&mut conn, &ctx.project_id, file_path)?;
                        graph.sync_file(
                            &facts.file_path,
                            &facts.imports,
                            &facts.definitions,
                            &facts.calls,
                            false,
                        )?;
                        db::mark_graph_synced(&mut conn, &ctx.project_id, file_path)?;
                        Ok(facts.definitions.len())
                    }) {
                    Ok(symbols) => symbols,
                    Err(err) => {
                        errors.push(format!("{file_path}: {err}"));
                        continue;
                    }
                };
            files_synced += 1;
            symbols_synced += synced_symbols;
        }
        Ok(())
    })?;
    if errors.is_empty()
        && files_synced > 0
        && let Err(err) = code_graph::cleanup_orphans(ctx)
    {
        errors.push(format!("cleanup_orphans: {err}"));
    }

    let report = if errors.is_empty() {
        ProjectionSyncReport::ok(files_synced, symbols_synced)
    } else {
        ProjectionSyncReport::degraded(
            "sync_failed",
            errors.join("; "),
            files_synced,
            symbols_synced,
        )
    };
    Ok(lifecycle_output(
        GraphLifecycleAction::Rebuild,
        ctx,
        json!({
            "success": errors.is_empty(),
            "project_id": ctx.project_id,
            "status": report.status,
            "synced_files": report.synced_files,
            "synced_symbols": report.synced_symbols,
            "degraded": report.degraded,
            "error": report.error,
            "files_processed": file_paths.len(),
            "files_synced": files_synced,
            "files_failed": errors.len(),
            "errors": errors,
            "summary": format!("synced {files_synced}/{} files", file_paths.len()),
        }),
    ))
}

pub fn clear(ctx: &Context, format: Format) -> anyhow::Result<()> {
    run_lifecycle_action_with_backend(
        ctx,
        GraphLifecycleAction::Clear,
        format,
        &CodeGraphLifecycleBackend,
    )
}

pub fn rebuild(ctx: &Context, format: Format) -> anyhow::Result<()> {
    run_lifecycle_action_with_backend(
        ctx,
        GraphLifecycleAction::Rebuild,
        format,
        &CodeGraphLifecycleBackend,
    )
}

pub fn sync_file(
    ctx: &Context,
    file_path: &str,
    allow_missing_indexed_file: bool,
    format: Format,
) -> anyhow::Result<()> {
    let sync = sync_file_graph(ctx, file_path, allow_missing_indexed_file)?;
    let GraphFileSyncOutcome::Synced {
        relationships_written,
        symbols_synced,
    } = sync
    else {
        let payload = skipped_missing_indexed_file_payload(ctx, file_path);
        return match format {
            Format::Json => output::print_json(&payload),
            Format::Text => {
                output::print_text(&format!(
                    "Skipped code-index graph sync for project {}: indexed file `{file_path}` was not found",
                    ctx.project_id
                ))?;
                output::print_json_compact(&payload)
            }
        };
    };
    let report = ProjectionSyncReport::ok(1, symbols_synced);
    let summary = format!("synced {relationships_written} graph relationships for {file_path}");
    let payload = json!({
        "success": true,
        "project_id": ctx.project_id,
        "file_path": file_path,
        "status": report.status,
        "synced_files": report.synced_files,
        "synced_symbols": report.synced_symbols,
        "degraded": report.degraded,
        "error": report.error,
        "relationships_written": relationships_written,
        "summary": summary,
    });
    match format {
        Format::Json => output::print_json(&payload),
        Format::Text => {
            output::print_text(&format!(
                "Synced code-index graph for project {}: {summary}",
                ctx.project_id
            ))?;
            output::print_json_compact(&payload)
        }
    }
}