Skip to main content

forgekit_core/dependency/
mod.rs

1use 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}