mecha10-templates 0.8.2

Mecha10 project templates — code generation, init, and template services
Documentation
//! Init service for project initialization
//!
//! Handles business logic for creating new Mecha10 projects.

use crate::paths;
use anyhow::Result;
use std::path::Path;

pub struct InitService;

impl InitService {
    /// Create a new InitService
    pub fn new() -> Self {
        Self
    }

    /// Create all the necessary directories for a new Mecha10 project.
    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
        let dirs = vec![
            paths::project::NODES_DIR,
            paths::project::DRIVERS_DIR,
            paths::project::TYPES_DIR,
            paths::project::BEHAVIORS_DIR,
            paths::project::LOGS_DIR,
            paths::project::MODELS_DIR,
            paths::project::MODELS_CATALOG_DIR,
            paths::project::SIMULATION_DIR,
            paths::project::SIMULATION_MODELS_DIR,
            paths::project::SIMULATION_ENVIRONMENTS_DIR,
            paths::project::SIMULATION_IMAGES_DIR,
            paths::project::RL_TASKS_DIR,
            paths::config::NODES_DIR,
        ];

        for dir in dirs {
            let dir_path = path.join(dir);
            tokio::fs::create_dir_all(&dir_path).await?;
        }

        Ok(())
    }

    /// Add an example node from the catalog to the project, updating Cargo.toml and
    /// mecha10.json as needed.
    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
        use crate::component_catalog::ComponentCatalog;

        let catalog = ComponentCatalog::new();
        let components = catalog.search(component_name);

        if components.is_empty() {
            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
        }

        let component = &components[0];

        for file_template in &component.files {
            let file_path = project_root.join(&file_template.path);

            if let Some(parent) = file_path.parent() {
                tokio::fs::create_dir_all(parent).await?;
            }

            tokio::fs::write(&file_path, &file_template.content).await?;
        }

        if !component.files.is_empty() {
            self.add_workspace_member(project_root, component_name).await?;
        }

        let is_monorepo_node = component.files.is_empty();
        if is_monorepo_node {
            self.add_dependency(project_root, component).await?;
        }

        self.add_node_to_config(project_root, component_name, is_monorepo_node)
            .await?;

        Ok(())
    }

    /// Add a workspace member to Cargo.toml
    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;

        let new_member = format!("nodes/{}", component_name);
        let updated_content = if content.contains("members = []") {
            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
        } else if let Some(start) = content.find("members = [") {
            if let Some(end) = content[start..].find(']') {
                let insert_pos = start + end;
                let members_section = &content[start..insert_pos];
                if members_section.contains('\"') {
                    format!(
                        "{}\n    \"{}\",{}",
                        &content[..insert_pos],
                        new_member,
                        &content[insert_pos..]
                    )
                } else {
                    format!(
                        "{}\n    \"{}\",\n{}",
                        &content[..insert_pos],
                        new_member,
                        &content[insert_pos..]
                    )
                }
            } else {
                content
            }
        } else {
            content
        };

        tokio::fs::write(&cargo_toml_path, updated_content).await?;
        Ok(())
    }

    /// Add a dependency to Cargo.toml
    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;

        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
            dep.name.clone()
        } else {
            format!("mecha10-nodes-{}", component.id)
        };

        if !content.contains(&package_name) {
            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
                if let Some(newline_pos) = content[pos..].find('\n') {
                    let insert_pos = pos + newline_pos + 1;
                    format!(
                        "{}{} = \"0.1\"\n{}",
                        &content[..insert_pos],
                        package_name,
                        &content[insert_pos..]
                    )
                } else {
                    content
                }
            } else {
                content
            };

            tokio::fs::write(&cargo_toml_path, updated_content).await?;
        }

        Ok(())
    }

    /// Add node entry to mecha10.json. Only mints the `nodes` map entry; `targets` placement
    /// is handled separately by `handlers/init.rs`.
    async fn add_node_to_config(
        &self,
        project_root: &Path,
        component_name: &str,
        is_monorepo_node: bool,
    ) -> Result<()> {
        use crate::services::ConfigService;

        let config_path = project_root.join(paths::PROJECT_CONFIG);
        let mut config = ConfigService::load_from(&config_path).await?;

        let (node_identifier, version) = if is_monorepo_node {
            (
                format!("@mecha10/{}", component_name),
                env!("CARGO_PKG_VERSION").to_string(),
            )
        } else {
            (component_name.to_string(), "0.1.0".to_string())
        };

        // `handlers/init.rs`'s `build_node_placement` already mints every selected framework
        // node into `nodes`, pinned to that node's own live-catalog version (`mecha10-nodes`
        // releases each node independently) - don't clobber that back to this crate's own
        // build version for a node that's already present. Only mint an entry that's actually
        // missing (e.g. `mecha10 init --update`/`mecha10 add`, which don't go through
        // `build_node_placement` first).
        if config.nodes.get_version(&node_identifier).is_none() {
            config.nodes.add_node(&node_identifier, &version);

            let config_json = serde_json::to_string_pretty(&config)?;
            tokio::fs::write(&config_path, config_json).await?;
        }

        Ok(())
    }
}

