Skip to main content

mecha10_cli_core/services/
project.rs

1#![allow(dead_code)]
2
3//! Project service for managing Mecha10 projects
4//!
5//! This service provides project detection, validation, and metadata operations.
6//! It centralizes all project-related logic that was previously scattered across commands.
7
8use crate::paths;
9use anyhow::{anyhow, Context, Result};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Project service for project management operations
14///
15/// # Examples
16///
17/// ```rust,ignore
18/// use mecha10_cli::services::ProjectService;
19/// use std::path::Path;
20///
21/// # fn example() -> anyhow::Result<()> {
22/// // Detect project from current directory
23/// let project = ProjectService::detect(Path::new("."))?;
24/// println!("Project: {}", project.name()?);
25///
26/// // Validate project structure
27/// project.validate()?;
28///
29/// // List all nodes
30/// let nodes = project.list_nodes()?;
31/// # Ok(())
32/// # }
33/// ```
34pub struct ProjectService {
35    /// Project root directory
36    root: PathBuf,
37}
38
39impl ProjectService {
40    /// Detect a Mecha10 project from a given path
41    ///
42    /// Searches upward from the given path to find a mecha10.json file.
43    ///
44    /// # Arguments
45    ///
46    /// * `path` - Starting path to search from
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if no mecha10.json is found in the path or any parent directory.
51    pub fn detect(path: &Path) -> Result<Self> {
52        let start = path.canonicalize().context("Failed to canonicalize path")?;
53
54        mecha10_core::fs_utils::find_project_root(&start)
55            .map(|root| Self { root })
56            .ok_or_else(|| {
57                anyhow!(
58                    "No mecha10.json found in {} or any parent directory.\n\
59                     Run 'mecha10 init' to create a new project.",
60                    path.display()
61                )
62            })
63    }
64
65    /// Create a new project at the given path
66    ///
67    /// This does not generate the project structure, it just creates
68    /// a ProjectService instance for a path where a project will be created.
69    /// Use the init handler to actually create project files.
70    ///
71    /// # Arguments
72    ///
73    /// * `path` - Path where the project will be created
74    pub fn new(path: PathBuf) -> Self {
75        Self { root: path }
76    }
77
78    /// Get the project root directory
79    pub fn root(&self) -> &Path {
80        &self.root
81    }
82
83    /// Get the path to mecha10.json
84    pub fn config_path(&self) -> PathBuf {
85        self.root.join(paths::PROJECT_CONFIG)
86    }
87
88    /// Check if a mecha10.json exists at the project root
89    pub fn is_initialized(&self) -> bool {
90        self.config_path().exists()
91    }
92
93    /// Get project name from metadata
94    ///
95    /// Tries mecha10.json first, then falls back to Cargo.toml
96    pub fn name(&self) -> Result<String> {
97        let (name, _) = self.load_metadata()?;
98        Ok(name)
99    }
100
101    /// Get project version from metadata
102    ///
103    /// Tries mecha10.json first, then falls back to Cargo.toml
104    pub fn version(&self) -> Result<String> {
105        let (_, version) = self.load_metadata()?;
106        Ok(version)
107    }
108
109    /// Load project metadata (name and version)
110    ///
111    /// Tries mecha10.json first, then falls back to Cargo.toml
112    pub fn load_metadata(&self) -> Result<(String, String)> {
113        // Try mecha10.json first
114        let mecha10_json = self.config_path();
115        if mecha10_json.exists() {
116            let content = fs::read_to_string(&mecha10_json).context("Failed to read mecha10.json")?;
117            let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
118
119            let name = json["name"]
120                .as_str()
121                .ok_or_else(|| anyhow!("Missing 'name' field in mecha10.json"))?
122                .to_string();
123
124            let version = json["version"]
125                .as_str()
126                .ok_or_else(|| anyhow!("Missing 'version' field in mecha10.json"))?
127                .to_string();
128
129            return Ok((name, version));
130        }
131
132        // Fall back to Cargo.toml
133        let cargo_toml = self.root.join(paths::rust::CARGO_TOML);
134        if cargo_toml.exists() {
135            let content = fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
136
137            // Parse TOML to extract name and version
138            let toml: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
139
140            let name = toml
141                .get("package")
142                .and_then(|p| p.get("name"))
143                .and_then(|n| n.as_str())
144                .ok_or_else(|| anyhow!("Missing 'package.name' in Cargo.toml"))?
145                .to_string();
146
147            let version = toml
148                .get("package")
149                .and_then(|p| p.get("version"))
150                .and_then(|v| v.as_str())
151                .ok_or_else(|| anyhow!("Missing 'package.version' in Cargo.toml"))?
152                .to_string();
153
154            return Ok((name, version));
155        }
156
157        Err(anyhow!(
158            "No mecha10.json or Cargo.toml found in project root: {}",
159            self.root.display()
160        ))
161    }
162
163    /// Validate project structure
164    ///
165    /// Checks that required directories and files exist.
166    pub fn validate(&self) -> Result<()> {
167        // Check mecha10.json exists
168        if !self.config_path().exists() {
169            return Err(anyhow!(
170                "Project not initialized: mecha10.json not found at {}",
171                self.root.display()
172            ));
173        }
174
175        // Check basic project structure
176        let required_dirs = vec!["nodes", "drivers", "types"];
177        for dir in required_dirs {
178            let dir_path = self.root.join(dir);
179            if !dir_path.exists() {
180                return Err(anyhow!("Invalid project structure: missing '{}' directory", dir));
181            }
182        }
183
184        Ok(())
185    }
186
187    /// List all nodes in the project
188    ///
189    /// Returns a list of node names found in the nodes/ directory.
190    pub fn list_nodes(&self) -> Result<Vec<String>> {
191        let nodes_dir = self.root.join(paths::project::NODES_DIR);
192        self.list_directories(&nodes_dir)
193    }
194
195    /// List all drivers in the project
196    ///
197    /// Returns a list of driver names found in the drivers/ directory.
198    pub fn list_drivers(&self) -> Result<Vec<String>> {
199        let drivers_dir = self.root.join("drivers");
200        self.list_directories(&drivers_dir)
201    }
202
203    /// List all custom types in the project
204    ///
205    /// Returns a list of type names found in the types/ directory.
206    pub fn list_types(&self) -> Result<Vec<String>> {
207        let types_dir = self.root.join("types");
208        self.list_directories(&types_dir)
209    }
210
211    /// List all nodes from configuration
212    ///
213    /// Loads the project config and returns all node names.
214    /// Note: Which nodes actually run is determined by lifecycle modes,
215    /// not per-node enabled flags.
216    pub async fn list_enabled_nodes(&self) -> Result<Vec<String>> {
217        use crate::services::ConfigService;
218
219        let config = ConfigService::load_from(&self.config_path()).await?;
220        Ok(config.nodes.get_node_names())
221    }
222
223    /// Helper to list directories in a given path
224    fn list_directories(&self, dir: &Path) -> Result<Vec<String>> {
225        if !dir.exists() {
226            return Ok(Vec::new());
227        }
228
229        let mut names = Vec::new();
230        for entry in fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))? {
231            let entry = entry?;
232            if entry.file_type()?.is_dir() {
233                if let Some(name) = entry.file_name().to_str() {
234                    names.push(name.to_string());
235                }
236            }
237        }
238
239        names.sort();
240        Ok(names)
241    }
242
243    /// Get a path relative to the project root
244    pub fn path(&self, relative: &str) -> PathBuf {
245        self.root.join(relative)
246    }
247}