fix(cli): add --version and --help flags to miroir-proxy

Adds clap-based CLI argument parsing so `miroir-proxy --version`
and `miroir-proxy --help` print version/usage and exit instead
of starting the server and hanging.

Also fixes numerous pre-existing clippy warnings in test files:
- digit grouping inconsistencies
- unused functions/variables
- useless_vec (vec! -> array)
- assert!(true) placeholders
- too_many_arguments

Resolves: bf-31ff
This commit is contained in:
jedarden 2026-05-26 03:02:56 -04:00
parent d10a9ac1fd
commit 4777bb6834
21 changed files with 117 additions and 78 deletions

1
Cargo.lock generated
View file

@ -2515,6 +2515,7 @@ dependencies = [
"bytes 1.11.1",
"chacha20poly1305",
"chrono",
"clap",
"config",
"dashmap",
"futures 0.3.32",

View file

@ -18,6 +18,7 @@ path = "src/main.rs"
anyhow = "1"
async-trait = "0.1"
axum = { version = "0.7", features = ["macros"] }
clap = { version = "4.5", features = ["derive"] }
http = "1.1"
tokio = { version = "1", features = ["rt-multi-thread", "signal"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }

View file

@ -3,6 +3,7 @@ use axum::{
routing::{get, post},
Router,
};
use clap::Parser;
use miroir_core::{
config::MiroirConfig,
peer_discovery::PeerDiscovery,
@ -16,6 +17,17 @@ use tokio::signal;
use tracing::{error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, registry, util::SubscriberInitExt, EnvFilter};
/// Miroir proxy - distributed search orchestrator for Meilisearch CE
#[derive(Parser, Debug)]
#[command(name = "miroir-proxy")]
#[command(author, version, about)]
#[command(long_version = option_env!("GIT_VERSION").unwrap_or_else(|| env!("CARGO_PKG_VERSION")))]
struct CliArgs {
/// Path to configuration file (YAML or TOML)
#[arg(short, long)]
config: Option<String>,
}
mod admin_session;
mod admin_ui;
mod auth;
@ -31,14 +43,12 @@ use admin_session::SealKey;
use auth::AuthState;
use middleware::{metrics_router, Metrics, TelemetryState};
use miroir_core::{
canary::{
CanaryRunner, QueryCapture, SearchQuery, SearchResponse,
},
canary::{CanaryRunner, QueryCapture, SearchQuery, SearchResponse},
task_store::TaskStore,
};
use routes::{
admin, admin_endpoints, health, indexes, keys, multi_search, search, search_ui,
settings, tasks, version,
admin, admin_endpoints, health, indexes, keys, multi_search, search, search_ui, settings,
tasks, version,
};
use scoped_key_rotation::ScopedKeyRotationState;
use std::sync::Arc;
@ -281,9 +291,35 @@ impl FromRef<UnifiedState> for routes::canary::CanaryState {
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Handle --version and --help before any other setup
// These must work without requiring config files or nodes
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 {
match args[1].as_str() {
"--version" | "-V" => {
println!("miroir-proxy {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
"--help" | "-h" => {
println!("Miroir proxy - distributed search orchestrator for Meilisearch CE");
println!();
println!("Usage: miroir-proxy [OPTIONS]");
println!();
println!("Options:");
println!(" -h, --help Print help information");
println!(" -V, --version Print version information");
println!(" -c, --config <FILE> Path to configuration file (YAML or TOML)");
std::process::exit(0);
}
_ => {}
}
}
// Parse CLI arguments - config file path can be specified here
let _cli = CliArgs::parse();
// Load configuration (file → env → CLI overlay)
let config =
MiroirConfig::load().map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let config = MiroirConfig::load().map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
// Initialize structured JSON logging (plan §10 format)
// Fields on every line: timestamp, level, target, message, pod_id

View file

@ -228,10 +228,7 @@ async fn test_document_round_trip() {
assert_eq!(status, 200, "Failed to fetch document {i}: {body}");
let doc: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
doc.get("id").and_then(|v| v.as_i64()),
Some(i64::from(i))
);
assert_eq!(doc.get("id").and_then(|v| v.as_i64()), Some(i64::from(i)));
assert_eq!(
doc.get("title").and_then(|v| v.as_str()),
Some(format!("Document {i}").as_str())
@ -325,10 +322,7 @@ async fn test_search_shard_coverage() {
.post("/indexes/shard_coverage_test/search", &search_body)
.await
.unwrap();
assert_eq!(
status, 200,
"Search failed for keyword {keyword}: {body}"
);
assert_eq!(status, 200, "Search failed for keyword {keyword}: {body}");
let response: serde_json::Value = serde_json::from_str(&body).unwrap();
let hits = response.get("hits").and_then(|v| v.as_array()).unwrap();

View file

@ -599,7 +599,7 @@ fn idempotency_key_follows_cross_vendor_convention() {
#[test]
fn validate_header_directions() {
// Request headers
let request_headers = vec![
let request_headers = [
"X-Miroir-Min-Settings-Version",
"X-Miroir-Session", // Both directions
"Idempotency-Key",
@ -611,7 +611,7 @@ fn validate_header_directions() {
];
// Response headers
let response_headers = vec![
let response_headers = [
"X-Miroir-Degraded",
"X-Miroir-Settings-Version",
"X-Miroir-Settings-Inconsistent",
@ -638,7 +638,8 @@ fn validate_header_directions() {
#[test]
fn header_contract_complete() {
// Verify all headers from plan §5 are covered by tests
let all_expected_headers = ["X-Miroir-Degraded",
let all_expected_headers = [
"X-Miroir-Degraded",
"X-Miroir-Settings-Version",
"X-Miroir-Min-Settings-Version",
"X-Miroir-Settings-Inconsistent",
@ -648,7 +649,8 @@ fn header_contract_complete() {
"X-Miroir-Tenant",
"X-Admin-Key",
"X-CSRF-Token",
"X-Search-UI-Key"];
"X-Search-UI-Key",
];
// This test serves as documentation that all headers are accounted for
assert_eq!(
@ -658,16 +660,20 @@ fn header_contract_complete() {
);
// Categorize by direction
let response_only = ["X-Miroir-Degraded",
let response_only = [
"X-Miroir-Degraded",
"X-Miroir-Settings-Version",
"X-Miroir-Settings-Inconsistent"];
let request_only = ["Idempotency-Key",
"X-Miroir-Settings-Inconsistent",
];
let request_only = [
"Idempotency-Key",
"X-Miroir-Min-Settings-Version",
"X-Miroir-Over-Fetch",
"X-Miroir-Tenant",
"X-Admin-Key",
"X-CSRF-Token",
"X-Search-UI-Key"];
"X-Search-UI-Key",
];
let bidirectional = ["X-Miroir-Session"];
assert_eq!(response_only.len(), 3, "3 response-only headers");

View file

@ -13,6 +13,7 @@ use tokio::time::sleep;
/// Test configuration helper.
struct TestSetup {
#[allow(dead_code)]
meilisearch_urls: Vec<String>,
proxy_url: String,
master_key: String,

View file

@ -128,10 +128,7 @@ async fn delete_key(
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!(
"DELETE /keys/{key_uid} failed: HTTP {status} — {text}"
)
.into());
return Err(format!("DELETE /keys/{key_uid} failed: HTTP {status}{text}").into());
}
Ok(())

View file

@ -11,9 +11,7 @@
use miroir_core::config::{MiroirConfig, NodeConfig, SearchUiConfig};
use miroir_core::task_store::{RedisTaskStore, SearchUiScopedKey, TaskStore};
use miroir_proxy::routes::indexes::MeilisearchClient;
use miroir_proxy::scoped_key_rotation::{
self, ScopedKeyRotationState,
};
use miroir_proxy::scoped_key_rotation::{self, ScopedKeyRotationState};
use serde_json::json;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::redis::Redis;
@ -55,6 +53,7 @@ async fn redis_store() -> RedisTaskStore {
}
/// Seed a scoped key into Redis (simulating a previous rotation).
#[allow(clippy::too_many_arguments)]
fn seed_scoped_key(
redis: &RedisTaskStore,
index: &str,

View file

@ -18,7 +18,7 @@ use miroir_core::task_store::NewAdminSession;
// Helpers
// ---------------------------------------------------------------------------
fn now_ms() -> i64 {
fn _now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
@ -26,20 +26,20 @@ fn now_ms() -> i64 {
}
/// Create an admin session for testing.
fn make_admin_session(id: &str, csrf_token: &str) -> NewAdminSession {
fn _make_admin_session(id: &str, csrf_token: &str) -> NewAdminSession {
NewAdminSession {
session_id: id.to_string(),
csrf_token: csrf_token.to_string(),
admin_key_hash: "test-admin-key-hash".to_string(),
created_at: now_ms(),
expires_at: now_ms() + 3_600_000, // 1 hour
created_at: _now_ms(),
expires_at: _now_ms() + 3_600_000, // 1 hour
user_agent: Some("test-agent".to_string()),
source_ip: Some("127.0.0.1".to_string()),
}
}
/// Extract error code from a Miroir error response.
fn extract_error_code(body: &str) -> Option<String> {
fn _extract_error_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
value
.get("code")

View file

@ -79,10 +79,7 @@ async fn five_failed_attempts_triggers_10_minute_backoff() {
.expect("record failure");
// First 4 failures don't trigger backoff
if i < 5 {
assert_eq!(
wait_seconds, None,
"failure {i} should not trigger backoff"
);
assert_eq!(wait_seconds, None, "failure {i} should not trigger backoff");
} else {
// 5th failure triggers backoff: 10 minutes = 600 seconds
assert_eq!(
@ -456,10 +453,7 @@ async fn different_ips_have_independent_buckets() {
let (allowed, _) = store
.check_rate_limit_admin_login(ip2, limit, window_seconds)
.expect("check rate limit");
assert!(
allowed,
"IP2 should not be affected by IP1's rate limit"
);
assert!(allowed, "IP2 should not be affected by IP1's rate limit");
}
/// Rate limit window expires after TTL.

View file

@ -43,7 +43,7 @@ fn create_test_config() -> MiroirConfig {
/// Test 1: Preview endpoint returns fingerprint and version information.
#[tokio::test]
async fn test_preview_endpoint_returns_fingerprint_and_version() {
let config = Arc::new(create_test_config());
let _config = Arc::new(create_test_config());
// This is a unit test for the response structure.
// In a full integration test, we would:

View file

@ -413,7 +413,7 @@ impl MockTaskRegistry {
}
/// Add a task with a specific status.
async fn add_task(&self, mtask_id: String, status: TaskStatus) {
async fn _add_task(&self, mtask_id: String, status: TaskStatus) {
let mut tasks = self.tasks.write().await;
tasks.insert(
mtask_id.clone(),
@ -436,7 +436,7 @@ impl MockTaskRegistry {
}
/// Update a task's status.
async fn update_task(&self, mtask_id: &str, status: TaskStatus) {
async fn _update_task(&self, mtask_id: &str, status: TaskStatus) {
let mut tasks = self.tasks.write().await;
if let Some(task) = tasks.get_mut(mtask_id) {
task.status = status;
@ -810,5 +810,4 @@ async fn integration_session_pinning_metrics() {
metrics.inc_session_wait_timeout("block");
// If we got here without panicking, the metrics methods work
assert!(true);
}

View file

@ -21,7 +21,7 @@ use miroir_core::router::shard_for_key;
use serde_json::json;
use std::collections::HashMap;
fn make_config(
fn _make_config(
shards: u32,
rf: u32,
replica_groups: u32,

View file

@ -48,8 +48,11 @@ fn contains_high_cardinality_id(s: &str) -> bool {
false
}
/// Type alias for parsed Prometheus metric line
type ParsedMetric = Option<(String, Vec<(String, String)>, f64)>;
/// Helper: parse a Prometheus metric line and extract labels
fn parse_metric_line(line: &str) -> Option<(String, Vec<(String, String)>, f64)> {
fn parse_metric_line(line: &str) -> ParsedMetric {
// Format: metric_name{label1="value1",label2="value2"} value
let brace_start = line.find('{')?;
let brace_end = line.find('}')?;

View file

@ -502,10 +502,7 @@ fn test_error_shape_byte_for_byte_parity() {
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// Must have all four fields
assert!(
parsed.get("message").is_some(),
"{code:?}: missing message"
);
assert!(parsed.get("message").is_some(), "{code:?}: missing message");
assert!(parsed.get("code").is_some(), "{code:?}: missing code");
assert!(parsed.get("type").is_some(), "{code:?}: missing type");
assert!(parsed.get("link").is_some(), "{code:?}: missing link");

View file

@ -13,8 +13,8 @@
use miroir_core::task_store::{
IdempotencyEntry, NewAdminSession, NewAlias, NewCanary, NewCanaryRun, NewCdcCursor, NewJob,
NewRolloverPolicy, NewSearchUiConfig, NewTask, NewTenantMapping, SessionRow, SqliteTaskStore, TaskFilter,
TaskStore,
NewRolloverPolicy, NewSearchUiConfig, NewTask, NewTenantMapping, SessionRow, SqliteTaskStore,
TaskFilter, TaskStore,
};
use std::collections::HashMap;
use std::path::PathBuf;
@ -875,7 +875,7 @@ fn test_prune_tasks_removes_old_terminal_tasks() {
store
.insert_task(&NewTask {
miroir_id: "old-task".to_string(),
created_at: now - 86400_000, // 1 day ago
created_at: now - 86_400_000, // 1 day ago
status: "succeeded".to_string(),
node_tasks,
error: None,
@ -911,7 +911,7 @@ fn test_prune_tasks_removes_old_terminal_tasks() {
store
.insert_task(&NewTask {
miroir_id: "active-task".to_string(),
created_at: now - 86400_000,
created_at: now - 86_400_000,
status: "processing".to_string(),
node_tasks,
error: None,

View file

@ -119,8 +119,10 @@ async fn test_fingerprint_shard_pagination() {
},
);
let mut config = AntiEntropyConfig::default();
config.fingerprint_batch_size = batch_size;
let config = AntiEntropyConfig {
fingerprint_batch_size: batch_size,
..Default::default()
};
let topology = Arc::new(RwLock::new(Topology::new(1, 1, 1)));
let reconciler = AntiEntropyReconciler::new(config, topology, Arc::new(mock_client));
@ -148,7 +150,7 @@ async fn test_fingerprint_shard_content_hash_excludes_internal_fields() {
"_rankingScore": 0.95,
});
let doc2 = json!({
let _doc2 = json!({
"id": "doc-1",
"title": "Same Title",
"content": "Same Content",
@ -462,8 +464,10 @@ async fn test_fingerprint_config_batch_size() {
},
);
let mut config = AntiEntropyConfig::default();
config.fingerprint_batch_size = batch_size;
let config = AntiEntropyConfig {
fingerprint_batch_size: batch_size,
..Default::default()
};
let topology = Arc::new(RwLock::new(Topology::new(1, 1, 1)));
let reconciler = AntiEntropyReconciler::new(config, topology, Arc::new(mock_client));
@ -493,7 +497,7 @@ async fn test_compute_content_hash_unit() {
// Create a dummy reconciler just to call the static method
let topology = Arc::new(RwLock::new(Topology::new(1, 1, 1)));
let reconciler = AntiEntropyReconciler::<MockNodeClient>::new(
let _reconciler = AntiEntropyReconciler::<MockNodeClient>::new(
AntiEntropyConfig::default(),
topology,
Arc::new(MockNodeClient::default()),

View file

@ -15,7 +15,7 @@ use miroir_proxy::middleware::Metrics;
/// Helper to parse a metric line from Prometheus text format.
///
/// Returns (metric_name, labels_map, value) or None if not a valid metric line.
fn parse_metric_line(
fn _parse_metric_line(
line: &str,
) -> Option<(String, std::collections::HashMap<String, String>, f64)> {
let line = line.trim();

View file

@ -111,9 +111,11 @@ fn test_request_id_format_in_logs() {
#[test]
fn test_request_id_extraction_from_logs() {
let logs = [r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","request_id":"abc12345","pod_id":"pod-1","message":"GET /search 200"}"#,
let logs = [
r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","request_id":"abc12345","pod_id":"pod-1","message":"GET /search 200"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.001Z","level":"debug","target":"miroir.node","request_id":"abc12345","pod_id":"pod-1","node_id":"node-1","message":"node call started"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.010Z","level":"info","target":"miroir.search","request_id":"abc12345","pod_id":"pod-1","index":"products","message":"search completed"}"#];
r#"{"timestamp":"2026-05-01T12:00:00.010Z","level":"info","target":"miroir.search","request_id":"abc12345","pod_id":"pod-1","index":"products","message":"search completed"}"#,
];
// Extract all logs with request_id = "abc12345"
let target_id = "abc12345";
@ -229,9 +231,11 @@ fn test_no_document_content_in_logs() {
fn test_log_volume_info_level() {
// At INFO level, search requests produce 2 INFO log entries:
// 1 from telemetry middleware (miroir.request) + 1 from search handler (miroir.search)
let request_logs = [r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","message":"GET /indexes/products/search 200"}"#,
let request_logs = [
r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","message":"GET /indexes/products/search 200"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.001Z","level":"debug","target":"miroir.node","message":"node call"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.002Z","level":"info","target":"miroir.search","message":"search completed"}"#];
r#"{"timestamp":"2026-05-01T12:00:00.002Z","level":"info","target":"miroir.search","message":"search completed"}"#,
];
let info_count = request_logs
.iter()
@ -247,10 +251,12 @@ fn test_log_volume_info_level() {
#[test]
fn test_debug_level_has_more_logs() {
// At DEBUG level, we get per-node logs in addition to the INFO logs
let debug_logs = [r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","message":"GET / 200"}"#,
let debug_logs = [
r#"{"timestamp":"2026-05-01T12:00:00.000Z","level":"info","target":"miroir.request","message":"GET / 200"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.001Z","level":"debug","target":"miroir.node","message":"node call started"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.002Z","level":"debug","target":"miroir.node","message":"node call completed"}"#,
r#"{"timestamp":"2026-05-01T12:00:00.003Z","level":"info","target":"miroir.search","message":"search completed"}"#];
r#"{"timestamp":"2026-05-01T12:00:00.003Z","level":"info","target":"miroir.search","message":"search completed"}"#,
];
let debug_count = debug_logs
.iter()
@ -570,7 +576,7 @@ async fn test_request_id_appears_in_all_log_lines_within_request() {
// Acceptance criterion: Every log line inside a request must carry request_id=<id>
// This is achieved via tracing::Span with request_id recorded on span enter
// and tracing_subscriber::fmt().with_current_span(true)
use axum::{routing::get, Extension};
use miroir_core::config::MiroirConfig;
use miroir_proxy::middleware::{
@ -659,7 +665,8 @@ async fn test_request_id_appears_in_all_log_lines_within_request() {
// Every log line should contain the request_id (either at top level or in span)
for line in &log_lines {
let json = parse_log_line(line).unwrap_or_else(|| panic!("Log line should be valid JSON: {line}"));
let json =
parse_log_line(line).unwrap_or_else(|| panic!("Log line should be valid JSON: {line}"));
// Verify request_id field exists and matches the response header
// It can be at the top level OR nested in the span object

View file

@ -164,7 +164,6 @@ fn test_feature_flag_exists() {
{
// If we're compiled with the tracing feature, the otel module should exist
// This is verified by the fact that this test compiles and links
assert!(true, "tracing feature is enabled");
}
#[cfg(not(feature = "tracing"))]
@ -173,7 +172,6 @@ fn test_feature_flag_exists() {
let config = MiroirConfig::default();
let _ = miroir_proxy::otel::init_otel_layer(&config);
miroir_proxy::otel::shutdown_otel();
assert!(true, "tracing feature is disabled, no-ops work");
}
}
@ -186,7 +184,6 @@ fn test_shutdown_otel_is_safe_to_call() {
// shutdown_otel should be safe to call regardless of feature flag
// or whether tracing was initialized
miroir_proxy::otel::shutdown_otel();
assert!(true, "shutdown_otel completed without panic");
}
#[test]
@ -195,7 +192,6 @@ fn test_shutdown_multiple_times_is_safe() {
miroir_proxy::otel::shutdown_otel();
miroir_proxy::otel::shutdown_otel();
miroir_proxy::otel::shutdown_otel();
assert!(true, "Multiple shutdown_otel calls completed without panic");
}
// ---------------------------------------------------------------------------
@ -227,8 +223,6 @@ fn test_span_hierarchy_exists_in_code() {
);
let _ = tracing::info_span!("merge", shard_count = 3, offset = 0, limit = 20);
assert!(true, "All span macros compile successfully");
}
// ---------------------------------------------------------------------------

View file

@ -13,7 +13,9 @@ use std::collections::HashSet;
#[derive(Clone)]
struct TestNode {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
base_url: String,
}
@ -25,6 +27,7 @@ impl TestNode {
}
}
#[allow(dead_code)]
async fn get(&self, path: &str) -> reqwest::Response {
let client = reqwest::Client::new();
client
@ -34,6 +37,7 @@ impl TestNode {
.unwrap()
}
#[allow(dead_code)]
async fn post(&self, path: &str, body: serde_json::Value) -> reqwest::Response {
let client = reqwest::Client::new();
client
@ -44,6 +48,7 @@ impl TestNode {
.unwrap()
}
#[allow(dead_code)]
async fn delete(&self, path: &str) -> reqwest::Response {
let client = reqwest::Client::new();
client
@ -56,6 +61,7 @@ impl TestNode {
struct TestCluster {
proxy_url: String,
#[allow(dead_code)]
nodes: Vec<TestNode>,
}