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