Skip to main content

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}