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        let mecha10_json = self.config_path();
114        if mecha10_json.exists() {
115            let content = fs::read_to_string(&mecha10_json).context("Failed to read mecha10.json")?;
116            let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
117
118            let name = json["name"]
119                .as_str()
120                .ok_or_else(|| anyhow!("Missing 'name' field in mecha10.json"))?
121                .to_string();
122
123            let version = json["version"]
124                .as_str()
125                .ok_or_else(|| anyhow!("Missing 'version' field in mecha10.json"))?
126                .to_string();
127
128            return Ok((name, version));
129        }
130
131        let cargo_toml = self.root.join(paths::rust::CARGO_TOML);
132        if cargo_toml.exists() {
133            let content = fs::read_to_string(&cargo_toml).context("Failed to read Cargo.toml")?;
134
135            let toml: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
136
137            let name = toml
138                .get("package")
139                .and_then(|p| p.get("name"))
140                .and_then(|n| n.as_str())
141                .ok_or_else(|| anyhow!("Missing 'package.name' in Cargo.toml"))?
142                .to_string();
143
144            let version = toml
145                .get("package")
146                .and_then(|p| p.get("version"))
147                .and_then(|v| v.as_str())
148                .ok_or_else(|| anyhow!("Missing 'package.version' in Cargo.toml"))?
149                .to_string();
150
151            return Ok((name, version));
152        }
153
154        Err(anyhow!(
155            "No mecha10.json or Cargo.toml found in project root: {}",
156            self.root.display()
157        ))
158    }
159
160    /// Validate project structure
161    ///
162    /// Checks that required directories and files exist.
163    pub fn validate(&self) -> Result<()> {
164        if !self.config_path().exists() {
165            return Err(anyhow!(
166                "Project not initialized: mecha10.json not found at {}",
167                self.root.display()
168            ));
169        }
170
171        let required_dirs = vec!["nodes", "drivers", "types"];
172        for dir in required_dirs {
173            let dir_path = self.root.join(dir);
174            if !dir_path.exists() {
175                return Err(anyhow!("Invalid project structure: missing '{}' directory", dir));
176            }
177        }
178
179        Ok(())
180    }
181
182    /// List all nodes in the project
183    ///
184    /// Returns a list of node names found in the nodes/ directory.
185    pub fn list_nodes(&self) -> Result<Vec<String>> {
186        let nodes_dir = self.root.join(paths::project::NODES_DIR);
187        self.list_directories(&nodes_dir)
188    }
189
190    /// List all drivers in the project
191    ///
192    /// Returns a list of driver names found in the drivers/ directory.
193    pub fn list_drivers(&self) -> Result<Vec<String>> {
194        self.list_project_subdir(paths::project::DRIVERS_DIR)
195    }
196
197    /// List all custom types in the project
198    ///
199    /// Returns a list of type names found in the types/ directory.
200    pub fn list_types(&self) -> Result<Vec<String>> {
201        self.list_project_subdir(paths::project::TYPES_DIR)
202    }
203
204    /// List directory names under `relative` (a project-root-relative path, e.g.
205    /// `paths::project::DRIVERS_DIR`). Shared by [`Self::list_drivers`] and
206    /// [`Self::list_types`], which otherwise differ only in which directory they list.
207    fn list_project_subdir(&self, relative: &str) -> Result<Vec<String>> {
208        self.list_directories(&self.root.join(relative))
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}
248
249#[cfg(test)]
250mod tests {
251    use super::ProjectService;
252
253    /// `list_drivers`/`list_types` share a `list_project_subdir` helper (LAB-2893) that only
254    /// differs on which project-relative directory it lists - exercise both ends against real
255    /// directories so a future edit to the shared helper can't quietly break one without the
256    /// other noticing.
257    #[test]
258    fn list_drivers_and_list_types_list_directory_names_in_their_own_subdir() {
259        let root = tempfile::tempdir().expect("tempdir");
260        std::fs::create_dir_all(root.path().join("drivers/servo")).unwrap();
261        std::fs::create_dir_all(root.path().join("drivers/camera")).unwrap();
262        std::fs::create_dir_all(root.path().join("types/pose")).unwrap();
263
264        let service = ProjectService::new(root.path().to_path_buf());
265
266        assert_eq!(
267            service.list_drivers().unwrap(),
268            vec!["camera".to_string(), "servo".to_string()]
269        );
270        assert_eq!(service.list_types().unwrap(), vec!["pose".to_string()]);
271    }
272
273    /// Missing directories list as empty rather than erroring, for both helpers.
274    #[test]
275    fn list_drivers_and_list_types_return_empty_when_subdir_is_absent() {
276        let root = tempfile::tempdir().expect("tempdir");
277        let service = ProjectService::new(root.path().to_path_buf());
278
279        assert!(service.list_drivers().unwrap().is_empty());
280        assert!(service.list_types().unwrap().is_empty());
281    }
282}