mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
#![allow(dead_code)]

//! Project service for managing Mecha10 projects
//!
//! This service provides project detection, validation, and metadata operations.
//! It centralizes all project-related logic that was previously scattered across commands.

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

/// Project service for project management operations
///
/// # Examples
///
/// ```rust,ignore
/// use mecha10_cli::services::ProjectService;
/// use std::path::Path;
///
/// # fn example() -> anyhow::Result<()> {
/// // Detect project from current directory
/// let project = ProjectService::detect(Path::new("."))?;
/// println!("Project: {}", project.name()?);
///
/// // Validate project structure
/// project.validate()?;
///
/// // List all nodes
/// let nodes = project.list_nodes()?;
/// # Ok(())
/// # }
/// ```
pub struct ProjectService {
    /// Project root directory
    root: PathBuf,
}

impl ProjectService {
    /// Detect a Mecha10 project from a given path
    ///
    /// Searches upward from the given path to find a mecha10.json file.
    ///
    /// # Arguments
    ///
    /// * `path` - Starting path to search from
    ///
    /// # Errors
    ///
    /// Returns an error if no mecha10.json is found in the path or any parent directory.
    pub fn detect(path: &Path) -> Result<Self> {
        let mut current = path.canonicalize().context("Failed to canonicalize path")?;

        loop {
            let config_path = current.join(paths::PROJECT_CONFIG);
            if config_path.exists() {
                return Ok(Self { root: current });
            }

            // Move to parent directory
            match current.parent() {
                Some(parent) => current = parent.to_path_buf(),
                None => {
                    return Err(anyhow!(
                        "No mecha10.json found in {} or any parent directory.\n\
                         Run 'mecha10 init' to create a new project.",
                        path.display()
                    ))
                }
            }
        }
    }

    /// Create a new project at the given path
    ///
    /// This does not generate the project structure, it just creates
    /// a ProjectService instance for a path where a project will be created.
    /// Use the init handler to actually create project files.
    ///
    /// # Arguments
    ///
    /// * `path` - Path where the project will be created
    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)> {
        // Try mecha10.json first
        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));
        }

        // Fall back to Cargo.toml
        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")?;

            // Parse TOML to extract name and version
            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<()> {
        // Check mecha10.json exists
        if !self.config_path().exists() {
            return Err(anyhow!(
                "Project not initialized: mecha10.json not found at {}",
                self.root.display()
            ));
        }

        // Check basic project structure
        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>> {
        let drivers_dir = self.root.join("drivers");
        self.list_directories(&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>> {
        let types_dir = self.root.join("types");
        self.list_directories(&types_dir)
    }

    /// List all nodes from configuration
    ///
    /// Loads the project config and returns all node names.
    /// Note: 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)
    }
}