Skip to main content

changepacks_cli/commands/
check.rs

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    /// Filter projects by language. Can be specified multiple times to include multiple languages.
33    #[arg(short, long, value_enum)]
34    pub language: Vec<CliLanguage>,
35}
36
37/// Check project status
38///
39/// # Errors
40/// Returns error if command context creation or project checking fails.
41pub 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 dependency updates (workspace:* dependencies)
67    apply_reverse_dependencies(&mut update_map, &projects, &ctx.repo_root_path);
68
69    if args.tree {
70        // Tree mode: show dependencies as a tree
71        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
114/// Display projects as a dependency tree
115fn display_tree(
116    projects: &[&Project],
117    repo_root_path: &std::path::Path,
118    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
119) -> Result<()> {
120    // Create a map from project relative_path to project
121    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    // Build reverse dependency graph: graph[dep] = list of projects that depend on dep
127    // This way, dependencies appear as children in the tree
128    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        // Filter dependencies to only include monorepo projects
135        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    // Root nodes are projects that are not dependencies of any other project
153    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    // Sort roots for consistent output
160    let mut sorted_roots: Vec<String> = roots.into_iter().collect();
161    sorted_roots.sort();
162
163    // Display tree starting from roots
164    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    // Display projects that weren't part of the tree (orphaned nodes)
179    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
191/// Context for tree display operations
192struct 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
199/// Display a single node in the tree
200fn 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    // Only print the project line if this is the first time visiting it
214    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    // Always display dependencies, even if the node was already visited
230    // This ensures all dependencies are shown in the tree
231    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                // Use a separate visited set for dependencies to avoid infinite loops
240                // but still show all dependencies
241                if visited.contains(dep_name) {
242                    // If already visited, just print it without recursion to avoid loops
243                    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
269/// Format a project line for display
270fn 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    // Only show dependencies that are in the monorepo (in path_to_project)
295    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    // Format similar to Project::Display but with version update and dependencies
313    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    // Test CheckArgs parsing via clap
343    #[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    // --- format_project_line tests using mock trait implementations ---
430
431    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        // The update display should show version transition
639        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}