1use std::fs;
2use std::path::{Path, PathBuf};
3
4use changeset_manifest::{InitConfig, MetadataSection};
5use changeset_project::{CargoProject, ProjectKind, RootChangesetConfig};
6
7use crate::Result;
8use crate::traits::{
9 ChangelogSettingsInput, GitSettingsInput, InitInteractionProvider, ManifestWriter,
10 ProjectContext, ProjectProvider, VersionSettingsInput,
11};
12
13#[derive(Debug, Default)]
20pub struct InitInput {
21 pub defaults: bool,
22 pub git_config: Option<GitSettingsInput>,
23 pub changelog_config: Option<ChangelogSettingsInput>,
24 pub version_config: Option<VersionSettingsInput>,
25}
26
27#[derive(Debug)]
29pub struct InitPlan {
30 pub changeset_dir: PathBuf,
31 pub dir_exists: bool,
32 pub gitkeep_exists: bool,
33 pub metadata_section: MetadataSection,
34 pub config: InitConfig,
35}
36
37#[derive(Debug)]
38#[must_use]
39pub struct InitOutput {
40 pub changeset_dir: PathBuf,
41 pub created_dir: bool,
42 pub created_gitkeep: bool,
43 pub wrote_config: bool,
44 pub config_location: Option<MetadataSection>,
45}
46
47pub struct InitOperation<P, M = (), I = ()> {
48 project_provider: P,
49 manifest_writer: Option<M>,
50 interaction_provider: Option<I>,
51}
52
53impl<P> InitOperation<P, (), ()>
54where
55 P: ProjectProvider,
56{
57 pub fn new(project_provider: P) -> Self {
58 Self {
59 project_provider,
60 manifest_writer: None,
61 interaction_provider: None,
62 }
63 }
64}
65
66impl<P, M, I> InitOperation<P, M, I>
67where
68 P: ProjectProvider,
69{
70 #[must_use]
71 pub fn with_manifest_writer<M2>(self, writer: M2) -> InitOperation<P, M2, I> {
72 InitOperation {
73 project_provider: self.project_provider,
74 manifest_writer: Some(writer),
75 interaction_provider: self.interaction_provider,
76 }
77 }
78
79 #[must_use]
80 pub fn with_interaction_provider<I2>(self, provider: I2) -> InitOperation<P, M, I2> {
81 InitOperation {
82 project_provider: self.project_provider,
83 manifest_writer: self.manifest_writer,
84 interaction_provider: Some(provider),
85 }
86 }
87}
88
89impl<P, M, I> InitOperation<P, M, I>
90where
91 P: ProjectProvider,
92 M: ManifestWriter,
93 I: InitInteractionProvider,
94{
95 pub fn prepare(&self, start_path: &Path, input: &InitInput) -> Result<InitPlan> {
103 let project = self.project_provider.discover_project(start_path)?;
104 let (root_config, _) = self.project_provider.load_configs(&project)?;
105
106 let context = ProjectContext {
107 is_single_package: project.kind == ProjectKind::SinglePackage,
108 };
109 let config = self.build_config(input, context)?;
110
111 Ok(build_init_plan(&project, &root_config, config))
112 }
113
114 pub fn execute_plan(&self, start_path: &Path, plan: &InitPlan) -> Result<InitOutput> {
121 let project = self.project_provider.discover_project(start_path)?;
122 let (root_config, _) = self.project_provider.load_configs(&project)?;
123
124 let changeset_dir = self
125 .project_provider
126 .ensure_changeset_dir(&project, &root_config)?;
127
128 let gitkeep_path = changeset_dir.join(".gitkeep");
129 if !plan.gitkeep_exists {
130 fs::write(&gitkeep_path, "")?;
131 }
132
133 let wrote_config = if let Some(ref writer) = self.manifest_writer {
134 if plan.config.is_empty() {
135 false
136 } else {
137 let manifest_path = project.root.join("Cargo.toml");
138 writer.write_metadata(&manifest_path, plan.metadata_section, &plan.config)?;
139 true
140 }
141 } else {
142 false
143 };
144
145 Ok(InitOutput {
146 changeset_dir,
147 created_dir: !plan.dir_exists,
148 created_gitkeep: !plan.gitkeep_exists,
149 wrote_config,
150 config_location: if wrote_config {
151 Some(plan.metadata_section)
152 } else {
153 None
154 },
155 })
156 }
157
158 pub fn execute(&self, start_path: &Path, input: &InitInput) -> Result<InitOutput> {
165 let plan = self.prepare(start_path, input)?;
166 self.execute_plan(start_path, &plan)
167 }
168
169 fn build_config(&self, input: &InitInput, context: ProjectContext) -> Result<InitConfig> {
170 if input.defaults {
171 return Ok(build_default_config(context));
172 }
173
174 let mut config = InitConfig::default();
175
176 if let Some(ref git) = input.git_config {
177 config.commit = Some(git.commit);
178 config.tags = Some(git.tags);
179 config.keep_changesets = Some(git.keep_changesets);
180 config.tag_format = Some(git.tag_format);
181 }
182
183 if let Some(ref changelog) = input.changelog_config {
184 config.changelog = Some(changelog.changelog);
185 config.comparison_links = Some(changelog.comparison_links);
186 }
187
188 if let Some(ref version) = input.version_config {
189 config.zero_version_behavior = Some(version.zero_version_behavior);
190 }
191
192 if config.is_empty() {
193 if let Some(ref provider) = self.interaction_provider {
194 if let Some(git) = provider.configure_git_settings(context)? {
195 config.commit = Some(git.commit);
196 config.tags = Some(git.tags);
197 config.keep_changesets = Some(git.keep_changesets);
198 config.tag_format = Some(git.tag_format);
199 }
200
201 if let Some(changelog) = provider.configure_changelog_settings(context)? {
202 config.changelog = Some(changelog.changelog);
203 config.comparison_links = Some(changelog.comparison_links);
204 }
205
206 if let Some(version) = provider.configure_version_settings()? {
207 config.zero_version_behavior = Some(version.zero_version_behavior);
208 }
209 }
210 }
211
212 Ok(config)
213 }
214}
215
216fn build_init_plan(
218 project: &CargoProject,
219 root_config: &RootChangesetConfig,
220 config: InitConfig,
221) -> InitPlan {
222 let changeset_dir_path = root_config.changeset_dir();
223 let full_changeset_dir = project.root.join(changeset_dir_path);
224 let dir_exists = full_changeset_dir.exists();
225 let gitkeep_exists = full_changeset_dir.join(".gitkeep").exists();
226
227 let metadata_section = match project.kind {
228 ProjectKind::VirtualWorkspace | ProjectKind::WorkspaceWithRoot => {
229 MetadataSection::Workspace
230 }
231 ProjectKind::SinglePackage => MetadataSection::Package,
232 };
233
234 InitPlan {
235 changeset_dir: full_changeset_dir,
236 dir_exists,
237 gitkeep_exists,
238 metadata_section,
239 config,
240 }
241}
242
243#[must_use]
249pub fn build_default_config(context: ProjectContext) -> InitConfig {
250 let tag_format = if context.is_single_package {
251 changeset_manifest::TagFormat::VersionOnly
252 } else {
253 changeset_manifest::TagFormat::CratePrefixed
254 };
255
256 InitConfig {
257 commit: Some(true),
258 tags: Some(true),
259 keep_changesets: Some(false),
260 tag_format: Some(tag_format),
261 changelog: Some(changeset_manifest::ChangelogLocation::default()),
262 comparison_links: Some(changeset_manifest::ComparisonLinks::default()),
263 zero_version_behavior: Some(changeset_manifest::ZeroVersionBehavior::default()),
264 }
265}
266
267#[must_use]
269pub fn build_config_from_input(input: &InitInput, context: ProjectContext) -> InitConfig {
270 if input.defaults {
271 return build_default_config(context);
272 }
273
274 let mut config = InitConfig::default();
275
276 if let Some(ref git) = input.git_config {
277 config.commit = Some(git.commit);
278 config.tags = Some(git.tags);
279 config.keep_changesets = Some(git.keep_changesets);
280 config.tag_format = Some(git.tag_format);
281 }
282
283 if let Some(ref changelog) = input.changelog_config {
284 config.changelog = Some(changelog.changelog);
285 config.comparison_links = Some(changelog.comparison_links);
286 }
287
288 if let Some(ref version) = input.version_config {
289 config.zero_version_behavior = Some(version.zero_version_behavior);
290 }
291
292 config
293}
294
295impl<P> InitOperation<P, (), ()>
296where
297 P: ProjectProvider,
298{
299 pub fn prepare_simple(&self, start_path: &Path) -> Result<InitPlan> {
305 let project = self.project_provider.discover_project(start_path)?;
306 let (root_config, _) = self.project_provider.load_configs(&project)?;
307
308 Ok(build_init_plan(
309 &project,
310 &root_config,
311 InitConfig::default(),
312 ))
313 }
314
315 pub fn execute_simple_plan(&self, start_path: &Path, plan: &InitPlan) -> Result<InitOutput> {
321 let project = self.project_provider.discover_project(start_path)?;
322 let (root_config, _) = self.project_provider.load_configs(&project)?;
323
324 let changeset_dir = self
325 .project_provider
326 .ensure_changeset_dir(&project, &root_config)?;
327
328 let gitkeep_path = changeset_dir.join(".gitkeep");
329 if !plan.gitkeep_exists {
330 fs::write(&gitkeep_path, "")?;
331 }
332
333 Ok(InitOutput {
334 changeset_dir,
335 created_dir: !plan.dir_exists,
336 created_gitkeep: !plan.gitkeep_exists,
337 wrote_config: false,
338 config_location: None,
339 })
340 }
341
342 pub fn execute_simple(&self, start_path: &Path) -> Result<InitOutput> {
350 let plan = self.prepare_simple(start_path)?;
351 self.execute_simple_plan(start_path, &plan)
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use std::sync::Arc;
358
359 use changeset_manifest::{ChangelogLocation, ComparisonLinks, TagFormat, ZeroVersionBehavior};
360
361 use super::*;
362 use crate::mocks::{MockInitInteractionProvider, MockManifestWriter, MockProjectProvider};
363
364 #[test]
365 fn returns_changeset_dir_path() {
366 let dir = tempfile::tempdir().expect("create temp dir");
367 let changeset_dir = dir.path().join(".changeset");
368 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
369
370 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
371 .with_changeset_dir(changeset_dir.clone());
372
373 let operation = InitOperation::new(project_provider);
374
375 let result = operation
376 .execute_simple(Path::new("/any"))
377 .expect("InitOperation failed for single-package project");
378
379 assert_eq!(result.changeset_dir, changeset_dir);
380 }
381
382 #[test]
383 fn works_with_workspace_projects() {
384 let dir = tempfile::tempdir().expect("create temp dir");
385 let changeset_dir = dir.path().join(".changeset");
386 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
387
388 let project_provider =
389 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")])
390 .with_changeset_dir(changeset_dir.clone());
391
392 let operation = InitOperation::new(project_provider);
393
394 let result = operation
395 .execute_simple(Path::new("/any"))
396 .expect("InitOperation failed for workspace project");
397
398 assert!(
399 result
400 .changeset_dir
401 .to_string_lossy()
402 .contains(".changeset")
403 );
404 }
405
406 #[test]
407 fn creates_gitkeep_file() {
408 let dir = tempfile::tempdir().expect("create temp dir");
409 let changeset_dir = dir.path().join(".changeset");
410 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
411
412 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
413 .with_changeset_dir(changeset_dir.clone());
414
415 let operation = InitOperation::new(project_provider);
416
417 let result = operation
418 .execute_simple(Path::new("/any"))
419 .expect("InitOperation failed");
420
421 assert!(result.created_gitkeep);
422 assert!(changeset_dir.join(".gitkeep").exists());
423 }
424
425 #[test]
426 fn creates_gitkeep_even_when_dir_exists() {
427 let dir = tempfile::tempdir().expect("create temp dir");
428 let changeset_dir = dir.path().join(".changeset");
429 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
430
431 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
432 .with_changeset_dir(changeset_dir.clone());
433
434 let operation = InitOperation::new(project_provider);
435 let result = operation
436 .execute_simple(Path::new("/any"))
437 .expect("InitOperation failed");
438
439 assert!(!result.created_dir);
440 assert!(result.created_gitkeep);
441 assert!(changeset_dir.join(".gitkeep").exists());
442 }
443
444 #[test]
445 fn writes_config_with_defaults() {
446 let dir = tempfile::tempdir().expect("create temp dir");
447 let changeset_dir = dir.path().join(".changeset");
448 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
449
450 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
451 .with_changeset_dir(changeset_dir.clone());
452 let manifest_writer = Arc::new(MockManifestWriter::new());
453 let interaction_provider = Arc::new(MockInitInteractionProvider::new());
454
455 let operation = InitOperation::new(project_provider)
456 .with_manifest_writer(Arc::clone(&manifest_writer))
457 .with_interaction_provider(Arc::clone(&interaction_provider));
458
459 let input = InitInput {
460 defaults: true,
461 ..Default::default()
462 };
463
464 let result = operation
465 .execute(Path::new("/any"), &input)
466 .expect("InitOperation failed");
467
468 assert!(result.wrote_config);
469 assert_eq!(result.config_location, Some(MetadataSection::Package));
470
471 let written = manifest_writer.written_metadata();
472 assert_eq!(written.len(), 1);
473 let (_, section, config) = &written[0];
474 assert_eq!(*section, MetadataSection::Package);
475 assert_eq!(config.commit, Some(true));
476 assert_eq!(config.tags, Some(true));
477 assert_eq!(config.keep_changesets, Some(false));
478 }
479
480 #[test]
481 fn writes_config_from_input() {
482 let dir = tempfile::tempdir().expect("create temp dir");
483 let changeset_dir = dir.path().join(".changeset");
484 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
485
486 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
487 .with_changeset_dir(changeset_dir.clone());
488 let manifest_writer = Arc::new(MockManifestWriter::new());
489 let interaction_provider = Arc::new(MockInitInteractionProvider::new());
490
491 let operation = InitOperation::new(project_provider)
492 .with_manifest_writer(Arc::clone(&manifest_writer))
493 .with_interaction_provider(Arc::clone(&interaction_provider));
494
495 let input = InitInput {
496 defaults: false,
497 git_config: Some(GitSettingsInput {
498 commit: false,
499 tags: true,
500 keep_changesets: true,
501 tag_format: TagFormat::CratePrefixed,
502 }),
503 changelog_config: Some(ChangelogSettingsInput {
504 changelog: ChangelogLocation::PerPackage,
505 comparison_links: ComparisonLinks::Enabled,
506 }),
507 version_config: Some(VersionSettingsInput {
508 zero_version_behavior: ZeroVersionBehavior::AutoPromoteOnMajor,
509 }),
510 };
511
512 let result = operation
513 .execute(Path::new("/any"), &input)
514 .expect("InitOperation failed");
515
516 assert!(result.wrote_config);
517
518 let written = manifest_writer.written_metadata();
519 assert_eq!(written.len(), 1);
520 let (_, _, config) = &written[0];
521 assert_eq!(config.commit, Some(false));
522 assert_eq!(config.tags, Some(true));
523 assert_eq!(config.keep_changesets, Some(true));
524 assert_eq!(config.tag_format, Some(TagFormat::CratePrefixed));
525 assert_eq!(config.changelog, Some(ChangelogLocation::PerPackage));
526 assert_eq!(config.comparison_links, Some(ComparisonLinks::Enabled));
527 assert_eq!(
528 config.zero_version_behavior,
529 Some(ZeroVersionBehavior::AutoPromoteOnMajor)
530 );
531 }
532
533 #[test]
534 fn writes_partial_config() {
535 let dir = tempfile::tempdir().expect("create temp dir");
536 let changeset_dir = dir.path().join(".changeset");
537 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
538
539 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
540 .with_changeset_dir(changeset_dir.clone());
541 let manifest_writer = Arc::new(MockManifestWriter::new());
542 let interaction_provider = Arc::new(MockInitInteractionProvider::new());
543
544 let operation = InitOperation::new(project_provider)
545 .with_manifest_writer(Arc::clone(&manifest_writer))
546 .with_interaction_provider(Arc::clone(&interaction_provider));
547
548 let input = InitInput {
549 defaults: false,
550 git_config: Some(GitSettingsInput {
551 commit: true,
552 tags: false,
553 keep_changesets: false,
554 tag_format: TagFormat::VersionOnly,
555 }),
556 changelog_config: None,
557 version_config: None,
558 };
559
560 let result = operation
561 .execute(Path::new("/any"), &input)
562 .expect("InitOperation failed");
563
564 assert!(result.wrote_config);
565
566 let written = manifest_writer.written_metadata();
567 assert_eq!(written.len(), 1);
568 let (_, _, config) = &written[0];
569 assert_eq!(config.commit, Some(true));
570 assert_eq!(config.tags, Some(false));
571 assert!(config.changelog.is_none());
572 assert!(config.zero_version_behavior.is_none());
573 }
574
575 #[test]
576 fn interactive_mode_collects_all_groups() {
577 let dir = tempfile::tempdir().expect("create temp dir");
578 let changeset_dir = dir.path().join(".changeset");
579 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
580
581 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
582 .with_changeset_dir(changeset_dir.clone());
583 let manifest_writer = Arc::new(MockManifestWriter::new());
584 let interaction_provider = Arc::new(MockInitInteractionProvider::all_defaults());
585
586 let operation = InitOperation::new(project_provider)
587 .with_manifest_writer(Arc::clone(&manifest_writer))
588 .with_interaction_provider(Arc::clone(&interaction_provider));
589
590 let input = InitInput::default();
591
592 let result = operation
593 .execute(Path::new("/any"), &input)
594 .expect("InitOperation failed");
595
596 assert!(result.wrote_config);
597
598 let written = manifest_writer.written_metadata();
599 assert_eq!(written.len(), 1);
600 let (_, _, config) = &written[0];
601 assert!(config.commit.is_some());
602 assert!(config.tags.is_some());
603 assert!(config.changelog.is_some());
604 assert!(config.zero_version_behavior.is_some());
605 }
606
607 #[test]
608 fn interactive_mode_skips_declined_groups() {
609 let dir = tempfile::tempdir().expect("create temp dir");
610 let changeset_dir = dir.path().join(".changeset");
611 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
612
613 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
614 .with_changeset_dir(changeset_dir.clone());
615 let manifest_writer = Arc::new(MockManifestWriter::new());
616 let interaction_provider = Arc::new(
617 MockInitInteractionProvider::new()
618 .with_git_settings(Some(GitSettingsInput::default()))
619 .with_changelog_settings(None)
620 .with_version_settings(None),
621 );
622
623 let operation = InitOperation::new(project_provider)
624 .with_manifest_writer(Arc::clone(&manifest_writer))
625 .with_interaction_provider(Arc::clone(&interaction_provider));
626
627 let input = InitInput::default();
628
629 let result = operation
630 .execute(Path::new("/any"), &input)
631 .expect("InitOperation failed");
632
633 assert!(result.wrote_config);
634
635 let written = manifest_writer.written_metadata();
636 assert_eq!(written.len(), 1);
637 let (_, _, config) = &written[0];
638 assert!(config.commit.is_some());
639 assert!(config.changelog.is_none());
640 assert!(config.zero_version_behavior.is_none());
641 }
642
643 #[test]
644 fn skips_config_write_when_empty() {
645 let dir = tempfile::tempdir().expect("create temp dir");
646 let changeset_dir = dir.path().join(".changeset");
647 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
648
649 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
650 .with_changeset_dir(changeset_dir.clone());
651 let manifest_writer = Arc::new(MockManifestWriter::new());
652 let interaction_provider = Arc::new(MockInitInteractionProvider::all_skipped());
653
654 let operation = InitOperation::new(project_provider)
655 .with_manifest_writer(Arc::clone(&manifest_writer))
656 .with_interaction_provider(Arc::clone(&interaction_provider));
657
658 let input = InitInput::default();
659
660 let result = operation
661 .execute(Path::new("/any"), &input)
662 .expect("InitOperation failed");
663
664 assert!(!result.wrote_config);
665 assert!(result.config_location.is_none());
666
667 let written = manifest_writer.written_metadata();
668 assert!(written.is_empty());
669 }
670
671 #[test]
672 fn workspace_uses_workspace_metadata() {
673 let dir = tempfile::tempdir().expect("create temp dir");
674 let changeset_dir = dir.path().join(".changeset");
675 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
676
677 let project_provider =
678 MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")])
679 .with_changeset_dir(changeset_dir.clone());
680 let manifest_writer = Arc::new(MockManifestWriter::new());
681 let interaction_provider = Arc::new(MockInitInteractionProvider::new());
682
683 let operation = InitOperation::new(project_provider)
684 .with_manifest_writer(Arc::clone(&manifest_writer))
685 .with_interaction_provider(Arc::clone(&interaction_provider));
686
687 let input = InitInput {
688 defaults: true,
689 ..Default::default()
690 };
691
692 let result = operation
693 .execute(Path::new("/any"), &input)
694 .expect("InitOperation failed");
695
696 assert!(result.wrote_config);
697 assert_eq!(result.config_location, Some(MetadataSection::Workspace));
698
699 let written = manifest_writer.written_metadata();
700 assert_eq!(written.len(), 1);
701 let (_, section, _) = &written[0];
702 assert_eq!(*section, MetadataSection::Workspace);
703 }
704
705 #[test]
706 fn single_package_uses_package_metadata() {
707 let dir = tempfile::tempdir().expect("create temp dir");
708 let changeset_dir = dir.path().join(".changeset");
709 std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
710
711 let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
712 .with_changeset_dir(changeset_dir.clone());
713 let manifest_writer = Arc::new(MockManifestWriter::new());
714 let interaction_provider = Arc::new(MockInitInteractionProvider::new());
715
716 let operation = InitOperation::new(project_provider)
717 .with_manifest_writer(Arc::clone(&manifest_writer))
718 .with_interaction_provider(Arc::clone(&interaction_provider));
719
720 let input = InitInput {
721 defaults: true,
722 ..Default::default()
723 };
724
725 let result = operation
726 .execute(Path::new("/any"), &input)
727 .expect("InitOperation failed");
728
729 assert!(result.wrote_config);
730 assert_eq!(result.config_location, Some(MetadataSection::Package));
731
732 let written = manifest_writer.written_metadata();
733 assert_eq!(written.len(), 1);
734 let (_, section, _) = &written[0];
735 assert_eq!(*section, MetadataSection::Package);
736 }
737}