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.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
110/// Display projects as a dependency tree
111fn display_tree(
112    projects: &[&Project],
113    repo_root_path: &std::path::Path,
114    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
115) -> Result<()> {
116    // Create a map from project relative_path to project
117    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    // Build reverse dependency graph: graph[dep] = list of projects that depend on dep
123    // This way, dependencies appear as children in the tree
124    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        // Filter dependencies to only include monorepo projects
131        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    // Root nodes are projects that are not dependencies of any other project
149    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    // Sort roots for consistent output
156    let mut sorted_roots: Vec<String> = roots.into_iter().collect();
157    sorted_roots.sort();
158
159    // Display tree starting from roots
160    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    // Display projects that weren't part of the tree (orphaned nodes)
175    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
187/// Context for tree display operations
188struct 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
195/// Display a single node in the tree
196fn 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    // Only print the project line if this is the first time visiting it
210    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    // Always display dependencies, even if the node was already visited
226    // This ensures all dependencies are shown in the tree
227    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                // Use a separate visited set for dependencies to avoid infinite loops
236                // but still show all dependencies
237                if visited.contains(dep_name) {
238                    // If already visited, just print it without recursion to avoid loops
239                    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
265/// Format a project line for display
266fn 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    // Only show dependencies that are in the monorepo (in path_to_project)
291    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    // Format similar to Project::Display but with version update and dependencies
309    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    // Test CheckArgs parsing via clap
339    #[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    // --- format_project_line tests using mock trait implementations ---
426
427    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        // The update display should show version transition
635        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}