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 across the codebase.
4
5#![allow(dead_code)]
6
7/// Project configuration file
8pub const PROJECT_CONFIG: &str = "mecha10.json";
9
10/// Project directories
11pub mod project {
12    /// Node source code directory
13    pub const NODES_DIR: &str = "nodes";
14
15    /// Framework node (`@mecha10` scope) schema directory, under the project's `nodes/`
16    /// directory. This is where a framework node's `<name>.schema.json` lives.
17    pub const NODES_MECHA10_DIR: &str = "nodes/@mecha10";
18
19    /// The framework node's schema *directory* (no `<name>.schema.json` suffix) under
20    /// `nodes/@mecha10/<node_name>`.
21    pub fn framework_node_schema_dir(node_name: &str) -> String {
22        format!("{}/{}", NODES_MECHA10_DIR, node_name)
23    }
24
25    /// Driver source code directory
26    pub const DRIVERS_DIR: &str = "drivers";
27
28    /// Types directory
29    pub const TYPES_DIR: &str = "types";
30
31    /// Behavior trees directory - nested under `configs/` (LAB-2072) so `mecha10 config
32    /// push`/`pull`/`list` genuinely sync it like any other config, instead of living as a
33    /// sibling top-level directory `collect_configs_from_dir` never walked.
34    pub const BEHAVIORS_DIR: &str = "configs/behaviors";
35
36    /// Models directory (ONNX models, etc.)
37    pub const MODELS_DIR: &str = "models";
38
39    /// Model catalog subdirectory, bind-mounted into the `ollama` Compose service as
40    /// `/root/.ollama/models` (see `docker-compose.yml.template`).
41    pub const MODELS_CATALOG_DIR: &str = "models/catalog";
42
43    /// Logs directory
44    pub const LOGS_DIR: &str = "logs";
45
46    /// Simulation directory - nested under `configs/` (moved from a top-level `simulation/`
47    /// sibling) so `mecha10 config push`/`pull` syncs it like any other config, the same reason
48    /// `BEHAVIORS_DIR` moved under `configs/` in LAB-2072.
49    pub const SIMULATION_DIR: &str = "configs/simulation";
50
51    /// Simulation models directory
52    pub const SIMULATION_MODELS_DIR: &str = "configs/simulation/models";
53
54    /// Simulation environments directory
55    pub const SIMULATION_ENVIRONMENTS_DIR: &str = "configs/simulation/environments";
56
57    /// Simulation billboard-image assets directory (`type: "image"` `environment.json`/
58    /// `task.json` objects' `source` fields point here) - flat, no per-environment nesting.
59    pub const SIMULATION_IMAGES_DIR: &str = "configs/simulation/images";
60
61    /// RL task-curriculum configs directory, scaffolded from the templates package's bundled
62    /// catalog at `mecha10 init` time. `mecha10-rl`'s `AssetResolver` resolves task configs
63    /// (e.g. `task1`) from here for training (`mecha10 train`).
64    pub const RL_TASKS_DIR: &str = "configs/rl/tasks";
65
66    /// Simulation output directory (generated robot/scene files)
67    pub const SIMULATION_OUTPUT_DIR: &str = "configs/simulation/output";
68
69    /// L6 dataset-build job catalog directory, scaffolded from the templates package's bundled
70    /// catalog at `mecha10 init` time - the Kestra starter job for building a dataset version
71    /// from recorded episodes (LAB-5257 stage 7/8), analogous to `rl/jobs` for training jobs.
72    /// Deliberately NOT nested under `RL_TASKS_DIR`'s `configs/rl/jobs` directory: that
73    /// directory is scanned and parsed as a `mecha10 train` job (`train_jobs::load_job_catalog`),
74    /// strictly validated against `mecha10_job_validation::FlowType`'s training-only flow types
75    /// (`train`/`tune`/`resume-run`/...) - a dataset-build job dropped in there would fail that
76    /// parse and break `mecha10 train` for the whole project.
77    pub const DATASET_JOBS_DIR: &str = "configs/datasets/jobs";
78
79    /// Cargo build target directory
80    pub const TARGET_DIR: &str = "target";
81
82    /// Source directory
83    pub const SRC_DIR: &str = "src";
84
85    /// Local (gitignored) project-scoped state directory - mirrors the `~/.mecha10`
86    /// user-home convention (`user::MECHA10_DIR`) but scoped to this project checkout.
87    pub const STATE_DIR: &str = ".mecha10";
88
89    /// Cache file for `mecha10 dev`'s project-level Python dependency preflight gate.
90    pub const DEV_PREFLIGHT_CACHE_FILE: &str = ".mecha10/dev-preflight-cache.json";
91
92    /// Project-local persisted copy of the `mecha10-assets` extraction, written once at
93    /// `mecha10 init` time and reused by every later command instead of re-downloading.
94    pub const ASSETS_CACHE_DIR: &str = ".mecha10/assets";
95}
96
97/// Configuration paths within project
98pub mod config {
99    use super::project;
100
101    /// Base configs directory
102    pub const DIR: &str = "configs";
103
104    /// Node configs base directory
105    pub const NODES_DIR: &str = "configs/nodes";
106
107    /// Framework node configs (@mecha10 scope)
108    pub const NODES_MECHA10_DIR: &str = "configs/nodes/@mecha10";
109
110    /// Framework-tier (`@mecha10`) `config.json` path for `name` under `category_dir`.
111    fn framework_config(category_dir: &str, name: &str) -> String {
112        format!("{category_dir}/@mecha10/{name}/config.json")
113    }
114
115    /// Project-tier (plain name) `config.json` path for `name` under `category_dir`.
116    fn project_config(category_dir: &str, name: &str) -> String {
117        format!("{category_dir}/{name}/config.json")
118    }
119
120    /// The 2-tier `config.json` lookup candidates for `name` under `category_dir`, in canonical
121    /// resolution order: framework (`@mecha10`) -> project (plain name).
122    fn config_candidates(category_dir: &str, name: &str) -> [String; 2] {
123        [framework_config(category_dir, name), project_config(category_dir, name)]
124    }
125
126    /// Get config path for a framework node
127    pub fn framework_node(node_name: &str) -> String {
128        framework_config(NODES_DIR, node_name)
129    }
130
131    /// Get config path for a project node (plain name)
132    pub fn project_node(node_name: &str) -> String {
133        project_config(NODES_DIR, node_name)
134    }
135
136    /// Get the framework node's config *directory* (no `config.json` suffix) - the `@mecha10`
137    /// counterpart to [`framework_node`], for callers that need the directory itself (e.g. to
138    /// copy a whole config template tree into it) rather than the `config.json` file path.
139    pub fn framework_node_dir(node_name: &str) -> String {
140        format!("{}/{}", NODES_MECHA10_DIR, node_name)
141    }
142
143    /// The 2-tier `config.json` lookup candidates for a node, in canonical resolution order:
144    /// framework (`@mecha10`) -> project (plain name).
145    pub fn node_config_candidates(node_name: &str) -> [String; 2] {
146        config_candidates(NODES_DIR, node_name)
147    }
148
149    /// Get config path for a framework-default behavior (bundled into the project at scaffold
150    /// time under `configs/behaviors/@mecha10/<name>/config.json`).
151    pub fn framework_behavior(behavior_name: &str) -> String {
152        framework_config(project::BEHAVIORS_DIR, behavior_name)
153    }
154
155    /// The framework behavior's directory (no `config.json` suffix) - the `@mecha10`
156    /// counterpart to [`framework_behavior`].
157    pub fn framework_behavior_dir(behavior_name: &str) -> String {
158        format!("{}/@mecha10/{}", project::BEHAVIORS_DIR, behavior_name)
159    }
160
161    /// Get config path for a project behavior (plain name:
162    /// `configs/behaviors/<name>/config.json`).
163    pub fn project_behavior(behavior_name: &str) -> String {
164        project_config(project::BEHAVIORS_DIR, behavior_name)
165    }
166
167    /// The 2-tier `config.json` lookup candidates for a behavior, in override-precedence order
168    /// (highest first): project (plain name) -> framework (`@mecha10`).
169    pub fn behavior_config_candidates(behavior_name: &str) -> [String; 2] {
170        [project_behavior(behavior_name), framework_behavior(behavior_name)]
171    }
172}
173
174/// Docker-related paths within project
175pub mod docker {
176    /// Docker directory
177    pub const DIR: &str = "docker";
178
179    /// Main docker-compose file
180    pub const COMPOSE_FILE: &str = "docker/docker-compose.yml";
181
182    /// Remote nodes docker-compose file
183    pub const COMPOSE_REMOTE_FILE: &str = "docker/docker-compose.remote.yml";
184
185    /// Remote nodes Dockerfile
186    pub const DOCKERFILE_REMOTE: &str = "docker/Dockerfile.remote";
187
188    /// Remote container entrypoint script (execs mecha10-launcher)
189    pub const ENTRYPOINT_REMOTE: &str = "docker/entrypoint.sh";
190
191    /// rl target docker-compose file - runs a trained RL policy in production
192    pub const COMPOSE_RL_FILE: &str = "docker/docker-compose.rl.yml";
193
194    /// rl target Dockerfile
195    pub const DOCKERFILE_RL: &str = "docker/Dockerfile.rl";
196
197    /// rl target container entrypoint script (execs mecha10-launcher --target rl) - a distinct
198    /// filename from `ENTRYPOINT_REMOTE` since both `docker/entrypoint.sh` and this file coexist
199    /// in the same project (remote and rl are independent, both-scaffolded targets).
200    pub const ENTRYPOINT_RL: &str = "docker/entrypoint-rl.sh";
201}
202
203/// Environment files
204pub mod env {
205    /// Environment example file
206    pub const EXAMPLE: &str = ".env.example";
207
208    /// Environment file
209    pub const FILE: &str = ".env";
210}
211
212/// Meta files (README, gitignore, etc.)
213pub mod meta {
214    /// README file
215    pub const README: &str = "README.md";
216
217    /// Git ignore file
218    pub const GITIGNORE: &str = ".gitignore";
219
220    /// Package.json for Node.js tooling
221    pub const PACKAGE_JSON: &str = "package.json";
222
223    /// Python requirements
224    pub const REQUIREMENTS_TXT: &str = "requirements.txt";
225
226    /// Pinned Python version for `mecha10 setup` to target (see
227    /// `find_python_executable` in `packages/cli/src/handlers/setup.rs`).
228    pub const PYTHON_VERSION: &str = ".python-version";
229}
230
231/// Rust project files
232pub mod rust {
233    /// Cargo workspace manifest
234    pub const CARGO_TOML: &str = "Cargo.toml";
235
236    /// Cargo lock file
237    pub const CARGO_LOCK: &str = "Cargo.lock";
238
239    /// Main entry point
240    pub const MAIN_RS: &str = "src/main.rs";
241
242    /// Library entry point
243    pub const LIB_RS: &str = "src/lib.rs";
244
245    /// Build script
246    pub const BUILD_RS: &str = "build.rs";
247
248    /// Rustfmt config
249    pub const RUSTFMT_TOML: &str = "rustfmt.toml";
250}
251
252/// Model files within a model directory
253pub mod model {
254    /// ONNX model file
255    pub const ONNX_FILE: &str = "model.onnx";
256
257    /// Labels file
258    pub const LABELS_FILE: &str = "labels.txt";
259
260    /// Model config file
261    pub const CONFIG_FILE: &str = "config.json";
262}
263
264/// User-level paths in home directory
265pub mod user {
266    use std::path::PathBuf;
267
268    /// Base mecha10 directory in user home.
269    ///
270    /// Re-exports `mecha10_core::fs_utils::MECHA10_DIR`, shared with `launcher-service`.
271    pub const MECHA10_DIR: &str = mecha10_core::fs_utils::MECHA10_DIR;
272
273    /// Credentials file, relative to the user home directory.
274    ///
275    /// Documentation-only - `credentials_file()` below is the actual source of truth.
276    pub const CREDENTIALS_FILE: &str = ".mecha10/credentials.json";
277
278    /// Templates cache directory
279    pub const TEMPLATES_DIR: &str = ".mecha10/templates";
280
281    /// Binary installation directory
282    pub const BIN_DIR: &str = ".mecha10/bin";
283
284    /// Get the base mecha10 directory path
285    pub fn mecha10_dir() -> PathBuf {
286        dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(MECHA10_DIR)
287    }
288
289    /// Get the credentials file path.
290    ///
291    /// Delegates to `mecha10-auth`'s `credentials::default_credentials_path()`.
292    pub fn credentials_file() -> PathBuf {
293        mecha10_auth::default_credentials_path()
294    }
295
296    /// Get the templates cache directory
297    pub fn templates_dir() -> PathBuf {
298        mecha10_dir().join("templates")
299    }
300
301    /// Get the binary directory
302    pub fn bin_dir() -> PathBuf {
303        mecha10_dir().join("bin")
304    }
305
306    /// Get path to a binary in the mecha10 bin directory
307    pub fn bin(name: &str) -> PathBuf {
308        bin_dir().join(name)
309    }
310
311    /// Get the ONNX Runtime cache directory (macOS only).
312    ///
313    /// Populated by `mecha10 setup`'s ONNX Runtime step (`packages/cli/src/handlers/setup.rs`) -
314    /// the only place this dylib is ever installed. It is deliberately *not* fetched lazily at
315    /// node-spawn time, nor at `mecha10 init`.
316    pub fn onnxruntime_dir() -> PathBuf {
317        mecha10_dir().join("onnxruntime")
318    }
319
320    /// Get the path to the cached macOS ONNX Runtime dylib.
321    ///
322    /// `image-classifier`, `instance-track`, `object-detector`, and `rl` (the four
323    /// `mecha10-nodes` crates depending on `ort` with the `load-dynamic` feature) look here via
324    /// `ORT_DYLIB_PATH`, wired up at node-spawn time by
325    /// `packages/cli/src/services/process/spawn_node.rs` - `load-dynamic` skips `ort-sys`'s
326    /// build-time download+static-link step entirely and defers to that env var lookup inside
327    /// `ort::init()`, so without this nothing ever points it at a real dylib on macOS.
328    pub fn onnxruntime_dylib_path() -> PathBuf {
329        onnxruntime_dir().join("libonnxruntime.dylib")
330    }
331
332    /// Get path to a binary in cargo bin directory
333    pub fn cargo_bin(name: &str) -> PathBuf {
334        home_bin_dir(".cargo", name)
335    }
336
337    /// Get path to a binary in local bin directory
338    pub fn local_bin(name: &str) -> PathBuf {
339        home_bin_dir(".local", name)
340    }
341
342    /// Get path to a binary in a `bin` subdirectory of the given home-relative directory
343    /// (e.g. `~/.cargo/bin/{name}`, `~/.local/bin/{name}`).
344    fn home_bin_dir(home_subdir: &str, name: &str) -> PathBuf {
345        mecha10_core::fs_utils::home_subpath(&[home_subdir, "bin", name])
346    }
347}
348
349/// Paths used inside Docker containers
350pub mod container {
351    /// Project mount point in container
352    pub const PROJECT_ROOT: &str = "/project";
353
354    /// Framework mount point in container
355    pub const FRAMEWORK_ROOT: &str = "/mecha10";
356
357    /// App directory (for mecha10-remote)
358    pub const APP_ROOT: &str = "/app";
359
360    /// Config file in mecha10-remote container
361    pub const REMOTE_CONFIG: &str = "/app/mecha10.json";
362
363    /// Get project-relative path inside container
364    pub fn project_path(relative: &str) -> String {
365        format!("{}/{}", PROJECT_ROOT, relative)
366    }
367
368    /// Get framework-relative path inside container
369    pub fn framework_path(relative: &str) -> String {
370        format!("{}/{}", FRAMEWORK_ROOT, relative)
371    }
372}
373
374/// Paths within the mecha10 framework source
375pub mod framework {
376    /// Packages directory
377    pub const PACKAGES_DIR: &str = "packages";
378
379    /// Nodes package directory
380    pub const NODES_DIR: &str = "packages/nodes";
381
382    /// Drivers package directory
383    pub const DRIVERS_DIR: &str = "packages/drivers";
384
385    /// Marker directory used to detect the mecha10 framework root
386    pub const ROOT_MARKER_DIR: &str = "packages/core";
387
388    /// Release binary
389    pub const RELEASE_BINARY: &str = "target/release/mecha10";
390
391    /// Debug binary
392    pub const DEBUG_BINARY: &str = "target/debug/mecha10";
393
394    /// Get node config path within framework
395    pub fn node_config(node_name: &str) -> String {
396        format!("packages/nodes/{}/configs/config.json", node_name)
397    }
398}
399
400/// External service URLs
401pub mod urls {
402    /// Default base URL for the public `mecha10-api` service. All framework/runtime artifact
403    /// downloads are proxied through its `/api/downloads/*` routes.
404    pub const MECHA10_API_URL: &str = "https://mecha.industries/api";
405
406    /// Build the `mecha10-api` base URL (e.g. `https://mecha.industries/api`).
407    ///
408    /// Override at runtime with the `MECHA10_API_URL` env var.
409    pub fn mecha10_api_base_url() -> String {
410        std::env::var("MECHA10_API_URL")
411            .unwrap_or_else(|_| MECHA10_API_URL.to_string())
412            .trim_end_matches('/')
413            .to_string()
414    }
415
416    /// Join `path` onto [`mecha10_api_base_url`]. `path` must not start with a leading
417    /// `/api` of its own, or the request doubles up to `.../api/api/...` and 404s.
418    pub fn mecha10_api_path(path: &str) -> String {
419        format!("{}/{}", mecha10_api_base_url(), path.trim_start_matches('/'))
420    }
421
422    /// Default authentication URL
423    pub const AUTH_URL: &str = "https://mecha.industries/api/auth";
424}
425
426/// Build a target output path
427pub fn target_path(profile: &str, binary: &str) -> String {
428    format!("target/{}/{}", profile, binary)
429}
430
431/// Build a target output path with optional target triple
432pub fn target_path_with_triple(target: Option<&str>, profile: &str) -> String {
433    match target {
434        Some(t) => format!("target/{}/{}", t, profile),
435        None => format!("target/{}", profile),
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn credentials_file_matches_mecha10_auth_source_of_truth() {
445        assert_eq!(user::credentials_file(), mecha10_auth::default_credentials_path());
446    }
447
448    /// `cargo_bin`/`local_bin` share a `home_bin_dir` helper that only differs on which
449    /// home-relative directory it joins.
450    #[test]
451    fn cargo_bin_and_local_bin_join_name_under_their_own_home_subdir() {
452        let home = dirs::home_dir().expect("home dir must resolve in test environment");
453        assert_eq!(
454            user::cargo_bin("mecha10"),
455            home.join(".cargo").join("bin").join("mecha10")
456        );
457        assert_eq!(
458            user::local_bin("mecha10"),
459            home.join(".local").join("bin").join("mecha10")
460        );
461    }
462
463    /// `onnxruntime_dylib_path` must follow the same `~/.mecha10/` base-dir convention as
464    /// `bin_dir`/`templates_dir` - `spawn_node.rs` and `handlers/setup.rs` both read this path,
465    /// so a drift here would silently break one side without the other noticing.
466    #[test]
467    fn onnxruntime_dylib_path_lives_under_the_mecha10_dir() {
468        assert_eq!(user::onnxruntime_dir(), user::mecha10_dir().join("onnxruntime"));
469        assert_eq!(
470            user::onnxruntime_dylib_path(),
471            user::mecha10_dir().join("onnxruntime").join("libonnxruntime.dylib")
472        );
473    }
474
475    #[test]
476    fn simulation_project_dirs_share_the_simulation_root() {
477        assert_eq!(project::SIMULATION_MODELS_DIR, "configs/simulation/models");
478        assert_eq!(project::SIMULATION_ENVIRONMENTS_DIR, "configs/simulation/environments");
479        for dir in [project::SIMULATION_MODELS_DIR, project::SIMULATION_ENVIRONMENTS_DIR] {
480            assert!(dir.starts_with(&format!("{}/", project::SIMULATION_DIR)));
481        }
482    }
483
484    /// Task-curriculum configs live under `configs/rl/`, NOT `SIMULATION_DIR` - they're a
485    /// project's RL configuration, not a physics/rendering asset.
486    #[test]
487    fn rl_tasks_dir_lives_under_configs_rl_not_simulation() {
488        assert_eq!(project::RL_TASKS_DIR, "configs/rl/tasks");
489        assert!(!project::RL_TASKS_DIR.starts_with(&format!("{}/", project::SIMULATION_DIR)));
490    }
491
492    /// Dataset-build jobs deliberately live outside `configs/rl/jobs` - see
493    /// `DATASET_JOBS_DIR`'s doc comment for why dropping one in the `mecha10 train` job catalog
494    /// directory would break it.
495    #[test]
496    fn dataset_jobs_dir_does_not_live_under_the_rl_train_job_catalog() {
497        assert_eq!(project::DATASET_JOBS_DIR, "configs/datasets/jobs");
498        assert!(!project::DATASET_JOBS_DIR.starts_with("configs/rl/jobs"));
499    }
500
501    #[test]
502    fn test_config_paths() {
503        assert_eq!(
504            config::framework_node("object-detector"),
505            "configs/nodes/@mecha10/object-detector/config.json"
506        );
507        assert_eq!(config::project_node("camera"), "configs/nodes/camera/config.json");
508    }
509
510    #[test]
511    fn test_framework_node_dir() {
512        assert_eq!(config::framework_node_dir("speaker"), "configs/nodes/@mecha10/speaker");
513    }
514
515    #[test]
516    fn test_framework_node_schema_dir_lives_under_nodes_not_configs_nodes() {
517        assert_eq!(project::framework_node_schema_dir("speaker"), "nodes/@mecha10/speaker");
518        assert_ne!(
519            project::framework_node_schema_dir("speaker"),
520            config::framework_node_dir("speaker"),
521            "schema dir (wave 10) must not collide with the config.json dir"
522        );
523    }
524
525    #[test]
526    fn test_node_config_candidates_order() {
527        assert_eq!(
528            config::node_config_candidates("teleop"),
529            [
530                "configs/nodes/@mecha10/teleop/config.json".to_string(),
531                "configs/nodes/teleop/config.json".to_string(),
532            ]
533        );
534    }
535
536    #[test]
537    fn test_behavior_config_paths() {
538        assert_eq!(
539            config::framework_behavior("idle_wander"),
540            "configs/behaviors/@mecha10/idle_wander/config.json"
541        );
542        assert_eq!(
543            config::project_behavior("idle_wander"),
544            "configs/behaviors/idle_wander/config.json"
545        );
546    }
547
548    #[test]
549    fn test_framework_behavior_dir() {
550        assert_eq!(
551            config::framework_behavior_dir("idle_wander"),
552            "configs/behaviors/@mecha10/idle_wander"
553        );
554        assert_eq!(
555            config::framework_behavior("idle_wander"),
556            format!("{}/config.json", config::framework_behavior_dir("idle_wander"))
557        );
558    }
559
560    #[test]
561    fn test_behavior_config_candidates_order() {
562        assert_eq!(
563            config::behavior_config_candidates("idle_wander"),
564            [
565                "configs/behaviors/idle_wander/config.json".to_string(),
566                "configs/behaviors/@mecha10/idle_wander/config.json".to_string(),
567            ]
568        );
569    }
570
571    #[test]
572    fn test_target_path() {
573        assert_eq!(target_path("release", "my-node"), "target/release/my-node");
574        assert_eq!(
575            target_path_with_triple(Some("aarch64-unknown-linux-gnu"), "release"),
576            "target/aarch64-unknown-linux-gnu/release"
577        );
578        assert_eq!(target_path_with_triple(None, "debug"), "target/debug");
579    }
580
581    #[test]
582    fn test_container_paths() {
583        assert_eq!(
584            container::project_path("configs/test.json"),
585            "/project/configs/test.json"
586        );
587        assert_eq!(container::framework_path("packages/core"), "/mecha10/packages/core");
588    }
589
590    #[test]
591    fn test_mecha10_api_base_url_default() {
592        let _guard = ENV_MUTEX.lock().unwrap();
593        std::env::remove_var("MECHA10_API_URL");
594        assert_eq!(urls::mecha10_api_base_url(), "https://mecha.industries/api");
595    }
596
597    #[test]
598    fn test_mecha10_api_base_url_env_override() {
599        let _guard = ENV_MUTEX.lock().unwrap();
600        std::env::set_var("MECHA10_API_URL", "http://localhost:4000/api/");
601        assert_eq!(urls::mecha10_api_base_url(), "http://localhost:4000/api");
602        std::env::remove_var("MECHA10_API_URL");
603    }
604
605    #[test]
606    fn test_mecha10_api_path_does_not_double_the_api_segment() {
607        let _guard = ENV_MUTEX.lock().unwrap();
608        std::env::remove_var("MECHA10_API_URL");
609        assert_eq!(
610            urls::mecha10_api_path("/models/catalog"),
611            "https://mecha.industries/api/models/catalog"
612        );
613    }
614
615    #[test]
616    fn test_mecha10_api_path_works_without_a_leading_slash() {
617        let _guard = ENV_MUTEX.lock().unwrap();
618        std::env::remove_var("MECHA10_API_URL");
619        assert_eq!(
620            urls::mecha10_api_path("downloads/model/deepghs/yolos/yolov8n/model.onnx"),
621            "https://mecha.industries/api/downloads/model/deepghs/yolos/yolov8n/model.onnx"
622        );
623    }
624
625    /// Serializes tests that mutate process-wide env vars, since `cargo test` runs
626    /// tests in this module concurrently by default.
627    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
628}