Skip to main content

changepacks_cli/commands/
check.rs

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
33/// Check project status
34///
35/// # Errors
36/// Returns error if command context creation or project checking fails.
37pub 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 dependency updates (workspace:* dependencies)
55    apply_reverse_dependencies(&mut update_map, &projects, &ctx.repo_root_path);
56
57    if args.tree {
58        // Tree mode: show dependencies as a tree
59        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
98/// Display projects as a dependency tree
99fn display_tree(
100    projects: &[&Project],
101    repo_root_path: &std::path::Path,
102    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
103) -> Result<()> {
104    // Create a map from project relative_path to project
105    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    // Build reverse dependency graph: graph[dep] = list of projects that depend on dep
111    // This way, dependencies appear as children in the tree
112    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        // Filter dependencies to only include monorepo projects
119        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    // Root nodes are projects that are not dependencies of any other project
137    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    // Sort roots for consistent output
144    let mut sorted_roots: Vec<String> = roots.into_iter().collect();
145    sorted_roots.sort();
146
147    // Display tree starting from roots
148    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    // Display projects that weren't part of the tree (orphaned nodes)
163    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
175/// Context for tree display operations
176struct 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
183/// Display a single node in the tree
184fn 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    // Only print the project line if this is the first time visiting it
198    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    // Always display dependencies, even if the node was already visited
214    // This ensures all dependencies are shown in the tree
215    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                // Use a separate visited set for dependencies to avoid infinite loops
224                // but still show all dependencies
225                if visited.contains(dep_name) {
226                    // If already visited, just print it without recursion to avoid loops
227                    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
253/// Format a project line for display
254fn 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    // Only show dependencies that are in the monorepo (in path_to_project)
279    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    // Format similar to Project::Display but with version update and dependencies
297    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    // Test CheckArgs parsing via clap
327    #[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    // --- format_project_line tests using mock trait implementations ---
396
397    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        // The update display should show version transition
605        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}