Skip to main content

changeset_operations/operations/
add.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use changeset_core::{BumpType, ChangeCategory, Changeset, PackageInfo, PackageRelease};
5use indexmap::IndexSet;
6
7use crate::Result;
8use crate::error::OperationError;
9use crate::traits::{
10    BumpSelection, CategorySelection, ChangesetWriter, DescriptionInput, InteractionProvider,
11    PackageSelection, ProjectProvider,
12};
13
14pub struct AddInput {
15    pub packages: Vec<String>,
16    pub bump: Option<BumpType>,
17    pub package_bumps: HashMap<String, BumpType>,
18    pub category: ChangeCategory,
19    pub description: Option<String>,
20}
21
22impl Default for AddInput {
23    fn default() -> Self {
24        Self {
25            packages: Vec::new(),
26            bump: None,
27            package_bumps: HashMap::new(),
28            category: ChangeCategory::Changed,
29            description: None,
30        }
31    }
32}
33
34#[derive(Debug)]
35pub enum AddResult {
36    Created {
37        changeset: Changeset,
38        file_path: PathBuf,
39    },
40    Cancelled,
41    NoPackages,
42}
43
44pub struct AddOperation<P, W, I> {
45    project_provider: P,
46    changeset_writer: W,
47    interaction_provider: I,
48}
49
50impl<P, W, I> AddOperation<P, W, I>
51where
52    P: ProjectProvider,
53    W: ChangesetWriter,
54    I: InteractionProvider,
55{
56    pub fn new(project_provider: P, changeset_writer: W, interaction_provider: I) -> Self {
57        Self {
58            project_provider,
59            changeset_writer,
60            interaction_provider,
61        }
62    }
63
64    /// # Errors
65    ///
66    /// Returns an error if the project cannot be discovered, has no packages, or
67    /// if the changeset cannot be written.
68    pub fn execute(&self, start_path: &Path, input: AddInput) -> Result<AddResult> {
69        let project = self.project_provider.discover_project(start_path)?;
70
71        if project.packages().is_empty() {
72            return Err(OperationError::EmptyProject(project.root().to_path_buf()));
73        }
74
75        let packages = match self.select_packages(project.packages(), &input)? {
76            Some(packages) if packages.is_empty() => return Ok(AddResult::NoPackages),
77            Some(packages) => packages,
78            None => return Ok(AddResult::Cancelled),
79        };
80
81        let Some(releases) = self.collect_releases(&packages, &input)? else {
82            return Ok(AddResult::Cancelled);
83        };
84
85        let Some(category) = self.select_category(&input)? else {
86            return Ok(AddResult::Cancelled);
87        };
88
89        let Some(description) = self.get_description(&input)? else {
90            return Ok(AddResult::Cancelled);
91        };
92
93        let description = description.trim();
94        if description.is_empty() {
95            return Err(OperationError::EmptyDescription);
96        }
97
98        let changeset = Changeset {
99            summary: description.to_string(),
100            releases,
101            category,
102            consumed_for_prerelease: None,
103            graduate: false,
104        };
105
106        let (root_config, _) = self.project_provider.load_configs(&project)?;
107        let changeset_dir = self
108            .project_provider
109            .ensure_changeset_dir(&project, &root_config)?;
110
111        let filename = self
112            .changeset_writer
113            .write_changeset(&changeset_dir, &changeset)?;
114        let file_path = changeset_dir.join(&filename);
115
116        Ok(AddResult::Created {
117            changeset,
118            file_path,
119        })
120    }
121
122    fn select_packages(
123        &self,
124        available: &[PackageInfo],
125        input: &AddInput,
126    ) -> Result<Option<Vec<PackageInfo>>> {
127        let explicit_packages = collect_explicit_packages(input);
128
129        if !explicit_packages.is_empty() {
130            let packages = resolve_explicit_packages(available, &explicit_packages)?;
131            return Ok(Some(packages));
132        }
133
134        if available.len() == 1 {
135            return Ok(Some(vec![available[0].clone()]));
136        }
137
138        match self.interaction_provider.select_packages(available)? {
139            PackageSelection::Selected(packages) => Ok(Some(packages)),
140            PackageSelection::Cancelled => Ok(None),
141        }
142    }
143
144    fn collect_releases(
145        &self,
146        packages: &[PackageInfo],
147        input: &AddInput,
148    ) -> Result<Option<Vec<PackageRelease>>> {
149        let mut releases = Vec::with_capacity(packages.len());
150
151        for package in packages {
152            let bump_type = if let Some(bump) = input.package_bumps.get(&package.name) {
153                *bump
154            } else if let Some(bump) = input.bump {
155                bump
156            } else {
157                match self.interaction_provider.select_bump_type(&package.name)? {
158                    BumpSelection::Selected(bump) => bump,
159                    BumpSelection::Cancelled => return Ok(None),
160                }
161            };
162
163            releases.push(PackageRelease {
164                name: package.name.clone(),
165                bump_type,
166            });
167        }
168
169        Ok(Some(releases))
170    }
171
172    fn select_category(&self, input: &AddInput) -> Result<Option<ChangeCategory>> {
173        let has_explicit_input = input.description.is_some()
174            || !input.packages.is_empty()
175            || !input.package_bumps.is_empty();
176
177        if input.category != ChangeCategory::default() || has_explicit_input {
178            return Ok(Some(input.category));
179        }
180
181        match self.interaction_provider.select_category()? {
182            CategorySelection::Selected(category) => Ok(Some(category)),
183            CategorySelection::Cancelled => Ok(None),
184        }
185    }
186
187    fn get_description(&self, input: &AddInput) -> Result<Option<String>> {
188        if let Some(description) = &input.description {
189            return Ok(Some(description.clone()));
190        }
191
192        match self.interaction_provider.get_description()? {
193            DescriptionInput::Provided(description) => Ok(Some(description)),
194            DescriptionInput::Cancelled => Ok(None),
195        }
196    }
197}
198
199fn collect_explicit_packages(input: &AddInput) -> Vec<String> {
200    let mut packages: IndexSet<String> = input.packages.iter().cloned().collect();
201
202    for name in input.package_bumps.keys() {
203        packages.insert(name.clone());
204    }
205
206    packages.into_iter().collect()
207}
208
209fn resolve_explicit_packages(
210    packages: &[PackageInfo],
211    package_names: &[String],
212) -> Result<Vec<PackageInfo>> {
213    let unique_names: IndexSet<&String> = package_names.iter().collect();
214    let mut selected = Vec::with_capacity(unique_names.len());
215
216    for name in unique_names {
217        let package = packages.iter().find(|p| p.name == *name).ok_or_else(|| {
218            let available = packages
219                .iter()
220                .map(|p| p.name.as_str())
221                .collect::<Vec<_>>()
222                .join(", ");
223            OperationError::UnknownPackage {
224                name: name.clone(),
225                available,
226            }
227        })?;
228        selected.push(package.clone());
229    }
230
231    Ok(selected)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn collect_explicit_packages_from_packages_list() {
240        let input = AddInput {
241            packages: vec!["a".to_string(), "b".to_string()],
242            ..Default::default()
243        };
244
245        let packages = collect_explicit_packages(&input);
246
247        assert_eq!(packages.len(), 2);
248        assert!(packages.contains(&"a".to_string()));
249        assert!(packages.contains(&"b".to_string()));
250    }
251
252    #[test]
253    fn collect_explicit_packages_from_package_bumps() {
254        let mut package_bumps = HashMap::new();
255        package_bumps.insert("a".to_string(), BumpType::Major);
256        package_bumps.insert("b".to_string(), BumpType::Minor);
257
258        let input = AddInput {
259            package_bumps,
260            ..Default::default()
261        };
262
263        let packages = collect_explicit_packages(&input);
264
265        assert_eq!(packages.len(), 2);
266        assert!(packages.contains(&"a".to_string()));
267        assert!(packages.contains(&"b".to_string()));
268    }
269
270    #[test]
271    fn collect_explicit_packages_merges_and_deduplicates() {
272        let mut package_bumps = HashMap::new();
273        package_bumps.insert("a".to_string(), BumpType::Major);
274        package_bumps.insert("b".to_string(), BumpType::Minor);
275
276        let input = AddInput {
277            packages: vec!["a".to_string(), "c".to_string()],
278            package_bumps,
279            ..Default::default()
280        };
281
282        let packages = collect_explicit_packages(&input);
283
284        assert_eq!(packages.len(), 3);
285        assert!(packages.contains(&"a".to_string()));
286        assert!(packages.contains(&"b".to_string()));
287        assert!(packages.contains(&"c".to_string()));
288    }
289
290    #[test]
291    fn collect_explicit_packages_empty() {
292        let input = AddInput::default();
293
294        let packages = collect_explicit_packages(&input);
295
296        assert!(packages.is_empty());
297    }
298}
299
300#[cfg(test)]
301mod operation_tests {
302    use super::*;
303    use crate::mocks::{
304        MockChangesetWriter, MockInteractionProvider, MockProjectProvider, make_package,
305    };
306
307    #[test]
308    fn creates_changeset_for_single_package_project() {
309        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
310        let writer = MockChangesetWriter::new().with_filename("test-changeset.md");
311        let interaction = MockInteractionProvider::all_cancelled();
312
313        let operation = AddOperation::new(project_provider, writer, interaction);
314
315        let input = AddInput {
316            packages: vec!["my-crate".to_string()],
317            bump: Some(BumpType::Patch),
318            description: Some("Fix a bug".to_string()),
319            ..Default::default()
320        };
321
322        let result = operation
323            .execute(Path::new("/any"), input)
324            .expect("AddOperation failed with valid single-package input");
325
326        match result {
327            AddResult::Created {
328                changeset,
329                file_path,
330            } => {
331                assert_eq!(changeset.summary, "Fix a bug");
332                assert_eq!(changeset.releases.len(), 1);
333                assert_eq!(changeset.releases[0].name, "my-crate");
334                assert_eq!(changeset.releases[0].bump_type, BumpType::Patch);
335                assert!(file_path.ends_with("test-changeset.md"));
336            }
337            _ => panic!("Expected AddResult::Created"),
338        }
339    }
340
341    #[test]
342    fn creates_changeset_with_multiple_packages() {
343        let project_provider =
344            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
345        let writer = MockChangesetWriter::new();
346        let interaction = MockInteractionProvider::all_cancelled();
347
348        let operation = AddOperation::new(project_provider, writer, interaction);
349
350        let mut package_bumps = HashMap::new();
351        package_bumps.insert("crate-a".to_string(), BumpType::Major);
352        package_bumps.insert("crate-b".to_string(), BumpType::Minor);
353
354        let input = AddInput {
355            package_bumps,
356            description: Some("Breaking change".to_string()),
357            ..Default::default()
358        };
359
360        let result = operation
361            .execute(Path::new("/any"), input)
362            .expect("AddOperation failed with valid multi-package input");
363
364        match result {
365            AddResult::Created { changeset, .. } => {
366                assert_eq!(changeset.releases.len(), 2);
367                let names: Vec<_> = changeset.releases.iter().map(|r| r.name.as_str()).collect();
368                assert!(names.contains(&"crate-a"));
369                assert!(names.contains(&"crate-b"));
370            }
371            _ => panic!("Expected AddResult::Created"),
372        }
373    }
374
375    #[test]
376    fn returns_cancelled_when_package_selection_cancelled() {
377        let project_provider = MockProjectProvider::workspace(vec![("a", "1.0.0"), ("b", "1.0.0")]);
378        let writer = MockChangesetWriter::new();
379        let interaction = MockInteractionProvider::all_cancelled();
380
381        let operation = AddOperation::new(project_provider, writer, interaction);
382
383        let result = operation
384            .execute(Path::new("/any"), AddInput::default())
385            .expect("AddOperation should not fail when interaction is cancelled");
386
387        assert!(matches!(result, AddResult::Cancelled));
388    }
389
390    #[test]
391    fn returns_cancelled_when_bump_selection_cancelled() {
392        let packages = vec![make_package("my-crate", "1.0.0")];
393        let project_provider = MockProjectProvider::workspace(vec![("my-crate", "1.0.0")]);
394        let writer = MockChangesetWriter::new();
395        let interaction = MockInteractionProvider {
396            package_selection: crate::traits::PackageSelection::Selected(packages),
397            bump_selections: std::sync::Mutex::new(vec![]),
398            category_selection: crate::traits::CategorySelection::Selected(ChangeCategory::Changed),
399            description: crate::traits::DescriptionInput::Provided("test".to_string()),
400        };
401
402        let operation = AddOperation::new(project_provider, writer, interaction);
403
404        let result = operation
405            .execute(Path::new("/any"), AddInput::default())
406            .expect("AddOperation should not fail when bump selection is cancelled");
407
408        assert!(matches!(result, AddResult::Cancelled));
409    }
410
411    #[test]
412    fn returns_error_for_unknown_package() {
413        let project_provider = MockProjectProvider::single_package("real-crate", "1.0.0");
414        let writer = MockChangesetWriter::new();
415        let interaction = MockInteractionProvider::all_cancelled();
416
417        let operation = AddOperation::new(project_provider, writer, interaction);
418
419        let input = AddInput {
420            packages: vec!["unknown-crate".to_string()],
421            bump: Some(BumpType::Patch),
422            description: Some("Test".to_string()),
423            ..Default::default()
424        };
425
426        let result = operation.execute(Path::new("/any"), input);
427
428        assert!(result.is_err());
429        let err = result.expect_err("AddOperation should fail for unknown package");
430        assert!(matches!(err, crate::OperationError::UnknownPackage { .. }));
431    }
432
433    #[test]
434    fn returns_error_for_empty_description() {
435        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
436        let writer = MockChangesetWriter::new();
437        let interaction = MockInteractionProvider::all_cancelled();
438
439        let operation = AddOperation::new(project_provider, writer, interaction);
440
441        let input = AddInput {
442            packages: vec!["my-crate".to_string()],
443            bump: Some(BumpType::Patch),
444            description: Some("   ".to_string()),
445            ..Default::default()
446        };
447
448        let result = operation.execute(Path::new("/any"), input);
449
450        assert!(result.is_err());
451        let err = result.expect_err("AddOperation should fail for empty description");
452        assert!(matches!(err, crate::OperationError::EmptyDescription));
453    }
454
455    #[test]
456    fn uses_interactive_selection_for_workspace_without_explicit_packages() {
457        let packages = vec![
458            make_package("crate-a", "1.0.0"),
459            make_package("crate-b", "2.0.0"),
460        ];
461        let project_provider =
462            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")]);
463        let writer = MockChangesetWriter::new();
464        let interaction =
465            MockInteractionProvider::with_selections(packages, BumpType::Minor, "Interactive desc")
466                .with_bump_sequence(vec![BumpType::Minor, BumpType::Minor]);
467
468        let operation = AddOperation::new(project_provider, writer, interaction);
469
470        let result = operation
471            .execute(Path::new("/any"), AddInput::default())
472            .expect("AddOperation failed with interactive workspace selection");
473
474        match result {
475            AddResult::Created { changeset, .. } => {
476                assert_eq!(changeset.summary, "Interactive desc");
477                assert_eq!(changeset.releases.len(), 2);
478            }
479            _ => panic!("Expected AddResult::Created"),
480        }
481    }
482
483    #[test]
484    fn auto_selects_single_package_without_interaction() {
485        let project_provider = MockProjectProvider::single_package("solo-crate", "1.0.0");
486        let writer = MockChangesetWriter::new();
487        let interaction = MockInteractionProvider::with_selections(
488            vec![make_package("solo-crate", "1.0.0")],
489            BumpType::Patch,
490            "Auto-selected",
491        );
492
493        let operation = AddOperation::new(project_provider, writer, interaction);
494
495        let input = AddInput {
496            bump: Some(BumpType::Patch),
497            description: Some("Non-interactive description".to_string()),
498            ..Default::default()
499        };
500
501        let result = operation
502            .execute(Path::new("/any"), input)
503            .expect("AddOperation failed for single-package auto-selection");
504
505        match result {
506            AddResult::Created { changeset, .. } => {
507                assert_eq!(changeset.releases.len(), 1);
508                assert_eq!(changeset.releases[0].name, "solo-crate");
509                assert_eq!(changeset.summary, "Non-interactive description");
510            }
511            _ => panic!("Expected AddResult::Created"),
512        }
513    }
514
515    #[test]
516    fn respects_explicit_category() {
517        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
518        let writer = MockChangesetWriter::new();
519        let interaction = MockInteractionProvider::all_cancelled();
520
521        let operation = AddOperation::new(project_provider, writer, interaction);
522
523        let input = AddInput {
524            packages: vec!["my-crate".to_string()],
525            bump: Some(BumpType::Minor),
526            category: ChangeCategory::Fixed,
527            description: Some("Bug fix".to_string()),
528            ..Default::default()
529        };
530
531        let result = operation
532            .execute(Path::new("/any"), input)
533            .expect("AddOperation failed with explicit category");
534
535        match result {
536            AddResult::Created { changeset, .. } => {
537                assert_eq!(changeset.category, ChangeCategory::Fixed);
538            }
539            _ => panic!("Expected AddResult::Created"),
540        }
541    }
542
543    #[test]
544    fn creates_changeset_file_in_project() {
545        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0");
546        let writer = MockChangesetWriter::new().with_filename("my-changeset.md");
547        let interaction = MockInteractionProvider::all_cancelled();
548
549        let operation = AddOperation::new(project_provider, writer, interaction);
550
551        let input = AddInput {
552            packages: vec!["my-crate".to_string()],
553            bump: Some(BumpType::Patch),
554            description: Some("Test description".to_string()),
555            ..Default::default()
556        };
557
558        let result = operation
559            .execute(Path::new("/any"), input)
560            .expect("AddOperation failed to create changeset file");
561
562        match result {
563            AddResult::Created { file_path, .. } => {
564                assert!(file_path.to_string_lossy().contains("my-changeset.md"));
565            }
566            _ => panic!("Expected AddResult::Created"),
567        }
568    }
569}