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