Skip to main content

changeset_operations/operations/
init.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use changeset_git::DEFAULT_BASE_BRANCH;
5use changeset_manifest::{InitConfig, MetadataSection};
6use changeset_project::{CargoProject, ProjectKind, RootChangesetConfig};
7
8use derive_builder::Builder;
9use gset::Getset;
10
11use crate::Result;
12use crate::traits::{
13    ChangelogSettingsInput, FilteringSettingsInput, GitSettingsInput, InitInteractionProvider,
14    ManifestMetadataWriter, ProjectContext, ProjectProvider, VersionSettingsInput,
15};
16
17#[derive(Debug, Default, Builder, Getset)]
18#[builder(default)]
19pub struct InitInput {
20    #[getset(get_copy, vis = "pub")]
21    defaults: bool,
22    #[getset(get_as_ref, vis = "pub", ty = "Option<&GitSettingsInput>")]
23    git_config: Option<GitSettingsInput>,
24    #[getset(get_as_ref, vis = "pub", ty = "Option<&ChangelogSettingsInput>")]
25    changelog_config: Option<ChangelogSettingsInput>,
26    #[getset(get_as_ref, vis = "pub", ty = "Option<&VersionSettingsInput>")]
27    version_config: Option<VersionSettingsInput>,
28    #[getset(get_as_ref, vis = "pub", ty = "Option<&FilteringSettingsInput>")]
29    filtering_config: Option<FilteringSettingsInput>,
30}
31
32#[derive(Debug, Getset)]
33pub struct InitPlan {
34    #[getset(get, vis = "pub")]
35    changeset_dir: PathBuf,
36    #[getset(get_copy, vis = "pub")]
37    dir_exists: bool,
38    #[getset(get_copy, vis = "pub")]
39    gitkeep_exists: bool,
40    #[getset(get_copy, vis = "pub")]
41    metadata_section: MetadataSection,
42    #[getset(get, vis = "pub")]
43    config: InitConfig,
44}
45
46impl InitPlan {
47    #[must_use]
48    pub fn new(
49        changeset_dir: PathBuf,
50        dir_exists: bool,
51        gitkeep_exists: bool,
52        metadata_section: MetadataSection,
53        config: InitConfig,
54    ) -> Self {
55        Self {
56            changeset_dir,
57            dir_exists,
58            gitkeep_exists,
59            metadata_section,
60            config,
61        }
62    }
63}
64
65#[derive(Debug, Getset)]
66#[must_use]
67pub struct InitOutput {
68    #[getset(get, vis = "pub")]
69    changeset_dir: PathBuf,
70    #[getset(get_copy, vis = "pub")]
71    created_dir: bool,
72    #[getset(get_copy, vis = "pub")]
73    created_gitkeep: bool,
74    #[getset(get_copy, vis = "pub")]
75    wrote_config: bool,
76    #[getset(get_copy, vis = "pub")]
77    config_location: Option<MetadataSection>,
78}
79
80impl InitOutput {
81    pub(crate) fn new(
82        changeset_dir: PathBuf,
83        created_dir: bool,
84        created_gitkeep: bool,
85        wrote_config: bool,
86        config_location: Option<MetadataSection>,
87    ) -> Self {
88        Self {
89            changeset_dir,
90            created_dir,
91            created_gitkeep,
92            wrote_config,
93            config_location,
94        }
95    }
96}
97
98pub struct InitOperation<P, M = (), I = ()> {
99    project_provider: P,
100    manifest_writer: Option<M>,
101    interaction_provider: Option<I>,
102}
103
104impl<P> InitOperation<P, (), ()>
105where
106    P: ProjectProvider,
107{
108    pub fn new(project_provider: P) -> Self {
109        Self {
110            project_provider,
111            manifest_writer: None,
112            interaction_provider: None,
113        }
114    }
115
116    /// # Errors
117    ///
118    /// Returns an error if the project cannot be discovered.
119    pub fn prepare_simple(&self, start_path: &Path) -> Result<InitPlan> {
120        let project = self.project_provider.discover_project(start_path)?;
121        let (root_config, _) = self.project_provider.load_configs(&project)?;
122
123        Ok(build_init_plan(
124            &project,
125            &root_config,
126            InitConfig::default(),
127        ))
128    }
129
130    /// # Errors
131    ///
132    /// Returns an error if the changeset directory cannot be created.
133    pub fn execute_simple_plan(&self, start_path: &Path, plan: &InitPlan) -> Result<InitOutput> {
134        let project = self.project_provider.discover_project(start_path)?;
135        let (root_config, _) = self.project_provider.load_configs(&project)?;
136
137        let changeset_dir = self
138            .project_provider
139            .ensure_changeset_dir(&project, &root_config)?;
140
141        let gitkeep_path = changeset_dir.join(".gitkeep");
142        if !plan.gitkeep_exists() {
143            fs::write(&gitkeep_path, "")?;
144        }
145
146        Ok(InitOutput::new(
147            changeset_dir,
148            !plan.dir_exists(),
149            !plan.gitkeep_exists(),
150            false,
151            None,
152        ))
153    }
154
155    /// # Errors
156    ///
157    /// Returns an error if the project cannot be discovered or the changeset
158    /// directory cannot be created.
159    pub fn execute_simple(&self, start_path: &Path) -> Result<InitOutput> {
160        let plan = self.prepare_simple(start_path)?;
161        self.execute_simple_plan(start_path, &plan)
162    }
163}
164
165impl<P, M, I> InitOperation<P, M, I>
166where
167    P: ProjectProvider,
168{
169    #[must_use]
170    pub fn with_manifest_writer<M2>(self, writer: M2) -> InitOperation<P, M2, I> {
171        InitOperation {
172            project_provider: self.project_provider,
173            manifest_writer: Some(writer),
174            interaction_provider: self.interaction_provider,
175        }
176    }
177
178    #[must_use]
179    pub fn with_interaction_provider<I2>(self, provider: I2) -> InitOperation<P, M, I2> {
180        InitOperation {
181            project_provider: self.project_provider,
182            manifest_writer: self.manifest_writer,
183            interaction_provider: Some(provider),
184        }
185    }
186}
187
188impl<P, M, I> InitOperation<P, M, I>
189where
190    P: ProjectProvider,
191    M: ManifestMetadataWriter,
192    I: InitInteractionProvider,
193{
194    /// # Errors
195    ///
196    /// Returns an error if the project cannot be discovered or configuration
197    /// cannot be built (e.g., interactive prompts fail).
198    pub fn prepare(&self, start_path: &Path, input: &InitInput) -> Result<InitPlan> {
199        let project = self.project_provider.discover_project(start_path)?;
200        let (root_config, _) = self.project_provider.load_configs(&project)?;
201
202        let context = ProjectContext {
203            is_single_package: *project.kind() == ProjectKind::SinglePackage,
204        };
205        let config = self.build_config(input, context)?;
206
207        Ok(build_init_plan(&project, &root_config, config))
208    }
209
210    /// # Errors
211    ///
212    /// Returns an error if the changeset directory cannot be created or
213    /// configuration cannot be written.
214    pub fn execute_plan(&self, start_path: &Path, plan: &InitPlan) -> Result<InitOutput> {
215        let project = self.project_provider.discover_project(start_path)?;
216        let (root_config, _) = self.project_provider.load_configs(&project)?;
217
218        let changeset_dir = self
219            .project_provider
220            .ensure_changeset_dir(&project, &root_config)?;
221
222        let gitkeep_path = changeset_dir.join(".gitkeep");
223        if !plan.gitkeep_exists() {
224            fs::write(&gitkeep_path, "")?;
225        }
226
227        let wrote_config = if let Some(ref writer) = self.manifest_writer {
228            if plan.config().is_empty() {
229                false
230            } else {
231                let manifest_path = project.root().join("Cargo.toml");
232                writer.write_metadata(&manifest_path, plan.metadata_section(), plan.config())?;
233                true
234            }
235        } else {
236            false
237        };
238
239        Ok(InitOutput::new(
240            changeset_dir,
241            !plan.dir_exists(),
242            !plan.gitkeep_exists(),
243            wrote_config,
244            if wrote_config {
245                Some(plan.metadata_section())
246            } else {
247                None
248            },
249        ))
250    }
251
252    /// # Errors
253    ///
254    /// Returns an error if the project cannot be discovered, the changeset
255    /// directory cannot be created, or configuration cannot be written.
256    pub fn execute(&self, start_path: &Path, input: &InitInput) -> Result<InitOutput> {
257        let plan = self.prepare(start_path, input)?;
258        self.execute_plan(start_path, &plan)
259    }
260
261    fn build_config(&self, input: &InitInput, context: ProjectContext) -> Result<InitConfig> {
262        let mut config = build_config_from_input(input, context);
263
264        if config.is_empty() {
265            if let Some(ref provider) = self.interaction_provider {
266                let interactive_input = InitInputBuilder::default()
267                    .git_config(provider.configure_git_settings(context)?)
268                    .changelog_config(provider.configure_changelog_settings(context)?)
269                    .version_config(provider.configure_version_settings()?)
270                    .filtering_config(provider.configure_filtering_settings()?)
271                    .build()
272                    .expect("all fields have defaults");
273                apply_settings_to_config(&mut config, &interactive_input);
274            }
275        }
276
277        Ok(config)
278    }
279}
280
281fn build_init_plan(
282    project: &CargoProject,
283    root_config: &RootChangesetConfig,
284    config: InitConfig,
285) -> InitPlan {
286    let changeset_dir_path = root_config.changeset_dir();
287    let full_changeset_dir = project.root().join(changeset_dir_path);
288    let dir_exists = full_changeset_dir.exists();
289    let gitkeep_exists = full_changeset_dir.join(".gitkeep").exists();
290
291    let metadata_section = match project.kind() {
292        ProjectKind::VirtualWorkspace | ProjectKind::WorkspaceWithRoot => {
293            MetadataSection::Workspace
294        }
295        ProjectKind::SinglePackage => MetadataSection::Package,
296    };
297
298    InitPlan::new(
299        full_changeset_dir,
300        dir_exists,
301        gitkeep_exists,
302        metadata_section,
303        config,
304    )
305}
306
307/// The tag format default varies by project type:
308/// - Single package: `version-only` (e.g., `v1.0.0`)
309/// - Workspace: `crate-prefixed` (e.g., `crate-name@1.0.0`)
310#[must_use]
311pub(crate) fn build_default_config(context: ProjectContext) -> InitConfig {
312    let tag_format = if context.is_single_package {
313        changeset_manifest::TagFormat::VersionOnly
314    } else {
315        changeset_manifest::TagFormat::CratePrefixed
316    };
317
318    InitConfig {
319        commit: Some(true),
320        tags: Some(true),
321        keep_changesets: Some(false),
322        tag_format: Some(tag_format),
323        changelog: Some(changeset_manifest::ChangelogLocation::default()),
324        comparison_links: Some(changeset_manifest::ComparisonLinks::default()),
325        zero_version_behavior: Some(changeset_manifest::ZeroVersionBehavior::default()),
326        dependency_bump_changelog_template: Some(String::from(
327            "Updated dependency `{dependency}` to v{version}",
328        )),
329        base_branch: Some(String::from(DEFAULT_BASE_BRANCH)),
330        none_bump_behavior: Some(changeset_manifest::NoneBumpBehavior::default()),
331        none_bump_promote_message_template: None,
332        commit_title_template: Some(String::from("{new-version}")),
333        changes_in_body: Some(true),
334        comparison_links_template: None,
335        ignored_files: None,
336    }
337}
338
339#[must_use]
340pub fn build_config_from_input(input: &InitInput, context: ProjectContext) -> InitConfig {
341    if input.defaults() {
342        return build_default_config(context);
343    }
344
345    let mut config = InitConfig::default();
346    apply_settings_to_config(&mut config, input);
347    config
348}
349
350fn apply_settings_to_config(config: &mut InitConfig, input: &InitInput) {
351    if let Some(git) = input.git_config() {
352        config.commit = Some(git.commit);
353        config.tags = Some(git.tags);
354        config.keep_changesets = Some(git.keep_changesets);
355        config.tag_format = Some(git.tag_format);
356        config.base_branch = Some(git.base_branch.clone());
357        config
358            .commit_title_template
359            .clone_from(&git.commit_title_template);
360        config.changes_in_body = git.changes_in_body;
361    }
362
363    if let Some(changelog) = input.changelog_config() {
364        config.changelog = Some(changelog.changelog);
365        config.comparison_links = Some(changelog.comparison_links);
366        config
367            .comparison_links_template
368            .clone_from(&changelog.comparison_links_template);
369        config
370            .dependency_bump_changelog_template
371            .clone_from(&changelog.dependency_bump_changelog_template);
372    }
373
374    if let Some(version) = input.version_config() {
375        config.zero_version_behavior = version.zero_version_behavior;
376        config.none_bump_behavior = version.none_bump_behavior;
377        config
378            .none_bump_promote_message_template
379            .clone_from(&version.none_bump_promote_message_template);
380    }
381
382    if let Some(filtering) = input.filtering_config() {
383        if !filtering.ignored_files.is_empty() {
384            config.ignored_files = Some(filtering.ignored_files.clone());
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use std::sync::Arc;
392
393    use changeset_manifest::{ChangelogLocation, ComparisonLinks, TagFormat, ZeroVersionBehavior};
394
395    use super::*;
396    use crate::mocks::{MockInitInteractionProvider, MockManifestWriter, MockProjectProvider};
397
398    #[test]
399    fn returns_changeset_dir_path() {
400        let dir = tempfile::tempdir().expect("create temp dir");
401        let changeset_dir = dir.path().join(".changeset");
402        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
403
404        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
405            .with_changeset_dir(changeset_dir.clone());
406
407        let operation = InitOperation::new(project_provider);
408
409        let result = operation
410            .execute_simple(Path::new("/any"))
411            .expect("InitOperation failed for single-package project");
412
413        assert_eq!(result.changeset_dir(), &changeset_dir);
414    }
415
416    #[test]
417    fn works_with_workspace_projects() {
418        let dir = tempfile::tempdir().expect("create temp dir");
419        let changeset_dir = dir.path().join(".changeset");
420        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
421
422        let project_provider =
423            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")])
424                .with_changeset_dir(changeset_dir.clone());
425
426        let operation = InitOperation::new(project_provider);
427
428        let result = operation
429            .execute_simple(Path::new("/any"))
430            .expect("InitOperation failed for workspace project");
431
432        assert!(
433            result
434                .changeset_dir()
435                .to_string_lossy()
436                .contains(".changeset")
437        );
438    }
439
440    #[test]
441    fn creates_gitkeep_file() {
442        let dir = tempfile::tempdir().expect("create temp dir");
443        let changeset_dir = dir.path().join(".changeset");
444        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
445
446        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
447            .with_changeset_dir(changeset_dir.clone());
448
449        let operation = InitOperation::new(project_provider);
450
451        let result = operation
452            .execute_simple(Path::new("/any"))
453            .expect("InitOperation failed");
454
455        assert!(result.created_gitkeep());
456        assert!(changeset_dir.join(".gitkeep").exists());
457    }
458
459    #[test]
460    fn creates_gitkeep_even_when_dir_exists() {
461        let dir = tempfile::tempdir().expect("create temp dir");
462        let changeset_dir = dir.path().join(".changeset");
463        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
464
465        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
466            .with_changeset_dir(changeset_dir.clone());
467
468        let operation = InitOperation::new(project_provider);
469        let result = operation
470            .execute_simple(Path::new("/any"))
471            .expect("InitOperation failed");
472
473        assert!(!result.created_dir());
474        assert!(result.created_gitkeep());
475        assert!(changeset_dir.join(".gitkeep").exists());
476    }
477
478    #[test]
479    fn writes_config_with_defaults() {
480        let dir = tempfile::tempdir().expect("create temp dir");
481        let changeset_dir = dir.path().join(".changeset");
482        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
483
484        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
485            .with_changeset_dir(changeset_dir.clone());
486        let manifest_writer = Arc::new(MockManifestWriter::new());
487        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
488
489        let operation = InitOperation::new(project_provider)
490            .with_manifest_writer(Arc::clone(&manifest_writer))
491            .with_interaction_provider(Arc::clone(&interaction_provider));
492
493        let input = InitInputBuilder::default()
494            .defaults(true)
495            .build()
496            .expect("all fields have defaults");
497
498        let result = operation
499            .execute(Path::new("/any"), &input)
500            .expect("InitOperation failed");
501
502        assert!(result.wrote_config());
503        assert_eq!(result.config_location(), Some(MetadataSection::Package));
504
505        let written = manifest_writer.written_metadata();
506        assert_eq!(written.len(), 1);
507        let (_, section, config) = &written[0];
508        assert_eq!(*section, MetadataSection::Package);
509        assert_eq!(config.commit, Some(true));
510        assert_eq!(config.tags, Some(true));
511        assert_eq!(config.keep_changesets, Some(false));
512    }
513
514    #[test]
515    fn writes_config_from_input() {
516        let dir = tempfile::tempdir().expect("create temp dir");
517        let changeset_dir = dir.path().join(".changeset");
518        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
519
520        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
521            .with_changeset_dir(changeset_dir.clone());
522        let manifest_writer = Arc::new(MockManifestWriter::new());
523        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
524
525        let operation = InitOperation::new(project_provider)
526            .with_manifest_writer(Arc::clone(&manifest_writer))
527            .with_interaction_provider(Arc::clone(&interaction_provider));
528
529        let input = InitInputBuilder::default()
530            .git_config(Some(GitSettingsInput {
531                commit: false,
532                tags: true,
533                keep_changesets: true,
534                tag_format: TagFormat::CratePrefixed,
535                base_branch: String::from("main"),
536                ..Default::default()
537            }))
538            .changelog_config(Some(ChangelogSettingsInput {
539                changelog: ChangelogLocation::PerPackage,
540                comparison_links: ComparisonLinks::Enabled,
541                ..Default::default()
542            }))
543            .version_config(Some(VersionSettingsInput {
544                zero_version_behavior: Some(ZeroVersionBehavior::AutoPromoteOnMajor),
545                ..Default::default()
546            }))
547            .build()
548            .expect("all fields have defaults");
549
550        let result = operation
551            .execute(Path::new("/any"), &input)
552            .expect("InitOperation failed");
553
554        assert!(result.wrote_config());
555
556        let written = manifest_writer.written_metadata();
557        assert_eq!(written.len(), 1);
558        let (_, _, config) = &written[0];
559        assert_eq!(config.commit, Some(false));
560        assert_eq!(config.tags, Some(true));
561        assert_eq!(config.keep_changesets, Some(true));
562        assert_eq!(config.tag_format, Some(TagFormat::CratePrefixed));
563        assert_eq!(config.changelog, Some(ChangelogLocation::PerPackage));
564        assert_eq!(config.comparison_links, Some(ComparisonLinks::Enabled));
565        assert_eq!(
566            config.zero_version_behavior,
567            Some(ZeroVersionBehavior::AutoPromoteOnMajor)
568        );
569    }
570
571    #[test]
572    fn writes_partial_config() {
573        let dir = tempfile::tempdir().expect("create temp dir");
574        let changeset_dir = dir.path().join(".changeset");
575        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
576
577        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
578            .with_changeset_dir(changeset_dir.clone());
579        let manifest_writer = Arc::new(MockManifestWriter::new());
580        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
581
582        let operation = InitOperation::new(project_provider)
583            .with_manifest_writer(Arc::clone(&manifest_writer))
584            .with_interaction_provider(Arc::clone(&interaction_provider));
585
586        let input = InitInputBuilder::default()
587            .git_config(Some(GitSettingsInput {
588                commit: true,
589                tags: false,
590                keep_changesets: false,
591                tag_format: TagFormat::VersionOnly,
592                base_branch: String::from("main"),
593                ..Default::default()
594            }))
595            .build()
596            .expect("all fields have defaults");
597
598        let result = operation
599            .execute(Path::new("/any"), &input)
600            .expect("InitOperation failed");
601
602        assert!(result.wrote_config());
603
604        let written = manifest_writer.written_metadata();
605        assert_eq!(written.len(), 1);
606        let (_, _, config) = &written[0];
607        assert_eq!(config.commit, Some(true));
608        assert_eq!(config.tags, Some(false));
609        assert!(config.changelog.is_none());
610        assert!(config.zero_version_behavior.is_none());
611    }
612
613    #[test]
614    fn interactive_mode_collects_all_groups() {
615        let dir = tempfile::tempdir().expect("create temp dir");
616        let changeset_dir = dir.path().join(".changeset");
617        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
618
619        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
620            .with_changeset_dir(changeset_dir.clone());
621        let manifest_writer = Arc::new(MockManifestWriter::new());
622        let interaction_provider = Arc::new(MockInitInteractionProvider::all_defaults());
623
624        let operation = InitOperation::new(project_provider)
625            .with_manifest_writer(Arc::clone(&manifest_writer))
626            .with_interaction_provider(Arc::clone(&interaction_provider));
627
628        let input = InitInput::default();
629
630        let result = operation
631            .execute(Path::new("/any"), &input)
632            .expect("InitOperation failed");
633
634        assert!(result.wrote_config());
635
636        let written = manifest_writer.written_metadata();
637        assert_eq!(written.len(), 1);
638        let (_, _, config) = &written[0];
639        assert!(config.commit.is_some());
640        assert!(config.tags.is_some());
641        assert!(config.changelog.is_some());
642        assert!(config.zero_version_behavior.is_some());
643    }
644
645    #[test]
646    fn interactive_mode_skips_declined_groups() {
647        let dir = tempfile::tempdir().expect("create temp dir");
648        let changeset_dir = dir.path().join(".changeset");
649        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
650
651        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
652            .with_changeset_dir(changeset_dir.clone());
653        let manifest_writer = Arc::new(MockManifestWriter::new());
654        let interaction_provider = Arc::new(
655            MockInitInteractionProvider::new()
656                .with_git_settings(Some(GitSettingsInput::default()))
657                .with_changelog_settings(None)
658                .with_version_settings(None),
659        );
660
661        let operation = InitOperation::new(project_provider)
662            .with_manifest_writer(Arc::clone(&manifest_writer))
663            .with_interaction_provider(Arc::clone(&interaction_provider));
664
665        let input = InitInput::default();
666
667        let result = operation
668            .execute(Path::new("/any"), &input)
669            .expect("InitOperation failed");
670
671        assert!(result.wrote_config());
672
673        let written = manifest_writer.written_metadata();
674        assert_eq!(written.len(), 1);
675        let (_, _, config) = &written[0];
676        assert!(config.commit.is_some());
677        assert!(config.changelog.is_none());
678        assert!(config.zero_version_behavior.is_none());
679    }
680
681    #[test]
682    fn skips_config_write_when_empty() {
683        let dir = tempfile::tempdir().expect("create temp dir");
684        let changeset_dir = dir.path().join(".changeset");
685        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
686
687        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
688            .with_changeset_dir(changeset_dir.clone());
689        let manifest_writer = Arc::new(MockManifestWriter::new());
690        let interaction_provider = Arc::new(MockInitInteractionProvider::all_skipped());
691
692        let operation = InitOperation::new(project_provider)
693            .with_manifest_writer(Arc::clone(&manifest_writer))
694            .with_interaction_provider(Arc::clone(&interaction_provider));
695
696        let input = InitInput::default();
697
698        let result = operation
699            .execute(Path::new("/any"), &input)
700            .expect("InitOperation failed");
701
702        assert!(!result.wrote_config());
703        assert!(result.config_location().is_none());
704
705        let written = manifest_writer.written_metadata();
706        assert!(written.is_empty());
707    }
708
709    #[test]
710    fn workspace_uses_workspace_metadata() {
711        let dir = tempfile::tempdir().expect("create temp dir");
712        let changeset_dir = dir.path().join(".changeset");
713        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
714
715        let project_provider =
716            MockProjectProvider::workspace(vec![("crate-a", "1.0.0"), ("crate-b", "2.0.0")])
717                .with_changeset_dir(changeset_dir.clone());
718        let manifest_writer = Arc::new(MockManifestWriter::new());
719        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
720
721        let operation = InitOperation::new(project_provider)
722            .with_manifest_writer(Arc::clone(&manifest_writer))
723            .with_interaction_provider(Arc::clone(&interaction_provider));
724
725        let input = InitInputBuilder::default()
726            .defaults(true)
727            .build()
728            .expect("all fields have defaults");
729
730        let result = operation
731            .execute(Path::new("/any"), &input)
732            .expect("InitOperation failed");
733
734        assert!(result.wrote_config());
735        assert_eq!(result.config_location(), Some(MetadataSection::Workspace));
736
737        let written = manifest_writer.written_metadata();
738        assert_eq!(written.len(), 1);
739        let (_, section, _) = &written[0];
740        assert_eq!(*section, MetadataSection::Workspace);
741    }
742
743    #[test]
744    fn single_package_uses_package_metadata() {
745        let dir = tempfile::tempdir().expect("create temp dir");
746        let changeset_dir = dir.path().join(".changeset");
747        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
748
749        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
750            .with_changeset_dir(changeset_dir.clone());
751        let manifest_writer = Arc::new(MockManifestWriter::new());
752        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
753
754        let operation = InitOperation::new(project_provider)
755            .with_manifest_writer(Arc::clone(&manifest_writer))
756            .with_interaction_provider(Arc::clone(&interaction_provider));
757
758        let input = InitInputBuilder::default()
759            .defaults(true)
760            .build()
761            .expect("all fields have defaults");
762
763        let result = operation
764            .execute(Path::new("/any"), &input)
765            .expect("InitOperation failed");
766
767        assert!(result.wrote_config());
768        assert_eq!(result.config_location(), Some(MetadataSection::Package));
769
770        let written = manifest_writer.written_metadata();
771        assert_eq!(written.len(), 1);
772        let (_, section, _) = &written[0];
773        assert_eq!(*section, MetadataSection::Package);
774    }
775
776    #[test]
777    fn default_config_includes_dependency_bump_changelog_template() {
778        let context = ProjectContext {
779            is_single_package: true,
780        };
781        let config = build_default_config(context);
782        assert_eq!(
783            config.dependency_bump_changelog_template,
784            Some("Updated dependency `{dependency}` to v{version}".to_string())
785        );
786    }
787
788    #[test]
789    fn default_config_includes_none_bump_behavior() {
790        use changeset_manifest::NoneBumpBehavior;
791
792        let context = ProjectContext {
793            is_single_package: true,
794        };
795        let config = build_default_config(context);
796        assert_eq!(config.none_bump_behavior, Some(NoneBumpBehavior::default()));
797        assert!(config.none_bump_promote_message_template.is_none());
798    }
799
800    #[test]
801    fn none_bump_fields_propagate_from_version_settings_input() {
802        use changeset_manifest::NoneBumpBehavior;
803
804        let context = ProjectContext {
805            is_single_package: true,
806        };
807
808        let input = InitInputBuilder::default()
809            .version_config(Some(VersionSettingsInput {
810                zero_version_behavior: Some(ZeroVersionBehavior::default()),
811                none_bump_behavior: Some(NoneBumpBehavior::Disallow),
812                none_bump_promote_message_template: Some("Custom message".to_string()),
813            }))
814            .build()
815            .expect("all fields have defaults");
816
817        let config = build_config_from_input(&input, context);
818        assert_eq!(config.none_bump_behavior, Some(NoneBumpBehavior::Disallow));
819        assert_eq!(
820            config.none_bump_promote_message_template,
821            Some("Custom message".to_string())
822        );
823    }
824
825    #[test]
826    fn none_bump_fields_default_when_no_version_config() {
827        let context = ProjectContext {
828            is_single_package: true,
829        };
830
831        let input = InitInput::default();
832
833        let config = build_config_from_input(&input, context);
834        assert!(config.none_bump_behavior.is_none());
835        assert!(config.none_bump_promote_message_template.is_none());
836    }
837
838    #[test]
839    fn commit_title_template_propagates_from_git_settings() {
840        let context = ProjectContext {
841            is_single_package: true,
842        };
843
844        let input = InitInputBuilder::default()
845            .git_config(Some(GitSettingsInput {
846                commit_title_template: Some("Release {new-version}".to_string()),
847                ..Default::default()
848            }))
849            .build()
850            .expect("all fields have defaults");
851
852        let config = build_config_from_input(&input, context);
853        assert_eq!(
854            config.commit_title_template,
855            Some("Release {new-version}".to_string())
856        );
857    }
858
859    #[test]
860    fn changes_in_body_propagates_from_git_settings() {
861        let context = ProjectContext {
862            is_single_package: true,
863        };
864
865        let input = InitInputBuilder::default()
866            .git_config(Some(GitSettingsInput {
867                changes_in_body: Some(false),
868                ..Default::default()
869            }))
870            .build()
871            .expect("all fields have defaults");
872
873        let config = build_config_from_input(&input, context);
874        assert_eq!(config.changes_in_body, Some(false));
875    }
876
877    #[test]
878    fn comparison_links_template_propagates_from_changelog_settings() {
879        let context = ProjectContext {
880            is_single_package: true,
881        };
882
883        let input = InitInputBuilder::default()
884            .changelog_config(Some(ChangelogSettingsInput {
885                comparison_links_template: Some(
886                    "https://github.com/org/repo/compare/{base}...{target}".to_string(),
887                ),
888                ..Default::default()
889            }))
890            .build()
891            .expect("all fields have defaults");
892
893        let config = build_config_from_input(&input, context);
894        assert_eq!(
895            config.comparison_links_template,
896            Some("https://github.com/org/repo/compare/{base}...{target}".to_string())
897        );
898    }
899
900    #[test]
901    fn dependency_bump_changelog_template_propagates_from_changelog_settings() {
902        let context = ProjectContext {
903            is_single_package: true,
904        };
905
906        let input = InitInputBuilder::default()
907            .changelog_config(Some(ChangelogSettingsInput {
908                dependency_bump_changelog_template: Some(
909                    "Updated `{dependency}` to v{version}".to_string(),
910                ),
911                ..Default::default()
912            }))
913            .build()
914            .expect("all fields have defaults");
915
916        let config = build_config_from_input(&input, context);
917        assert_eq!(
918            config.dependency_bump_changelog_template,
919            Some("Updated `{dependency}` to v{version}".to_string())
920        );
921    }
922
923    #[test]
924    fn filtering_config_propagates_ignored_files() {
925        let context = ProjectContext {
926            is_single_package: true,
927        };
928
929        let input = InitInputBuilder::default()
930            .filtering_config(Some(FilteringSettingsInput {
931                ignored_files: vec!["*.lock".to_string(), "docs/**".to_string()],
932            }))
933            .build()
934            .expect("all fields have defaults");
935
936        let config = build_config_from_input(&input, context);
937        assert_eq!(
938            config.ignored_files,
939            Some(vec!["*.lock".to_string(), "docs/**".to_string()])
940        );
941    }
942
943    #[test]
944    fn filtering_config_skips_empty_ignored_files() {
945        let context = ProjectContext {
946            is_single_package: true,
947        };
948
949        let input = InitInputBuilder::default()
950            .filtering_config(Some(FilteringSettingsInput {
951                ignored_files: vec![],
952            }))
953            .build()
954            .expect("all fields have defaults");
955
956        let config = build_config_from_input(&input, context);
957        assert!(config.ignored_files.is_none());
958    }
959
960    #[test]
961    fn interactive_mode_collects_filtering_settings() {
962        let dir = tempfile::tempdir().expect("create temp dir");
963        let changeset_dir = dir.path().join(".changeset");
964        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
965
966        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
967            .with_changeset_dir(changeset_dir.clone());
968        let manifest_writer = Arc::new(MockManifestWriter::new());
969        let interaction_provider = Arc::new(
970            MockInitInteractionProvider::new()
971                .with_git_settings(Some(GitSettingsInput::default()))
972                .with_changelog_settings(None)
973                .with_version_settings(None)
974                .with_filtering_settings(Some(FilteringSettingsInput {
975                    ignored_files: vec!["*.lock".to_string()],
976                })),
977        );
978
979        let operation = InitOperation::new(project_provider)
980            .with_manifest_writer(Arc::clone(&manifest_writer))
981            .with_interaction_provider(Arc::clone(&interaction_provider));
982
983        let input = InitInput::default();
984
985        let result = operation
986            .execute(Path::new("/any"), &input)
987            .expect("InitOperation failed");
988
989        assert!(result.wrote_config());
990
991        let written = manifest_writer.written_metadata();
992        assert_eq!(written.len(), 1);
993        let (_, _, config) = &written[0];
994        assert_eq!(config.ignored_files, Some(vec!["*.lock".to_string()]));
995    }
996
997    #[test]
998    fn base_branch_propagates_from_git_settings_input() {
999        let dir = tempfile::tempdir().expect("create temp dir");
1000        let changeset_dir = dir.path().join(".changeset");
1001        std::fs::create_dir_all(&changeset_dir).expect("create changeset dir");
1002
1003        let project_provider = MockProjectProvider::single_package("my-crate", "1.0.0")
1004            .with_changeset_dir(changeset_dir.clone());
1005        let manifest_writer = Arc::new(MockManifestWriter::new());
1006        let interaction_provider = Arc::new(MockInitInteractionProvider::new());
1007
1008        let operation = InitOperation::new(project_provider)
1009            .with_manifest_writer(Arc::clone(&manifest_writer))
1010            .with_interaction_provider(Arc::clone(&interaction_provider));
1011
1012        let input = InitInputBuilder::default()
1013            .git_config(Some(GitSettingsInput {
1014                commit: true,
1015                tags: true,
1016                keep_changesets: false,
1017                tag_format: TagFormat::VersionOnly,
1018                base_branch: String::from("develop"),
1019                ..Default::default()
1020            }))
1021            .build()
1022            .expect("all fields have defaults");
1023
1024        let _ = operation
1025            .execute(Path::new("/any"), &input)
1026            .expect("InitOperation failed");
1027
1028        let written = manifest_writer.written_metadata();
1029        assert_eq!(written.len(), 1);
1030        let (_, _, config) = &written[0];
1031        assert_eq!(config.base_branch, Some(String::from("develop")));
1032    }
1033}