Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ finalized in place with a date — no renaming/migration step needed.

- **TUI stack majors: ratatui 0.30, crossterm 0.29.** Zero call-site changes — the serve TUI sits on stable surface (`CrosstermBackend`, `Terminal`, `TableState`, `Paragraph`, `Layout`). This removes the last lru 0.12.5 path (ratatui's chain now carries lru 0.18.4; tantivy stays on the 0.16.4 that upstream 0.26 pins).

- **axum 0.7 → 0.8.** The only breaking surface hit: path parameters changed syntax from `:param` to `{param}` — all route registrations (`/repos/:alias*`, `/chunk/:id`) and the shared `CHUNK_PATH` constant move to brace syntax, including the federation client's URL templating that derives from the same constant. Extractors, middleware and `axum::serve` compile unchanged; the serve + federation test suites exercise the rebuilt router end-to-end.

- **thiserror 1.0 → 2.0 (direct).** Drop-in for all error enums (`#[error]`, `#[from]` unchanged); tantivy's chain still carries thiserror 1.x transitively until upstream moves.

## [1.3.19]
Expand Down
21 changes: 9 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ tantivy = "0.26"
regex = "1.12"

# Server
axum = "0.7"
axum = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }

Expand Down
10 changes: 5 additions & 5 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ pub fn resolve_serve_host() -> String {
}

/// Environment variable to set the admin API key for management endpoints.
/// When set, all management routes (`POST /repos`, `DELETE /repos/:alias`,
/// `POST /repos/:alias/reindex`, `POST /reload`) require this key.
/// When set, all management routes (`POST /repos`, `DELETE /repos/{alias}`,
/// `POST /repos/{alias}/reindex`, `POST /reload`) require this key.
/// When unset or empty, management routes are unauthenticated (backward compatible).
/// The key is validated against `Authorization: Bearer <key>` or `X-API-Key: <key>` headers.
pub const SERVE_API_KEY_ENV: &str = "CODESEARCH_SERVE_API_KEY";
Expand Down Expand Up @@ -387,8 +387,8 @@ pub const FIND_PATH: &str = "/find";
pub const EXPLORE_PATH: &str = "/explore";

/// REST get-chunk endpoint (HTTP mirror of the `get_chunk` MCP tool).
/// GET `/chunk/:id?context_lines=&project=&group=`.
pub const CHUNK_PATH: &str = "/chunk/:id";
/// GET `/chunk/{id}?context_lines=&project=&group=`.
pub const CHUNK_PATH: &str = "/chunk/{id}";

/// REST find-impact endpoint (HTTP mirror of the `find_impact` MCP tool).
/// POST a `FindImpactRequest` body; returns the tool's JSON payload
Expand Down Expand Up @@ -570,7 +570,7 @@ pub const DB_DELETE_RETRY_BACKOFF_CAP_MS: u64 = 2000;
/// an actually-unlocked directory instead of burning attempts blind.
pub const DB_DELETE_ENV_RELEASE_POLL_MS: u64 = 100;

/// Unallocated margin (seconds) the CLI's delegated `DELETE /repos/:alias`
/// Unallocated margin (seconds) the CLI's delegated `DELETE /repos/{alias}`
/// request adds on top of serve's legitimate worst-case removal time —
/// `DB_DELETE_RETRY_BUDGET_SECS` plus one `BG_TASK_COOPERATIVE_TIMEOUT_SECS`
/// per cooperative join (FSW task + index task) — so the CLI receives
Expand Down
36 changes: 18 additions & 18 deletions src/federation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ pub struct RemoteRepoStatus {
pub tool_call_count: Option<u64>,
}

/// `GET /repos/:alias/info` payload — on-disk index stats for one repo on the
/// `GET /repos/{alias}/info` payload — on-disk index stats for one repo on the
/// peer. Only the fields the TUI mount-info overlay renders are typed; every
/// field is optional/defaulted so an older/newer remote still parses.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
Expand Down Expand Up @@ -216,7 +216,7 @@ pub struct RemoteRepoAdded {
pub message: Option<String>,
}

