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