changepacks_cli/commands/
check.rs

1use changepacks_core::{ChangePackResultLog, Project, UpdateType};
2
3use anyhow::{Context, Result};
4use changepacks_utils::{
5    apply_reverse_dependencies, display_update, find_current_git_repo, find_project_dirs,
6    gen_changepack_result_map, gen_update_map, get_changepacks_config, get_relative_path,
7};
8use clap::Args;
9use std::collections::{HashMap, HashSet};
10use std::path::PathBuf;
11
12use crate::{
13    finders::get_finders,
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
34pub async fn handle_check(args: &CheckArgs) -> Result<()> {
35    let current_dir = std::env::current_dir()?;
36    let repo = find_current_git_repo(&current_dir)?;
37    let repo_root_path = repo.work_dir().context("Not a working directory")?;
38    // check if config.json exists
39    let config = get_changepacks_config(&current_dir).await?;
40    let mut project_finders = get_finders();
41
42    find_project_dirs(&repo, &mut project_finders, &config, args.remote).await?;
43
44    let mut projects = project_finders
45        .iter()
46        .flat_map(|finder| finder.projects())
47        .collect::<Vec<_>>();
48    if let Some(filter) = &args.filter {
49        projects.retain(|project| match filter {
50            FilterOptions::Workspace => matches!(project, Project::Workspace(_)),
51            FilterOptions::Package => matches!(project, Project::Package(_)),
52        });
53    }
54    projects.sort();
55    if let FormatOptions::Stdout = args.format {
56        println!("Found {} projects", projects.len());
57    }
58    let mut update_map = gen_update_map(&current_dir, &config).await?;
59
60    // Apply reverse dependency updates (workspace:* dependencies)
61    apply_reverse_dependencies(&mut update_map, &projects, repo_root_path);
62
63    if args.tree {
64        // Tree mode: show dependencies as a tree
65        display_tree(&projects, repo_root_path, &update_map)?;
66    } else {
67        match args.format {
68            FormatOptions::Stdout => {
69                use colored::Colorize;
70                for project in projects {
71                    let changed_marker = if project.is_changed() {
72                        " (changed)".bright_yellow()
73                    } else {
74                        "".normal()
75                    };
76                    println!(
77                        "{}",
78                        format!("{}{}", project, changed_marker,).replace(
79                            project.version().unwrap_or("unknown"),
80                            &if let Some(update_type) =
81                                update_map.get(&get_relative_path(repo_root_path, project.path())?)
82                            {
83                                display_update(project.version(), update_type.0.clone())?
84                            } else {
85                                project.version().unwrap_or("unknown").to_string()
86                            },
87                        ),
88                    )
89                }
90            }
91            FormatOptions::Json => {
92                let json = serde_json::to_string_pretty(&gen_changepack_result_map(
93                    projects.as_slice(),
94                    repo_root_path,
95                    &mut update_map,
96                )?)?;
97                println!("{}", json);
98            }
99        }
100    }
101    Ok(())
102}
103
104/// Display projects as a dependency tree
105fn display_tree(
106    projects: &[&Project],
107    repo_root_path: &std::path::Path,
108    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
109) -> Result<()> {
110    // Create a map from project relative_path to project
111    let mut path_to_project: HashMap<String, &Project> = HashMap::new();
112    for project in projects {
113        path_to_project.insert(project.name().unwrap_or("noname").to_string(), project);
114    }
115
116    // Build reverse dependency graph: graph[dep] = list of projects that depend on dep
117    // This way, dependencies appear as children in the tree
118    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
119    let mut roots: HashSet<String> = HashSet::new();
120    let mut has_dependencies: HashSet<String> = HashSet::new();
121
122    for project in projects {
123        let deps = project.dependencies();
124        // Filter dependencies to only include monorepo projects
125        let monorepo_deps: Vec<String> = deps
126            .iter()
127            .filter(|dep| path_to_project.contains_key(*dep))
128            .cloned()
129            .collect();
130
131        if !monorepo_deps.is_empty() {
132            graph.insert(
133                project.name().unwrap_or("noname").to_string(),
134                monorepo_deps.clone(),
135            );
136            for dep in &monorepo_deps {
137                has_dependencies.insert(dep.clone());
138            }
139        }
140    }
141
142    // Root nodes are projects that are not dependencies of any other project
143    for project in projects {
144        if !has_dependencies.contains(project.name().unwrap_or("noname")) {
145            roots.insert(project.name().unwrap_or("noname").to_string());
146        }
147    }
148
149    // Sort roots for consistent output
150    let mut sorted_roots: Vec<String> = roots.into_iter().collect();
151    sorted_roots.sort();
152
153    // Display tree starting from roots
154    let mut visited: HashSet<String> = HashSet::new();
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(
159                project,
160                &graph,
161                &path_to_project,
162                repo_root_path,
163                update_map,
164                "",
165                is_last,
166                &mut visited,
167            )?;
168        }
169    }
170
171    // Display projects that weren't part of the tree (orphaned nodes)
172    for project in projects {
173        if !visited.contains(project.name().unwrap_or("noname")) {
174            println!(
175                "{}",
176                format_project_line(project, repo_root_path, update_map, &path_to_project)?
177            );
178        }
179    }
180
181    Ok(())
182}
183
184/// Display a single node in the tree
185#[allow(clippy::too_many_arguments)]
186fn display_tree_node(
187    project: &Project,
188    graph: &HashMap<String, Vec<String>>,
189    path_to_project: &HashMap<String, &Project>,
190    repo_root_path: &std::path::Path,
191    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
192    prefix: &str,
193    is_last: bool,
194    visited: &mut HashSet<String>,
195) -> Result<()> {
196    let project_name = project.name().unwrap_or("noname").to_string();
197    let is_first_visit = !visited.contains(&project_name);
198    if is_first_visit {
199        visited.insert(project_name.clone());
200    }
201
202    // Only print the project line if this is the first time visiting it
203    if is_first_visit {
204        let connector = if is_last { "└── " } else { "├── " };
205        println!(
206            "{}{}{}",
207            prefix,
208            connector,
209            format_project_line(project, repo_root_path, update_map, path_to_project)?
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) = graph.get(&project_name) {
216        let mut sorted_deps = deps.clone();
217        sorted_deps.sort();
218        for (idx, dep_name) in sorted_deps.iter().enumerate() {
219            if let Some(dep_project) = path_to_project.get(dep_name) {
220                let is_last_dep = idx == sorted_deps.len() - 1;
221                let new_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
222                // Use a separate visited set for dependencies to avoid infinite loops
223                // but still show all dependencies
224                if !visited.contains(dep_name) {
225                    display_tree_node(
226                        dep_project,
227                        graph,
228                        path_to_project,
229                        repo_root_path,
230                        update_map,
231                        &new_prefix,
232                        is_last_dep,
233                        visited,
234                    )?;
235                } else {
236                    // If already visited, just print it without recursion to avoid loops
237                    let dep_connector = if is_last_dep {
238                        "└── "
239                    } else {
240                        "├── "
241                    };
242                    println!(
243                        "{}{}{}",
244                        new_prefix,
245                        dep_connector,
246                        format_project_line(
247                            dep_project,
248                            repo_root_path,
249                            update_map,
250                            path_to_project
251                        )?
252                    );
253                }
254            }
255        }
256    }
257
258    Ok(())
259}
260
261/// Format a project line for display
262fn format_project_line(
263    project: &Project,
264    repo_root_path: &std::path::Path,
265    update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
266    path_to_project: &HashMap<String, &Project>,
267) -> Result<String> {
268    use changepacks_utils::get_relative_path;
269    use colored::Colorize;
270
271    let relative_path = get_relative_path(repo_root_path, project.path())?;
272    let version = if let Some(update_entry) = update_map.get(&relative_path) {
273        changepacks_utils::display_update(project.version(), update_entry.0.clone())?
274    } else {
275        project
276            .version()
277            .map(|v| format!("v{}", v))
278            .unwrap_or("unknown".to_string())
279    };
280
281    let changed_marker = if project.is_changed() {
282        " (changed)".bright_yellow()
283    } else {
284        "".normal()
285    };
286
287    // Only show dependencies that are in the monorepo (in path_to_project)
288    let monorepo_deps: Vec<String> = project
289        .dependencies()
290        .iter()
291        .filter(|dep| path_to_project.contains_key(*dep))
292        .map(|dep| dep.to_string())
293        .collect();
294
295    let deps_info = if !monorepo_deps.is_empty() {
296        format!(" [deps:\n        {}]", monorepo_deps.join("\n        ")).bright_black()
297    } else {
298        "".normal()
299    };
300
301    // Format similar to Project::Display but with version update and dependencies
302    let base_format = match project {
303        Project::Workspace(w) => format!(
304            "{} {} {} {} {}",
305            format!("[Workspace - {}]", w.language())
306                .bright_blue()
307                .bold(),
308            w.name().unwrap_or("noname").bright_white().bold(),
309            format!("({})", version).bright_green(),
310            "-".bright_cyan(),
311            w.relative_path().display().to_string().bright_black()
312        ),
313        Project::Package(p) => format!(
314            "{} {} {} {} {}",
315            format!("[{}]", p.language()).bright_blue().bold(),
316            p.name().unwrap_or("noname").bright_white().bold(),
317            format!("({})", version).bright_green(),
318            "-".bright_cyan(),
319            p.relative_path().display().to_string().bright_black()
320        ),
321    };
322
323    Ok(format!("{}{}{}", base_format, changed_marker, deps_info))
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use clap::Parser;
330
331    // Test CheckArgs parsing via clap
332    #[derive(Parser)]
333    struct TestCli {
334        #[command(flatten)]
335        check: CheckArgs,
336    }
337
338    #[test]
339    fn test_check_args_default() {
340        let cli = TestCli::parse_from(["test"]);
341        assert!(cli.check.filter.is_none());
342        assert!(matches!(cli.check.format, FormatOptions::Stdout));
343        assert!(!cli.check.remote);
344        assert!(!cli.check.tree);
345    }
346
347    #[test]
348    fn test_check_args_with_json_format() {
349        let cli = TestCli::parse_from(["test", "--format", "json"]);
350        assert!(matches!(cli.check.format, FormatOptions::Json));
351    }
352
353    #[test]
354    fn test_check_args_with_tree() {
355        let cli = TestCli::parse_from(["test", "--tree"]);
356        assert!(cli.check.tree);
357    }
358
359    #[test]
360    fn test_check_args_with_remote() {
361        let cli = TestCli::parse_from(["test", "--remote"]);
362        assert!(cli.check.remote);
363    }
364
365    #[test]
366    fn test_check_args_with_filter_workspace() {
367        let cli = TestCli::parse_from(["test", "--filter", "workspace"]);
368        assert!(matches!(cli.check.filter, Some(FilterOptions::Workspace)));
369    }
370
371    #[test]
372    fn test_check_args_with_filter_package() {
373        let cli = TestCli::parse_from(["test", "--filter", "package"]);
374        assert!(matches!(cli.check.filter, Some(FilterOptions::Package)));
375    }
376
377    #[test]
378    fn test_check_args_combined() {
379        let cli = TestCli::parse_from([
380            "test", "--filter", "package", "--format", "json", "--tree", "--remote",
381        ]);
382        assert!(matches!(cli.check.filter, Some(FilterOptions::Package)));
383        assert!(matches!(cli.check.format, FormatOptions::Json));
384        assert!(cli.check.tree);
385        assert!(cli.check.remote);
386    }
387}