Skip to main content

changepacks_cli/commands/
changepacks.rs

1use changepacks_core::{ChangePackLog, Language, Project, UpdateType};
2use std::{collections::HashMap, path::PathBuf};
3use tokio::fs::write;
4
5use changepacks_utils::{get_changepacks_dir, get_relative_path};
6
7use anyhow::Result;
8
9use crate::{
10    CommandContext,
11    options::{CliLanguage, FilterOptions},
12    prompter::{InquirePrompter, Prompter},
13};
14
15#[derive(Debug)]
16pub struct ChangepackArgs {
17    pub filter: Option<FilterOptions>,
18    pub remote: bool,
19    pub yes: bool,
20    pub message: Option<String>,
21    pub update_type: Option<UpdateType>,
22    pub language: Vec<CliLanguage>,
23}
24
25/// # Errors
26/// Returns error if command context creation or changepack creation fails.
27pub async fn handle_changepack(args: &ChangepackArgs) -> Result<()> {
28    handle_changepack_with_prompter(args, &InquirePrompter).await
29}
30
31/// # Errors
32/// Returns error if project discovery, prompting, or changepack file creation fails.
33pub async fn handle_changepack_with_prompter(
34    args: &ChangepackArgs,
35    prompter: &dyn Prompter,
36) -> Result<()> {
37    let ctx = CommandContext::new(args.remote).await?;
38
39    let mut projects = ctx
40        .project_finders
41        .iter()
42        .flat_map(|finder| finder.projects())
43        .collect::<Vec<_>>();
44
45    // Hide packages that inherit their version from workspace root.
46    // They are updated automatically when the workspace version bumps.
47    projects.retain(|p| {
48        if let Project::Package(pkg) = p {
49            !pkg.inherits_workspace_version()
50        } else {
51            true
52        }
53    });
54
55    if let Some(filter) = &args.filter {
56        projects.retain(|p| filter.matches(p));
57    }
58    if !args.language.is_empty() {
59        let allowed_languages: Vec<Language> = args
60            .language
61            .iter()
62            .map(|&lang| Language::from(lang))
63            .collect();
64        projects.retain(|project| allowed_languages.contains(&project.language()));
65    }
66
67    println!("Found {} projects", projects.len());
68    // workspace first
69    projects.sort();
70
71    let mut update_map = HashMap::<PathBuf, UpdateType>::new();
72
73    for update_type in if let Some(update_type) = &args.update_type {
74        vec![*update_type]
75    } else {
76        vec![UpdateType::Major, UpdateType::Minor, UpdateType::Patch]
77    } {
78        if projects.is_empty() {
79            break;
80        }
81
82        let selected_projects = if args.yes {
83            projects.clone()
84        } else if update_type == UpdateType::Patch && projects.len() == 1 {
85            vec![projects[0]]
86        } else {
87            let message = format!("Select projects to update for {update_type}");
88            let defaults = projects
89                .iter()
90                .enumerate()
91                .filter_map(|(index, project)| {
92                    if project.is_changed() {
93                        Some(index)
94                    } else {
95                        None
96                    }
97                })
98                .collect::<Vec<_>>();
99            prompter.multi_select(&message, projects.clone(), defaults)?
100        };
101
102        // remove selected projects from projects by index
103        for project in selected_projects {
104            update_map.insert(
105                get_relative_path(&ctx.repo_root_path, project.path())?,
106                update_type,
107            );
108        }
109
110        let project_with_relpath: Vec<_> = projects
111            .iter()
112            .map(|project| {
113                get_relative_path(&ctx.repo_root_path, project.path()).map(|rel| (project, rel))
114            })
115            .collect::<Result<Vec<_>>>()?;
116
117        let keep_projects: Vec<_> = project_with_relpath
118            .into_iter()
119            .filter(|(_, rel_path)| !update_map.contains_key(rel_path))
120            .map(|(project, _)| *project)
121            .collect();
122
123        projects = keep_projects;
124    }
125
126    if update_map.is_empty() {
127        println!("No projects selected");
128        return Ok(());
129    }
130
131    let notes = if let Some(message) = &args.message {
132        message.clone()
133    } else {
134        prompter.text("write notes here")?
135    };
136
137    if notes.is_empty() {
138        println!("Notes are empty");
139        return Ok(());
140    }
141    let changepack_log = ChangePackLog::new(update_map, notes);
142    // random uuid
143    let changepack_log_id = nanoid::nanoid!();
144    let changepack_log_file = get_changepacks_dir(&CommandContext::current_dir()?)?
145        .join(format!("changepack_log_{changepack_log_id}.json"));
146    write(changepack_log_file, serde_json::to_string(&changepack_log)?).await?;
147
148    Ok(())
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_changepack_args_debug() {
157        let args = ChangepackArgs {
158            filter: None,
159            remote: false,
160            yes: true,
161            message: Some("Test".to_string()),
162            update_type: Some(UpdateType::Patch),
163            language: vec![],
164        };
165
166        // Test Debug trait
167        let debug_str = format!("{:?}", args);
168        assert!(debug_str.contains("ChangepackArgs"));
169    }
170
171    #[test]
172    fn test_changepack_args_with_filter() {
173        let args = ChangepackArgs {
174            filter: Some(FilterOptions::Package),
175            remote: true,
176            yes: false,
177            message: None,
178            update_type: None,
179            language: vec![],
180        };
181
182        assert!(args.filter.is_some());
183        assert!(args.remote);
184        assert!(!args.yes);
185        assert!(args.message.is_none());
186        assert!(args.update_type.is_none());
187    }
188
189    #[test]
190    fn test_changepack_args_workspace_filter() {
191        let args = ChangepackArgs {
192            filter: Some(FilterOptions::Workspace),
193            remote: false,
194            yes: true,
195            message: Some("msg".to_string()),
196            update_type: Some(UpdateType::Major),
197            language: vec![],
198        };
199
200        assert!(matches!(args.filter, Some(FilterOptions::Workspace)));
201        assert!(matches!(args.update_type, Some(UpdateType::Major)));
202    }
203
204    #[test]
205    fn test_changepack_args_minor_update() {
206        let args = ChangepackArgs {
207            filter: None,
208            remote: false,
209            yes: true,
210            message: Some("feature".to_string()),
211            update_type: Some(UpdateType::Minor),
212            language: vec![],
213        };
214
215        assert!(matches!(args.update_type, Some(UpdateType::Minor)));
216    }
217
218    #[test]
219    fn test_changepack_args_with_language() {
220        let args = ChangepackArgs {
221            filter: None,
222            remote: false,
223            yes: true,
224            message: None,
225            update_type: None,
226            language: vec![CliLanguage::Node, CliLanguage::Rust],
227        };
228
229        assert_eq!(args.language.len(), 2);
230    }
231}