Skip to main content

mecha10_cli_core/services/
project.rs

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