/// `DELETE /repos/:alias` success payload (HTTP 200).
/// `DELETE /repos/{alias}` success payload (HTTP 200).
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RemoteRepoRemoved {
#[serde(default)]
Expand All @@ -229,7 +229,7 @@ pub struct RemoteRepoRemoved {
pub message: Option<String>,
}

/// `POST /repos/:alias/reindex` success payload (HTTP 202).
/// `POST /repos/{alias}/reindex` success payload (HTTP 202).
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct RemoteReindexResult {
#[serde(default)]
Expand Down Expand Up @@ -382,7 +382,7 @@ impl FederationClient {
unreachable!("retry loop always returns on its final iteration")
}

/// Fetch a single chunk from a remote peer's `/chunk/:id` endpoint.
/// Fetch a single chunk from a remote peer's `/chunk/{id}` endpoint.
///
/// Scoping mirrors [`Self::search_project`]:
/// - When `remote_alias` is `Some`, the lookup is scoped to that single
Expand All @@ -402,7 +402,7 @@ impl FederationClient {
) -> Outcome<serde_json::Value> {
let mut url = Self::peer_url(
peer,
&crate::constants::CHUNK_PATH.replace(":id", &chunk_id.to_string()),
&crate::constants::CHUNK_PATH.replace("{id", &chunk_id.to_string()),
);
// Scope the lookup: prefer a single-project scope (`project=<alias>`)
// so the multi-repo peer can disambiguate the chunk_id; fall back to
Expand Down Expand Up @@ -476,7 +476,7 @@ impl FederationClient {
}

/// Shared request/response handling for the management endpoints
/// (`/status`, `/repos`, `/repos/:alias`, `/repos/:alias/reindex`).
/// (`/status`, `/repos`, `/repos/{alias}`, `/repos/{alias}/reindex`).
///
/// Distinguishes three failure modes (see [`ManagementOutcome`]):
/// transport failure → `Unreachable`; non-2xx → `HttpError` with the peer's
Expand Down Expand Up @@ -577,7 +577,7 @@ impl FederationClient {
.await
}

/// `DELETE /repos/:alias` — unregister a repo on the peer and delete its DB.
/// `DELETE /repos/{alias}` — unregister a repo on the peer and delete its DB.
/// `alias` is the peer's repo alias (NOT a local path).
pub async fn remove_repo(
&self,
Expand All @@ -591,7 +591,7 @@ impl FederationClient {
.await
}

/// `GET /repos/:alias/info` — fetch on-disk index stats (chunks/files/db
/// `GET /repos/{alias}/info` — fetch on-disk index stats (chunks/files/db
/// size/model) for one repo on the peer. `alias` is the peer's repo alias.
pub async fn repo_info(
&self,
Expand All @@ -608,7 +608,7 @@ impl FederationClient {
.await
}

/// `POST /repos/:alias/reindex[?force=true]` — trigger a background
/// `POST /repos/{alias}/reindex[?force=true]` — trigger a background
/// incremental (or forced full) reindex of a repo on the peer.
pub async fn reindex(
&self,
Expand Down Expand Up @@ -866,7 +866,7 @@ mod tests {
// `group`) for a namespaced lookup — the fix for `ambiguous_chunk_id`
// on a multi-repo peer.
let app = axum::Router::new().route(
"/chunk/:id",
"/chunk/{id}",
axum::routing::get(
|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
Expand Down Expand Up @@ -918,7 +918,7 @@ mod tests {
// lookup must then fall back to the peer's group scope and NOT send a
// `project` param — preserving pre-fix behaviour for old refs.
let app = axum::Router::new().route(
"/chunk/:id",
"/chunk/{id}",
axum::routing::get(
|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
Expand Down Expand Up @@ -1041,7 +1041,7 @@ mod tests {
async fn remove_repo_targets_alias_in_url() {
// Echo the captured alias back to prove it landed in the DELETE path.
let app = axum::Router::new().route(
"/repos/:alias",
"/repos/{alias}",
axum::routing::delete(
|axum::extract::Path(alias): axum::extract::Path<String>| async move {
axum::Json(serde_json::json!({
Expand Down Expand Up @@ -1072,7 +1072,7 @@ mod tests {
async fn reindex_posts_to_alias_reindex_path() {
// Capture the alias from the path to prove the reindex URL was built.
let app = axum::Router::new().route(
"/repos/:alias/reindex",
"/repos/{alias}/reindex",
axum::routing::post(
|axum::extract::Path(alias): axum::extract::Path<String>| async move {
axum::Json(serde_json::json!({
Expand Down Expand Up @@ -1106,7 +1106,7 @@ mod tests {
async fn reindex_with_force_appends_force_query() {
// Capture the query string to prove ?force=true was forwarded.
let app = axum::Router::new().route(
"/repos/:alias/reindex",
"/repos/{alias}/reindex",
axum::routing::post(
|axum::extract::Query(params): axum::extract::Query<
std::collections::HashMap<String, String>,
Expand Down Expand Up @@ -1140,7 +1140,7 @@ mod tests {
// An alias with a space must be percent-encoded on the wire and decoded
// back by axum — proves the encoding round-trips through the HTTP layer.
let app = axum::Router::new().route(
"/repos/:alias",
"/repos/{alias}",
axum::routing::delete(
|axum::extract::Path(alias): axum::extract::Path<String>| async move {
axum::Json(serde_json::json!({
Expand Down Expand Up @@ -1219,7 +1219,7 @@ mod tests {
// Transient-status retry (502/503/504 — scale-to-zero cold starts, todo #58)
// =========================================================================

/// Helper: an axum route answering `/chunk/:id` that returns 503 (with a
/// Helper: an axum route answering `/chunk/{id}` that returns 503 (with a
/// non-JSON body, like a real cold-starting gateway) for the first
/// `fail_first` calls, then a valid 200 JSON chunk payload. Counts every
/// hit so tests can assert exactly how many attempts were made.
Expand All @@ -1230,7 +1230,7 @@ mod tests {
let hits = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let hits_clone = hits.clone();
let router = axum::Router::new().route(
"/chunk/:id",
"/chunk/{id}",
axum::routing::get(move || {
let hits = hits_clone.clone();
async move {
Expand Down Expand Up @@ -1340,7 +1340,7 @@ mod tests {
let hits = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let hits_clone = hits.clone();
let router = axum::Router::new().route(
"/chunk/:id",
"/chunk/{id}",
axum::routing::get(move || {
let hits = hits_clone.clone();
async move {
Expand Down
12 changes: 6 additions & 6 deletions src/index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1962,8 +1962,8 @@ const SERVE_HEALTH_RETRY_SLEEP: std::time::Duration = std::time::Duration::from_
/// makes. This is required:
/// - when serve is bound to a non-localhost address (the `require_auth_for_network`
/// middleware guards ALL endpoints, including `/health`), and
/// - for management endpoints (`POST /repos`, `DELETE /repos/:alias`,
/// `POST /repos/:alias/reindex`, `POST /reload`) when serve is bound to
/// - for management endpoints (`POST /repos`, `DELETE /repos/{alias}`,
/// `POST /repos/{alias}/reindex`, `POST /reload`) when serve is bound to
/// localhost with the key set.
///
/// Without this, delegation to a network-bound serve returns 401 and falls back
Expand Down Expand Up @@ -2386,7 +2386,7 @@ pub(crate) async fn try_delegate_add_to_serve(
}

/// The outcome a running serve instance reported for a delegated `index rm`
/// — the parsed `DELETE /repos/:alias` success payload.
/// — the parsed `DELETE /repos/{alias}` success payload.
///
/// `db_deleted == false` means the repo is functionally removed (FSW stopped,
/// evicted from memory, unregistered from repos.json) but the database
Expand Down Expand Up @@ -2472,7 +2472,7 @@ pub(crate) async fn try_delegate_rm_to_serve(
.map(|(a, _)| a.clone())
.ok_or_else(|| format!("path '{}' not found in repos.json", project_path.display()))?;

// 3. DELETE /repos/:alias
// 3. DELETE /repos/{alias}
//
// The DELETE needs its OWN client with a timeout that covers serve's
// legitimate worst-case removal time: `remove_repo` can spend up to
Expand Down Expand Up @@ -3154,7 +3154,7 @@ mod remove_order_tests {
}),
)
.route(
"/repos/:alias",
"/repos/{alias}",
axum::routing::delete(
|axum::extract::Path(alias): axum::extract::Path<String>| async move {
axum::Json(serde_json::json!({
Expand Down Expand Up @@ -3232,7 +3232,7 @@ mod remove_order_tests {
}),
)
.route(
"/repos/:alias",
"/repos/{alias}",
axum::routing::delete(
|axum::extract::Path(alias): axum::extract::Path<String>| async move {
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
Expand Down
22 changes: 11 additions & 11 deletions src/serve/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
//! Binds on `{host}:{port}` (default `127.0.0.1:39725`) and serves:
//! - `GET /health` → JSON health check
//! - `POST /repos` → register + index + warmup a new repo
//! - `DELETE /repos/:alias` → stop FSW + evict + unregister + delete DB
//! - `POST /repos/:alias/reindex` → trigger incremental or force reindex
//! - `DELETE /repos/{alias}` → stop FSW + evict + unregister + delete DB
//! - `POST /repos/{alias}/reindex` → trigger incremental or force reindex
//! - MCP streamable HTTP at `/mcp` via rmcp tower service
//!
//! Holds a `DashMap<String, Arc<SharedStores>>` keyed by repo alias.
Expand Down Expand Up @@ -1372,7 +1372,7 @@ impl ServeState {

/// Remove a repo: stop FSW, evict from memory, unregister from config, delete DB.
///
/// This is the shared logic used by both the HTTP `DELETE /repos/:alias` handler
/// This is the shared logic used by both the HTTP `DELETE /repos/{alias}` handler
/// and the TUI confirmation flow.
pub(crate) async fn remove_repo(&self, alias: &str) -> Result<RepoRemovalOutcome> {
// 1. Resolve project path from config
Expand Down Expand Up @@ -4319,7 +4319,7 @@ pub(crate) struct RepoRemovalOutcome {
pub db_delete_error: Option<String>,
}

/// Remove-repo handler: DELETE /repos/:alias
/// Remove-repo handler: DELETE /repos/{alias}
///
/// Stops the FSW, evicts the repo from memory, unregisters from repos.json,
/// and deletes the database directory. Returns 200 on success (status is
Expand Down Expand Up @@ -4500,8 +4500,8 @@ fn request_has_valid_api_key(headers: &axum::http::HeaderMap, configured: &str)
///
/// When the env var is unset or empty, all requests pass through (backward compatible).
///
/// Management endpoints are: `POST /repos`, `DELETE /repos/:alias`,
/// `POST /repos/:alias/reindex`, `POST /reload`.
/// Management endpoints are: `POST /repos`, `DELETE /repos/{alias}`,
/// `POST /repos/{alias}/reindex`, `POST /reload`.
/// All other routes (health, status, MCP) are always unauthenticated.
///
/// Key comparison is constant-time (see `api_key_matches`).
Expand Down Expand Up @@ -5136,24 +5136,24 @@ pub async fn run_serve(
// /remotes is a status-like read-only observability endpoint (lists the
// configured federation peers). It is NOT in require_admin_auth's
// `is_management` set, so it inherits exactly the same auth policy as
// /status, /repos/:alias/info and /repos/:alias/doctor: reachable
// /status, /repos/{alias}/info and /repos/{alias}/doctor: reachable
// without the admin key on localhost, protected by
// require_auth_for_network on network binds. See REMOTES_PATH doc.
.route(REMOTES_PATH, axum::routing::get(remotes_handler))
.route("/repos", axum::routing::post(add_repo_handler))
.route("/repos/:alias", axum::routing::delete(remove_repo_handler))
.route("/repos/{alias}", axum::routing::delete(remove_repo_handler))
.route("/reload", axum::routing::post(reload_handler))
.route(
"/repos/:alias/reindex",
"/repos/{alias}/reindex",
axum::routing::post(reindex_handler),
)
.route("/repos/:alias/info", axum::routing::get(info_handler))
.route("/repos/{alias}/info", axum::routing::get(info_handler))
// /doctor is a POST but is intentionally read-only (diagnostics only, no
// --fix path), so like /info and /status it is NOT in require_admin_auth's
// management set — reachable without the admin key on localhost, and still
// protected by require_auth_for_network on network binds. If doctor ever
// gains a mutating mode, add it to `is_management` in require_admin_auth.
.route("/repos/:alias/doctor", axum::routing::post(doctor_handler))
.route("/repos/{alias}/doctor", axum::routing::post(doctor_handler))
// REST endpoints — federation-friendly HTTP+JSON mirror of the read-only
// MCP tools (search/find/explore/get_chunk). Lets a remote codesearch
// serve be queried WITHOUT an MCP session. Same auth layers as /mcp &
Expand Down
Loading
Loading