forgekit_core/dependency/
mod.rs1use std::path::PathBuf;
2
3use crate::error::{ForgeError, Result};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct Dependency {
7 pub name: String,
8 pub version: Option<String>,
9 pub source: DependencySource,
10 pub dev: bool,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum DependencySource {
15 Registry(String),
16 Git { url: String, rev: Option<String> },
17 Path(PathBuf),
18}
19
20#[derive(Debug, Clone)]
21pub struct DependencyManifest {
22 pub path: PathBuf,
23 pub dependencies: Vec<Dependency>,
24 pub dev_dependencies: Vec<Dependency>,
25}
26
27pub struct DependencyModule {
28 project_root: PathBuf,
29}
30
31impl DependencyModule {
32 pub fn new(project_root: PathBuf) -> Self {
33 Self { project_root }
34 }
35
36 pub fn list(&self) -> Result<Vec<DependencyManifest>> {
37 let mut manifests = Vec::new();
38
39 if let Some(m) = self.cargo_manifest()? {
40 manifests.push(m);
41 }
42 if let Some(m) = self.npm_manifest()? {
43 manifests.push(m);
44 }
45 if let Some(m) = self.go_manifest()? {
46 manifests.push(m);
47 }
48
49 Ok(manifests)
50 }
51
52 pub fn add(&self, name: &str, version: Option<&str>, dev: bool) -> Result<()> {
53 if self.project_root.join("Cargo.toml").exists() {
54 return self.add_cargo_dep(name, version, dev);
55 }
56 if self.project_root.join("package.json").exists() {
57 return self.add_npm_dep(name, version, dev);
58 }
59 if self.project_root.join("go.mod").exists() {
60 return self.add_go_dep(name, version);
61 }
62 Err(ForgeError::ToolError(
63 "No recognized manifest found".to_string(),
64 ))
65 }
66
67 pub fn remove(&self, name: &str) -> Result<()> {
68 if self.project_root.join("Cargo.toml").exists() {
69 return self.remove_cargo_dep(name);
70 }
71 if self.project_root.join("package.json").exists() {
72 return self.remove_npm_dep(name);
73 }
74 Err(ForgeError::ToolError(
75 "No recognized manifest found".to_string(),
76 ))
77 }
78
79 fn cargo_manifest(&self) -> Result<Option<DependencyManifest>> {
80 let path = self.project_root.join("Cargo.toml");
81 if !path.exists() {
82 return Ok(None);
83 }
84 let content = std::fs::read_to_string(&path)?;
85 let doc = content
86 .parse::<toml::Value>()
87 .map_err(|e| ForgeError::ToolError(format!("Failed to parse Cargo.toml: {}", e)))?;
88
89 let mut deps = Vec::new();
90 let mut dev_deps = Vec::new();
91
92 if let Some(table) = doc.get("dependencies").and_then(|v| v.as_table()) {
93 for (name, value) in table {
94 deps.push(parse_cargo_dep(name, value, false));
95 }
96 }
97 if let Some(table) = doc.get("dev-dependencies").and_then(|v| v.as_table()) {
98 for (name, value) in table {
99 dev_deps.push(parse_cargo_dep(name, value, true));
100 }
101 }
102
103 Ok(Some(DependencyManifest {
104 path,
105 dependencies: deps,
106 dev_dependencies: dev_deps,
107 }))
108 }
109
110 fn npm_manifest(&self) -> Result<Option<DependencyManifest>> {
111 let path = self.project_root.join("package.json");
112 if !path.exists() {
113 return Ok(None);
114 }
115 let content = std::fs::read_to_string(&path)?;
116 let doc: serde_json::Value = serde_json::from_str(&content)
117 .map_err(|e| ForgeError::ToolError(format!("Failed to parse package.json: {}", e)))?;
118
119 let mut deps = Vec::new();
120 let mut dev_deps = Vec::new();
121
122 if let Some(obj) = doc.get("dependencies").and_then(|v| v.as_object()) {
123 for (name, value) in obj {
124 let version = value.as_str().map(|s| s.to_string());
125 deps.push(Dependency {
126 name: name.clone(),
127 version,
128 source: DependencySource::Registry("npm".to_string()),
129 dev: false,
130 });
131 }
132 }
133 if let Some(obj) = doc.get("devDependencies").and_then(|v| v.as_object()) {
134 for (name, value) in obj {
135 let version = value.as_str().map(|s| s.to_string());
136 dev_deps.push(Dependency {
137 name: name.clone(),
138 version,
139 source: DependencySource::Registry("npm".to_string()),
140 dev: true,
141 });
142 }
143 }
144
145 Ok(Some(DependencyManifest {
146 path,
147 dependencies: deps,
148 dev_dependencies: dev_deps,
149 }))
150 }
151
152 fn go_manifest(&self) -> Result<Option<DependencyManifest>> {
153 let path = self.project_root.join("go.mod");
154 if !path.exists() {
155 return Ok(None);
156 }
157 let content = std::fs::read_to_string(&path)?;
158 let mut deps = Vec::new();
159 let mut in_require_block = false;
160
161 for line in content.lines() {
162 let trimmed = line.trim();
163
164 if trimmed.starts_with("require (") {
165 in_require_block = true;
166 continue;
167 }
168 if in_require_block && trimmed == ")" {
169 in_require_block = false;
170 continue;
171 }
172
173 if in_require_block {
174 let parts: Vec<&str> = trimmed.split_whitespace().collect();
175 if parts.len() >= 2 && !parts[0].starts_with("//") {
176 deps.push(Dependency {
177 name: parts[0].to_string(),
178 version: Some(parts[1].to_string()),
179 source: DependencySource::Registry("go".to_string()),
180 dev: false,
181 });
182 }
183 } else if trimmed.starts_with("require ") && !trimmed.contains('(') {
184 let parts: Vec<&str> = trimmed.split_whitespace().collect();
185 if parts.len() >= 3 {
186 deps.push(Dependency {
187 name: parts[1].to_string(),
188 version: Some(parts[2].to_string()),
189 source: DependencySource::Registry("go".to_string()),
190 dev: false,
191 });
192 }
193 }
194 }
195
196 Ok(Some(DependencyManifest {
197 path,
198 dependencies: deps,
199 dev_dependencies: Vec::new(),
200 }))
201 }
202
203 fn add_cargo_dep(&self, name: &str, version: Option<&str>, dev: bool) -> Result<()> {
204 let path = self.project_root.join("Cargo.toml");
205 let content = std::fs::read_to_string(&path)?;
206 let section = if dev {
207 "dev-dependencies"
208 } else {
209 "dependencies"
210 };
211 let ver = version.unwrap_or("*");
212 let dep_line = format!("{} = \"{}\"\n", name, ver);
213
214 let new_content = if let Some(pos) = content.find(&format!("[{}]", section)) {
215 let mut s = String::new();
216 s.push_str(&content[..pos + format!("[{}]", section).len()]);
217 s.push('\n');
218 s.push_str(&dep_line);
219 s.push_str(&content[pos + format!("[{}]", section).len()..]);
220 s
221 } else {
222 let mut s = content;
223 if !s.ends_with('\n') {
224 s.push('\n');
225 }
226 s.push_str(&format!("\n[{}]\n", section));
227 s.push_str(&dep_line);
228 s
229 };
230
231 std::fs::write(&path, new_content)?;
232 Ok(())
233 }
234
235 fn add_npm_dep(&self, name: &str, version: Option<&str>, dev: bool) -> Result<()> {
236 let path = self.project_root.join("package.json");
237 let content = std::fs::read_to_string(&path)?;
238 let ver = version.unwrap_or("*");
239 let key = if dev {
240 "devDependencies"
241 } else {
242 "dependencies"
243 };
244
245 let mut doc: serde_json::Value = serde_json::from_str(&content)
246 .map_err(|e| ForgeError::ToolError(format!("Failed to parse package.json: {}", e)))?;
247
248 let obj = doc.as_object_mut().ok_or_else(|| {
249 ForgeError::ToolError("package.json root is not an object".to_string())
250 })?;
251 if !obj.contains_key(key) {
252 obj.insert(
253 key.to_string(),
254 serde_json::Value::Object(serde_json::Map::new()),
255 );
256 }
257 if let Some(deps) = obj.get_mut(key).and_then(|v| v.as_object_mut()) {
258 deps.insert(name.to_string(), serde_json::Value::String(ver.to_string()));
259 }
260
261 let output = serde_json::to_string_pretty(&doc)
262 .map_err(|e| ForgeError::ToolError(format!("Failed to serialize: {}", e)))?;
263 std::fs::write(&path, output)?;
264 Ok(())
265 }
266
267 fn add_go_dep(&self, _name: &str, _version: Option<&str>) -> Result<()> {
268 Err(ForgeError::ToolError(
269 "Go dependencies should be managed via `go get`. Use BuildModule::build() instead."
270 .to_string(),
271 ))
272 }
273
274 fn remove_cargo_dep(&self, name: &str) -> Result<()> {
275 let path = self.project_root.join("Cargo.toml");
276 let content = std::fs::read_to_string(&path)?;
277 let mut output = String::new();
278 let pattern = format!("{} ", name);
279
280 for line in content.lines() {
281 let trimmed = line.trim();
282 if trimmed.starts_with(&pattern) || trimmed == name {
283 continue;
284 }
285 output.push_str(line);
286 output.push('\n');
287 }
288
289 std::fs::write(&path, output)?;
290 Ok(())
291 }
292
293 fn remove_npm_dep(&self, name: &str) -> Result<()> {
294 let path = self.project_root.join("package.json");
295 let content = std::fs::read_to_string(&path)?;
296 let mut doc: serde_json::Value = serde_json::from_str(&content)
297 .map_err(|e| ForgeError::ToolError(format!("Failed to parse: {}", e)))?;
298
299 for key in &["dependencies", "devDependencies"] {
300 if let Some(deps) = doc.get_mut(*key).and_then(|v| v.as_object_mut()) {
301 deps.remove(name);
302 }
303 }
304
305 let output = serde_json::to_string_pretty(&doc)
306 .map_err(|e| ForgeError::ToolError(format!("Failed to serialize: {}", e)))?;
307 std::fs::write(&path, output)?;
308 Ok(())
309 }
310}
311
312fn parse_cargo_dep(name: &str, value: &toml::Value, dev: bool) -> Dependency {
313 match value {
314 toml::Value::String(ver) => Dependency {
315 name: name.to_string(),
316 version: Some(ver.clone()),
317 source: DependencySource::Registry("crates.io".to_string()),
318 dev,
319 },
320 toml::Value::Table(table) => {
321 let version = table
322 .get("version")
323 .and_then(|v| v.as_str())
324 .map(|s| s.to_string());
325 let source = if let Some(git) = table.get("git").and_then(|v| v.as_str()) {
326 DependencySource::Git {
327 url: git.to_string(),
328 rev: table
329 .get("rev")
330 .and_then(|v| v.as_str())
331 .map(|s| s.to_string()),
332 }
333 } else if let Some(p) = table.get("path").and_then(|v| v.as_str()) {
334 DependencySource::Path(PathBuf::from(p))
335 } else {
336 DependencySource::Registry("crates.io".to_string())
337 };
338 Dependency {
339 name: name.to_string(),
340 version,
341 source,
342 dev,
343 }
344 }
345 _ => Dependency {
346 name: name.to_string(),
347 version: None,
348 source: DependencySource::Registry("crates.io".to_string()),
349 dev,
350 },
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 #[test]
359 fn test_parse_cargo_toml() {
360 let temp = tempfile::tempdir().unwrap();
361 std::fs::write(
362 temp.path().join("Cargo.toml"),
363 "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\ntokio = { version = \"1\", features = [\"full\"] }\nlocal = { path = \"../local\" }\n\n[dev-dependencies]\ntempfile = \"3\"\n",
364 ).unwrap();
365
366 let module = DependencyModule::new(temp.path().to_path_buf());
367 let manifests = module.list().unwrap();
368 assert_eq!(manifests.len(), 1);
369 let m = &manifests[0];
370 let deps_by_name: std::collections::HashMap<&str, &Dependency> = m
371 .dependencies
372 .iter()
373 .map(|d| (d.name.as_str(), d))
374 .collect();
375 assert_eq!(m.dependencies.len(), 3);
376 assert_eq!(m.dev_dependencies.len(), 1);
377 assert_eq!(deps_by_name["serde"].version.as_deref(), Some("1.0"));
378 assert!(matches!(
379 deps_by_name["tokio"].source,
380 DependencySource::Registry(_)
381 ));
382 assert!(matches!(
383 deps_by_name["local"].source,
384 DependencySource::Path(_)
385 ));
386 assert_eq!(m.dev_dependencies[0].name, "tempfile");
387 assert!(m.dev_dependencies[0].dev);
388 }
389
390 #[test]
391 fn test_parse_package_json() {
392 let temp = tempfile::tempdir().unwrap();
393 std::fs::write(
394 temp.path().join("package.json"),
395 "{\"dependencies\": {\"express\": \"^4.18.0\"}, \"devDependencies\": {\"jest\": \"^29.0.0\"}}",
396 ).unwrap();
397
398 let module = DependencyModule::new(temp.path().to_path_buf());
399 let manifests = module.list().unwrap();
400 assert_eq!(manifests.len(), 1);
401 let m = &manifests[0];
402 assert_eq!(m.dependencies.len(), 1);
403 assert_eq!(m.dependencies[0].name, "express");
404 assert_eq!(m.dev_dependencies[0].name, "jest");
405 assert!(m.dev_dependencies[0].dev);
406 }
407
408 #[test]
409 fn test_parse_go_mod() {
410 let temp = tempfile::tempdir().unwrap();
411 std::fs::write(
412 temp.path().join("go.mod"),
413 "module example.com/m\n\ngo 1.21\n\nrequire (\n\tfmt \"fmt\"\n\tstrings \"strings\"\n)\n",
414 ).unwrap();
415
416 let module = DependencyModule::new(temp.path().to_path_buf());
417 let manifests = module.list().unwrap();
418 assert_eq!(manifests.len(), 1);
419 assert_eq!(manifests[0].dependencies.len(), 2);
420 }
421
422 #[test]
423 fn test_add_cargo_dep() {
424 let temp = tempfile::tempdir().unwrap();
425 std::fs::write(
426 temp.path().join("Cargo.toml"),
427 "[package]\nname = \"test\"\n\n[dependencies]\n",
428 )
429 .unwrap();
430
431 let module = DependencyModule::new(temp.path().to_path_buf());
432 module.add("serde", Some("1.0"), false).unwrap();
433
434 let content = std::fs::read_to_string(temp.path().join("Cargo.toml")).unwrap();
435 assert!(content.contains("serde = \"1.0\""));
436 }
437
438 #[test]
439 fn test_add_cargo_dev_dep() {
440 let temp = tempfile::tempdir().unwrap();
441 std::fs::write(
442 temp.path().join("Cargo.toml"),
443 "[package]\nname = \"test\"\n\n[dependencies]\n",
444 )
445 .unwrap();
446
447 let module = DependencyModule::new(temp.path().to_path_buf());
448 module.add("tempfile", Some("3"), true).unwrap();
449
450 let content = std::fs::read_to_string(temp.path().join("Cargo.toml")).unwrap();
451 assert!(content.contains("[dev-dependencies]"));
452 assert!(content.contains("tempfile = \"3\""));
453 }
454
455 #[test]
456 fn test_add_npm_dep() {
457 let temp = tempfile::tempdir().unwrap();
458 std::fs::write(temp.path().join("package.json"), "{\"name\": \"test\"}").unwrap();
459
460 let module = DependencyModule::new(temp.path().to_path_buf());
461 module.add("express", Some("^4.18"), false).unwrap();
462
463 let content = std::fs::read_to_string(temp.path().join("package.json")).unwrap();
464 let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
465 assert_eq!(doc["dependencies"]["express"], "^4.18");
466 }
467
468 #[test]
469 fn test_remove_cargo_dep() {
470 let temp = tempfile::tempdir().unwrap();
471 std::fs::write(
472 temp.path().join("Cargo.toml"),
473 "[package]\nname = \"test\"\n\n[dependencies]\nserde = \"1.0\"\ntokio = \"1\"\n",
474 )
475 .unwrap();
476
477 let module = DependencyModule::new(temp.path().to_path_buf());
478 module.remove("serde").unwrap();
479
480 let content = std::fs::read_to_string(temp.path().join("Cargo.toml")).unwrap();
481 assert!(!content.contains("serde"));
482 assert!(content.contains("tokio"));
483 }
484
485 #[test]
486 fn test_remove_npm_dep() {
487 let temp = tempfile::tempdir().unwrap();
488 std::fs::write(
489 temp.path().join("package.json"),
490 "{\"dependencies\": {\"express\": \"^4.18\", \"lodash\": \"^4.0\"}}",
491 )
492 .unwrap();
493
494 let module = DependencyModule::new(temp.path().to_path_buf());
495 module.remove("express").unwrap();
496
497 let content = std::fs::read_to_string(temp.path().join("package.json")).unwrap();
498 let doc: serde_json::Value = serde_json::from_str(&content).unwrap();
499 assert!(doc["dependencies"]["express"].is_null());
500 assert_eq!(doc["dependencies"]["lodash"], "^4.0");
501 }
502
503 #[test]
504 fn test_list_no_manifests() {
505 let temp = tempfile::tempdir().unwrap();
506 let module = DependencyModule::new(temp.path().to_path_buf());
507 let manifests = module.list().unwrap();
508 assert!(manifests.is_empty());
509 }
510
511 #[test]
512 fn test_add_no_manifest_error() {
513 let temp = tempfile::tempdir().unwrap();
514 let module = DependencyModule::new(temp.path().to_path_buf());
515 let result = module.add("foo", Some("1.0"), false);
516 assert!(result.is_err());
517 }
518}