mecha10-cli-core 0.8.1

Mecha10 CLI core foundation — shared types, services, and utilities
Documentation
#![allow(dead_code)]

//! Project service for managing Mecha10 projects
//!
//! Centralizes project detection, validation, and metadata operations.

use crate::paths;
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};

pub struct ProjectService {
    root: PathBuf,
}

impl ProjectService {
    /// Detect a Mecha10 project by searching upward from `path` for mecha10.json.
    pub fn detect(path: &Path) -> Result<Self> {
        let start = path.canonicalize().context("Failed to canonicalize path")?;

        mecha10_core::fs_utils::find_project_root(&start)
            .map(|root| Self { root })
            .ok_or_else(|| {
                anyhow!(
                    "No mecha10.json found in {} or any parent directory.\n\
                     Run 'mecha10 init' to create a new project.",
                    path.display()
                )
            })
    }

    /// Create a `ProjectService` for `path` without generating project files;
    /// use the init handler to actually create them.
    pub fn new(path: PathBuf) -> Self {
        Self { root: path }
    }

    /// Get the project root directory
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Get the path to mecha10.json
    pub fn config_path(&self) -> PathBuf {
        self.root.join(paths::PROJECT_CONFIG)
    }

    /// Check if a mecha10.json exists at the project root
    pub fn is_initialized(&self) -> bool {
        self.config_path().exists()
    }

    /// Get project name from metadata
    ///
    /// Tries mecha10.json first, then falls back to Cargo.toml
    pub fn name(&self) -> Result<String> {
        let (name, _) = self.load_metadata()?;
        Ok(name)
    }

    /// Get project version from metadata
    ///
    /// Tries mecha10.json first, then falls back to Cargo.toml
    pub fn version(&self) -> Result<String> {
        let (_, version) = self.load_metadata()?;
        Ok(version)
    }

    /// Load project metadata (name and version)
    ///
    /// Tries mecha10.json first, then falls back to Cargo.toml
    pub fn load_metadata(&self) -> Result<(String, String)> {
        let mecha10_json = self.config_path();
        if mecha10_json.exists() {
            let content = fs::read_to_string(&mecha10_json).context("Failed to read mecha10.json")?;
            let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;

            let name = json["name"]
                .as_str()
                .ok_or_else(|| anyhow!("Missing 'name' field in mecha10.json"))?
                .to_string();

            let version = json["version"]
                .as_str()
                .ok_or_else(|| anyhow!("Missing 'version' field in mecha10.json"))?
                .to_string();

            return Ok((name, version));
        }

        let cargo_toml = self.root.join(paths::rust::CARGO_TOML);
        if cargo_toml.exists() {
            let content = fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;

            let toml: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;

            let name = toml
                .get("package")
                .and_then(|p| p.get("name"))
                .and_then(|n| n.as_str())
                .ok_or_else(|| anyhow!("Missing 'package.name' in Cargo.toml"))?
                .to_string();

            let version = toml
                .get("package")
                .and_then(|p| p.get("version"))
                .and_then(|v| v.as_str())
                .ok_or_else(|| anyhow!("Missing 'package.version' in Cargo.toml"))?
                .to_string();

            return Ok((name, version));
        }

        Err(anyhow!(
            "No mecha10.json or Cargo.toml found in project root: {}",
            self.root.display()
        ))
    }

    /// Validate project structure
    ///
    /// Checks that required directories and files exist.
    pub fn validate(&self) -> Result<()> {
        if !self.config_path().exists() {
            return Err(anyhow!(
                "Project not initialized: mecha10.json not found at {}",
                self.root.display()
            ));
        }

        let required_dirs = vec!["nodes", "drivers", "types"];
        for dir in required_dirs {
            let dir_path = self.root.join(dir);
            if !dir_path.exists() {
                return Err(anyhow!("Invalid project structure: missing '{}' directory", dir));
            }
        }

        Ok(())
    }

    /// List all nodes in the project
    ///
    /// Returns a list of node names found in the nodes/ directory.
    pub fn list_nodes(&self) -> Result<Vec<String>> {
        let nodes_dir = self.root.join(paths::project::NODES_DIR);
        self.list_directories(&nodes_dir)
    }

    /// List all drivers in the project
    ///
    /// Returns a list of driver names found in the drivers/ directory.
    pub fn list_drivers(&self) -> Result<Vec<String>> {
        self.list_project_subdir(paths::project::DRIVERS_DIR)
    }

    /// List all custom types in the project
    ///
    /// Returns a list of type names found in the types/ directory.
    pub fn list_types(&self) -> Result<Vec<String>> {
        self.list_project_subdir(paths::project::TYPES_DIR)
    }

    /// List directory names under `relative` (a project-root-relative path, e.g.
    /// `paths::project::DRIVERS_DIR`). Shared by [`Self::list_drivers`] and
    /// [`Self::list_types`], which otherwise differ only in which directory they list.
    fn list_project_subdir(&self, relative: &str) -> Result<Vec<String>> {
        self.list_directories(&self.root.join(relative))
    }

    /// List all node names from the project config. Which nodes actually run is
    /// determined by lifecycle modes, not per-node enabled flags.
    pub async fn list_enabled_nodes(&self) -> Result<Vec<String>> {
        use crate::services::ConfigService;

        let config = ConfigService::load_from(&self.config_path()).await?;
        Ok(config.nodes.get_node_names())
    }

    /// Helper to list directories in a given path
    fn list_directories(&self, dir: &Path) -> Result<Vec<String>> {
        if !dir.exists() {
            return Ok(Vec::new());
        }

        let mut names = Vec::new();
        for entry in fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))? {
            let entry = entry?;
            if entry.file_type()?.is_dir() {
                if let Some(name) = entry.file_name().to_str() {
                    names.push(name.to_string());
                }
            }
        }

        names.sort();
        Ok(names)
    }

    /// Get a path relative to the project root
    pub fn path(&self, relative: &str) -> PathBuf {
        self.root.join(relative)
    }
}

#[cfg(test)]
mod tests {
    use super::ProjectService;

    /// `list_drivers`/`list_types` share a `list_project_subdir` helper that only differs on
    /// which project-relative directory it lists.
    #[test]
    fn list_drivers_and_list_types_list_directory_names_in_their_own_subdir() {
        let root = tempfile::tempdir().expect("tempdir");
        std::fs::create_dir_all(root.path().join("drivers/servo")).unwrap();
        std::fs::create_dir_all(root.path().join("drivers/camera")).unwrap();
        std::fs::create_dir_all(root.path().join("types/pose")).unwrap();

        let service = ProjectService::new(root.path().to_path_buf());

        assert_eq!(
            service.list_drivers().unwrap(),
            vec!["camera".to_string(), "servo".to_string()]
        );
        assert_eq!(service.list_types().unwrap(), vec!["pose".to_string()]);
    }

    /// Missing directories list as empty rather than erroring, for both helpers.
    #[test]
    fn list_drivers_and_list_types_return_empty_when_subdir_is_absent() {
        let root = tempfile::tempdir().expect("tempdir");
        let service = ProjectService::new(root.path().to_path_buf());

        assert!(service.list_drivers().unwrap().is_empty());
        assert!(service.list_types().unwrap().is_empty());
    }
}