Skip to main content

mecha10_cli_core/
paths.rs

1//! Centralized path definitions for the CLI
2//!
3//! All hardcoded paths should be defined here to ensure consistency
4//! and make it easy to update paths across the codebase.
5//!
6//! # Organization
7//!
8//! - **Project paths**: Paths relative to project root (mecha10.json location)
9//! - **User paths**: Paths in user's home directory (~/.mecha10)
10//! - **Docker paths**: Paths used inside Docker containers
11//! - **Framework paths**: Paths within the mecha10 framework source
12
13#![allow(dead_code)]
14
15/// Project configuration file
16pub const PROJECT_CONFIG: &str = "mecha10.json";
17
18/// Project directories
19pub mod project {
20    /// Node source code directory
21    pub const NODES_DIR: &str = "nodes";
22
23    /// Driver source code directory
24    pub const DRIVERS_DIR: &str = "drivers";
25
26    /// Types directory
27    pub const TYPES_DIR: &str = "types";
28
29    /// Behavior trees directory - nested under `configs/` (LAB-2072) so `mecha10 config
30    /// push`/`pull`/`list` genuinely sync it like any other config, instead of living as a
31    /// sibling top-level directory `collect_configs_from_dir` never walked.
32    pub const BEHAVIORS_DIR: &str = "configs/behaviors";
33
34    /// Models directory (ONNX models, etc.)
35    pub const MODELS_DIR: &str = "models";
36
37    /// Logs directory
38    pub const LOGS_DIR: &str = "logs";
39
40    /// Simulation directory - nested under `configs/` (moved from a top-level `simulation/`
41    /// sibling) so `mecha10 config push`/`pull` syncs it like any other config, the same reason
42    /// `BEHAVIORS_DIR` moved under `configs/` in LAB-2072. Shares its parent directory with the
43    /// pre-existing `configs::SIMULATION_CONFIG` sim-settings file (`configs/simulation/
44    /// config.json`) - no collision, since that file's fixed name never matches an asset file
45    /// (`model.json`/`environment.json`/binary meshes/textures) synced under this tree.
46    pub const SIMULATION_DIR: &str = "configs/simulation";
47
48    /// Simulation models directory
49    pub const SIMULATION_MODELS_DIR: &str = "configs/simulation/models";
50
51    /// Simulation environments directory
52    pub const SIMULATION_ENVIRONMENTS_DIR: &str = "configs/simulation/environments";
53
54    /// Simulation billboard-image assets directory (`type: "image"` `environment.json`/
55    /// `task.json` objects' `source` fields point here) - flat, no per-environment nesting: a
56    /// project only ever has one pool of these images, so the earlier shared-vs-env-specific
57    /// tiering (`mecha10_cli_core::simulation_assets`, now removed) was unneeded complexity.
58    /// Nested under `SIMULATION_DIR` (not a top-level `assets/` sibling) so `mecha10 config
59    /// push`/`pull` covers it like every other simulation asset - the previous top-level
60    /// `<project>/assets/` location was never synced anywhere, which silently broke remote RL
61    /// training for any environment with an image billboard.
62    pub const SIMULATION_IMAGES_DIR: &str = "configs/simulation/images";
63
64    /// RL task-curriculum configs directory (LAB-2430) - scaffolded from the templates
65    /// package's bundled catalog at `mecha10 init` time. Deliberately NOT under
66    /// `SIMULATION_MODELS_DIR`/`SIMULATION_ENVIRONMENTS_DIR`'s `simulation/` tree (LAB-2429):
67    /// task-curriculum configs are a project's RL configuration (room_size, target_range,
68    /// obstacle_count, ...), not a physics/rendering asset, so they live alongside the rest of a
69    /// project's RL config (`configs/rl/jobs/`) instead. `mecha10-rl`'s
70    /// `AssetResolver` resolves task configs (e.g. `task1`) from here for training
71    /// (`mecha10 train`); `mecha10_rl.mujoco.cli serve --assets-path` (supervised by
72    /// `packages/nodes/simulator`, live dev-mode physics only) never reads task configs at all.
73    pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
74
75    /// Get path to a model's config file
76    pub fn model_config(model_name: &str) -> String {
77        format!("{}/models/{}/model.json", SIMULATION_DIR, model_name)
78    }
79
80    /// Get path to an environment's config file
81    pub fn environment_config(env_name: &str) -> String {
82        format!("{}/environments/{}/environment.json", SIMULATION_DIR, env_name)
83    }
84
85    /// Simulation output directory (generated robot/scene files)
86    pub const SIMULATION_OUTPUT_DIR: &str = "configs/simulation/output";
87
88    /// Cargo build target directory
89    pub const TARGET_DIR: &str = "target";
90
91    /// Source directory
92    pub const SRC_DIR: &str = "src";
93
94    /// Local (gitignored) project-scoped state directory - mirrors the `~/.mecha10`
95    /// user-home convention (`user::MECHA10_DIR`) but scoped to this project checkout, for
96    /// caches/state that are specific to one clone and shouldn't be committed to git (e.g.
97    /// `mecha10 dev`'s Python dependency preflight cache - see
98    /// `DEV_PREFLIGHT_CACHE_FILE`/`handlers/dev/ops/preflight.rs` in the `mecha10-cli` crate).
99    pub const STATE_DIR: &str = ".mecha10";
100
101    /// Cache file for `mecha10 dev`'s project-level Python dependency preflight gate - see
102    /// `handlers/dev/ops/preflight.rs` in the `mecha10-cli` crate. Purely a speed
103    /// optimization: a missing or stale-and-discarded cache always falls back to a real
104    /// scan, so nothing about correctness depends on this file existing or being fresh.
105    pub const DEV_PREFLIGHT_CACHE_FILE: &str = ".mecha10/dev-preflight-cache.json";
106}
107
108/// Configuration paths within project
109pub mod config {
110    use super::project;
111
112    /// Base configs directory
113    pub const DIR: &str = "configs";
114
115    /// Node configs base directory
116    pub const NODES_DIR: &str = "configs/nodes";
117
118    /// Framework node configs (@mecha10 scope)
119    pub const NODES_MECHA10_DIR: &str = "configs/nodes/@mecha10";
120
121    /// Simulation config file. `configs::SIMULATION_DIR` was removed as a duplicate once
122    /// `project::SIMULATION_DIR` moved to this same `configs/simulation` value - use that
123    /// instead for the directory.
124    pub const SIMULATION_CONFIG: &str = "configs/simulation/config.json";
125
126    /// Framework-tier (`@mecha10`) `config.json` path for `name` under `category_dir`.
127    fn framework_config(category_dir: &str, name: &str) -> String {
128        format!("{category_dir}/@mecha10/{name}/config.json")
129    }
130
131    /// Project-tier (plain name) `config.json` path for `name` under `category_dir`.
132    fn project_config(category_dir: &str, name: &str) -> String {
133        format!("{category_dir}/{name}/config.json")
134    }
135
136    /// The 2-tier `config.json` lookup candidates for `name` under `category_dir`, in canonical
137    /// resolution order: framework (`@mecha10`) -> project (plain name).
138    fn config_candidates(category_dir: &str, name: &str) -> [String; 2] {
139        [framework_config(category_dir, name), project_config(category_dir, name)]
140    }
141
142    /// Get config path for a framework node
143    pub fn framework_node(node_name: &str) -> String {
144        framework_config(NODES_DIR, node_name)
145    }
146
147    /// Get config path for a project node (plain name)
148    pub fn project_node(node_name: &str) -> String {
149        project_config(NODES_DIR, node_name)
150    }
151
152    /// Get the framework node's config *directory* (no `config.json` suffix) - the `@mecha10`
153    /// counterpart to [`framework_node`], for callers that need the directory itself (e.g. to
154    /// copy a whole config template tree into it) rather than the `config.json` file path.
155    pub fn framework_node_dir(node_name: &str) -> String {
156        format!("{}/{}", NODES_MECHA10_DIR, node_name)
157    }
158
159    /// The 2-tier `config.json` lookup candidates for a node, in canonical resolution order:
160    /// framework (`@mecha10`) -> project (plain name). This is the ONE place that knows the tier
161    /// order/naming convention for "does this node have a config.json, and if so which scope is
162    /// it in" - callers that need to check both tiers (e.g. "use the first one that exists on
163    /// disk") should use this rather than hand-rolling the same `format!()`/literal strings
164    /// themselves, which is exactly what caused LAB-1982.
165    pub fn node_config_candidates(node_name: &str) -> [String; 2] {
166        config_candidates(NODES_DIR, node_name)
167    }
168
169    /// Get config path for a framework-default behavior (bundled into the project at scaffold
170    /// time under `configs/behaviors/@mecha10/<name>/config.json`).
171    pub fn framework_behavior(behavior_name: &str) -> String {
172        framework_config(project::BEHAVIORS_DIR, behavior_name)
173    }
174
175    /// Get config path for a project behavior (plain name:
176    /// `configs/behaviors/<name>/config.json`).
177    pub fn project_behavior(behavior_name: &str) -> String {
178        project_config(project::BEHAVIORS_DIR, behavior_name)
179    }
180
181    /// The 2-tier `config.json` lookup candidates for a behavior, in override-precedence order
182    /// (highest first): project (plain name) -> framework (`@mecha10`). A project-committed
183    /// override beats the bundled framework default. There is no `@local` tier for behaviors
184    /// (LAB-1990). See [`node_config_candidates`] for the sibling helper for nodes.
185    pub fn behavior_config_candidates(behavior_name: &str) -> [String; 2] {
186        [project_behavior(behavior_name), framework_behavior(behavior_name)]
187    }
188}
189
190/// Docker-related paths within project
191pub mod docker {
192    /// Docker directory
193    pub const DIR: &str = "docker";
194
195    /// Main docker-compose file
196    pub const COMPOSE_FILE: &str = "docker/docker-compose.yml";
197
198    /// Remote nodes docker-compose file
199    pub const COMPOSE_REMOTE_FILE: &str = "docker/docker-compose.remote.yml";
200
201    /// Remote nodes Dockerfile
202    pub const DOCKERFILE_REMOTE: &str = "docker/Dockerfile.remote";
203
204    /// Remote container entrypoint script (execs mecha10-launcher)
205    pub const ENTRYPOINT_REMOTE: &str = "docker/entrypoint.sh";
206
207    /// rl target docker-compose file - runs a trained RL policy in production
208    pub const COMPOSE_RL_FILE: &str = "docker/docker-compose.rl.yml";
209
210    /// rl target Dockerfile
211    pub const DOCKERFILE_RL: &str = "docker/Dockerfile.rl";
212
213    /// rl target container entrypoint script (execs mecha10-launcher --target rl) - a distinct
214    /// filename from `ENTRYPOINT_REMOTE` since both `docker/entrypoint.sh` and this file coexist
215    /// in the same project (remote and rl are independent, both-scaffolded targets).
216    pub const ENTRYPOINT_RL: &str = "docker/entrypoint-rl.sh";
217}
218
219/// Environment files
220pub mod env {
221    /// Environment example file
222    pub const EXAMPLE: &str = ".env.example";
223
224    /// Environment file
225    pub const FILE: &str = ".env";
226}
227
228/// Meta files (README, gitignore, etc.)
229pub mod meta {
230    /// README file
231    pub const README: &str = "README.md";
232
233    /// Git ignore file
234    pub const GITIGNORE: &str = ".gitignore";
235
236    /// Package.json for Node.js tooling
237    pub const PACKAGE_JSON: &str = "package.json";
238
239    /// Python requirements
240    pub const REQUIREMENTS_TXT: &str = "requirements.txt";
241
242    /// Pinned Python version for `mecha10 setup` to target (see
243    /// `find_python_executable` in `packages/cli/src/handlers/setup.rs`) -
244    /// matches the exact-version-pin pattern `scripts/dev/setup.py` already
245    /// uses for framework-contributor setup, applied here to scaffolded
246    /// end-user projects too.
247    pub const PYTHON_VERSION: &str = ".python-version";
248}
249
250/// Rust project files
251pub mod rust {
252    /// Cargo workspace manifest
253    pub const CARGO_TOML: &str = "Cargo.toml";
254
255    /// Cargo lock file
256    pub const CARGO_LOCK: &str = "Cargo.lock";
257
258    /// Main entry point
259    pub const MAIN_RS: &str = "src/main.rs";
260
261    /// Library entry point
262    pub const LIB_RS: &str = "src/lib.rs";
263
264    /// Build script
265    pub const BUILD_RS: &str = "build.rs";
266
267    /// Rustfmt config
268    pub const RUSTFMT_TOML: &str = "rustfmt.toml";
269}
270
271/// Model files within a model directory
272pub mod model {
273    /// ONNX model file
274    pub const ONNX_FILE: &str = "model.onnx";
275
276    /// Labels file
277    pub const LABELS_FILE: &str = "labels.txt";
278
279    /// Model config file
280    pub const CONFIG_FILE: &str = "config.json";
281}
282
283/// User-level paths in home directory
284pub mod user {
285    use std::path::PathBuf;
286
287    /// Base mecha10 directory in user home.
288    ///
289    /// Re-exports `mecha10_core::fs_utils::MECHA10_DIR`, the single source of truth for this
290    /// literal shared with `launcher-service` (which can't depend on this crate - see that
291    /// constant's doc comment) - kept as a `mecha10-cli-core`-local constant (rather than
292    /// requiring callers to reach into `mecha10-core` directly) so existing callers of
293    /// `paths::user::MECHA10_DIR` don't need to change (LAB-1999).
294    pub const MECHA10_DIR: &str = mecha10_core::fs_utils::MECHA10_DIR;
295
296    /// Credentials file, relative to the user home directory.
297    ///
298    /// Documentation-only - `mecha10-auth`'s `credentials::default_credentials_path()` is the
299    /// actual source of truth for this path (see `credentials_file()` below), since
300    /// `mecha10-auth` can't depend back on this crate (`mecha10-cli-core` already depends on
301    /// `mecha10-auth`, so the reverse edge would be a dependency cycle).
302    pub const CREDENTIALS_FILE: &str = ".mecha10/credentials.json";
303
304    /// Templates cache directory
305    pub const TEMPLATES_DIR: &str = ".mecha10/templates";
306
307    /// Binary installation directory
308    pub const BIN_DIR: &str = ".mecha10/bin";
309
310    /// Get the base mecha10 directory path
311    pub fn mecha10_dir() -> PathBuf {
312        dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(MECHA10_DIR)
313    }
314
315    /// Get the credentials file path.
316    ///
317    /// Delegates to `mecha10-auth`'s `credentials::default_credentials_path()`, which is the
318    /// single implementation of this resolution logic (see the `CREDENTIALS_FILE` doc comment
319    /// for why the dependency runs in this direction).
320    pub fn credentials_file() -> PathBuf {
321        mecha10_auth::default_credentials_path()
322    }
323
324    /// Get the templates cache directory
325    pub fn templates_dir() -> PathBuf {
326        mecha10_dir().join("templates")
327    }
328
329    /// Get the binary directory
330    pub fn bin_dir() -> PathBuf {
331        mecha10_dir().join("bin")
332    }
333
334    /// Get path to a binary in the mecha10 bin directory
335    pub fn bin(name: &str) -> PathBuf {
336        bin_dir().join(name)
337    }
338
339    /// Get path to a binary in cargo bin directory
340    pub fn cargo_bin(name: &str) -> PathBuf {
341        home_bin_dir(".cargo", name)
342    }
343
344    /// Get path to a binary in local bin directory
345    pub fn local_bin(name: &str) -> PathBuf {
346        home_bin_dir(".local", name)
347    }
348
349    /// Get path to a binary in a `bin` subdirectory of the given home-relative directory
350    /// (e.g. `~/.cargo/bin/{name}`, `~/.local/bin/{name}`).
351    fn home_bin_dir(home_subdir: &str, name: &str) -> PathBuf {
352        mecha10_core::fs_utils::home_subpath(&[home_subdir, "bin", name])
353    }
354}
355
356/// Paths used inside Docker containers
357pub mod container {
358    /// Project mount point in container
359    pub const PROJECT_ROOT: &str = "/project";
360
361    /// Framework mount point in container
362    pub const FRAMEWORK_ROOT: &str = "/mecha10";
363
364    /// App directory (for mecha10-remote)
365    pub const APP_ROOT: &str = "/app";
366
367    /// Config file in mecha10-remote container
368    pub const REMOTE_CONFIG: &str = "/app/mecha10.json";
369
370    /// Get project-relative path inside container
371    pub fn project_path(relative: &str) -> String {
372        format!("{}/{}", PROJECT_ROOT, relative)
373    }
374
375    /// Get framework-relative path inside container
376    pub fn framework_path(relative: &str) -> String {
377        format!("{}/{}", FRAMEWORK_ROOT, relative)
378    }
379}
380
381/// Paths within the mecha10 framework source
382pub mod framework {
383    /// Packages directory
384    pub const PACKAGES_DIR: &str = "packages";
385
386    /// Nodes package directory
387    pub const NODES_DIR: &str = "packages/nodes";
388
389    /// Drivers package directory
390    pub const DRIVERS_DIR: &str = "packages/drivers";
391
392    /// Simulation package directory
393    pub const SIMULATION_DIR: &str = "packages/simulation";
394
395    /// Simulation models directory
396    pub const SIMULATION_MODELS_DIR: &str = "packages/simulation/models";
397
398    /// Simulation environments directory
399    pub const SIMULATION_ENVIRONMENTS_DIR: &str = "packages/simulation/environments";
400
401    /// Simulation billboard-image assets directory - the framework-tree counterpart to
402    /// `project::SIMULATION_IMAGES_DIR`.
403    pub const SIMULATION_IMAGES_DIR: &str = "packages/simulation/images";
404
405    /// Robot tasks environments directory (for environment matching)
406    pub const ROBOT_TASKS_DIR: &str = "packages/simulation/environments/robot-tasks";
407
408    /// Robot tasks catalog file
409    pub const ROBOT_TASKS_CATALOG: &str = "packages/simulation/environments/robot-tasks/catalog.json";
410
411    /// Marker directory used to detect the mecha10 framework root
412    pub const ROOT_MARKER_DIR: &str = "packages/core";
413
414    /// Release binary
415    pub const RELEASE_BINARY: &str = "target/release/mecha10";
416
417    /// Debug binary
418    pub const DEBUG_BINARY: &str = "target/debug/mecha10";
419
420    /// Get node config path within framework
421    pub fn node_config(node_name: &str) -> String {
422        format!("packages/nodes/{}/configs/config.json", node_name)
423    }
424}
425
426/// External service URLs
427pub mod urls {
428    /// Default base URL for the public `mecha10-api` service.
429    ///
430    /// The CLI never talks to the private homelab Minio instance directly - all
431    /// framework/runtime artifact downloads (CLI/launcher/node binaries, simulation
432    /// assets, templates, remote-image manifests, ML models) are proxied through
433    /// `mecha10-api`'s `/api/downloads/*` routes (see `packages/mecha10-api/src/catalog/downloads`),
434    /// which run *inside* the private network and stream bytes back out.
435    pub const MECHA10_API_URL: &str = "https://mecha.industries/api";
436
437    /// Build the `mecha10-api` base URL (e.g. `https://mecha.industries/api`).
438    ///
439    /// Override at runtime with the `MECHA10_API_URL` env var (e.g. to point at a
440    /// local `mecha10-api` instance during development).
441    pub fn mecha10_api_base_url() -> String {
442        std::env::var("MECHA10_API_URL")
443            .unwrap_or_else(|_| MECHA10_API_URL.to_string())
444            .trim_end_matches('/')
445            .to_string()
446    }
447
448    /// Default authentication URL
449    pub const AUTH_URL: &str = "https://mecha.industries/api/auth";
450}
451
452/// Build a target output path
453pub fn target_path(profile: &str, binary: &str) -> String {
454    format!("target/{}/{}", profile, binary)
455}
456
457/// Build a target output path with optional target triple
458pub fn target_path_with_triple(target: Option<&str>, profile: &str) -> String {
459    match target {
460        Some(t) => format!("target/{}/{}", t, profile),
461        None => format!("target/{}", profile),
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn credentials_file_matches_mecha10_auth_source_of_truth() {
471        assert_eq!(user::credentials_file(), mecha10_auth::default_credentials_path());
472    }
473
474    /// `cargo_bin`/`local_bin` share a `home_bin_dir` helper (LAB-2893) that only differs on
475    /// which home-relative directory it joins - pin both ends of that against the home dir so
476    /// a future edit to the shared helper can't quietly break one without the other noticing.
477    #[test]
478    fn cargo_bin_and_local_bin_join_name_under_their_own_home_subdir() {
479        let home = dirs::home_dir().expect("home dir must resolve in test environment");
480        assert_eq!(
481            user::cargo_bin("mecha10"),
482            home.join(".cargo").join("bin").join("mecha10")
483        );
484        assert_eq!(
485            user::local_bin("mecha10"),
486            home.join(".local").join("bin").join("mecha10")
487        );
488    }
489
490    #[test]
491    fn simulation_project_dirs_share_the_simulation_root() {
492        assert_eq!(project::SIMULATION_MODELS_DIR, "configs/simulation/models");
493        assert_eq!(project::SIMULATION_ENVIRONMENTS_DIR, "configs/simulation/environments");
494        for dir in [project::SIMULATION_MODELS_DIR, project::SIMULATION_ENVIRONMENTS_DIR] {
495            assert!(dir.starts_with(&format!("{}/", project::SIMULATION_DIR)));
496        }
497    }
498
499    /// LAB-2429: task-curriculum configs deliberately live under `configs/rl/`, NOT
500    /// `SIMULATION_DIR` - they're a project's RL configuration (room_size, target_range,
501    /// obstacle_count, ...), not a physics/rendering asset, so they're not part of the
502    /// simulation-root grouping the test above pins.
503    #[test]
504    fn rl_tasks_dir_lives_under_configs_rl_not_simulation() {
505        assert_eq!(project::RL_TASKS_DIR, "configs/rl/tasks");
506        assert!(!project::RL_TASKS_DIR.starts_with(&format!("{}/", project::SIMULATION_DIR)));
507    }
508
509    #[test]
510    fn test_config_paths() {
511        assert_eq!(
512            config::framework_node("object-detector"),
513            "configs/nodes/@mecha10/object-detector/config.json"
514        );
515        assert_eq!(config::project_node("camera"), "configs/nodes/camera/config.json");
516    }
517
518    #[test]
519    fn test_framework_node_dir() {
520        assert_eq!(config::framework_node_dir("speaker"), "configs/nodes/@mecha10/speaker");
521    }
522
523    #[test]
524    fn test_node_config_candidates_order() {
525        assert_eq!(
526            config::node_config_candidates("teleop"),
527            [
528                "configs/nodes/@mecha10/teleop/config.json".to_string(),
529                "configs/nodes/teleop/config.json".to_string(),
530            ]
531        );
532    }
533
534    #[test]
535    fn test_behavior_config_paths() {
536        assert_eq!(
537            config::framework_behavior("idle_wander"),
538            "configs/behaviors/@mecha10/idle_wander/config.json"
539        );
540        assert_eq!(
541            config::project_behavior("idle_wander"),
542            "configs/behaviors/idle_wander/config.json"
543        );
544    }
545
546    #[test]
547    fn test_behavior_config_candidates_order() {
548        assert_eq!(
549            config::behavior_config_candidates("idle_wander"),
550            [
551                "configs/behaviors/idle_wander/config.json".to_string(),
552                "configs/behaviors/@mecha10/idle_wander/config.json".to_string(),
553            ]
554        );
555    }
556
557    #[test]
558    fn test_target_path() {
559        assert_eq!(target_path("release", "my-node"), "target/release/my-node");
560        assert_eq!(
561            target_path_with_triple(Some("aarch64-unknown-linux-gnu"), "release"),
562            "target/aarch64-unknown-linux-gnu/release"
563        );
564        assert_eq!(target_path_with_triple(None, "debug"), "target/debug");
565    }
566
567    #[test]
568    fn test_container_paths() {
569        assert_eq!(
570            container::project_path("configs/test.json"),
571            "/project/configs/test.json"
572        );
573        assert_eq!(container::framework_path("packages/core"), "/mecha10/packages/core");
574    }
575
576    #[test]
577    fn test_mecha10_api_base_url_default() {
578        let _guard = ENV_MUTEX.lock().unwrap();
579        std::env::remove_var("MECHA10_API_URL");
580        assert_eq!(urls::mecha10_api_base_url(), "https://mecha.industries/api");
581    }
582
583    #[test]
584    fn test_mecha10_api_base_url_env_override() {
585        let _guard = ENV_MUTEX.lock().unwrap();
586        std::env::set_var("MECHA10_API_URL", "http://localhost:4000/api/");
587        assert_eq!(urls::mecha10_api_base_url(), "http://localhost:4000/api");
588        std::env::remove_var("MECHA10_API_URL");
589    }
590
591    /// Serializes tests that mutate process-wide env vars, since `cargo test` runs
592    /// tests in this module concurrently by default.
593    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
594}