impl Default for InitService {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod add_node_to_config_tests {
    use super::*;
    use mecha10_cli_core::services::ConfigService;

    /// Minimal valid mecha10.json content for a freshly-initialized project.
    fn minimal_project_config_json() -> &'static str {
        r#"{
            "name": "test-project",
            "version": "0.1.0",
            "robot_id": "test-robot"
        }"#
    }

    #[tokio::test]
    async fn mints_project_nodes_with_plain_identifier() {
        let temp_dir = tempfile::tempdir().expect("create temp dir");
        let project_root = temp_dir.path();
        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
            .await
            .expect("write mecha10.json");

        let service = InitService::new();

        service
            .add_node_to_config(project_root, "my-custom", false)
            .await
            .expect("add_node_to_config should succeed");

        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
            .await
            .expect("reload config");

        assert!(
            config.nodes.identifiers().contains(&"my-custom"),
            "expected plain identifier 'my-custom' in nodes, got: {:?}",
            config.nodes.identifiers()
        );
        assert!(
            !config.nodes.identifiers().contains(&"@local/my-custom"),
            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
            config.nodes.identifiers()
        );
    }

    /// `add_node_to_config` must preserve `template_version` across its save, since it runs for
    /// every default/selected node on every `mecha10 init`.
    #[tokio::test]
    async fn preserves_template_version_written_by_mecha10_init() {
        let temp_dir = tempfile::tempdir().expect("create temp dir");
        let project_root = temp_dir.path();
        tokio::fs::write(
            project_root.join(paths::PROJECT_CONFIG),
            r#"{
                "name": "test-project",
                "version": "0.6.5",
                "template_version": "0.6.5",
                "robot_id": "test-robot"
            }"#,
        )
        .await
        .expect("write mecha10.json");

        let service = InitService::new();

        service
            .add_node_to_config(project_root, "speaker", true)
            .await
            .expect("add_node_to_config should succeed");

        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
            .await
            .expect("reload config");

        assert_eq!(
            config.template_version,
            Some("0.6.5".to_string()),
            "template_version written at `mecha10 init` time must survive add_node_to_config's save"
        );
    }

    #[tokio::test]
    async fn mints_framework_nodes_with_mecha10_scope() {
        let temp_dir = tempfile::tempdir().expect("create temp dir");
        let project_root = temp_dir.path();
        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
            .await
            .expect("write mecha10.json");

        let service = InitService::new();

        service
            .add_node_to_config(project_root, "speaker", true)
            .await
            .expect("add_node_to_config should succeed");

        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
            .await
            .expect("reload config");

        assert!(
            config.nodes.identifiers().contains(&"@mecha10/speaker"),
            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
            config.nodes.identifiers()
        );
    }

    /// `handlers/init.rs`'s `build_node_placement` runs before this and already pins a
    /// selected framework node to its own live-catalog version. `add_node_to_config` must not
    /// clobber that back to this crate's own `CARGO_PKG_VERSION`.
    #[tokio::test]
    async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
        let temp_dir = tempfile::tempdir().expect("create temp dir");
        let project_root = temp_dir.path();
        let config_with_pinned_node = r#"{
            "name": "test-project",
            "version": "0.1.0",
            "robot_id": "test-robot",
            "nodes": { "@mecha10/speaker": "0.7.0" }
        }"#;
        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
            .await
            .expect("write mecha10.json");

        let service = InitService::new();

        service
            .add_node_to_config(project_root, "speaker", true)
            .await
            .expect("add_node_to_config should succeed");

        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
            .await
            .expect("reload config");

        assert_eq!(
            config.nodes.get_version("@mecha10/speaker"),
            Some("0.7.0"),
            "the live-catalog-pinned version must survive add_node_to_config's save, not be \
             reset to this crate's own CARGO_PKG_VERSION"
        );
    }

    /// `add_node_to_config` must not touch `targets` at all - that placement is already
    /// written by `handlers/init.rs`'s `build_node_placement` before this ever runs.
    #[tokio::test]
    async fn does_not_touch_targets_at_all() {
        let temp_dir = tempfile::tempdir().expect("create temp dir");
        let project_root = temp_dir.path();
        let config_with_targets = r#"{
            "name": "test-project",
            "version": "0.1.0",
            "robot_id": "test-robot",
            "targets": {
                "robot": { "nodes": ["@mecha10/camera"] }
            }
        }"#;
        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
            .await
            .expect("write mecha10.json");

        let service = InitService::new();

        service
            .add_node_to_config(project_root, "image-classifier", true)
            .await
            .expect("add_node_to_config should succeed");

        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
            .await
            .expect("reload config");

        assert!(
            config.targets.simulation.is_none(),
            "add_node_to_config must not synthesize any targets.* entry anymore"
        );
        let robot = config
            .targets
            .robot
            .expect("pre-existing targets.robot must be preserved");
        assert_eq!(
            robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
            vec!["@mecha10/camera".to_string()],
            "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
            robot.nodes
        );
    }
}