mecha10_cli/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 mut current = path.canonicalize().context("Failed to canonicalize path")?;
53
54        loop {
55            let config_path = current.join(paths::PROJECT_CONFIG);
56            if config_path.exists() {
57                return Ok(Self { root: current });
58            }
59
60            // Move to parent directory
61            match current.parent() {
62                Some(parent) => current = parent.to_path_buf(),
63                None => {
64                    return Err(anyhow!(
65                        "No mecha10.json found in {} or any parent directory.\n\
66                         Run 'mecha10 init' to create a new project.",
67                        path.display()
68                    ))
69                }
70            }
71        }
72    }
73
74    /// Create a new project at the given path
75    ///
76    /// This does not generate the project structure, it just creates
77    /// a ProjectService instance for a path where a project will be created.
78    /// Use the init handler to actually create project files.
79    ///
80    /// # Arguments
81    ///
82    /// * `path` - Path where the project will be created
83    pub fn new(path: PathBuf) -> Self {
84        Self { root: path }
85    }
86
87    /// Get the project root directory
88    pub fn root(&self) -> &Path {
89        &self.root
90    }
91
92    /// Get the path to mecha10.json
93    pub fn config_path(&self) -> PathBuf {
94        self.root.join(paths::PROJECT_CONFIG)
95    }
96
97    /// Check if a mecha10.json exists at the project root
98    pub fn is_initialized(&self) -> bool {
99        self.config_path().exists()
100    }
101
102    /// Get project name from metadata
103    ///
104    /// Tries mecha10.json first, then falls back to Cargo.toml
105    pub fn name(&self) -> Result<String> {
106        let (name, _) = self.load_metadata()?;
107        Ok(name)
108    }
109
110    /// Get project version from metadata
111    ///
112    /// Tries mecha10.json first, then falls back to Cargo.toml
113    pub fn version(&self) -> Result<String> {
114        let (_, version) = self.load_metadata()?;
115        Ok(version)
116    }
117
118    /// Load project metadata (name and version)
119    ///
120    /// Tries mecha10.json first, then falls back to Cargo.toml
121    pub fn load_metadata(&self) -> Result<(String, String)> {
122        // Try mecha10.json first
123        let mecha10_json = self.config_path();
124        if mecha10_json.exists() {
125            let content = fs::read_to_string(&mecha10_json).context("Failed to read mecha10.json")?;
126            let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
127
128            let name = json["name"]
129                .as_str()
130                .ok_or_else(|| anyhow!("Missing 'name' field in mecha10.json"))?
131                .to_string();
132
133            let version = json["version"]
134                .as_str()
135                .ok_or_else(|| anyhow!("Missing 'version' field in mecha10.json"))?
136                .to_string();
137
138            return Ok((name, version));
139        }
140
141        // Fall back to Cargo.toml
142        let cargo_toml = self.root.join(paths::rust::CARGO_TOML);
143        if cargo_toml.exists() {
144            let content = fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
145
146            // Parse TOML to extract name and version
147            let toml: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
148
149            let name = toml
150                .get("package")
151                .and_then(|p| p.get("name"))
152                .and_then(|n| n.as_str())
153                .ok_or_else(|| anyhow!("Missing 'package.name' in Cargo.toml"))?
154                .to_string();
155
156            let version = toml
157                .get("package")
158                .and_then(|p| p.get("version"))
159                .and_then(|v| v.as_str())
160                .ok_or_else(|| anyhow!("Missing 'package.version' in Cargo.toml"))?
161                .to_string();
162
163            return Ok((name, version));
164        }
165
166        Err(anyhow!(
167            "No mecha10.json or Cargo.toml found in project root: {}",
168            self.root.display()
169        ))
170    }
171
172    /// Validate project structure
173    ///
174    /// Checks that required directories and files exist.
175    pub fn validate(&self) -> Result<()> {
176        // Check mecha10.json exists
177        if !self.config_path().exists() {
178            return Err(anyhow!(
179                "Project not initialized: mecha10.json not found at {}",
180                self.root.display()
181            ));
182        }
183
184        // Check basic project structure
185        let required_dirs = vec!["nodes", "drivers", "types"];
186        for dir in required_dirs {
187            let dir_path = self.root.join(dir);
188            if !dir_path.exists() {
189                return Err(anyhow!("Invalid project structure: missing '{}' directory", dir));
190            }
191        }
192
193        Ok(())
194    }
195
196    /// List all nodes in the project
197    ///
198    /// Returns a list of node names found in the nodes/ directory.
199    pub fn list_nodes(&self) -> Result<Vec<String>> {
200        let nodes_dir = self.root.join(paths::project::NODES_DIR);
201        self.list_directories(&nodes_dir)
202    }
203
204    /// List all drivers in the project
205    ///
206    /// Returns a list of driver names found in the drivers/ directory.
207    pub fn list_drivers(&self) -> Result<Vec<String>> {
208        let drivers_dir = self.root.join("drivers");
209        self.list_directories(&drivers_dir)
210    }
211
212    /// List all custom types in the project
213    ///
214    /// Returns a list of type names found in the types/ directory.
215    pub fn list_types(&self) -> Result<Vec<String>> {
216        let types_dir = self.root.join("types");
217        self.list_directories(&types_dir)
218    }
219
220    /// List all nodes from configuration
221    ///
222    /// Loads the project config and returns all node names.
223    /// Note: Which nodes actually run is determined by lifecycle modes,
224    /// not per-node enabled flags.
225    pub async fn list_enabled_nodes(&self) -> Result<Vec<String>> {
226        use crate::services::ConfigService;
227
228        let config = ConfigService::load_from(&self.config_path()).await?;
229        Ok(config.nodes.get_node_names())
230    }
231
232    /// Helper to list directories in a given path
233    fn list_directories(&self, dir: &Path) -> Result<Vec<String>> {
234        if !dir.exists() {
235            return Ok(Vec::new());
236        }
237
238        let mut names = Vec::new();
239        for entry in fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))? {
240            let entry = entry?;
241            if entry.file_type()?.is_dir() {
242                if let Some(name) = entry.file_name().to_str() {
243                    names.push(name.to_string());
244                }
245            }
246        }
247
248        names.sort();
249        Ok(names)
250    }
251
252    /// Get a path relative to the project root
253    pub fn path(&self, relative: &str) -> PathBuf {
254        self.root.join(relative)
255    }
256}