1use changepacks_core::{ChangePackResultLog, Language, Project, UpdateType};
2
3use anyhow::Result;
4use changepacks_utils::{
5 apply_reverse_dependencies, display_update, gen_changepack_result_map, gen_update_map,
6 get_relative_path,
7};
8use clap::Args;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use crate::{
13 CommandContext,
14 options::{CliLanguage, FilterOptions, FormatOptions},
15};
16
17#[derive(Args, Debug)]
18#[command(about = "Check project status")]
19pub struct CheckArgs {
20 #[arg(short, long)]
21 filter: Option<FilterOptions>,
22
23 #[arg(long, default_value = "stdout")]
24 format: FormatOptions,
25
26 #[arg(short, long, default_value = "false")]
27 remote: bool,
28
29 #[arg(long)]
30 tree: bool,
31
32 #[arg(short, long, value_enum)]
34 pub language: Vec<CliLanguage>,
35}
36
37pub async fn handle_check(args: &CheckArgs) -> Result<()> {
42 let ctx = CommandContext::new(args.remote).await?;
43
44 let mut projects = ctx
45 .project_finders
46 .iter()
47 .flat_map(|finder| finder.projects())
48 .collect::<Vec<_>>();
49 if let Some(filter) = &args.filter {
50 projects.retain(|p| filter.matches(p));
51 }
52 if !args.language.is_empty() {
53 let allowed_languages: Vec<Language> = args
54 .language
55 .iter()
56 .map(|&lang| Language::from(lang))
57 .collect();
58 projects.retain(|project| allowed_languages.contains(&project.language()));
59 }
60 projects.sort();
61 if let FormatOptions::Stdout = args.format {
62 println!("Found {} projects", projects.len());
63 }
64 let mut update_map = gen_update_map(&CommandContext::current_dir()?, &ctx.config).await?;
65
66 apply_reverse_dependencies(&mut update_map, &projects, &ctx.repo_root_path);
68
69 if args.tree {
70 display_tree(&projects, &ctx.repo_root_path, &update_map)?;
72 } else {
73 match args.format {
74 FormatOptions::Stdout => {
75 use colored::Colorize;
76 for project in projects {
77 let changed_marker = if project.is_changed() {
78 " (changed)".bright_yellow()
79 } else {
80 "".normal()
81 };
82 println!(
83 "{}",
84 format!("{project}{changed_marker}",).replace(
85 &project
86 .version()
87 .map_or_else(|| "unknown".to_string(), |v| format!("v{v}"),),
88 &if let Some(update_type) = update_map
89 .get(&get_relative_path(&ctx.repo_root_path, project.path())?)
90 {
91 display_update(project.version(), update_type.0)?
92 } else {
93 project
94 .version()
95 .map_or_else(|| "unknown".to_string(), |v| format!("v{v}"))
96 },
97 ),
98 );
99 }
100 }
101 FormatOptions::Json => {
102 let json = serde_json::to_string_pretty(&gen_changepack_result_map(
103 projects.as_slice(),
104 &ctx.repo_root_path,
105 &mut update_map,
106 )?)?;
107 println!("{json}");
108 }
109 }
110 }
111 Ok(())
112}
113
114fn display_tree(
116 projects: &[&Project],
117 repo_root_path: &std::path::Path,
118 update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
119) -> Result<()> {
120 let mut path_to_project: HashMap<String, &Project> = HashMap::new();
122 for project in projects {
123 path_to_project.insert(project.name().unwrap_or("noname").to_string(), project);
124 }
125
126 let mut graph: HashMap<String, Vec<String>> = HashMap::new();
129 let mut roots: HashSet<String> = HashSet::new();
130 let mut has_dependencies: HashSet<String> = HashSet::new();
131
132 for project in projects {
133 let deps = project.dependencies();
134 let monorepo_deps: Vec<String> = deps
136 .iter()
137 .filter(|dep| path_to_project.contains_key(*dep))
138 .cloned()
139 .collect();
140
141 if !monorepo_deps.is_empty() {
142 graph.insert(
143 project.name().unwrap_or("noname").to_string(),
144 monorepo_deps.clone(),
145 );
146 for dep in &monorepo_deps {
147 has_dependencies.insert(dep.clone());
148 }
149 }
150 }
151
152 for project in projects {
154 if !has_dependencies.contains(project.name().unwrap_or("noname")) {
155 roots.insert(project.name().unwrap_or("noname").to_string());
156 }
157 }
158
159 let mut sorted_roots: Vec<String> = roots.into_iter().collect();
161 sorted_roots.sort();
162
163 let mut visited: HashSet<String> = HashSet::new();
165 let mut ctx = TreeContext {
166 graph: &graph,
167 path_to_project: &path_to_project,
168 repo_root_path,
169 update_map,
170 };
171 for (idx, root) in sorted_roots.iter().enumerate() {
172 if let Some(project) = path_to_project.get(root) {
173 let is_last = idx == sorted_roots.len() - 1;
174 display_tree_node(project, &mut ctx, "", is_last, &mut visited)?;
175 }
176 }
177
178 for project in projects {
180 if !visited.contains(project.name().unwrap_or("noname")) {
181 println!(
182 "{}",
183 format_project_line(project, repo_root_path, update_map, &path_to_project)?
184 );
185 }
186 }
187
188 Ok(())
189}
190
191struct TreeContext<'a> {
193 graph: &'a HashMap<String, Vec<String>>,
194 path_to_project: &'a HashMap<String, &'a Project>,
195 repo_root_path: &'a Path,
196 update_map: &'a HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
197}
198
199fn display_tree_node(
201 project: &Project,
202 ctx: &mut TreeContext,
203 prefix: &str,
204 is_last: bool,
205 visited: &mut HashSet<String>,
206) -> Result<()> {
207 let project_name = project.name().unwrap_or("noname").to_string();
208 let is_first_visit = !visited.contains(&project_name);
209 if is_first_visit {
210 visited.insert(project_name.clone());
211 }
212
213 if is_first_visit {
215 let connector = if is_last { "└── " } else { "├── " };
216 println!(
217 "{}{}{}",
218 prefix,
219 connector,
220 format_project_line(
221 project,
222 ctx.repo_root_path,
223 ctx.update_map,
224 ctx.path_to_project
225 )?
226 );
227 }
228
229 if let Some(deps) = ctx.graph.get(&project_name) {
232 let mut sorted_deps = deps.clone();
233 sorted_deps.sort();
234 let sorted_deps_count = sorted_deps.len();
235 for (idx, dep_name) in sorted_deps.iter().enumerate() {
236 if let Some(dep_project) = ctx.path_to_project.get(dep_name) {
237 let is_last_dep = idx == sorted_deps_count - 1;
238 let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
239 if visited.contains(dep_name) {
242 let dep_connector = if is_last_dep {
244 "└── "
245 } else {
246 "├── "
247 };
248 println!(
249 "{}{}{}",
250 new_prefix,
251 dep_connector,
252 format_project_line(
253 dep_project,
254 ctx.repo_root_path,
255 ctx.update_map,
256 ctx.path_to_project
257 )?
258 );
259 } else {
260 display_tree_node(dep_project, ctx, &new_prefix, is_last_dep, visited)?;
261 }
262 }
263 }
264 }
265
266 Ok(())
267}
268
269fn format_project_line(
271 project: &Project,
272 repo_root_path: &std::path::Path,
273 update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
274 path_to_project: &HashMap<String, &Project>,
275) -> Result<String> {
276 use changepacks_utils::get_relative_path;
277 use colored::Colorize;
278
279 let relative_path = get_relative_path(repo_root_path, project.path())?;
280 let version = if let Some(update_entry) = update_map.get(&relative_path) {
281 changepacks_utils::display_update(project.version(), update_entry.0)?
282 } else {
283 project
284 .version()
285 .map_or_else(|| "unknown".to_string(), |v| format!("v{v}"))
286 };
287
288 let changed_marker = if project.is_changed() {
289 " (changed)".bright_yellow()
290 } else {
291 "".normal()
292 };
293
294 let monorepo_deps: Vec<&String> = project
296 .dependencies()
297 .iter()
298 .filter(|dep| path_to_project.contains_key(*dep))
299 .collect();
300
301 let deps_info = if monorepo_deps.is_empty() {
302 "".normal()
303 } else {
304 let deps_str = monorepo_deps
305 .iter()
306 .map(|d| d.as_str())
307 .collect::<Vec<_>>()
308 .join("\n ");
309 format!(" [deps:\n {deps_str}]").bright_black()
310 };
311
312 let base_format = match project {
314 Project::Workspace(w) => format!(
315 "{} {} {} {} {}",
316 format!("[Workspace - {}]", w.language())
317 .bright_blue()
318 .bold(),
319 w.name().unwrap_or("noname").bright_white().bold(),
320 format!("({version})").bright_green(),
321 "-".bright_cyan(),
322 w.relative_path().display().to_string().bright_black()
323 ),
324 Project::Package(p) => format!(
325 "{} {} {} {} {}",
326 format!("[{}]", p.language()).bright_blue().bold(),
327 p.name().unwrap_or("noname").bright_white().bold(),
328 format!("({version})").bright_green(),
329 "-".bright_cyan(),
330 p.relative_path().display().to_string().bright_black()
331 ),
332 };
333
334 Ok(format!("{base_format}{changed_marker}{deps_info}"))
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use clap::Parser;
341
342 #[derive(Parser)]
344 struct TestCli {
345 #[command(flatten)]
346 check: CheckArgs,
347 }
348
349 #[test]
350 fn test_check_args_default() {
351 let cli = TestCli::parse_from(["test"]);
352 assert!(cli.check.filter.is_none());
353 assert!(matches!(cli.check.format, FormatOptions::Stdout));
354 assert!(!cli.check.remote);
355 assert!(!cli.check.tree);
356 }
357
358 #[test]
359 fn test_check_args_with_json_format() {
360 let cli = TestCli::parse_from(["test", "--format", "json"]);
361 assert!(matches!(cli.check.format, FormatOptions::Json));
362 }
363
364 #[test]
365 fn test_check_args_with_tree() {
366 let cli = TestCli::parse_from(["test", "--tree"]);
367 assert!(cli.check.tree);
368 }
369
370 #[test]
371 fn test_check_args_with_remote() {
372 let cli = TestCli::parse_from(["test", "--remote"]);
373 assert!(cli.check.remote);
374 }
375
376 #[test]
377 fn test_check_args_with_filter_workspace() {
378 let cli = TestCli::parse_from(["test", "--filter", "workspace"]);
379 assert!(matches!(cli.check.filter, Some(FilterOptions::Workspace)));
380 }
381
382 #[test]
383 fn test_check_args_with_filter_package() {
384 let cli = TestCli::parse_from(["test", "--filter", "package"]);
385 assert!(matches!(cli.check.filter, Some(FilterOptions::Package)));
386 }
387
388 #[test]
389 fn test_check_args_combined() {
390 let cli = TestCli::parse_from([
391 "test", "--filter", "package", "--format", "json", "--tree", "--remote",
392 ]);
393 assert!(matches!(cli.check.filter, Some(FilterOptions::Package)));
394 assert!(matches!(cli.check.format, FormatOptions::Json));
395 assert!(cli.check.tree);
396 assert!(cli.check.remote);
397 }
398
399 #[test]
400 fn test_check_args_short_filter() {
401 let cli = TestCli::parse_from(["test", "-f", "workspace"]);
402 assert!(matches!(cli.check.filter, Some(FilterOptions::Workspace)));
403 }
404
405 #[test]
406 fn test_check_args_short_remote() {
407 let cli = TestCli::parse_from(["test", "-r"]);
408 assert!(cli.check.remote);
409 }
410
411 #[test]
412 fn test_check_args_with_language_filter() {
413 let cli = TestCli::parse_from(["test", "--language", "node"]);
414 assert_eq!(cli.check.language.len(), 1);
415 }
416
417 #[test]
418 fn test_check_args_with_multiple_languages() {
419 let cli = TestCli::parse_from(["test", "--language", "node", "--language", "python"]);
420 assert_eq!(cli.check.language.len(), 2);
421 }
422
423 #[test]
424 fn test_check_args_short_language() {
425 let cli = TestCli::parse_from(["test", "-l", "rust"]);
426 assert_eq!(cli.check.language.len(), 1);
427 }
428
429 use async_trait::async_trait;
432 use changepacks_core::{Language, Package, Workspace};
433 use std::collections::HashSet;
434
435 #[derive(Debug)]
436 struct MockPackageForCheck {
437 name: Option<String>,
438 version: Option<String>,
439 path: PathBuf,
440 relative_path: PathBuf,
441 language: Language,
442 dependencies: HashSet<String>,
443 changed: bool,
444 }
445
446 impl MockPackageForCheck {
447 fn new(
448 name: Option<&str>,
449 version: Option<&str>,
450 path: &str,
451 relative_path: &str,
452 language: Language,
453 ) -> Self {
454 Self {
455 name: name.map(String::from),
456 version: version.map(String::from),
457 path: PathBuf::from(path),
458 relative_path: PathBuf::from(relative_path),
459 language,
460 dependencies: HashSet::new(),
461 changed: false,
462 }
463 }
464 }
465
466 #[async_trait]
467 impl Package for MockPackageForCheck {
468 fn name(&self) -> Option<&str> {
469 self.name.as_deref()
470 }
471 fn version(&self) -> Option<&str> {
472 self.version.as_deref()
473 }
474 fn path(&self) -> &std::path::Path {
475 &self.path
476 }
477 fn relative_path(&self) -> &std::path::Path {
478 &self.relative_path
479 }
480 async fn update_version(
481 &mut self,
482 _update_type: changepacks_core::UpdateType,
483 ) -> anyhow::Result<()> {
484 Ok(())
485 }
486 fn is_changed(&self) -> bool {
487 self.changed
488 }
489 fn language(&self) -> Language {
490 self.language
491 }
492 fn dependencies(&self) -> &HashSet<String> {
493 &self.dependencies
494 }
495 fn add_dependency(&mut self, dependency: &str) {
496 self.dependencies.insert(dependency.to_string());
497 }
498 fn set_changed(&mut self, changed: bool) {
499 self.changed = changed;
500 }
501 fn default_publish_command(&self) -> String {
502 "echo publish".to_string()
503 }
504 }
505
506 #[derive(Debug)]
507 struct MockWorkspaceForCheck {
508 name: Option<String>,
509 version: Option<String>,
510 path: PathBuf,
511 relative_path: PathBuf,
512 language: Language,
513 dependencies: HashSet<String>,
514 changed: bool,
515 }
516
517 impl MockWorkspaceForCheck {
518 fn new(
519 name: Option<&str>,
520 version: Option<&str>,
521 path: &str,
522 relative_path: &str,
523 language: Language,
524 ) -> Self {
525 Self {
526 name: name.map(String::from),
527 version: version.map(String::from),
528 path: PathBuf::from(path),
529 relative_path: PathBuf::from(relative_path),
530 language,
531 dependencies: HashSet::new(),
532 changed: false,
533 }
534 }
535 }
536
537 #[async_trait]
538 impl Workspace for MockWorkspaceForCheck {
539 fn name(&self) -> Option<&str> {
540 self.name.as_deref()
541 }
542 fn version(&self) -> Option<&str> {
543 self.version.as_deref()
544 }
545 fn path(&self) -> &std::path::Path {
546 &self.path
547 }
548 fn relative_path(&self) -> &std::path::Path {
549 &self.relative_path
550 }
551 async fn update_version(
552 &mut self,
553 _update_type: changepacks_core::UpdateType,
554 ) -> anyhow::Result<()> {
555 Ok(())
556 }
557 fn is_changed(&self) -> bool {
558 self.changed
559 }
560 fn language(&self) -> Language {
561 self.language
562 }
563 fn dependencies(&self) -> &HashSet<String> {
564 &self.dependencies
565 }
566 fn add_dependency(&mut self, dependency: &str) {
567 self.dependencies.insert(dependency.to_string());
568 }
569 fn set_changed(&mut self, changed: bool) {
570 self.changed = changed;
571 }
572 fn default_publish_command(&self) -> String {
573 "echo publish".to_string()
574 }
575 }
576
577 #[test]
578 fn test_format_project_line_package() {
579 let pkg = MockPackageForCheck::new(
580 Some("my-lib"),
581 Some("1.2.3"),
582 "/repo/crates/my-lib/Cargo.toml",
583 "crates/my-lib/Cargo.toml",
584 Language::Rust,
585 );
586 let project = Project::Package(Box::new(pkg));
587 let repo_root = Path::new("/repo");
588 let update_map = HashMap::new();
589 let mut path_to_project: HashMap<String, &Project> = HashMap::new();
590 path_to_project.insert("my-lib".to_string(), &project);
591
592 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
593 assert!(line.contains("my-lib"));
594 assert!(line.contains("v1.2.3"));
595 }
596
597 #[test]
598 fn test_format_project_line_workspace() {
599 let ws = MockWorkspaceForCheck::new(
600 Some("my-workspace"),
601 Some("2.0.0"),
602 "/repo/package.json",
603 "package.json",
604 Language::Node,
605 );
606 let project = Project::Workspace(Box::new(ws));
607 let repo_root = Path::new("/repo");
608 let update_map = HashMap::new();
609 let mut path_to_project: HashMap<String, &Project> = HashMap::new();
610 path_to_project.insert("my-workspace".to_string(), &project);
611
612 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
613 assert!(line.contains("my-workspace"));
614 assert!(line.contains("Workspace"));
615 assert!(line.contains("v2.0.0"));
616 }
617
618 #[test]
619 fn test_format_project_line_with_update() {
620 let pkg = MockPackageForCheck::new(
621 Some("updated-pkg"),
622 Some("1.0.0"),
623 "/repo/packages/foo/package.json",
624 "packages/foo/package.json",
625 Language::Node,
626 );
627 let project = Project::Package(Box::new(pkg));
628 let repo_root = Path::new("/repo");
629 let mut update_map = HashMap::new();
630 update_map.insert(
631 PathBuf::from("packages/foo/package.json"),
632 (UpdateType::Minor, vec![]),
633 );
634 let path_to_project: HashMap<String, &Project> = HashMap::new();
635
636 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
637 assert!(line.contains("updated-pkg"));
638 assert!(line.contains("1.1.0") || line.contains("1.0.0"));
640 }
641
642 #[test]
643 fn test_format_project_line_changed_marker() {
644 let mut pkg = MockPackageForCheck::new(
645 Some("changed-pkg"),
646 Some("3.0.0"),
647 "/repo/lib/Cargo.toml",
648 "lib/Cargo.toml",
649 Language::Rust,
650 );
651 pkg.changed = true;
652 let project = Project::Package(Box::new(pkg));
653 let repo_root = Path::new("/repo");
654 let update_map = HashMap::new();
655 let path_to_project: HashMap<String, &Project> = HashMap::new();
656
657 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
658 assert!(line.contains("changed-pkg"));
659 assert!(line.contains("changed"));
660 }
661
662 #[test]
663 fn test_format_project_line_with_dependencies() {
664 let mut pkg = MockPackageForCheck::new(
665 Some("app"),
666 Some("1.0.0"),
667 "/repo/app/package.json",
668 "app/package.json",
669 Language::Node,
670 );
671 pkg.dependencies.insert("core-lib".to_string());
672 let project = Project::Package(Box::new(pkg));
673
674 let dep_pkg = MockPackageForCheck::new(
675 Some("core-lib"),
676 Some("1.0.0"),
677 "/repo/core/package.json",
678 "core/package.json",
679 Language::Node,
680 );
681 let dep_project = Project::Package(Box::new(dep_pkg));
682
683 let repo_root = Path::new("/repo");
684 let update_map = HashMap::new();
685 let mut path_to_project: HashMap<String, &Project> = HashMap::new();
686 path_to_project.insert("app".to_string(), &project);
687 path_to_project.insert("core-lib".to_string(), &dep_project);
688
689 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
690 assert!(line.contains("app"));
691 assert!(line.contains("deps:"));
692 assert!(line.contains("core-lib"));
693 }
694
695 #[test]
696 fn test_format_project_line_no_deps_shows_no_bracket() {
697 let pkg = MockPackageForCheck::new(
698 Some("standalone"),
699 Some("1.0.0"),
700 "/repo/standalone/Cargo.toml",
701 "standalone/Cargo.toml",
702 Language::Rust,
703 );
704 let project = Project::Package(Box::new(pkg));
705 let repo_root = Path::new("/repo");
706 let update_map = HashMap::new();
707 let path_to_project: HashMap<String, &Project> = HashMap::new();
708
709 let line = format_project_line(&project, repo_root, &update_map, &path_to_project).unwrap();
710 assert!(line.contains("standalone"));
711 assert!(!line.contains("deps:"));
712 }
713}