reposix_sim/lib.rs
1//! Reposix simulator — in-process REST API that mimics issue-tracker semantics.
2//!
3//! Exposes a handful of pure functions so integration tests can spin a real
4//! HTTP server on a random port without forking a process. The standalone
5//! `reposix-sim` binary is a thin `tokio::main` wrapper over [`run`].
6//!
7//! # Module layout
8//!
9//! - [`state`] — [`AppState`] shared across handlers.
10//! - [`db`] — `SQLite` connection opener + issues-table DDL.
11//! - [`seed`] — deterministic seed loader (reads `fixtures/seed.json`).
12//! - [`error`] — [`error::ApiError`] enum + `IntoResponse` impl.
13//! - (routes and middleware land in task 2 of plan 02-01 / plan 02-02.)
14
15#![forbid(unsafe_code)]
16#![warn(clippy::pedantic, missing_docs)]
17#![allow(clippy::module_name_repetitions)]
18
19use std::net::SocketAddr;
20use std::path::PathBuf;
21
22use axum::Router;
23use serde::{Deserialize, Serialize};
24
25pub mod db;
26pub mod error;
27pub mod middleware;
28pub mod routes;
29pub mod seed;
30pub mod state;
31
32pub use error::{Result, SimError};
33pub use state::AppState;
34
35/// Capability matrix row published by this backend for `reposix doctor`.
36///
37/// The simulator implements the full reference matrix: read, create, update,
38/// delete, comments round-tripped in the body, and strong versioning via the
39/// `version` field. Other backends adopt this shape with caveats; the sim is
40/// the contract every other connector is benchmarked against.
41pub const CAPABILITIES: reposix_core::BackendCapabilities = reposix_core::BackendCapabilities::new(
42 true,
43 true,
44 true,
45 true,
46 reposix_core::CommentSupport::InBody,
47 reposix_core::VersioningModel::Strong,
48);
49
50/// Runtime configuration for the simulator.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct SimConfig {
53 /// Bind address. Use `127.0.0.1:0` for a random port (recommended in tests).
54 pub bind: SocketAddr,
55 /// Path to the `SQLite` DB file. Created if absent. Use `:memory:` or set
56 /// `ephemeral=true` for a transient DB.
57 pub db_path: PathBuf,
58 /// Whether to install seed data on first run.
59 pub seed: bool,
60 /// Optional path to the seed JSON. If `None` and `seed=true`, nothing is
61 /// seeded — callers pass the fixture path via `--seed-file`.
62 #[serde(default)]
63 pub seed_file: Option<PathBuf>,
64 /// Open DB as `:memory:` regardless of `db_path`.
65 #[serde(default)]
66 pub ephemeral: bool,
67 /// Per-agent rate limit in requests per second. Default 100.
68 #[serde(default = "default_rate_limit_rps")]
69 pub rate_limit_rps: u32,
70}
71
72fn default_rate_limit_rps() -> u32 {
73 100
74}
75
76impl SimConfig {
77 /// Default config for a one-off in-memory simulator.
78 ///
79 /// # Panics
80 /// Never in practice; the bind address is a static, valid `SocketAddr` literal.
81 #[must_use]
82 pub fn ephemeral() -> Self {
83 Self {
84 bind: "127.0.0.1:0".parse().expect("static addr parses"),
85 db_path: PathBuf::from(":memory:"),
86 seed: true,
87 seed_file: None,
88 ephemeral: true,
89 rate_limit_rps: default_rate_limit_rps(),
90 }
91 }
92}
93
94/// Build the axum router with both middleware layers attached.
95///
96/// Layer ordering (outermost first): **audit → rate-limit → handlers**. Axum
97/// `.layer()` wraps inside-out, so the last `.layer()` call is the
98/// outermost. That means audit sees every request (including 429s), and
99/// rate-limit sees every request that survives the audit recording.
100pub fn build_router(state: AppState, rate_limit_rps: u32) -> Router {
101 let handlers = Router::new()
102 .route("/healthz", axum::routing::get(healthz))
103 .merge(routes::router(state.clone()));
104 // Attach INNER first (rate-limit), then OUTER (audit).
105 let with_rate_limit = middleware::rate_limit::attach(handlers, rate_limit_rps);
106 middleware::audit::attach(with_rate_limit, state)
107}
108
109#[allow(clippy::unused_async)]
110async fn healthz() -> &'static str {
111 "ok"
112}
113
114/// Open the DB, seed if configured, and return an [`AppState`].
115///
116/// # Errors
117/// Returns [`SimError::Api`] if [`db::open_db`] or [`seed::load_seed`] fails.
118pub fn prepare_state(cfg: &SimConfig) -> Result<AppState> {
119 let conn = db::open_db(&cfg.db_path, cfg.ephemeral)?;
120
121 if cfg.seed {
122 if let Some(ref path) = cfg.seed_file {
123 let inserted = seed::load_seed(&conn, path)?;
124 tracing::info!(inserted, path = %path.display(), "seed loaded");
125 }
126 }
127
128 Ok(AppState::new(conn, cfg.clone()))
129}
130
131/// Run the sim on an already-bound listener. Integration tests use this to
132/// bind `127.0.0.1:0`, read the ephemeral port, and drive the sim without
133/// racing a separate binary.
134///
135/// # Errors
136/// Returns [`SimError::Io`] for any I/O error surfaced by `axum::serve` or
137/// `TcpListener::local_addr`; [`SimError::Api`] for state-preparation
138/// failures from [`prepare_state`].
139pub async fn run_with_listener(listener: tokio::net::TcpListener, cfg: SimConfig) -> Result<()> {
140 let state = prepare_state(&cfg)?;
141 tracing::info!(addr = %listener.local_addr()?, "reposix-sim listening");
142 axum::serve(listener, build_router(state, cfg.rate_limit_rps)).await?;
143 Ok(())
144}
145
146/// Bind the configured address and serve until the listener dies.
147///
148/// # Errors
149/// Returns [`SimError::Bind`] if binding the listener fails (so operators
150/// see the failed address in the error message); otherwise the same error
151/// set as [`run_with_listener`].
152pub async fn run(cfg: SimConfig) -> Result<()> {
153 let listener = tokio::net::TcpListener::bind(cfg.bind)
154 .await
155 .map_err(|source| SimError::Bind {
156 addr: cfg.bind.to_string(),
157 source,
158 })?;
159 run_with_listener(listener, cfg).await
160}