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 #[arg(short, long, value_enum)]
43 pub language: Vec<CliLanguage>,
44}
45
46pub async fn handle_update(args: &UpdateArgs) -> Result<()> {
51 handle_update_with_prompter(args, &InquirePrompter).await
52}
53
54pub 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 let current_dir = CommandContext::current_dir()?;
67 let repo = changepacks_utils::find_current_git_repo(¤t_dir)?;
68 find_project_dirs(
69 &repo,
70 &mut all_finders,
71 &changepacks_core::Config::default(),
72 args.remote,
73 )
74 .await?;
75
76 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_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 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 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_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
239fn 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 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 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 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}