From 515f867fe1dc5f84f230328e114268a05912899d Mon Sep 17 00:00:00 2001 From: rustfuture <121799572+rustfuture@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:59:52 +0300 Subject: [PATCH] Report CLI and server errors instead of panicking Indexing, saving or loading an index and binding the HTTP listener now print one "error: ..." line to stderr and exit with code 1 (usage errors keep code 2). A poisoned index lock in the server is recovered with into_inner(): the index is read-only between reloads, so serving the last state beats taking the server down. Tests cover a missing repository, a missing index file and an occupied address. --- src/main.rs | 50 ++++++++++++++++++++++++++++++------------ tests/ri_regression.rs | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/main.rs b/src/main.rs index 045ab12..daa399b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,27 @@ use repository_intelligence::{ }; use std::{ env, + fmt::Display, io::{Read, Write}, net::TcpListener, path::Path, sync::{Arc, RwLock}, }; +fn fail(message: impl Display) -> ! { + eprintln!("error: {message}"); + std::process::exit(1); +} + +fn build_index(path: &Path) -> Index { + Index::build(path).unwrap_or_else(|error| { + fail(format!( + "could not index repository at {}: {error}", + path.display() + )) + }) +} + fn main() { let mut raw_args: Vec = env::args().skip(1).collect(); if let Some(pos) = raw_args.iter().position(|a| a == "--embedding") { @@ -67,7 +82,7 @@ fn main() { } "--analytics" => { let repository = args.next().unwrap_or_else(|| ".".into()); - let index = Index::build(Path::new(&repository)).expect("index repository"); + let index = build_index(Path::new(&repository)); println!("{}", index.analytics()); } "--index" => { @@ -75,8 +90,12 @@ fn main() { let index_path = args .next() .unwrap_or_else(|| ".repository-intelligence/index.ri".into()); - let index = Index::build(Path::new(&repository)).expect("index repository"); - index.save_to(Path::new(&index_path)).expect("save index"); + let index = build_index(Path::new(&repository)); + index + .save_to(Path::new(&index_path)) + .unwrap_or_else(|error| { + fail(format!("could not save index to {index_path}: {error}")) + }); println!( "saved={} files={} chunks={} commit={}", index_path, @@ -90,7 +109,9 @@ fn main() { .next() .unwrap_or_else(|| ".repository-intelligence/index.ri".into()); let query = args.collect::>().join(" "); - let index = Index::load_from(Path::new(&index_path)).expect("load index"); + let index = Index::load_from(Path::new(&index_path)).unwrap_or_else(|error| { + fail(format!("could not load index from {index_path}: {error}")) + }); for item in index .search_evidence(&query, 10, RetrievalMode::Hybrid) .into_iter() @@ -112,7 +133,7 @@ fn main() { }; let repository = args.next().unwrap_or_else(|| ".".into()); let query = args.collect::>().join(" "); - let index = Index::build(Path::new(&repository)).expect("index repository"); + let index = build_index(Path::new(&repository)); for item in index .search_evidence(&query, 10, retrieval) .into_iter() @@ -129,7 +150,7 @@ fn main() { "--commits" => { let repository = args.next().unwrap_or_else(|| ".".into()); let query = args.collect::>().join(" "); - let index = Index::build(Path::new(&repository)).expect("index repository"); + let index = build_index(Path::new(&repository)); for commit in index .search_commits(&query, 10) .into_iter() @@ -144,7 +165,7 @@ fn main() { eprintln!("usage: repository-intelligence "); std::process::exit(2); } - let index = Index::build(Path::new(&mode)).expect("index repository"); + let index = build_index(Path::new(&mode)); for hit in index .search(&query, 10) .into_iter() @@ -185,7 +206,7 @@ fn extract_top(args: Vec) -> Result<(Option, Vec), String fn answer_command(args: &mut impl Iterator, json_mode: bool) { let repository = args.next().unwrap_or_else(|| ".".into()); let question = args.collect::>().join(" "); - let index = Index::build(Path::new(&repository)).expect("index repository"); + let index = build_index(Path::new(&repository)); let evidence_items = index.build_evidence(&question, 5); let has_lexical_anchor = !index .search_evidence(&question, 1, RetrievalMode::Lexical) @@ -380,8 +401,9 @@ fn answer_json_model_error( fn serve(address: &str, root: &Path) { let root = root.to_path_buf(); - let index = Arc::new(RwLock::new(Index::build(&root).expect("index repository"))); - let listener = TcpListener::bind(address).expect("bind HTTP listener"); + let index = Arc::new(RwLock::new(build_index(&root))); + let listener = TcpListener::bind(address) + .unwrap_or_else(|error| fail(format!("could not listen on {address}: {error}"))); eprintln!("repository-intelligence listening on http://{address}"); for stream in listener.incoming() { let index = Arc::clone(&index); @@ -395,7 +417,7 @@ fn serve(address: &str, root: &Path) { let first_line = request.lines().next().unwrap_or_default(); let path = first_line.split_whitespace().nth(1).unwrap_or("/"); let body = if path == "/health" { - let index = index.read().expect("index lock"); + let index = index.read().unwrap_or_else(|error| error.into_inner()); format!( "{{\"status\":\"ok\",\"commit\":{},\"files\":{},\"chunks\":{},\"embedding\":\"{}\"}}", index @@ -409,7 +431,7 @@ fn serve(address: &str, root: &Path) { } else if path == "/reload" { match current_git_revision(&root) { Some(revision) => { - let mut index = index.write().expect("index lock"); + let mut index = index.write().unwrap_or_else(|error| error.into_inner()); match index.sync_git(&root, &revision) { Ok(()) => format!( "{{\"status\":\"reloaded\",\"commit\":\"{}\",\"files\":{},\"chunks\":{}}}", @@ -425,7 +447,7 @@ fn serve(address: &str, root: &Path) { None => "{\"error\":\"repository has no readable Git HEAD\"}".to_owned(), } } else if let Some(query) = path.strip_prefix("/commits?q=") { - let index = index.read().expect("index lock"); + let index = index.read().unwrap_or_else(|error| error.into_inner()); let query = decode_query(query); let items = index .search_commits(&query, 10) @@ -446,7 +468,7 @@ fn serve(address: &str, root: &Path) { items ) } else if let Some(query) = path.strip_prefix("/search?q=") { - let index = index.read().expect("index lock"); + let index = index.read().unwrap_or_else(|error| error.into_inner()); let query = decode_query(query); let hits = index.search_evidence(&query, 10, RetrievalMode::Hybrid); let items = hits diff --git a/tests/ri_regression.rs b/tests/ri_regression.rs index cf6d2fa..6d6cfc0 100644 --- a/tests/ri_regression.rs +++ b/tests/ri_regression.rs @@ -162,6 +162,53 @@ fn malformed_percent_encoding_does_not_crash_http_server() { let _ = child.wait(); } +fn assert_cli_error(output: std::process::Output) { + assert_eq!(output.status.code(), Some(1)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.starts_with("error:"), "stderr was: {stderr}"); + assert!(!stderr.contains("panicked"), "stderr was: {stderr}"); +} + +#[test] +fn nonexistent_repository_reports_cli_error_without_panicking() { + let root = temp_dir("missing-repository"); + let missing = root.join("does-not-exist"); + let output = Command::new(env!("CARGO_BIN_EXE_repository-intelligence")) + .args([missing.to_str().expect("utf8 path"), "query"]) + .output() + .expect("run CLI"); + + assert_cli_error(output); +} + +#[test] +fn missing_load_index_reports_cli_error_without_panicking() { + let root = temp_dir("missing-index"); + let missing = root.join("does-not-exist.ri"); + let output = Command::new(env!("CARGO_BIN_EXE_repository-intelligence")) + .args([ + "--load-index", + missing.to_str().expect("utf8 path"), + "query", + ]) + .output() + .expect("run CLI"); + + assert_cli_error(output); +} + +#[test] +fn occupied_serve_address_reports_cli_error_without_panicking() { + let listener = TcpListener::bind("127.0.0.1:0").expect("reserve local port"); + let address = listener.local_addr().expect("read local address"); + let output = Command::new(env!("CARGO_BIN_EXE_repository-intelligence")) + .args(["--serve", &address.to_string(), "."]) + .output() + .expect("run CLI"); + + assert_cli_error(output); +} + #[test] fn git_sync_handles_git_quoted_paths() { let root = temp_dir("quoted-path");