changepacks_cli/commands/
check.rs1use 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
33pub async fn handle_check(args: &CheckArgs) -> Result<()> {
35 let current_dir = std::env::current_dir()?;
36 let repo = find_current_git_repo(¤t_dir)?;
37 let repo_root_path = repo.work_dir().context("Not a working directory")?;
38 let config = get_changepacks_config(¤t_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(¤t_dir, &config).await?;
59
60 apply_reverse_dependencies(&mut update_map, &projects, repo_root_path);
62
63 if args.tree {
64 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
104fn display_tree(
106 projects: &[&Project],
107 repo_root_path: &std::path::Path,
108 update_map: &HashMap<PathBuf, (UpdateType, Vec<ChangePackResultLog>)>,
109) -> Result<()> {
110 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 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 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 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 let mut sorted_roots: Vec<String> = roots.into_iter().collect();
151 sorted_roots.sort();
152
153 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 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#[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 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 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 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 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
261fn 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 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 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 #[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}