Skip to main content

mecha10_templates/services/
init_service.rs

1//! Init service for project initialization
2//!
3//! Handles business logic for creating new Mecha10 projects.
4
5use crate::paths;
6use anyhow::{Context, Result};
7use mecha10_cli_core::utils::build_client;
8use std::path::Path;
9
10/// crates.io requests a descriptive User-Agent identifying the caller and a contact point
11/// (https://crates.io/data-access) - this mirrors the one `scripts/release/publish-crates.sh`
12/// already uses for its own crates.io API calls.
13const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
14
15/// Body of a successful `GET https://crates.io/api/v1/crates/{name}` response - only the one
16/// field this module needs out of the much larger payload.
17#[derive(serde::Deserialize)]
18struct CratesIoCrateResponse {
19    #[serde(rename = "crate")]
20    krate: CratesIoCrateMeta,
21}
22
23#[derive(serde::Deserialize)]
24struct CratesIoCrateMeta {
25    max_version: String,
26}
27
28/// Look up the latest published version of `package_name` on crates.io.
29///
30/// This is the same source of truth `scripts/release/publish-crates.sh::get_published_version`
31/// queries when publishing, so `mecha10 add <component>` never writes a node crate version into
32/// a project's `Cargo.toml` that can silently go stale (unlike a hand-maintained literal in the
33/// component catalog).
34async fn latest_published_crate_version(package_name: &str) -> Result<String> {
35    let url = format!("https://crates.io/api/v1/crates/{}", package_name);
36
37    let client = build_client()?;
38    let response = client
39        .get(&url)
40        .header("User-Agent", CRATES_IO_USER_AGENT)
41        .send()
42        .await
43        .with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
44
45    if !response.status().is_success() {
46        anyhow::bail!(
47            "Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
48            package_name,
49            response.status(),
50            url
51        );
52    }
53
54    let body = response
55        .text()
56        .await
57        .with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
58
59    parse_max_version(&body, package_name)
60}
61
62/// Pull `.crate.max_version` out of a `GET https://crates.io/api/v1/crates/{name}` response
63/// body, split out from [`latest_published_crate_version`] so the parsing logic can be unit
64/// tested without a live network call.
65fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
66    let parsed: CratesIoCrateResponse = serde_json::from_str(body)
67        .with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
68
69    Ok(parsed.krate.max_version)
70}
71
72pub struct InitService;
73
74impl InitService {
75    /// Create a new InitService
76    pub fn new() -> Self {
77        Self
78    }
79
80    /// Create all the necessary directories for a new Mecha10 project.
81    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
82        let dirs = vec![
83            paths::project::NODES_DIR,
84            paths::project::DRIVERS_DIR,
85            paths::project::TYPES_DIR,
86            paths::project::BEHAVIORS_DIR,
87            paths::project::LOGS_DIR,
88            paths::project::MODELS_DIR,
89            paths::project::MODELS_CATALOG_DIR,
90            paths::project::SIMULATION_DIR,
91            paths::project::SIMULATION_MODELS_DIR,
92            paths::project::SIMULATION_ENVIRONMENTS_DIR,
93            paths::project::SIMULATION_IMAGES_DIR,
94            paths::project::RL_TASKS_DIR,
95            paths::config::NODES_DIR,
96        ];
97
98        for dir in dirs {
99            let dir_path = path.join(dir);
100            tokio::fs::create_dir_all(&dir_path).await?;
101        }
102
103        Ok(())
104    }
105
106    /// Add an example node from the catalog to the project, updating Cargo.toml and
107    /// mecha10.json as needed.
108    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
109        use crate::component_catalog::ComponentCatalog;
110
111        let catalog = ComponentCatalog::new();
112        let components = catalog.search(component_name);
113
114        if components.is_empty() {
115            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
116        }
117
118        let component = &components[0];
119
120        for file_template in &component.files {
121            let file_path = project_root.join(&file_template.path);
122
123            if let Some(parent) = file_path.parent() {
124                tokio::fs::create_dir_all(parent).await?;
125            }
126
127            tokio::fs::write(&file_path, &file_template.content).await?;
128        }
129
130        if !component.files.is_empty() {
131            self.add_workspace_member(project_root, component_name).await?;
132        }
133
134        let is_monorepo_node = component.files.is_empty();
135        if is_monorepo_node {
136            self.add_dependency(project_root, component).await?;
137        }
138
139        self.add_node_to_config(project_root, component_name, is_monorepo_node)
140            .await?;
141
142        Ok(())
143    }
144
145    /// Add a workspace member to Cargo.toml
146    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
147        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
148        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
149
150        let new_member = format!("nodes/{}", component_name);
151        let updated_content = if content.contains("members = []") {
152            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
153        } else if let Some(start) = content.find("members = [") {
154            if let Some(end) = content[start..].find(']') {
155                let insert_pos = start + end;
156                let members_section = &content[start..insert_pos];
157                if members_section.contains('\"') {
158                    format!(
159                        "{}\n    \"{}\",{}",
160                        &content[..insert_pos],
161                        new_member,
162                        &content[insert_pos..]
163                    )
164                } else {
165                    format!(
166                        "{}\n    \"{}\",\n{}",
167                        &content[..insert_pos],
168                        new_member,
169                        &content[insert_pos..]
170                    )
171                }
172            } else {
173                content
174            }
175        } else {
176            content
177        };
178
179        tokio::fs::write(&cargo_toml_path, updated_content).await?;
180        Ok(())
181    }
182
183    /// The crate names `add_dependency` must resolve/add for a component.
184    ///
185    /// Walks every entry in `component.cargo_dependencies`, not just the first one - driver
186    /// components (see `component_catalog/drivers.rs`) list their local `mecha10-core` path
187    /// dependency first and their real third-party crate (e.g. `realsense-rust`, `serialport`)
188    /// second, so stopping at `.first()` silently resolved to `mecha10-core` - which is always
189    /// already present in a scaffolded Cargo.toml - and skipped adding the driver's actual
190    /// crate dependency entirely. Local workspace path dependencies (`dep.path.is_some()`, e.g.
191    /// `mecha10-core`) are already present via the project's own scaffolding and must not be
192    /// touched here - only real external crates need resolving/adding.
193    fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
194        if component.cargo_dependencies.is_empty() {
195            return vec![format!("mecha10-nodes-{}", component.id)];
196        }
197
198        component
199            .cargo_dependencies
200            .iter()
201            .filter(|dep| dep.path.is_none())
202            .map(|dep| dep.name.clone())
203            .collect()
204    }
205
206    /// Insert `package_name = "version"` into `content` right after the
207    /// `mecha10-core = "0.1"` anchor line that every scaffolded project's Cargo.toml has.
208    /// Returns `content` unchanged if the anchor line isn't found.
209    fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
210        let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
211            return content.to_string();
212        };
213        let Some(newline_pos) = content[pos..].find('\n') else {
214            return content.to_string();
215        };
216        let insert_pos = pos + newline_pos + 1;
217        format!(
218            "{}{} = \"{}\"\n{}",
219            &content[..insert_pos],
220            package_name,
221            version,
222            &content[insert_pos..]
223        )
224    }
225
226    /// Add a dependency to Cargo.toml
227    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
228        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
229        let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
230
231        let mut changed = false;
232
233        for package_name in Self::external_dependency_names(component) {
234            if content.contains(&package_name) {
235                continue;
236            }
237
238            let version = latest_published_crate_version(&package_name).await?;
239            content = Self::insert_dependency_version(&content, &package_name, &version);
240            changed = true;
241        }
242
243        if changed {
244            tokio::fs::write(&cargo_toml_path, content).await?;
245        }
246
247        Ok(())
248    }
249
250    /// Add node entry to mecha10.json. Only mints the `nodes` map entry; `targets` placement
251    /// is handled separately by `handlers/init.rs`.
252    async fn add_node_to_config(
253        &self,
254        project_root: &Path,
255        component_name: &str,
256        is_monorepo_node: bool,
257    ) -> Result<()> {
258        use crate::services::ConfigService;
259
260        let config_path = project_root.join(paths::PROJECT_CONFIG);
261        let mut config = ConfigService::load_from(&config_path).await?;
262
263        let (node_identifier, version) = if is_monorepo_node {
264            (
265                format!("@mecha10/{}", component_name),
266                env!("CARGO_PKG_VERSION").to_string(),
267            )
268        } else {
269            (component_name.to_string(), "0.1.0".to_string())
270        };
271
272        if config.nodes.get_version(&node_identifier).is_none() {
273            config.nodes.add_node(&node_identifier, &version);
274
275            let config_json = serde_json::to_string_pretty(&config)?;
276            tokio::fs::write(&config_path, config_json).await?;
277        }
278
279        Ok(())
280    }
281}
282
283impl Default for InitService {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod parse_max_version_tests {
291    use super::*;
292
293    #[test]
294    fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
295        let body = r#"{
296            "crate": {
297                "id": "mecha10-nodes-behavior-executor",
298                "max_version": "0.4.2",
299                "max_stable_version": "0.4.2"
300            },
301            "versions": []
302        }"#;
303
304        let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
305        assert_eq!(version, "0.4.2");
306    }
307
308    #[test]
309    fn errors_instead_of_silently_falling_back_on_malformed_body() {
310        let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
311        assert!(result.is_err());
312    }
313}
314
315#[cfg(test)]
316mod add_dependency_tests {
317    use super::*;
318
319    /// Minimal workspace `Cargo.toml` shaped like what `mecha10 init` scaffolds, with the
320    /// `mecha10-core = "0.1"` anchor line `add_dependency` inserts after.
321    fn minimal_cargo_toml() -> &'static str {
322        "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
323    }
324
325    #[tokio::test]
326    async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
327        // If the package name is already in Cargo.toml, `add_dependency` must return early
328        // before ever resolving a version - this must be able to run with no network access.
329        let temp_dir = tempfile::tempdir().expect("create temp dir");
330        let project_root = temp_dir.path();
331        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
332
333        let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
334        tokio::fs::write(&cargo_toml_path, &existing)
335            .await
336            .expect("write Cargo.toml");
337
338        let catalog = crate::component_catalog::ComponentCatalog::new();
339        let component = catalog
340            .get("behavior-executor")
341            .expect("behavior-executor is in the catalog")
342            .clone();
343
344        let service = InitService::new();
345        service
346            .add_dependency(project_root, &component)
347            .await
348            .expect("add_dependency should no-op without touching the network");
349
350        let updated = tokio::fs::read_to_string(&cargo_toml_path)
351            .await
352            .expect("read Cargo.toml");
353        assert_eq!(
354            updated, existing,
355            "an already-present dependency must be left untouched"
356        );
357    }
358
359    #[tokio::test]
360    async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
361        // Same no-network guarantee as the Package-type test above, but for a Driver-type
362        // component whose `cargo_dependencies` has two entries (local `mecha10-core` path dep
363        // first, real third-party crate second) - both already present must mean no network
364        // calls and no changes at all.
365        let temp_dir = tempfile::tempdir().expect("create temp dir");
366        let project_root = temp_dir.path();
367        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
368
369        let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
370        tokio::fs::write(&cargo_toml_path, &existing)
371            .await
372            .expect("write Cargo.toml");
373
374        let catalog = crate::component_catalog::ComponentCatalog::new();
375        let component = catalog
376            .get("realsense-camera")
377            .expect("realsense-camera is in the catalog")
378            .clone();
379
380        let service = InitService::new();
381        service
382            .add_dependency(project_root, &component)
383            .await
384            .expect("add_dependency should no-op without touching the network");
385
386        let updated = tokio::fs::read_to_string(&cargo_toml_path)
387            .await
388            .expect("read Cargo.toml");
389        assert_eq!(
390            updated, existing,
391            "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
392        );
393    }
394
395    /// `external_dependency_names` is the pure piece of `add_dependency` that decides which
396    /// crate names need resolving/adding - covering it directly avoids a live crates.io call in
397    /// this test suite while still proving the fix for the underlying bug.
398    mod external_dependency_names_tests {
399        use super::*;
400
401        #[test]
402        fn resolves_only_the_single_external_crate_for_a_package_component() {
403            // Package-type catalog entries (behavior-executor, camera, servo, ...) have exactly
404            // one `cargo_dependencies` entry for their own `mecha10-nodes-<id>` crate - this
405            // must be unaffected by the driver fix.
406            let catalog = crate::component_catalog::ComponentCatalog::new();
407            let component = catalog
408                .get("behavior-executor")
409                .expect("behavior-executor is in the catalog");
410
411            let names = InitService::external_dependency_names(component);
412
413            assert_eq!(
414                names,
415                vec!["mecha10-nodes-behavior-executor".to_string()],
416                "a Package component must resolve exactly its own node crate"
417            );
418        }
419
420        #[test]
421        fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
422            // This is the bug: `realsense-camera` lists `mecha10-core` (a local path dep) first
423            // and `realsense-rust` (its real third-party crate) second. The fixed logic must
424            // skip the path dependency and resolve only the real crate - not stop at `.first()`
425            // and silently resolve to `mecha10-core`, which is always already present.
426            let catalog = crate::component_catalog::ComponentCatalog::new();
427            let component = catalog
428                .get("realsense-camera")
429                .expect("realsense-camera is in the catalog");
430
431            let names = InitService::external_dependency_names(component);
432
433            assert_eq!(
434                names,
435                vec!["realsense-rust".to_string()],
436                "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
437            );
438        }
439    }
440
441    mod insert_dependency_version_tests {
442        use super::*;
443
444        #[test]
445        fn inserts_right_after_the_mecha10_core_anchor_line() {
446            let content = minimal_cargo_toml();
447
448            let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
449
450            assert_eq!(
451                updated,
452                "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
453            );
454        }
455    }
456
457    #[tokio::test]
458    async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
459        // End-to-end (minus the network hop) check that `add_dependency`'s loop, when the
460        // driver's real crate is missing but its local mecha10-core dep is already present,
461        // adds the real crate and does not duplicate/touch mecha10-core.
462        let temp_dir = tempfile::tempdir().expect("create temp dir");
463        let project_root = temp_dir.path();
464        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
465        tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
466            .await
467            .expect("write Cargo.toml");
468
469        let catalog = crate::component_catalog::ComponentCatalog::new();
470        let component = catalog
471            .get("realsense-camera")
472            .expect("realsense-camera is in the catalog")
473            .clone();
474
475        // Exercise the same package-name resolution `add_dependency` uses, but substitute a
476        // fixed version in place of the live crates.io lookup so this test has no network
477        // dependency.
478        let names = InitService::external_dependency_names(&component);
479        assert_eq!(names, vec!["realsense-rust".to_string()]);
480
481        let content = tokio::fs::read_to_string(&cargo_toml_path)
482            .await
483            .expect("read Cargo.toml");
484        let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
485        tokio::fs::write(&cargo_toml_path, &updated)
486            .await
487            .expect("write Cargo.toml");
488
489        let final_content = tokio::fs::read_to_string(&cargo_toml_path)
490            .await
491            .expect("read Cargo.toml");
492        assert!(
493            final_content.contains("realsense-rust = \"0.6.1\""),
494            "the driver's real third-party crate must be added, got: {}",
495            final_content
496        );
497        assert_eq!(
498            final_content.matches("mecha10-core").count(),
499            1,
500            "mecha10-core must not be duplicated, got: {}",
501            final_content
502        );
503    }
504}
505
506#[cfg(test)]
507mod add_node_to_config_tests {
508    use super::*;
509    use mecha10_cli_core::services::ConfigService;
510
511    /// Minimal valid mecha10.json content for a freshly-initialized project.
512    fn minimal_project_config_json() -> &'static str {
513        r#"{
514            "name": "test-project",
515            "version": "0.1.0",
516            "robot_id": "test-robot"
517        }"#
518    }
519
520    #[tokio::test]
521    async fn mints_project_nodes_with_plain_identifier() {
522        let temp_dir = tempfile::tempdir().expect("create temp dir");
523        let project_root = temp_dir.path();
524        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
525            .await
526            .expect("write mecha10.json");
527
528        let service = InitService::new();
529
530        service
531            .add_node_to_config(project_root, "my-custom", false)
532            .await
533            .expect("add_node_to_config should succeed");
534
535        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
536            .await
537            .expect("reload config");
538
539        assert!(
540            config.nodes.identifiers().contains(&"my-custom"),
541            "expected plain identifier 'my-custom' in nodes, got: {:?}",
542            config.nodes.identifiers()
543        );
544        assert!(
545            !config.nodes.identifiers().contains(&"@local/my-custom"),
546            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
547            config.nodes.identifiers()
548        );
549    }
550
551    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
552    /// every default/selected node on every `mecha10 init`.
553    #[tokio::test]
554    async fn preserves_template_version_written_by_mecha10_init() {
555        let temp_dir = tempfile::tempdir().expect("create temp dir");
556        let project_root = temp_dir.path();
557        tokio::fs::write(
558            project_root.join(paths::PROJECT_CONFIG),
559            r#"{
560                "name": "test-project",
561                "version": "0.6.5",
562                "template_version": "0.6.5",
563                "robot_id": "test-robot"
564            }"#,
565        )
566        .await
567        .expect("write mecha10.json");
568
569        let service = InitService::new();
570
571        service
572            .add_node_to_config(project_root, "speaker", true)
573            .await
574            .expect("add_node_to_config should succeed");
575
576        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
577            .await
578            .expect("reload config");
579
580        assert_eq!(
581            config.template_version,
582            Some("0.6.5".to_string()),
583            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
584        );
585    }
586
587    #[tokio::test]
588    async fn mints_framework_nodes_with_mecha10_scope() {
589        let temp_dir = tempfile::tempdir().expect("create temp dir");
590        let project_root = temp_dir.path();
591        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
592            .await
593            .expect("write mecha10.json");
594
595        let service = InitService::new();
596
597        service
598            .add_node_to_config(project_root, "speaker", true)
599            .await
600            .expect("add_node_to_config should succeed");
601
602        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
603            .await
604            .expect("reload config");
605
606        assert!(
607            config.nodes.identifiers().contains(&"@mecha10/speaker"),
608            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
609            config.nodes.identifiers()
610        );
611    }
612
613    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
614    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
615    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
616    #[tokio::test]
617    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
618        let temp_dir = tempfile::tempdir().expect("create temp dir");
619        let project_root = temp_dir.path();
620        let config_with_pinned_node = r#"{
621            "name": "test-project",
622            "version": "0.1.0",
623            "robot_id": "test-robot",
624            "nodes": { "@mecha10/speaker": "0.7.0" }
625        }"#;
626        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
627            .await
628            .expect("write mecha10.json");
629
630        let service = InitService::new();
631
632        service
633            .add_node_to_config(project_root, "speaker", true)
634            .await
635            .expect("add_node_to_config should succeed");
636
637        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
638            .await
639            .expect("reload config");
640
641        assert_eq!(
642            config.nodes.get_version("@mecha10/speaker"),
643            Some("0.7.0"),
644            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
645             reset to this crate's own CARGO_PKG_VERSION"
646        );
647    }
648
649    /// `add_node_to_config` must not touch `targets` at all - that placement is already
650    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
651    #[tokio::test]
652    async fn does_not_touch_targets_at_all() {
653        let temp_dir = tempfile::tempdir().expect("create temp dir");
654        let project_root = temp_dir.path();
655        let config_with_targets = r#"{
656            "name": "test-project",
657            "version": "0.1.0",
658            "robot_id": "test-robot",
659            "targets": {
660                "robot": { "nodes": ["@mecha10/camera"] }
661            }
662        }"#;
663        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
664            .await
665            .expect("write mecha10.json");
666
667        let service = InitService::new();
668
669        service
670            .add_node_to_config(project_root, "image-classifier", true)
671            .await
672            .expect("add_node_to_config should succeed");
673
674        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
675            .await
676            .expect("reload config");
677
678        assert!(
679            config.targets.simulation.is_none(),
680            "add_node_to_config must not synthesize any targets.* entry anymore"
681        );
682        let robot = config
683            .targets
684            .robot
685            .expect("pre-existing targets.robot must be preserved");
686        assert_eq!(
687            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
688            vec!["@mecha10/camera".to_string()],
689            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
690            robot.nodes
691        );
692    }
693}