Skip to main content

changepacks_cli/commands/
update.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4};
5
6use anyhow::Result;
7use changepacks_core::{
8    ChangePackResultLog, Language, Package, Project, ProjectFinder, UpdateType, Workspace,
9};
10use changepacks_utils::{
11    apply_reverse_dependencies, clear_update_logs, display_update, find_project_dirs,
12    gen_changepack_result_map, gen_update_map, get_changepacks_dir, get_relative_path,
13};
14use clap::Args;
15
16use crate::{
17    CommandContext,
18    finders::get_finders,
19    options::{CliLanguage, FormatOptions},
20    prompter::{InquirePrompter, Prompter},
21};
22
23type UpdateProjectMut<'a> = (&'a mut Project, UpdateType);
24type WorkspaceRef<'a> = &'a dyn Workspace;
25
26#[derive(Args, Debug)]
27#[command(about = "Check project status")]
28pub struct UpdateArgs {
29    #[arg(short, long)]
30    pub dry_run: bool,
31
32    #[arg(short, long)]
33    pub yes: bool,
34
35    #[arg(long, default_value = "stdout")]
36    pub format: FormatOptions,
37
38    #[arg(short, long, default_value = "false")]
39    pub remote: bool,
40
41    /// Filter projects by language. Can be specified multiple times to include multiple languages.
42    #[arg(short, long, value_enum)]
43    pub language: Vec<CliLanguage>,
44}
45
46/// Update project version
47///
48/// # Errors
49/// Returns error if command context creation or version update fails.
50pub async fn handle_update(args: &UpdateArgs) -> Result<()> {
51    handle_update_with_prompter(args, &InquirePrompter).await
52}
53
54/// # Errors
55/// Returns error if reading changepack logs, updating versions, or writing results fails.
56pub async fn handle_update_with_prompter(args: &UpdateArgs, prompter: &dyn Prompter) -> Result<()> {
57    let ctx = CommandContext::new(args.remote).await?;
58    let changepacks_dir = get_changepacks_dir(&CommandContext::current_dir()?)?;
59    let mut update_map = gen_update_map(&CommandContext::current_dir()?, &ctx.config).await?;
60
61    let mut project_finders = ctx.project_finders;
62    let mut all_finders = get_finders();
63
64    // Need a second git repo reference for the all_finders, but since CommandContext already called find_project_dirs
65    // we use an empty config for all_finders which won't filter anything
66    let current_dir = CommandContext::current_dir()?;
67    let repo = changepacks_utils::find_current_git_repo(&current_dir)?;
68    find_project_dirs(
69        &repo,
70        &mut all_finders,
71        &changepacks_core::Config::default(),
72        args.remote,
73    )
74    .await?;
75
76    // Apply reverse dependency updates (workspace:* dependencies)
77    let all_projects: Vec<&Project> = all_finders
78        .iter()
79        .flat_map(|finder| finder.projects())
80        .collect();
81    apply_reverse_dependencies(&mut update_map, &all_projects, &ctx.repo_root_path);
82
83    // Merge workspace-inherited package updates into workspace entries
84    merge_workspace_inherited_updates(&mut update_map, &all_finders, &ctx.repo_root_path);
85
86    if update_map.is_empty() {
87        args.format.print("No updates found", "{}");
88        return Ok(());
89    }
90
91    if let FormatOptions::Stdout = args.format {
92        println!("Updates found:");
93    }
94
95    // Filter update_map by language if specified
96    if !args.language.is_empty() {
97        let allowed_languages: Vec<Language> = args
98            .language
99            .iter()
100            .map(|&lang| Language::from(lang))
101            .collect();
102        let all_projects_for_filter: Vec<&Project> = project_finders
103            .iter()
104            .flat_map(|finder| finder.projects())
105            .collect();
106        update_map.retain(|path, _| {
107            all_projects_for_filter.iter().any(|p| {
108                get_relative_path(&ctx.repo_root_path, p.path()).is_ok_and(|rel| &rel == path)
109                    && allowed_languages.contains(&p.language())
110            })
111        });
112    }
113
114    let (mut update_projects, workspace_projects) = collect_update_projects(
115        &mut project_finders,
116        &all_finders,
117        &update_map,
118        &ctx.repo_root_path,
119    )?;
120
121    if let FormatOptions::Stdout = args.format {
122        for (project, update_type) in &update_projects {
123            println!(
124                "{} {}",
125                project,
126                display_update(project.version(), *update_type)?
127            );
128        }
129    }
130
131    if args.dry_run {
132        args.format.print("Dry run, no updates will be made", "{}");
133        return Ok(());
134    }
135
136    // confirm
137    let confirm = if args.yes {
138        true
139    } else {
140        prompter.confirm("Are you sure you want to update the projects?")?
141    };
142
143    if !confirm {
144        args.format.print("Update cancelled", "{}");
145        return Ok(());
146    }
147
148    apply_updates(&mut update_projects, &workspace_projects).await?;
149    drop(update_projects);
150
151    if let FormatOptions::Json = args.format {
152        println!(
153            "{}",
154            serde_json::to_string_pretty(&gen_changepack_result_map(
155                project_finders
156                    .iter()
157                    .flat_map(|finder| finder.projects())
158                    .collect::<Vec<_>>()
159                    .as_slice(),
160                &ctx.repo_root_path,
161                &mut update_map,
162            )?)?
163        );
164    }
165
166    // Clear files
167    clear_update_logs(&changepacks_dir).await?;
168
169    Ok(())
170}
171
172fn collect_update_projects<'a>(
173    project_finders: &'a mut [Box<dyn ProjectFinder>],
174    all_finders: &'a [Box<dyn ProjectFinder>],
175    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
176    repo_root_path: &Path,
177) -> Result<(Vec<UpdateProjectMut<'a>>, Vec<WorkspaceRef<'a>>)> {
178    let mut update_projects = Vec::new();
179    let mut workspace_projects = Vec::new();
180
181    for finder in project_finders {
182        for project in finder.projects_mut() {
183            if let Some((update_type, _)) =
184                update_map.get(&get_relative_path(repo_root_path, project.path())?)
185            {
186                update_projects.push((project, *update_type));
187            }
188        }
189    }
190
191    for finder in all_finders {
192        for project in finder.projects() {
193            if let Project::Workspace(workspace) = project {
194                workspace_projects.push(workspace.as_ref());
195            }
196        }
197    }
198
199    update_projects.sort();
200    Ok((update_projects, workspace_projects))
201}
202
203async fn apply_updates(
204    update_projects: &mut [UpdateProjectMut<'_>],
205    workspace_projects: &[WorkspaceRef<'_>],
206) -> Result<()> {
207    futures::future::join_all(
208        update_projects
209            .iter_mut()
210            .map(|(project, update_type)| project.update_version(*update_type)),
211    )
212    .await
213    .into_iter()
214    .collect::<Result<Vec<_>>>()?;
215
216    let projects: Vec<&dyn Package> = update_projects
217        .iter()
218        .filter_map(|(project, _)| {
219            if let Project::Package(package) = project {
220                Some(package.as_ref())
221            } else {
222                None
223            }
224        })
225        .collect();
226
227    futures::future::join_all(
228        workspace_projects
229            .iter()
230            .map(|workspace| workspace.update_workspace_dependencies(&projects)),
231    )
232    .await
233    .into_iter()
234    .collect::<Result<Vec<_>>>()?;
235
236    Ok(())
237}
238
239/// Merge workspace-inherited package updates into workspace entries.
240/// Packages with `version.workspace = true` should have their bumps promoted
241/// to the workspace level (most significant bump wins). The packages are then
242/// removed from the update map since their Cargo.toml doesn't need changes.
243fn merge_workspace_inherited_updates(
244    update_map: &mut HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
245    project_finders: &[Box<dyn ProjectFinder>],
246    repo_root_path: &Path,
247) {
248    // Collect (pkg_rel_path, ws_rel_path) pairs to merge
249    let mut merge_targets: Vec<(PathBuf, PathBuf)> = Vec::new();
250
251    for finder in project_finders {
252        for project in finder.projects() {
253            if let Project::Package(pkg) = project
254                && pkg.inherits_workspace_version()
255                && let Ok(rel_path) = get_relative_path(repo_root_path, pkg.path())
256                && update_map.contains_key(&rel_path)
257                && let Some(ws_root) = pkg.workspace_root_path()
258                && let Ok(ws_rel_path) = get_relative_path(repo_root_path, ws_root)
259            {
260                merge_targets.push((rel_path, ws_rel_path));
261            }
262        }
263    }
264
265    for (pkg_path, ws_path) in merge_targets {
266        // Remove takes ownership, avoiding Clone requirement
267        if let Some((update_type, logs)) = update_map.remove(&pkg_path) {
268            let ws_entry = update_map
269                .entry(ws_path)
270                .or_insert((UpdateType::Patch, vec![]));
271            // More significant bump wins (Major=0 < Minor=1 < Patch=2)
272            if update_type < ws_entry.0 {
273                ws_entry.0 = update_type;
274            }
275            ws_entry.1.extend(logs);
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{UpdateArgs, merge_workspace_inherited_updates};
283    use anyhow::Result;
284    use async_trait::async_trait;
285    use changepacks_core::{
286        ChangePackResultLog, Language, Package, Project, ProjectFinder, UpdateType,
287    };
288    use clap::Parser;
289    use std::{
290        collections::{HashMap, HashSet},
291        path::{Path, PathBuf},
292    };
293
294    use crate::options::FormatOptions;
295
296    #[derive(Parser)]
297    struct TestCli {
298        #[command(flatten)]
299        update: UpdateArgs,
300    }
301
302    #[derive(Debug)]
303    struct MockInheritPackage {
304        name: Option<String>,
305        version: Option<String>,
306        path: PathBuf,
307        relative_path: PathBuf,
308        language: Language,
309        dependencies: HashSet<String>,
310        changed: bool,
311        inherits_ws_version: bool,
312        workspace_root: Option<PathBuf>,
313    }
314
315    impl MockInheritPackage {
316        fn new(
317            path: &str,
318            relative_path: &str,
319            inherits_ws_version: bool,
320            workspace_root: Option<&str>,
321        ) -> Self {
322            Self {
323                name: Some("mock-package".to_string()),
324                version: Some("1.0.0".to_string()),
325                path: PathBuf::from(path),
326                relative_path: PathBuf::from(relative_path),
327                language: Language::Rust,
328                dependencies: HashSet::new(),
329                changed: false,
330                inherits_ws_version,
331                workspace_root: workspace_root.map(PathBuf::from),
332            }
333        }
334    }
335
336    #[async_trait]
337    impl Package for MockInheritPackage {
338        fn name(&self) -> Option<&str> {
339            self.name.as_deref()
340        }
341
342        fn version(&self) -> Option<&str> {
343            self.version.as_deref()
344        }
345
346        fn path(&self) -> &Path {
347            &self.path
348        }
349
350        fn relative_path(&self) -> &Path {
351            &self.relative_path
352        }
353
354        async fn update_version(&mut self, _update_type: UpdateType) -> Result<()> {
355            Ok(())
356        }
357
358        fn is_changed(&self) -> bool {
359            self.changed
360        }
361
362        fn language(&self) -> Language {
363            self.language
364        }
365
366        fn dependencies(&self) -> &HashSet<String> {
367            &self.dependencies
368        }
369
370        fn add_dependency(&mut self, dep: &str) {
371            self.dependencies.insert(dep.to_string());
372        }
373
374        fn set_changed(&mut self, changed: bool) {
375            self.changed = changed;
376        }
377
378        fn default_publish_command(&self) -> String {
379            "echo publish".to_string()
380        }
381
382        fn inherits_workspace_version(&self) -> bool {
383            self.inherits_ws_version
384        }
385
386        fn workspace_root_path(&self) -> Option<&Path> {
387            self.workspace_root.as_deref()
388        }
389    }
390
391    #[derive(Debug)]
392    struct MockFinder {
393        projects: Vec<Project>,
394    }
395
396    impl MockFinder {
397        fn new(projects: Vec<Project>) -> Self {
398            Self { projects }
399        }
400    }
401
402    #[async_trait]
403    impl ProjectFinder for MockFinder {
404        fn projects(&self) -> Vec<&Project> {
405            self.projects.iter().collect()
406        }
407
408        fn projects_mut(&mut self) -> Vec<&mut Project> {
409            self.projects.iter_mut().collect()
410        }
411
412        fn project_files(&self) -> &[&str] {
413            &["Cargo.toml"]
414        }
415
416        async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
417            Ok(())
418        }
419    }
420
421    fn mock_package_project(
422        path: &str,
423        relative_path: &str,
424        inherits_ws_version: bool,
425        workspace_root: Option<&str>,
426    ) -> Project {
427        Project::Package(Box::new(MockInheritPackage::new(
428            path,
429            relative_path,
430            inherits_ws_version,
431            workspace_root,
432        )))
433    }
434
435    fn mock_log(note: &str) -> ChangePackResultLog {
436        ChangePackResultLog::new(UpdateType::Patch, note.to_string())
437    }
438
439    fn summarize_update_map(
440        update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
441    ) -> HashMap<PathBuf, (UpdateType, usize)> {
442        update_map
443            .iter()
444            .map(|(path, (update_type, logs))| (path.clone(), (*update_type, logs.len())))
445            .collect()
446    }
447
448    #[test]
449    fn test_merge_workspace_inherited_updates_no_inherited_packages() {
450        let repo_root = Path::new("/repo");
451        let pkg_rel_path = PathBuf::from("crates/foo/Cargo.toml");
452        let mut update_map = HashMap::from([(
453            pkg_rel_path.clone(),
454            (UpdateType::Minor, vec![mock_log("pkg update")]),
455        )]);
456
457        let project_finders: Vec<Box<dyn ProjectFinder>> =
458            vec![Box::new(MockFinder::new(vec![mock_package_project(
459                "/repo/crates/foo/Cargo.toml",
460                "crates/foo/Cargo.toml",
461                false,
462                Some("/repo/Cargo.toml"),
463            )]))];
464
465        let before = summarize_update_map(&update_map);
466        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
467
468        assert_eq!(summarize_update_map(&update_map), before);
469        assert!(update_map.contains_key(&pkg_rel_path));
470    }
471
472    #[test]
473    fn test_merge_workspace_inherited_updates_basic_merge() {
474        let repo_root = Path::new("/repo");
475        let pkg_rel_path = PathBuf::from("crates/foo/Cargo.toml");
476        let ws_rel_path = PathBuf::from("Cargo.toml");
477        let mut update_map = HashMap::from([(
478            pkg_rel_path.clone(),
479            (UpdateType::Minor, vec![mock_log("pkg update")]),
480        )]);
481
482        let project_finders: Vec<Box<dyn ProjectFinder>> =
483            vec![Box::new(MockFinder::new(vec![mock_package_project(
484                "/repo/crates/foo/Cargo.toml",
485                "crates/foo/Cargo.toml",
486                true,
487                Some("/repo/Cargo.toml"),
488            )]))];
489
490        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
491
492        assert!(!update_map.contains_key(&pkg_rel_path));
493        let (update_type, logs) = update_map
494            .get(&ws_rel_path)
495            .expect("workspace entry should exist");
496        assert_eq!(*update_type, UpdateType::Minor);
497        assert_eq!(logs.len(), 1);
498    }
499
500    #[test]
501    fn test_merge_workspace_inherited_updates_most_significant_bump_wins() {
502        let repo_root = Path::new("/repo");
503        let pkg1_rel_path = PathBuf::from("crates/foo/Cargo.toml");
504        let pkg2_rel_path = PathBuf::from("crates/bar/Cargo.toml");
505        let ws_rel_path = PathBuf::from("Cargo.toml");
506        let mut update_map = HashMap::from([
507            (
508                pkg1_rel_path.clone(),
509                (UpdateType::Minor, vec![mock_log("foo update")]),
510            ),
511            (
512                pkg2_rel_path.clone(),
513                (UpdateType::Major, vec![mock_log("bar update")]),
514            ),
515        ]);
516
517        let project_finders: Vec<Box<dyn ProjectFinder>> = vec![Box::new(MockFinder::new(vec![
518            mock_package_project(
519                "/repo/crates/foo/Cargo.toml",
520                "crates/foo/Cargo.toml",
521                true,
522                Some("/repo/Cargo.toml"),
523            ),
524            mock_package_project(
525                "/repo/crates/bar/Cargo.toml",
526                "crates/bar/Cargo.toml",
527                true,
528                Some("/repo/Cargo.toml"),
529            ),
530        ]))];
531
532        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
533
534        assert!(!update_map.contains_key(&pkg1_rel_path));
535        assert!(!update_map.contains_key(&pkg2_rel_path));
536        let (update_type, logs) = update_map
537            .get(&ws_rel_path)
538            .expect("workspace entry should exist");
539        assert_eq!(*update_type, UpdateType::Major);
540        assert_eq!(logs.len(), 2);
541    }
542
543    #[test]
544    fn test_merge_workspace_inherited_updates_package_not_in_update_map() {
545        let repo_root = Path::new("/repo");
546        let mut update_map = HashMap::from([(
547            PathBuf::from("crates/bar/Cargo.toml"),
548            (UpdateType::Patch, vec![mock_log("bar update")]),
549        )]);
550
551        let project_finders: Vec<Box<dyn ProjectFinder>> =
552            vec![Box::new(MockFinder::new(vec![mock_package_project(
553                "/repo/crates/foo/Cargo.toml",
554                "crates/foo/Cargo.toml",
555                true,
556                Some("/repo/Cargo.toml"),
557            )]))];
558
559        let before = summarize_update_map(&update_map);
560        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
561
562        assert_eq!(summarize_update_map(&update_map), before);
563        assert!(!update_map.contains_key(&PathBuf::from("Cargo.toml")));
564    }
565
566    #[test]
567    fn test_merge_workspace_inherited_updates_workspace_already_in_update_map() {
568        let repo_root = Path::new("/repo");
569        let pkg_rel_path = PathBuf::from("crates/foo/Cargo.toml");
570        let ws_rel_path = PathBuf::from("Cargo.toml");
571        let mut update_map = HashMap::from([
572            (
573                pkg_rel_path.clone(),
574                (UpdateType::Major, vec![mock_log("foo update")]),
575            ),
576            (
577                ws_rel_path.clone(),
578                (UpdateType::Minor, vec![mock_log("workspace update")]),
579            ),
580        ]);
581
582        let project_finders: Vec<Box<dyn ProjectFinder>> =
583            vec![Box::new(MockFinder::new(vec![mock_package_project(
584                "/repo/crates/foo/Cargo.toml",
585                "crates/foo/Cargo.toml",
586                true,
587                Some("/repo/Cargo.toml"),
588            )]))];
589
590        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
591
592        assert!(!update_map.contains_key(&pkg_rel_path));
593        let (update_type, logs) = update_map
594            .get(&ws_rel_path)
595            .expect("workspace entry should exist");
596        assert_eq!(*update_type, UpdateType::Major);
597        assert_eq!(logs.len(), 2);
598    }
599
600    #[test]
601    fn test_merge_workspace_inherited_updates_logs_accumulated() {
602        let repo_root = Path::new("/repo");
603        let pkg1_rel_path = PathBuf::from("crates/foo/Cargo.toml");
604        let pkg2_rel_path = PathBuf::from("crates/bar/Cargo.toml");
605        let ws_rel_path = PathBuf::from("Cargo.toml");
606        let mut update_map = HashMap::from([
607            (
608                pkg1_rel_path.clone(),
609                (
610                    UpdateType::Patch,
611                    vec![mock_log("foo update 1"), mock_log("foo update 2")],
612                ),
613            ),
614            (
615                pkg2_rel_path.clone(),
616                (UpdateType::Patch, vec![mock_log("bar update")]),
617            ),
618        ]);
619
620        let project_finders: Vec<Box<dyn ProjectFinder>> = vec![Box::new(MockFinder::new(vec![
621            mock_package_project(
622                "/repo/crates/foo/Cargo.toml",
623                "crates/foo/Cargo.toml",
624                true,
625                Some("/repo/Cargo.toml"),
626            ),
627            mock_package_project(
628                "/repo/crates/bar/Cargo.toml",
629                "crates/bar/Cargo.toml",
630                true,
631                Some("/repo/Cargo.toml"),
632            ),
633        ]))];
634
635        merge_workspace_inherited_updates(&mut update_map, &project_finders, repo_root);
636
637        assert!(!update_map.contains_key(&pkg1_rel_path));
638        assert!(!update_map.contains_key(&pkg2_rel_path));
639        let (update_type, logs) = update_map
640            .get(&ws_rel_path)
641            .expect("workspace entry should exist");
642        assert_eq!(*update_type, UpdateType::Patch);
643        assert_eq!(logs.len(), 3);
644    }
645
646    #[test]
647    fn test_update_args_default() {
648        let cli = TestCli::parse_from(["test"]);
649        assert!(!cli.update.dry_run);
650        assert!(!cli.update.yes);
651        assert!(matches!(cli.update.format, FormatOptions::Stdout));
652        assert!(!cli.update.remote);
653    }
654
655    #[test]
656    fn test_update_args_with_dry_run() {
657        let cli = TestCli::parse_from(["test", "--dry-run"]);
658        assert!(cli.update.dry_run);
659    }
660
661    #[test]
662    fn test_update_args_with_yes() {
663        let cli = TestCli::parse_from(["test", "--yes"]);
664        assert!(cli.update.yes);
665    }
666
667    #[test]
668    fn test_update_args_with_format_json() {
669        let cli = TestCli::parse_from(["test", "--format", "json"]);
670        assert!(matches!(cli.update.format, FormatOptions::Json));
671    }
672
673    #[test]
674    fn test_update_args_with_remote() {
675        let cli = TestCli::parse_from(["test", "--remote"]);
676        assert!(cli.update.remote);
677    }
678
679    #[test]
680    fn test_update_args_combined() {
681        let cli =
682            TestCli::parse_from(["test", "--dry-run", "--yes", "--format", "json", "--remote"]);
683        assert!(cli.update.dry_run);
684        assert!(cli.update.yes);
685        assert!(matches!(cli.update.format, FormatOptions::Json));
686        assert!(cli.update.remote);
687    }
688
689    #[test]
690    fn test_update_args_short_dry_run() {
691        let cli = TestCli::parse_from(["test", "-d"]);
692        assert!(cli.update.dry_run);
693    }
694
695    #[test]
696    fn test_update_args_short_yes() {
697        let cli = TestCli::parse_from(["test", "-y"]);
698        assert!(cli.update.yes);
699    }
700
701    #[test]
702    fn test_update_args_short_remote() {
703        let cli = TestCli::parse_from(["test", "-r"]);
704        assert!(cli.update.remote);
705    }
706
707    #[test]
708    fn test_update_args_all_short_flags() {
709        let cli = TestCli::parse_from(["test", "-d", "-y", "-r"]);
710        assert!(cli.update.dry_run);
711        assert!(cli.update.yes);
712        assert!(cli.update.remote);
713    }
714
715    #[test]
716    fn test_update_args_with_language_filter() {
717        let cli = TestCli::parse_from(["test", "--language", "node"]);
718        assert_eq!(cli.update.language.len(), 1);
719    }
720
721    #[test]
722    fn test_update_args_with_multiple_languages() {
723        let cli = TestCli::parse_from(["test", "--language", "node", "--language", "python"]);
724        assert_eq!(cli.update.language.len(), 2);
725    }
726
727    #[test]
728    fn test_update_args_short_language() {
729        let cli = TestCli::parse_from(["test", "-l", "rust"]);
730        assert_eq!(cli.update.language.len(), 1);
731    }
732}