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
#![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};
/// Project service for project management operations
///
/// # Examples
///
/// ```rust,ignore
/// use mecha10_cli::services::ProjectService;
/// use std::path::Path;
///
/// # fn example() -> anyhow::Result<()> {
/// // Detect project from current directory
/// let project = ProjectService::detect(Path::new("."))?;
/// println!("Project: {}", project.name()?);
///
/// // Validate project structure
/// project.validate()?;
///
/// // List all nodes
/// let nodes = project.list_nodes()?;
/// # Ok(())
/// # }
/// ```
pub struct ProjectService {
/// Project root directory
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 mut current = path.canonicalize().context("Failed to canonicalize path")?;
loop {
let config_path = current.join(paths::PROJECT_CONFIG);
if config_path.exists() {
return Ok(Self { root: current });
}
// Move to parent directory
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => {
return Err(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)> {
// Try mecha10.json first
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));
}
// Fall back to Cargo.toml
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")?;
// Parse TOML to extract name and version
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<()> {
// Check mecha10.json exists
if !self.config_path().exists() {
return Err(anyhow!(
"Project not initialized: mecha10.json not found at {}",
self.root.display()
));
}
// Check basic project structure
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>> {
let drivers_dir = self.root.join("drivers");
self.list_directories(&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>> {
let types_dir = self.root.join("types");
self.list_directories(&types_dir)
}
/// 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)
}
}