gobby-code 1.3.2

Fast Rust CLI for Gobby's code index — AST-aware search, symbol navigation, and dependency graph
Documentation
use super::support::*;

#[test]
fn graph_commands_run_without_daemon_when_services_are_available() {
    let Some(env) = StandaloneEnv::from_env() else {
        eprintln!(
            "skipping graph_standalone smoke; set GCODE_GRAPH_STANDALONE_DATABASE_URL, GCODE_GRAPH_STANDALONE_FALKOR_HOST, and GCODE_GRAPH_STANDALONE_FALKOR_PORT"
        );
        return;
    };

    let project = tempfile::tempdir().expect("temp project");
    fs::create_dir_all(project.path().join(".gobby")).expect("create .gobby");
    fs::create_dir_all(project.path().join("src")).expect("create src");
    fs::create_dir_all(project.path().join("docs")).expect("create docs");
    fs::create_dir_all(project.path().join("crates/app/src")).expect("create app crate src");
    fs::create_dir_all(project.path().join("crates/core/src")).expect("create core crate src");
    fs::write(
        project.path().join("src/lib.rs"),
        "pub fn caller() { callee(); }\npub fn callee() {}\n",
    )
    .expect("write source");
    fs::write(
        project.path().join(CONTENT_ONLY_FILE),
        "plain prose without code graph facts\n",
    )
    .expect("write content-only source");
    fs::write(
        project.path().join(CROSS_CRATE_CALLER_FILE),
        "pub fn app_entry() {\n    core_leaf();\n}\n",
    )
    .expect("write app crate source");
    fs::write(
        project.path().join(CROSS_CRATE_CALLEE_FILE),
        "pub fn core_leaf() {}\n",
    )
    .expect("write core crate source");
    fs::write(
        project.path().join(".gobby/gcode.json"),
        serde_json::json!({
            "id": TEST_PROJECT_ID,
            "name": "graph-standalone",
            "created_at": "2026-05-28T00:00:00Z"
        })
        .to_string(),
    )
    .expect("write gcode identity");

    let mut conn = Client::connect(&env.database_url, NoTls).expect("connect PostgreSQL");
    let _cleanup = ProjectCleanup::new(&env.database_url, TEST_PROJECT_ID);
    seed_project(&mut conn);

    let missing = run_gcode(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", "src/missing.rs"],
    );
    assert_eq!(
        missing.status.code(),
        Some(2),
        "missing indexed file should exit 2\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&missing.stdout),
        String::from_utf8_lossy(&missing.stderr)
    );
    let missing_json: Value =
        serde_json::from_slice(&missing.stdout).expect("missing file emits JSON");
    assert_eq!(missing_json["reason"], "indexed_file_not_found");

    let skipped = json_command(
        &env,
        project.path(),
        &[
            "graph",
            "sync-file",
            "--file",
            "src/missing.rs",
            "--allow-missing-indexed-file",
        ],
    );
    assert_eq!(skipped["status"], "skipped");
    assert_eq!(skipped["reason"], "indexed_file_not_found");

    let content_skip = json_command(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", CONTENT_ONLY_FILE],
    );
    assert_no_graph_facts_skip(&content_skip);
    assert!(graph_synced(&mut conn, CONTENT_ONLY_FILE));
    let overview_after_content_skip = json_command(&env, project.path(), &["graph", "overview"]);
    assert!(
        !overview_has_file(&overview_after_content_skip, CONTENT_ONLY_FILE),
        "content-only skip should not create a file node: {overview_after_content_skip}"
    );

    seed_temporary_content_import(&mut conn);
    let stale_seed = run_gcode(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", CONTENT_ONLY_FILE],
    );
    assert_success(stale_seed, "seed stale content graph projection");
    let overview_with_stale = json_command(&env, project.path(), &["graph", "overview"]);
    assert!(
        overview_has_file(&overview_with_stale, CONTENT_ONLY_FILE),
        "temporary import sync should create a stale file node: {overview_with_stale}"
    );

    clear_temporary_content_import(&mut conn);
    let stale_cleanup = json_command(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", CONTENT_ONLY_FILE],
    );
    assert_no_graph_facts_skip(&stale_cleanup);
    assert!(graph_synced(&mut conn, CONTENT_ONLY_FILE));
    let overview_after_cleanup = json_command(&env, project.path(), &["graph", "overview"]);
    assert!(
        !overview_has_file(&overview_after_cleanup, CONTENT_ONLY_FILE),
        "no-fact sync should remove stale file node: {overview_after_cleanup}"
    );

    let sync = run_gcode(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", TEST_FILE],
    );
    assert_success(sync, "graph sync-file");

    let overview = json_command(&env, project.path(), &["graph", "overview"]);
    assert!(
        overview["nodes"]
            .as_array()
            .is_some_and(|nodes| !nodes.is_empty())
    );

    let file = json_command(
        &env,
        project.path(),
        &["graph", "file", "--file", TEST_FILE],
    );
    assert!(
        file["links"]
            .as_array()
            .is_some_and(|links| !links.is_empty())
    );

    let neighbors = json_command(
        &env,
        project.path(),
        &[
            "graph",
            "neighbors",
            "--symbol-id",
            CALLER_ID,
            "--limit",
            "10",
        ],
    );
    assert!(
        neighbors["nodes"]
            .as_array()
            .is_some_and(|nodes| nodes.iter().any(|node| node["id"] == CALLEE_ID))
    );

    let blast_symbol = json_command(
        &env,
        project.path(),
        &[
            "graph",
            "blast-radius",
            "--symbol-id",
            CALLER_ID,
            "--depth",
            "2",
            "--limit",
            "10",
        ],
    );
    assert_eq!(blast_symbol["center"], CALLER_ID);

    let blast_file = json_command(
        &env,
        project.path(),
        &[
            "graph",
            "blast-radius",
            "--file",
            TEST_FILE,
            "--depth",
            "2",
            "--limit",
            "10",
        ],
    );
    assert_eq!(blast_file["center"], TEST_FILE);

    let path = json_command(&env, project.path(), &["path", "caller", "callee"]);
    assert_eq!(path["found"], true);
    assert_eq!(path["hops"], 1);
    let path_steps = path["path"]
        .as_array()
        .unwrap_or_else(|| panic!("path steps must be an array: {path}"));
    assert_eq!(path_steps.len(), 2);
    assert_eq!(path_steps[0]["name"], "caller");
    assert_eq!(path_steps[0]["file_path"], TEST_FILE);
    assert_eq!(path_steps[0]["line"], 1);
    assert_eq!(path_steps[1]["name"], "callee");
    assert_eq!(path_steps[1]["file_path"], TEST_FILE);
    assert_eq!(path_steps[1]["line"], 2);

    let no_path = json_command(&env, project.path(), &["path", "callee", "caller"]);
    assert_eq!(no_path["found"], false);
    assert!(
        no_path["path"]
            .as_array()
            .is_some_and(|steps| steps.is_empty()),
        "reverse path should be empty: {no_path}"
    );

    let path_text =
        run_gcode_with_format(&env, project.path(), "text", &["path", "caller", "callee"]);
    assert!(
        path_text.status.success(),
        "text path failed\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&path_text.stdout),
        String::from_utf8_lossy(&path_text.stderr)
    );
    let path_text_stdout = String::from_utf8_lossy(&path_text.stdout);
    assert!(path_text_stdout.contains("Shortest path from 'caller' to 'callee'"));
    assert!(path_text_stdout.contains("1. caller (src/lib.rs:1)"));
    assert!(path_text_stdout.contains("2. callee (src/lib.rs:2)"));

    let clear = json_command(&env, project.path(), &["graph", "clear"]);
    assert_eq!(clear["success"], true);

    let rebuild = json_command(&env, project.path(), &["graph", "rebuild"]);
    assert_eq!(rebuild["success"], true);
    assert_eq!(rebuild["files_processed"], 4);
    assert_eq!(rebuild["files_synced"], 4);
    let overview_after_rebuild = json_command(&env, project.path(), &["graph", "overview"]);
    assert!(
        !overview_has_file(&overview_after_rebuild, CONTENT_ONLY_FILE),
        "rebuild should sync content-only files without creating graph nodes: {overview_after_rebuild}"
    );

    let cross_crate_path = json_command(&env, project.path(), &["path", "app_entry", "core_leaf"]);
    assert_eq!(cross_crate_path["found"], true);
    assert_eq!(cross_crate_path["hops"], 1);
    let cross_crate_steps = cross_crate_path["path"]
        .as_array()
        .unwrap_or_else(|| panic!("cross-crate path steps must be an array: {cross_crate_path}"));
    assert_eq!(cross_crate_steps.len(), 2);
    assert_eq!(cross_crate_steps[0]["id"], CROSS_CRATE_CALLER_ID);
    assert_eq!(cross_crate_steps[0]["file_path"], CROSS_CRATE_CALLER_FILE);
    assert_eq!(cross_crate_steps[1]["id"], CROSS_CRATE_CALLEE_ID);
    assert_eq!(cross_crate_steps[1]["file_path"], CROSS_CRATE_CALLEE_FILE);

    let cleanup = json_command(&env, project.path(), &["graph", "cleanup-orphans"]);
    assert_eq!(cleanup["status"], "ok");
    assert_eq!(cleanup["stale_graph_files_deleted"], 0);
}

