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 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}