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