#[test]
fn graph_sync_file_classifies_missing_project_before_graph_access() {
    let Some(env) = StandaloneEnv::from_env() else {
        eprintln!(
            "skipping graph sync-file missing-project contract; set GCODE_GRAPH_STANDALONE_DATABASE_URL, GCODE_GRAPH_STANDALONE_FALKOR_HOST, and GCODE_GRAPH_STANDALONE_FALKOR_PORT"
        );
        return;
    };

    let project = tempfile::tempdir().expect("temp project");
    fs::create_dir_all(project.path().join(".gobby")).expect("create .gobby");
    fs::create_dir_all(project.path().join("src")).expect("create src");
    fs::write(project.path().join("src/lib.rs"), "pub fn orphan() {}\n").expect("write source");
    fs::write(
        project.path().join(".gobby/gcode.json"),
        serde_json::json!({
            "id": "graph-missing-indexed-project",
            "name": "graph-missing-indexed-project",
            "created_at": "2026-05-28T00:00:00Z"
        })
        .to_string(),
    )
    .expect("write gcode identity");

    let _cleanup = ProjectCleanup::new(&env.database_url, "graph-missing-indexed-project");
    let output = run_gcode(
        &env,
        project.path(),
        &["graph", "sync-file", "--file", TEST_FILE],
    );
    assert_eq!(
        output.status.code(),
        Some(2),
        "missing indexed project should exit 2\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let payload: Value = serde_json::from_slice(&output.stdout).expect("missing project JSON");
    assert_eq!(payload["reason"], "project_not_indexed");
}