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