cargo_autodd/dependency_manager/
updater.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::BufReader;
4use std::path::PathBuf;
5use std::process::Command;
6
7use anyhow::{Context, Result};
8use semver::Version;
9use serde::Deserialize;
10use serde_json;
11use toml_edit::{DocumentMut, Item, Table};
12use ureq;
13
14use crate::models::CrateReference;
15use crate::utils::is_essential_dep;
16
17#[derive(Deserialize)]
18struct CratesIoResponse {
19    versions: Vec<CrateVersion>,
20}
21
22#[derive(Deserialize)]
23struct CrateVersion {
24    num: String,
25    yanked: bool,
26}
27
28pub struct DependencyUpdater {
29    project_root: PathBuf,
30    cargo_toml: PathBuf,
31    debug: bool,
32}
33
34impl DependencyUpdater {
35    pub fn new(project_root: PathBuf) -> Self {
36        let cargo_toml = project_root.join("Cargo.toml");
37        Self {
38            project_root,
39            cargo_toml,
40            debug: false,
41        }
42    }
43
44    pub fn with_debug(project_root: PathBuf, debug: bool) -> Self {
45        let cargo_toml = project_root.join("Cargo.toml");
46        Self {
47            project_root,
48            cargo_toml,
49            debug,
50        }
51    }
52
53    pub fn update_cargo_toml(&self, crate_refs: &HashMap<String, CrateReference>) -> Result<()> {
54        let content = fs::read_to_string(&self.cargo_toml)?;
55        let mut doc = content.parse::<DocumentMut>()?;
56
57        // Check if this is a workspace or a package
58        let is_workspace = doc.get("workspace").is_some();
59        if is_workspace && doc.get("package").is_none() {
60            if self.debug {
61                println!("This is a workspace root without a package. Skipping dependency update.");
62            }
63            return Ok(());
64        }
65
66        // Separate regular dependencies and dev-dependencies
67        let (regular_deps, dev_deps): (HashMap<_, _>, HashMap<_, _>) = crate_refs
68            .iter()
69            .partition(|(_, crate_ref)| !crate_ref.is_dev_dependency);
70
71        // Get the dependencies path
72        let deps_path = self.get_dependencies_path()?;
73        let dev_deps_path = "dev-dependencies".to_string();
74
75        // Update regular dependencies
76        self.update_dependency_section(&mut doc, &regular_deps, &deps_path)?;
77
78        // Update dev-dependencies (only if not a workspace with shared deps)
79        if !is_workspace {
80            self.update_dependency_section(&mut doc, &dev_deps, &dev_deps_path)?;
81        }
82
83        // Write back to Cargo.toml
84        fs::write(&self.cargo_toml, doc.to_string())?;
85
86        Ok(())
87    }
88
89    fn update_dependency_section(
90        &self,
91        doc: &mut DocumentMut,
92        deps_map: &HashMap<&String, &CrateReference>,
93        deps_path: &str,
94    ) -> Result<()> {
95        // Get existing dependencies
96        let existing_deps = if let Some(deps) = doc.get(deps_path) {
97            if let Some(table) = deps.as_table() {
98                table
99                    .iter()
100                    .map(|(k, _)| k.to_string())
101                    .collect::<HashSet<_>>()
102            } else {
103                HashSet::new()
104            }
105        } else {
106            HashSet::new()
107        };
108
109        // Add new dependencies
110        for crate_ref in deps_map.values() {
111            if !existing_deps.contains(&crate_ref.name) {
112                self.add_dependency(doc, crate_ref, deps_path)?;
113            }
114        }
115
116        // Remove unused dependencies
117        let used_deps = deps_map
118            .keys()
119            .map(|k| (*k).clone())
120            .collect::<HashSet<_>>();
121        let to_remove = existing_deps
122            .iter()
123            .filter(|dep| !used_deps.contains(*dep) && !is_essential_dep(dep))
124            .cloned()
125            .collect::<Vec<_>>();
126
127        for dep in to_remove {
128            self.remove_dependency(doc, &dep, deps_path)?;
129        }
130
131        Ok(())
132    }
133
134    fn add_dependency(
135        &self,
136        doc: &mut DocumentMut,
137        crate_ref: &CrateReference,
138        deps_path: &str,
139    ) -> Result<()> {
140        // For internal crates (path dependencies), add without searching on crates.io
141        if crate_ref.is_path_dependency
142            && let Some(path) = &crate_ref.path
143        {
144            if self.debug {
145                println!(
146                    "Adding path dependency: {} with path {}",
147                    crate_ref.name, path
148                );
149            }
150
151            // Get or create the dependencies table
152            let deps = doc
153                .entry(deps_path)
154                .or_insert(toml_edit::table())
155                .as_table_mut()
156                .ok_or_else(|| anyhow::anyhow!("Failed to get dependencies table"))?;
157
158            // Add internal crate as path dependency
159            let mut table = Table::new();
160            table["path"] = toml_edit::value(path.clone());
161
162            // Add publish setting if available
163            if let Some(publish) = crate_ref.publish {
164                table["publish"] = toml_edit::value(publish);
165            }
166
167            deps[&crate_ref.name] = toml_edit::Item::Table(table);
168            return Ok(());
169        }
170
171        // For regular dependencies, get the latest version from crates.io
172        let version = match self.get_latest_version(&crate_ref.name) {
173            Ok(v) => v,
174            Err(e) => {
175                // If not found on crates.io, it might be an internal crate, so continue with a warning
176                if self.debug {
177                    println!(
178                        "Warning: Failed to get version for {}: {}",
179                        crate_ref.name, e
180                    );
181                    println!("This might be an internal crate not published on crates.io.");
182                    println!("Skipping this dependency.");
183                }
184                return Ok(());
185            }
186        };
187
188        if self.debug {
189            println!("Adding dependency: {} = \"{}\"", crate_ref.name, version);
190        }
191
192        // Get or create the dependencies table
193        let deps = doc
194            .entry(deps_path)
195            .or_insert(toml_edit::table())
196            .as_table_mut()
197            .ok_or_else(|| anyhow::anyhow!("Failed to get dependencies table"))?;
198
199        // Add the dependency
200        deps[&crate_ref.name] = toml_edit::value(version);
201
202        Ok(())
203    }
204
205    fn remove_dependency(&self, doc: &mut DocumentMut, name: &str, deps_path: &str) -> Result<()> {
206        if deps_path.contains('.') {
207            // Handle nested table path like "workspace.dependencies"
208            let parts: Vec<&str> = deps_path.split('.').collect();
209            if let Some(Item::Table(parent)) = doc.get_mut(parts[0])
210                && let Some(Item::Table(deps)) = parent.get_mut(parts[1])
211            {
212                deps.remove(name);
213            }
214        } else if let Some(Item::Table(deps)) = doc.get_mut(deps_path) {
215            deps.remove(name);
216        }
217        Ok(())
218    }
219
220    pub fn get_latest_version(&self, crate_name: &str) -> Result<String> {
221        // Return an error for internal crates
222        if crate_name.contains('-') && crate_name.replace('-', "_") != crate_name {
223            let normalized_name = crate_name.replace('-', "_");
224            if self.debug {
225                println!(
226                    "Checking if {} is an internal crate (normalized: {})",
227                    crate_name, normalized_name
228                );
229            }
230
231            // Check if it's an internal crate by reading Cargo.toml
232            let workspace_root = self.find_workspace_root()?;
233            let workspace_cargo_toml = workspace_root.join("Cargo.toml");
234
235            if workspace_cargo_toml.exists() {
236                let content = fs::read_to_string(&workspace_cargo_toml)?;
237                if content.contains(&format!("name = \"{}\"", crate_name))
238                    || content.contains(&format!("name = \"{}\"", normalized_name))
239                {
240                    if self.debug {
241                        println!(
242                            "{} appears to be an internal crate in the workspace",
243                            crate_name
244                        );
245                    }
246                    return Err(anyhow::anyhow!("Internal crate not published on crates.io"));
247                }
248            }
249        }
250
251        // Get the latest version from crates.io
252        let url = format!("https://crates.io/api/v1/crates/{}", crate_name);
253        let response = ureq::get(&url).call();
254
255        match response {
256            Ok(res) => {
257                let reader = BufReader::new(res.into_reader());
258                let crates_io_data: CratesIoResponse = serde_json::from_reader(reader)?;
259
260                // Find the latest non-yanked version
261                let latest_version = crates_io_data
262                    .versions
263                    .iter()
264                    .filter(|v| !v.yanked)
265                    .map(|v| Version::parse(&v.num))
266                    .filter_map(Result::ok)
267                    .max();
268
269                match latest_version {
270                    Some(v) => {
271                        // Include patch version for more accurate updates
272                        Ok(format!("{}.{}.{}", v.major, v.minor, v.patch))
273                    }
274                    None => Err(anyhow::anyhow!(
275                        "No valid versions found for {}",
276                        crate_name
277                    )),
278                }
279            }
280            Err(e) => Err(anyhow::anyhow!("Failed to fetch crate info: {}", e)),
281        }
282    }
283
284    /// Find the workspace root directory
285    fn find_workspace_root(&self) -> Result<PathBuf> {
286        let mut current_dir = self.project_root.clone();
287
288        loop {
289            let cargo_toml = current_dir.join("Cargo.toml");
290            if cargo_toml.exists() {
291                let content = fs::read_to_string(&cargo_toml)?;
292                if content.contains("[workspace]") {
293                    return Ok(current_dir);
294                }
295            }
296
297            if !current_dir.pop() {
298                // If we've reached the root directory, return the current project root
299                return Ok(self.project_root.clone());
300            }
301        }
302    }
303
304    pub fn verify_dependencies(&self) -> Result<()> {
305        Command::new("cargo")
306            .current_dir(&self.project_root)
307            .arg("check")
308            .status()
309            .context("Failed to run cargo check")?;
310        Ok(())
311    }
312
313    pub fn get_dependency_version(&self, dep: &Item) -> Option<String> {
314        match dep {
315            Item::Value(v) => Some(v.as_str()?.to_string()),
316            Item::Table(t) => t
317                .get("version")
318                .and_then(|v| v.as_str())
319                .map(|s| s.to_string()),
320            _ => None,
321        }
322    }
323
324    // New method to detect if the current Cargo.toml is a workspace
325    pub fn is_workspace(&self) -> Result<bool> {
326        let content = fs::read_to_string(&self.cargo_toml)?;
327        let doc = content.parse::<DocumentMut>()?;
328        Ok(doc.get("workspace").is_some())
329    }
330
331    // New method to get dependencies path
332    pub fn get_dependencies_path(&self) -> Result<String> {
333        if self.is_workspace()? {
334            Ok("workspace.dependencies".to_string())
335        } else {
336            Ok("dependencies".to_string())
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::fs::File;
345    use std::io::Write;
346    use tempfile::TempDir;
347
348    fn create_cargo_toml(dir: &TempDir) -> PathBuf {
349        let path = dir.path().join("Cargo.toml");
350        let content = r#"
351[package]
352name = "test-package"
353version = "0.1.0"
354edition = "2021"
355
356[dependencies]
357serde = "1.0"
358tokio = "1.0"
359"#;
360        let mut file = File::create(&path).unwrap();
361        writeln!(file, "{}", content).unwrap();
362        path
363    }
364
365    fn create_workspace_cargo_toml(dir: &TempDir) -> PathBuf {
366        let path = dir.path().join("Cargo.toml");
367        let content = r#"
368[workspace]
369members = ["crate1", "crate2"]
370
371[package]
372name = "workspace-root"
373version = "0.1.0"
374edition = "2021"
375
376[workspace.dependencies]
377serde = "1.0"
378tokio = "1.0"
379"#;
380        let mut file = File::create(&path).unwrap();
381        writeln!(file, "{}", content).unwrap();
382        path
383    }
384
385    #[test]
386    fn test_update_cargo_toml() -> Result<()> {
387        let temp_dir = TempDir::new()?;
388        create_cargo_toml(&temp_dir);
389
390        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
391        let mut crate_refs = HashMap::new();
392
393        // Add a new dependency
394        let mut new_crate = CrateReference::new("regex".to_string());
395        new_crate.add_feature("unicode".to_string());
396        crate_refs.insert("regex".to_string(), new_crate);
397
398        // Add an existing dependency
399        let serde_crate = CrateReference::new("serde".to_string());
400        crate_refs.insert("serde".to_string(), serde_crate);
401
402        updater.update_cargo_toml(&crate_refs)?;
403
404        // Verify the changes
405        let content = fs::read_to_string(updater.cargo_toml)?;
406        assert!(content.contains("regex"));
407        assert!(content.contains("serde"));
408        assert!(!content.contains("unused-dep"));
409
410        Ok(())
411    }
412
413    #[test]
414    fn test_update_workspace_cargo_toml() -> Result<()> {
415        let temp_dir = TempDir::new()?;
416        create_workspace_cargo_toml(&temp_dir);
417
418        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
419        let mut crate_refs = HashMap::new();
420
421        // Add a new dependency
422        let mut new_crate = CrateReference::new("regex".to_string());
423        new_crate.add_feature("unicode".to_string());
424        crate_refs.insert("regex".to_string(), new_crate);
425
426        // Add an existing dependency
427        let serde_crate = CrateReference::new("serde".to_string());
428        crate_refs.insert("serde".to_string(), serde_crate);
429
430        updater.update_cargo_toml(&crate_refs)?;
431
432        // Verify the changes
433        let content = fs::read_to_string(updater.cargo_toml)?;
434        assert!(content.contains("regex"));
435        assert!(content.contains("serde"));
436        assert!(content.contains("[workspace.dependencies]"));
437
438        Ok(())
439    }
440
441    #[test]
442    fn test_is_workspace() -> Result<()> {
443        let temp_dir = TempDir::new()?;
444
445        // Test regular package
446        create_cargo_toml(&temp_dir);
447        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
448        assert!(!updater.is_workspace()?);
449
450        // Test workspace
451        create_workspace_cargo_toml(&temp_dir);
452        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
453        assert!(updater.is_workspace()?);
454
455        Ok(())
456    }
457
458    #[test]
459    fn test_remove_unused_dependency() -> Result<()> {
460        let temp_dir = TempDir::new()?;
461
462        // Create Cargo.toml with multiple dependencies
463        let path = temp_dir.path().join("Cargo.toml");
464        let content = r#"
465[package]
466name = "test-package"
467version = "0.1.0"
468edition = "2021"
469
470[dependencies]
471serde = "1.0"
472tokio = "1.0"
473unused_crate = "0.1"
474another_unused = "0.2"
475"#;
476        let mut file = File::create(&path)?;
477        writeln!(file, "{}", content)?;
478
479        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
480        let mut crate_refs = HashMap::new();
481
482        // Only serde and tokio are used
483        crate_refs.insert(
484            "serde".to_string(),
485            CrateReference::new("serde".to_string()),
486        );
487        crate_refs.insert(
488            "tokio".to_string(),
489            CrateReference::new("tokio".to_string()),
490        );
491
492        updater.update_cargo_toml(&crate_refs)?;
493
494        // Verify unused dependencies are removed
495        let result = fs::read_to_string(&path)?;
496        assert!(result.contains("serde"), "serde should remain");
497        assert!(result.contains("tokio"), "tokio should remain");
498        assert!(
499            !result.contains("unused_crate"),
500            "unused_crate should be removed"
501        );
502        assert!(
503            !result.contains("another_unused"),
504            "another_unused should be removed"
505        );
506
507        Ok(())
508    }
509
510    #[test]
511    fn test_preserve_essential_dependencies() -> Result<()> {
512        let temp_dir = TempDir::new()?;
513
514        // Create Cargo.toml with essential dependencies
515        let path = temp_dir.path().join("Cargo.toml");
516        let content = r#"
517[package]
518name = "test-package"
519version = "0.1.0"
520edition = "2021"
521
522[dependencies]
523serde = "1.0"
524tokio = "1.0"
525anyhow = "1.0"
526thiserror = "1.0"
527unused_crate = "0.1"
528"#;
529        let mut file = File::create(&path)?;
530        writeln!(file, "{}", content)?;
531
532        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
533
534        // Empty crate_refs - nothing is used
535        let crate_refs = HashMap::new();
536
537        updater.update_cargo_toml(&crate_refs)?;
538
539        // Verify essential dependencies are preserved even if not used
540        let result = fs::read_to_string(&path)?;
541        assert!(
542            result.contains("serde"),
543            "serde (essential) should be preserved"
544        );
545        assert!(
546            result.contains("tokio"),
547            "tokio (essential) should be preserved"
548        );
549        assert!(
550            result.contains("anyhow"),
551            "anyhow (essential) should be preserved"
552        );
553        assert!(
554            result.contains("thiserror"),
555            "thiserror (essential) should be preserved"
556        );
557        assert!(
558            !result.contains("unused_crate"),
559            "non-essential unused_crate should be removed"
560        );
561
562        Ok(())
563    }
564
565    #[test]
566    fn test_get_dependency_version() -> Result<()> {
567        let temp_dir = TempDir::new()?;
568        create_cargo_toml(&temp_dir);
569
570        let updater = DependencyUpdater::new(temp_dir.path().to_path_buf());
571
572        // Test simple version string
573        let simple_version = toml_edit::value("1.0.0");
574        assert_eq!(
575            updater.get_dependency_version(&simple_version),
576            Some("1.0.0".to_string())
577        );
578
579        // Test table with version
580        let mut table = toml_edit::Table::new();
581        table["version"] = toml_edit::value("2.0.0");
582        let table_version = toml_edit::Item::Table(table);
583        assert_eq!(
584            updater.get_dependency_version(&table_version),
585            Some("2.0.0".to_string())
586        );
587
588        Ok(())
589    }
590}