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