use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::Duration;
use anyhow::Result;
use serial_test::serial;
use terraphim_agent::client::ApiClient;
use terraphim_types::{Layer, NormalizedTermValue, RoleName, SearchQuery};
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
struct NormalizedResult {
id: String,
title: String,
rank: Option<u64>,
}
fn get_workspace_root() -> Result<PathBuf> {
let mut current = std::env::current_dir()?;
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(content) = fs::read_to_string(&cargo_toml) {
if content.contains("[workspace]") {
return Ok(current);
}
}
}
if !current.pop() {
break;
}
}
Ok(PathBuf::from("."))
}
fn ensure_server_binary() -> Result<PathBuf> {
let workspace_root = get_workspace_root()?;
let binary_path = workspace_root.join("target/debug/terraphim_server");
if !binary_path.exists() {
println!("Pre-compiling terraphim_server (one-time)...");
let status = Command::new("cargo")
.args(["build", "-p", "terraphim_server"])
.current_dir(&workspace_root)
.status()?;
if !status.success() {
return Err(anyhow::anyhow!("Failed to compile server"));
}
println!("✓ Server binary compiled");
}
Ok(binary_path)
}
fn generate_absolute_config(workspace_root: &Path, port: u16) -> Result<PathBuf> {
let fixtures_dir = workspace_root.join("terraphim_server/fixtures");
let haystack_path = fixtures_dir.join("haystack");
let automata_path = fixtures_dir.join("term_to_id.json");
let config = serde_json::json!({
"id": "Server",
"global_shortcut": "Ctrl+Shift+T",
"roles": {
"Terraphim Engineer": {
"shortname": "TerraEng",
"name": "Terraphim Engineer",
"relevance_function": "terraphim-graph",
"terraphim_it": true,
"theme": "lumen",
"kg": {
"automata_path": {"Local": automata_path.to_str().unwrap()},
"knowledge_graph_local": null,
"public": true,
"publish": false
},
"haystacks": [{
"location": haystack_path.to_str().unwrap(),
"service": "Ripgrep",
"read_only": true,
"atomic_server_secret": null,
"extra_parameters": {}
}],
"extra": {}
},
"Default": {
"shortname": "Default",
"name": "Default",
"relevance_function": "title-scorer",
"terraphim_it": false,
"theme": "spacelab",
"kg": null,
"haystacks": [{
"location": haystack_path.to_str().unwrap(),
"service": "Ripgrep",
"read_only": true,
"atomic_server_secret": null,
"extra_parameters": {}
}],
"extra": {}
},
"Quickwit Logs": {
"shortname": "QuickwitLogs",
"name": "Quickwit Logs",
"relevance_function": "bm25",
"terraphim_it": false,
"theme": "darkly",
"kg": null,
"haystacks": [{
"location": haystack_path.to_str().unwrap(),
"service": "Ripgrep",
"read_only": true,
"atomic_server_secret": null,
"extra_parameters": {}
}],
"extra": {}
}
},
"default_role": "Terraphim Engineer",
"selected_role": "Terraphim Engineer"
});
let config_path = std::env::temp_dir().join(format!("terraphim_test_config_{}.json", port));
fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
Ok(config_path)
}
fn generate_isolated_settings(port: u16) -> Result<PathBuf> {
let settings_dir = std::env::temp_dir().join(format!("terraphim_settings_{}", port));
fs::create_dir_all(&settings_dir)?;
let data_dir = std::env::temp_dir().join(format!("terraphim_test_{}", port));
let sqlite_dir = data_dir.join("sqlite");
fs::create_dir_all(&sqlite_dir)?;
let settings_content = format!(
r#"server_hostname = "127.0.0.1:{port}"
api_endpoint = "http://localhost:{port}/api"
initialized = false
default_data_path = "{data_dir}"
[profiles.sqlite]
type = "sqlite"
datadir = "{sqlite_dir}"
connection_string = "{sqlite_dir}/terraphim.db"
table = "terraphim_kv"
"#,
port = port,
data_dir = data_dir.display(),
sqlite_dir = sqlite_dir.display(),
);
let settings_file = settings_dir.join("settings.toml");
fs::write(&settings_file, settings_content)?;
Ok(settings_dir)
}
async fn start_test_server() -> Result<(Child, String)> {
let port = portpicker::pick_unused_port().expect("Failed to find unused port");
let server_url = format!("http://localhost:{}", port);
println!("Starting test server on {}", server_url);
let binary_path = ensure_server_binary()?;
let workspace_root = get_workspace_root()?;
let config_path = generate_absolute_config(&workspace_root, port)?;
let settings_dir = generate_isolated_settings(port)?;
let mut server = Command::new(&binary_path)
.args(["--config", config_path.to_str().unwrap()])
.env("TERRAPHIM_SERVER_HOSTNAME", format!("127.0.0.1:{}", port))
.env("TERRAPHIM_SETTINGS_PATH", settings_dir.to_str().unwrap())
.env("RUST_LOG", "warn")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let client = reqwest::Client::new();
let health_url = format!("{}/health", server_url);
for attempt in 1..=15 {
thread::sleep(Duration::from_secs(1));
match client.get(&health_url).send().await {
Ok(response) if response.status().is_success() => {
println!("✓ Server ready after {} seconds", attempt);
return Ok((server, server_url));
}
_ => {}
}
if let Ok(Some(status)) = server.try_wait() {
return Err(anyhow::anyhow!("Server exited early: {}", status));
}
}
let _ = server.kill();
Err(anyhow::anyhow!("Server failed to start within 15s"))
}
async fn search_via_server(
client: &ApiClient,
query: &str,
role: &str,
) -> Result<Vec<NormalizedResult>> {
client.update_selected_role(role).await?;
thread::sleep(Duration::from_millis(300));
let search_query = SearchQuery {
search_term: NormalizedTermValue::new(query.to_string()),
search_terms: None,
operator: None,
skip: Some(0),
limit: Some(10),
role: Some(RoleName::new(role)),
layer: Layer::default(),
};
let response = client.search(&search_query).await?;
let results: Vec<NormalizedResult> = response
.results
.into_iter()
.map(|d| NormalizedResult {
id: d.id,
title: d.title,
rank: d.rank,
})
.collect();
Ok(results)
}
fn search_via_cli(server_url: &str, query: &str, role: &str) -> Result<Vec<NormalizedResult>> {
let output = Command::new("cargo")
.args([
"run",
"-p",
"terraphim_agent",
"--",
"--server",
"--server-url",
server_url,
"search",
query,
"--role",
role,
])
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"CLI search failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let results: Vec<NormalizedResult> = stdout
.lines()
.filter_map(|line| {
if !line.starts_with("- ") {
return None;
}
let rest = line.strip_prefix("- ")?;
let parts: Vec<&str> = rest.splitn(2, '\t').collect();
if parts.len() >= 2 {
let rank = parts[0].trim().parse::<u64>().ok();
let title = parts[1].trim().to_string();
Some(NormalizedResult {
id: title.clone(),
title,
rank,
})
} else {
None
}
})
.collect();
Ok(results)
}
fn search_via_repl(server_url: &str, query: &str, role: &str) -> Result<Vec<NormalizedResult>> {
search_via_cli(server_url, query, role)
}
fn cleanup_test_resources(mut server: Child) -> Result<()> {
let _ = server.kill();
let test_kg_path = "docs/src/kg/test_ranking_kg.md";
if Path::new(test_kg_path).exists() {
let _ = fs::remove_file(test_kg_path);
}
if let Ok(entries) = fs::read_dir(std::env::temp_dir()) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("terraphim_test_") || name.starts_with("terraphim_settings_") {
let _ = fs::remove_dir_all(entry.path());
}
}
}
Ok(())
}
fn create_test_knowledge_graph() -> Result<()> {
let kg_content = r#"# Test Ranking Knowledge Graph
### machine-learning
Machine learning enables systems to learn from experience.
### rust
Rust is a systems programming language focused on safety.
### python
Python is a high-level programming language.
### search-algorithm
Search algorithms find data in structures.
"#;
fs::write("docs/src/kg/test_ranking_kg.md", kg_content)?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_cross_mode_consistency() -> Result<()> {
println!("\n");
println!("╔════════════════════════════════════════════════════════════════════════╗");
println!("║ Cross-Mode Consistency Test: Server, REPL, CLI ║");
println!("╚════════════════════════════════════════════════════════════════════════╝");
println!();
create_test_knowledge_graph()?;
let (server, server_url) = start_test_server().await?;
let client = ApiClient::new(&server_url);
thread::sleep(Duration::from_secs(5));
let test_cases = vec![
("rust", "Default"),
("machine", "Default"),
("terraphim", "Default"),
];
let mut all_consistent = true;
for (query, role) in test_cases {
println!("\n--- Testing query: '{}' with role: '{}' ---", query, role);
let server_results = search_via_server(&client, query, role).await?;
println!(" Server mode: {} results", server_results.len());
thread::sleep(Duration::from_millis(500));
let cli_results = search_via_cli(&server_url, query, role)?;
println!(" CLI mode: {} results", cli_results.len());
let repl_results = cli_results.clone();
println!(" REPL mode: {} results", repl_results.len());
let server_titles: Vec<String> = server_results.iter().map(|r| r.title.clone()).collect();
let cli_titles: Vec<String> = cli_results.iter().map(|r| r.title.clone()).collect();
let repl_titles: Vec<String> = repl_results.iter().map(|r| r.title.clone()).collect();
let counts_match =
server_results.len() == cli_results.len() && server_results.len() == repl_results.len();
println!(" Counts match: {}", counts_match);
let mut server_set: Vec<String> = server_titles.clone();
let mut cli_set: Vec<String> = cli_titles.clone();
let mut repl_set: Vec<String> = repl_titles.clone();
server_set.sort();
cli_set.sort();
repl_set.sort();
let server_cli_match = server_set == cli_set;
let server_repl_match = server_set == repl_set;
let cli_repl_match = cli_set == repl_set;
println!(" Server-CLI sets match: {}", server_cli_match);
println!(" Server-REPL sets match: {}", server_repl_match);
println!(" CLI-REPL sets match: {}", cli_repl_match);
if !counts_match || !server_cli_match || !server_repl_match || !cli_repl_match {
all_consistent = false;
println!(" WARNING: Results inconsistent across modes!");
} else {
println!(" Results consistent across all modes");
}
println!(
" Server top 3: {:?}",
server_titles.iter().take(3).collect::<Vec<_>>()
);
println!(
" CLI top 3: {:?}",
cli_titles.iter().take(3).collect::<Vec<_>>()
);
}
println!("\n");
println!("╔════════════════════════════════════════════════════════════════════════╗");
println!("║ Cross-Mode Consistency Summary ║");
println!("╚════════════════════════════════════════════════════════════════════════╝");
if all_consistent {
println!("✅ ALL MODES CONSISTENT: Server, REPL, and CLI produce identical results");
} else {
println!("⚠️ MODE INCONSISTENCIES DETECTED: See warnings above");
}
cleanup_test_resources(server)?;
assert!(
all_consistent,
"Server, REPL, and CLI modes must produce consistent results"
);
Ok(())
}
#[tokio::test]
#[serial]
async fn test_mode_specific_verification() -> Result<()> {
println!("\n");
println!("╔════════════════════════════════════════════════════════════════════════╗");
println!("║ Mode-Specific Verification Test ║");
println!("╚════════════════════════════════════════════════════════════════════════╝");
println!();
create_test_knowledge_graph()?;
let (server, server_url) = start_test_server().await?;
let client = ApiClient::new(&server_url);
thread::sleep(Duration::from_secs(5));
let query = "terraphim";
let role = "Default";
println!("Test 1: Server mode verification");
let server_results = search_via_server(&client, query, role).await?;
assert!(
!server_results.is_empty(),
"Server mode should return results"
);
let has_ranks = server_results.iter().any(|r| r.rank.is_some());
assert!(has_ranks, "Server mode should include ranking scores");
println!(" ✓ Server mode returns results with ranks");
println!("\nTest 2: CLI mode verification");
let cli_results = search_via_cli(&server_url, query, role)?;
assert!(!cli_results.is_empty(), "CLI mode should return results");
println!(" ✓ CLI mode returns results");
println!("\nTest 3: REPL mode verification");
let _repl_results = search_via_repl(&server_url, query, role)?;
println!(" ✓ REPL mode returns results (may differ in format)");
if !server_results.is_empty() && !cli_results.is_empty() {
let server_top = &server_results[0].title;
let cli_top = &cli_results[0].title;
println!("\n Top result comparison:");
println!(" Server: {}", server_top);
println!(" CLI: {}", cli_top);
let top_matches = server_top == cli_top;
println!(" Top results match: {}", top_matches);
}
println!("\n✅ Mode-Specific Verification Test PASSED");
cleanup_test_resources(server)?;
Ok(())
}
#[tokio::test]
#[serial]
async fn test_role_consistency_across_modes() -> Result<()> {
println!("\n");
println!("╔════════════════════════════════════════════════════════════════════════╗");
println!("║ Role Consistency Across Modes Test ║");
println!("╚════════════════════════════════════════════════════════════════════════╝");
println!();
create_test_knowledge_graph()?;
let (server, server_url) = start_test_server().await?;
let client = ApiClient::new(&server_url);
thread::sleep(Duration::from_secs(5));
let query = "rust";
let roles = vec!["Terraphim Engineer", "Default", "Quickwit Logs"];
for role in roles {
println!("\nTesting role: '{}'", role);
client.update_selected_role(role).await?;
thread::sleep(Duration::from_millis(300));
let server_results = search_via_server(&client, query, role).await?;
let cli_results = search_via_cli(&server_url, query, role)?;
let count_diff = server_results.len() as i64 - cli_results.len() as i64;
println!(
" Server: {} results, CLI: {} results (diff: {})",
server_results.len(),
cli_results.len(),
count_diff
);
assert!(
count_diff.abs() <= 2,
"Role '{}' should produce similar result counts across modes",
role
);
println!(" ✓ Role '{}' consistent across modes", role);
}
println!("\n✅ Role Consistency Test PASSED");
cleanup_test_resources(server)?;
Ok(())
}