pub(super) use crate::common::{ProjectCleanup, cleanup_project};
pub(super) use gobby_code::graph::typed_query::string_params;
pub(super) use gobby_core::falkor::GraphClient;
pub(super) use postgres::{Client, NoTls};
pub(super) use serde_json::Value;
pub(super) use std::fs;
pub(super) use std::process::{Command, Output};
pub(super) const TEST_PROJECT_ID: &str = "graph-standalone-project";
pub(super) const LOCAL_IMPORT_PROJECT_ID: &str = "graph-standalone-local-import";
pub(super) const NO_PHANTOM_PROJECT_ID: &str = "graph-standalone-no-phantom";
pub(super) const GO_LOCAL_PROJECT_ID: &str = "graph-standalone-go-local";
pub(super) const JS_DEFAULT_LOCAL_PROJECT_ID: &str = "graph-standalone-js-default-local";
pub(super) const CPP_LOCAL_PROJECT_ID: &str = "graph-standalone-cpp-local";
pub(super) const CPP_DB_LOCAL_PROJECT_ID: &str = "graph-standalone-cpp-db-local";
pub(super) const JAVA_LOCAL_PROJECT_ID: &str = "graph-standalone-java-local";
pub(super) const RUST_LOCAL_PROJECT_ID: &str = "graph-standalone-rust-local";
pub(super) const RUST_TUPLE_LOCAL_PROJECT_ID: &str = "graph-standalone-rust-tuple-local";
pub(super) const CSHARP_LOCAL_PROJECT_ID: &str = "graph-standalone-csharp-local";
pub(super) const KOTLIN_LOCAL_PROJECT_ID: &str = "graph-standalone-kotlin-local";
pub(super) const RUBY_LOCAL_PROJECT_ID: &str = "graph-standalone-ruby-local";
pub(super) const PHP_LOCAL_PROJECT_ID: &str = "graph-standalone-php-local";
pub(super) const SWIFT_LOCAL_PROJECT_ID: &str = "graph-standalone-swift-local";
pub(super) const DART_LOCAL_PROJECT_ID: &str = "graph-standalone-dart-local";
pub(super) const ELIXIR_LOCAL_PROJECT_ID: &str = "graph-standalone-elixir-local";
pub(super) const BASH_LOCAL_PROJECT_ID: &str = "graph-standalone-bash-local";
pub(super) const SCALA_LOCAL_PROJECT_ID: &str = "graph-standalone-scala-local";
pub(super) const LUA_LOCAL_PROJECT_ID: &str = "graph-standalone-lua-local";
pub(super) const OBJC_LOCAL_PROJECT_ID: &str = "graph-standalone-objc-local";
pub(super) const TEST_FILE: &str = "src/lib.rs";
pub(super) const CONTENT_ONLY_FILE: &str = "docs/content.txt";
pub(super) const CROSS_CRATE_CALLER_FILE: &str = "crates/app/src/lib.rs";
pub(super) const CROSS_CRATE_CALLEE_FILE: &str = "crates/core/src/lib.rs";
pub(super) const CALLER_ID: &str = "graph-standalone-caller";
pub(super) const CALLEE_ID: &str = "graph-standalone-callee";
pub(super) const CROSS_CRATE_CALLER_ID: &str = "graph-standalone-cross-crate-caller";
pub(super) const CROSS_CRATE_CALLEE_ID: &str = "graph-standalone-cross-crate-callee";
pub(super) struct StandaloneEnv {
pub(super) database_url: String,
pub(super) falkor_host: String,
pub(super) falkor_port: String,
pub(super) falkor_password: Option<String>,
}
impl StandaloneEnv {
pub(super) fn from_env() -> Option<Self> {
Some(Self {
database_url: std::env::var("GCODE_GRAPH_STANDALONE_DATABASE_URL").ok()?,
falkor_host: std::env::var("GCODE_GRAPH_STANDALONE_FALKOR_HOST").ok()?,
falkor_port: std::env::var("GCODE_GRAPH_STANDALONE_FALKOR_PORT").ok()?,
falkor_password: std::env::var("GCODE_GRAPH_STANDALONE_FALKOR_PASSWORD").ok(),
})
}
}
pub(super) fn run_gcode(env: &StandaloneEnv, cwd: &std::path::Path, args: &[&str]) -> Output {
run_gcode_with_format(env, cwd, "json", args)
}
pub(super) fn run_gcode_with_format(
env: &StandaloneEnv,
cwd: &std::path::Path,
format: &str,
args: &[&str],
) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_gcode"));
command
.current_dir(cwd)
.env("GCODE_DATABASE_URL", &env.database_url)
.env("GOBBY_FALKORDB_HOST", &env.falkor_host)
.env("GOBBY_FALKORDB_PORT", &env.falkor_port)
.env("GOBBY_HOME", cwd.join(".no-daemon-home"))
.arg("--no-freshness")
.arg("--format")
.arg(format)
.args(args);
if let Some(password) = &env.falkor_password {
command.env("GOBBY_FALKORDB_PASSWORD", password);
}
command.output().expect("run gcode")
}
pub(super) fn json_command(env: &StandaloneEnv, cwd: &std::path::Path, args: &[&str]) -> Value {
let output = run_gcode(env, cwd, args);
assert_success(output, &args.join(" "))
}
pub(super) fn assert_success(output: Output, label: &str) -> Value {
assert!(
output.status.success(),
"{label} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
panic!(
"{label} did not emit JSON: {err}\nstdout:\n{}",
String::from_utf8_lossy(&output.stdout)
)
})
}
pub(super) fn assert_no_graph_facts_skip(payload: &Value) {
assert_eq!(payload["success"], true);
assert_eq!(payload["status"], "skipped");
assert_eq!(payload["reason"], "no_graph_facts");
assert_eq!(payload["file_path"], CONTENT_ONLY_FILE);
assert_eq!(payload["relationships_written"], 0);
assert_eq!(payload["synced_files"], 1);
assert_eq!(payload["synced_symbols"], 0);
}
pub(super) fn overview_has_file(overview: &Value, file_path: &str) -> bool {
overview["nodes"].as_array().is_some_and(|nodes| {
nodes
.iter()
.any(|node| node["type"] == "file" && node["id"] == file_path)
})
}
pub(super) fn graph_synced(conn: &mut Client, file_path: &str) -> bool {
conn.query_one(
"SELECT graph_synced
FROM code_indexed_files
WHERE project_id = $1 AND file_path = $2",
&[&TEST_PROJECT_ID, &file_path],
)
.expect("read graph_synced")
.get(0)
}
pub(super) fn seed_temporary_content_import(conn: &mut Client) {
conn.execute(
"INSERT INTO code_imports (project_id, source_file, target_module)
VALUES ($1, $2, 'temporary.stale.module')",
&[&TEST_PROJECT_ID, &CONTENT_ONLY_FILE],
)
.expect("insert temporary content import");
conn.execute(
"UPDATE code_indexed_files
SET graph_synced = false, graph_sync_attempted_at = NULL
WHERE project_id = $1 AND file_path = $2",
&[&TEST_PROJECT_ID, &CONTENT_ONLY_FILE],
)
.expect("mark content file graph stale");
}
pub(super) fn clear_temporary_content_import(conn: &mut Client) {
conn.execute(
"DELETE FROM code_imports
WHERE project_id = $1 AND source_file = $2",
&[&TEST_PROJECT_ID, &CONTENT_ONLY_FILE],
)
.expect("delete temporary content import");
conn.execute(
"UPDATE code_indexed_files
SET graph_synced = false, graph_sync_attempted_at = NULL
WHERE project_id = $1 AND file_path = $2",
&[&TEST_PROJECT_ID, &CONTENT_ONLY_FILE],
)
.expect("mark content file graph stale after import removal");
}
pub(super) fn seed_project(conn: &mut Client) {
cleanup_project(conn, TEST_PROJECT_ID).expect("cleanup graph rows");
conn.batch_execute(
"INSERT INTO code_indexed_projects
(id, root_path, total_files, total_symbols, last_indexed_at, index_duration_ms)
VALUES
('graph-standalone-project', '/tmp/graph-standalone', 4, 4, NOW(), 0);
INSERT INTO code_indexed_files
(id, project_id, file_path, language, content_hash, symbol_count, byte_size,
graph_synced, vectors_synced, graph_sync_attempted_at, indexed_at)
VALUES
('graph-standalone-file', 'graph-standalone-project', 'src/lib.rs', 'rust',
'hash-1', 2, 54, false, true, NULL, NOW()),
('graph-standalone-content-file', 'graph-standalone-project', 'docs/content.txt', 'text',
'hash-content', 0, 35, false, true, NULL, NOW()),
('graph-standalone-cross-crate-caller-file', 'graph-standalone-project',
'crates/app/src/lib.rs', 'rust', 'hash-cross-caller', 1, 38, false, true, NULL,
NOW()),
('graph-standalone-cross-crate-callee-file', 'graph-standalone-project',
'crates/core/src/lib.rs', 'rust', 'hash-cross-callee', 1, 24, false, true, NULL,
NOW());
INSERT INTO code_content_chunks
(id, project_id, file_path, chunk_index, line_start, line_end, content, language,
created_at)
VALUES
('graph-standalone-content-chunk-0', 'graph-standalone-project', 'docs/content.txt',
0, 1, 1, 'plain prose without code graph facts', 'text', NOW());
INSERT INTO code_symbols
(id, project_id, file_path, name, qualified_name, kind, language, byte_start, byte_end,
line_start, line_end, signature, docstring, parent_symbol_id, content_hash,
summary, created_at, updated_at)
VALUES
('graph-standalone-caller', 'graph-standalone-project', 'src/lib.rs', 'caller',
'crate::caller', 'function', 'rust', 0, 28, 1, 1, 'pub fn caller()', NULL, NULL,
'hash-1', NULL, NOW(), NOW()),
('graph-standalone-callee', 'graph-standalone-project', 'src/lib.rs', 'callee',
'crate::callee', 'function', 'rust', 29, 47, 2, 2, 'pub fn callee()', NULL, NULL,
'hash-1', NULL, NOW(), NOW()),
('graph-standalone-cross-crate-caller', 'graph-standalone-project',
'crates/app/src/lib.rs', 'app_entry', 'app::app_entry', 'function', 'rust', 0, 38,
1, 3, 'pub fn app_entry()', NULL, NULL, 'hash-cross-caller', NULL, NOW(), NOW()),
('graph-standalone-cross-crate-callee', 'graph-standalone-project',
'crates/core/src/lib.rs', 'core_leaf', 'core::core_leaf', 'function', 'rust', 0, 24,
1, 1, 'pub fn core_leaf()', NULL, NULL, 'hash-cross-callee', NULL, NOW(), NOW());
INSERT INTO code_imports (project_id, source_file, target_module)
VALUES ('graph-standalone-project', 'src/lib.rs', 'std');
INSERT INTO code_calls
(project_id, caller_symbol_id, callee_symbol_id, callee_name, callee_target_kind,
callee_external_module, file_path, line)
VALUES
('graph-standalone-project', 'graph-standalone-caller', 'graph-standalone-callee',
'callee', 'symbol', '', 'src/lib.rs', 1),
('graph-standalone-project', 'graph-standalone-cross-crate-caller',
'graph-standalone-cross-crate-callee', 'core_leaf', 'symbol', '',
'crates/app/src/lib.rs', 2);",
)
.expect("seed graph rows");
}
pub(super) fn phantom_graph_client(env: &StandaloneEnv) -> GraphClient {
let config = gobby_core::config::FalkorConfig {
host: env.falkor_host.clone(),
port: env.falkor_port.parse().expect("falkor port"),
password: env.falkor_password.clone(),
};
GraphClient::from_config(&config, gobby_core::config::CODE_GRAPH_NAME)
.expect("connect FalkorDB")
}
pub(super) fn phantom_call_target_count(graph: &mut GraphClient, project_id: &str) -> i64 {
let params = string_params(&[("project", project_id)]);
let rows = graph
.query(
"MATCH ()-[:CALLS]->(s:CodeSymbol {project: $project})
WHERE NOT (:CodeFile {project: $project})-[:DEFINES]->(s)
RETURN count(DISTINCT s) AS phantoms",
Some(params),
)
.expect("phantom count query");
rows.first()
.and_then(|row| row.get("phantoms"))
.and_then(serde_json::Value::as_i64)
.unwrap_or_else(|| panic!("expected a phantom count row: {rows:?}"))
}
pub(super) fn resolved_target_is_defined_and_called(
graph: &mut GraphClient,
project_id: &str,
symbol_id: &str,
) -> bool {
let params = string_params(&[("project", project_id), ("id", symbol_id)]);
let rows = graph
.query(
"MATCH (:CodeFile {project: $project})-[:DEFINES]->(s:CodeSymbol {project: $project, id: $id})
WHERE ()-[:CALLS]->(s)
RETURN count(s) AS defined",
Some(params),
)
.expect("defined-target query");
rows.first()
.and_then(|row| row.get("defined"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0)
> 0
}
pub(super) fn assert_caller_present(
env: &StandaloneEnv,
cwd: &std::path::Path,
target: &str,
caller: &str,
when: &str,
) {
let callers = json_command(env, cwd, &["callers", target]);
assert!(
callers["total"].as_u64().is_some_and(|total| total >= 1),
"expected a cross-file caller of `{target}` {when}: {callers}"
);
assert!(
callers["results"]
.as_array()
.is_some_and(|results| results.iter().any(|result| result["name"] == caller)),
"expected `{caller}` among callers of `{target}` {when}: {callers}"
);
}
pub(super) fn assert_blast_radius_reports_affected_callers(blast: &Value) {
let results = blast["results"]
.as_array()
.unwrap_or_else(|| panic!("blast-radius results must be an array: {blast}"));
assert!(
results.iter().any(|result| {
result["id"].as_str().is_some()
&& result["distance"]
.as_i64()
.is_some_and(|distance| distance >= 1)
}),
"blast-radius should report affected callers: {blast}"
);
}
pub(super) fn required_symbol_id(
conn: &mut Client,
project_id: &str,
file_path: &str,
name: &str,
) -> String {
conn.query_one(
"SELECT id FROM code_symbols WHERE project_id = $1 AND file_path = $2 AND name = $3",
&[&project_id, &file_path, &name],
)
.unwrap_or_else(|err| panic!("symbol {file_path}:{name} not indexed: {err}"))
.get::<_, String>(0)
}
pub(super) fn resolved_call_target(
conn: &mut Client,
project_id: &str,
file_path: &str,
callee_name: &str,
) -> Option<String> {
let row = conn
.query_opt(
"SELECT callee_symbol_id, callee_target_kind FROM code_calls
WHERE project_id = $1 AND file_path = $2 AND callee_name = $3",
&[&project_id, &file_path, &callee_name],
)
.expect("read code_calls row")?;
let kind: String = row.get("callee_target_kind");
let callee_symbol_id: String = row.get("callee_symbol_id");
(kind == "symbol" && !callee_symbol_id.is_empty()).then_some(callee_symbol_id)
}
pub(super) fn pending_local_import_count(conn: &mut Client, project_id: &str) -> i64 {
conn.query_one(
"SELECT COUNT(*) FROM code_calls
WHERE project_id = $1 AND callee_target_kind = 'local_import'",
&[&project_id],
)
.expect("count local_import rows")
.get::<_, i64>(0)
}