Skip to main content

changeset_manifest/
writer.rs

1use std::path::Path;
2
3use semver::Version;
4use toml_edit::{DocumentMut, Item, Table, TableLike, value};
5
6use crate::config::{InitConfig, MetadataSection};
7use crate::error::ManifestError;
8use crate::reader::{read_document, read_version};
9
10const DEPENDENCY_SECTIONS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
11
12/// # Errors
13///
14/// Returns an error if the manifest cannot be read, parsed, or written.
15pub fn write_version(path: &Path, version: &Version) -> Result<(), ManifestError> {
16    let mut doc = read_document(path)?;
17
18    let package = doc
19        .get_mut("package")
20        .ok_or_else(|| ManifestError::MissingField {
21            path: path.to_path_buf(),
22            field: "package".to_string(),
23        })?;
24
25    let package_table = package
26        .as_table_like_mut()
27        .ok_or_else(|| ManifestError::MissingField {
28            path: path.to_path_buf(),
29            field: "package (as table)".to_string(),
30        })?;
31
32    package_table.insert("version", value(version.to_string()));
33
34    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
35        path: path.to_path_buf(),
36        source,
37    })
38}
39
40/// # Errors
41///
42/// Returns an error if the manifest cannot be read, parsed, or written.
43pub fn remove_workspace_version(path: &Path) -> Result<(), ManifestError> {
44    let mut doc = read_document(path)?;
45
46    let Some(workspace) = doc.get_mut("workspace") else {
47        return Ok(());
48    };
49
50    let Some(workspace_table) = workspace.as_table_like_mut() else {
51        return Ok(());
52    };
53
54    let Some(package) = workspace_table.get_mut("package") else {
55        return Ok(());
56    };
57
58    let Some(package_table) = package.as_table_like_mut() else {
59        return Ok(());
60    };
61
62    package_table.remove("version");
63
64    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
65        path: path.to_path_buf(),
66        source,
67    })
68}
69
70/// Writes or restores the workspace package version in a root manifest.
71///
72/// # Errors
73///
74/// Returns an error if the manifest cannot be read, parsed, or written.
75pub fn write_workspace_version(path: &Path, version: &Version) -> Result<(), ManifestError> {
76    let mut doc = read_document(path)?;
77
78    let workspace = doc
79        .get_mut("workspace")
80        .ok_or_else(|| ManifestError::MissingField {
81            path: path.to_path_buf(),
82            field: "workspace".to_string(),
83        })?;
84
85    let workspace_table =
86        workspace
87            .as_table_like_mut()
88            .ok_or_else(|| ManifestError::MissingField {
89                path: path.to_path_buf(),
90                field: "workspace (as table)".to_string(),
91            })?;
92
93    let package = workspace_table
94        .entry("package")
95        .or_insert_with(|| Item::Table(Table::new()));
96
97    let package_table = package
98        .as_table_like_mut()
99        .ok_or_else(|| ManifestError::MissingField {
100            path: path.to_path_buf(),
101            field: "workspace.package (as table)".to_string(),
102        })?;
103
104    package_table.insert("version", value(version.to_string()));
105
106    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
107        path: path.to_path_buf(),
108        source,
109    })
110}
111
112/// # Errors
113///
114/// Returns `ManifestError::VerificationFailed` if the version in the manifest
115/// does not match the expected version.
116pub fn verify_version(path: &Path, expected: &Version) -> Result<(), ManifestError> {
117    let actual = read_version(path)?;
118
119    if actual != *expected {
120        return Err(ManifestError::VerificationFailed {
121            path: path.to_path_buf(),
122            expected: expected.to_string(),
123            actual: actual.to_string(),
124        });
125    }
126
127    Ok(())
128}
129
130/// Writes changeset configuration to the metadata section of a Cargo.toml file.
131///
132/// # Errors
133///
134/// Returns an error if the manifest cannot be read, parsed, or written.
135pub fn write_metadata_section(
136    path: &Path,
137    section: MetadataSection,
138    config: &InitConfig,
139) -> Result<(), ManifestError> {
140    if config.is_empty() {
141        return Ok(());
142    }
143
144    let mut doc = read_document(path)?;
145
146    let changeset_table = navigate_to_changeset_table(&mut doc, section, path)?;
147    populate_changeset_table(changeset_table, config);
148
149    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
150        path: path.to_path_buf(),
151        source,
152    })
153}
154
155/// Updates the version of a dependency in all relevant sections of a Cargo.toml.
156///
157/// Checks `[workspace.dependencies]`, `[dependencies]`, `[dev-dependencies]`,
158/// `[build-dependencies]`, and all `[target.'...'.dependencies]` (including
159/// `dev-dependencies` and `build-dependencies` under each target). Only updates
160/// table-form entries that have an explicit `version` key and do NOT have
161/// `workspace = true`.
162///
163/// # Errors
164///
165/// Returns an error if the manifest cannot be read, parsed, or written.
166pub fn update_dependency_version(
167    path: &Path,
168    dependency_name: &str,
169    new_version: &Version,
170) -> Result<bool, ManifestError> {
171    let mut doc = read_document(path)?;
172    let mut changed = false;
173
174    if let Some(workspace) = doc.get_mut("workspace")
175        && let Some(deps) = workspace.get_mut("dependencies")
176        && update_dep_entry(deps, dependency_name, new_version)
177    {
178        changed = true;
179    }
180
181    for section in &DEPENDENCY_SECTIONS {
182        if let Some(deps) = doc.get_mut(section)
183            && update_dep_entry(deps, dependency_name, new_version)
184        {
185            changed = true;
186        }
187    }
188
189    if let Some(target_table) = doc.get_mut("target")
190        && let Some(target_table) = target_table.as_table_like_mut()
191    {
192        for (_, target_value) in target_table.iter_mut() {
193            for section in &DEPENDENCY_SECTIONS {
194                if let Some(deps) = target_value.get_mut(section)
195                    && update_dep_entry(deps, dependency_name, new_version)
196                {
197                    changed = true;
198                }
199            }
200        }
201    }
202
203    if changed {
204        std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
205            path: path.to_path_buf(),
206            source,
207        })?;
208    }
209
210    Ok(changed)
211}
212
213fn update_dep_entry(deps: &mut Item, dep_name: &str, new_version: &Version) -> bool {
214    let Some(entry) = deps.get_mut(dep_name) else {
215        return false;
216    };
217
218    if let Some(table) = entry.as_table_like_mut() {
219        return update_versioned_table(table, new_version);
220    }
221
222    if entry.is_str() {
223        *entry = value(new_version.to_string());
224        return true;
225    }
226
227    false
228}
229
230fn update_versioned_table(table: &mut dyn TableLike, new_version: &Version) -> bool {
231    let has_workspace_true = table
232        .get("workspace")
233        .and_then(toml_edit::Item::as_bool)
234        .unwrap_or(false);
235    if has_workspace_true {
236        return false;
237    }
238
239    if table.get("version").is_some() {
240        table.insert("version", value(new_version.to_string()));
241        return true;
242    }
243
244    false
245}
246
247pub(crate) fn navigate_to_changeset_table<'a>(
248    doc: &'a mut DocumentMut,
249    section: MetadataSection,
250    path: &Path,
251) -> Result<&'a mut Table, ManifestError> {
252    let root_key = match section {
253        MetadataSection::Workspace => "workspace",
254        MetadataSection::Package => "package",
255    };
256
257    let root = doc
258        .entry(root_key)
259        .or_insert_with(|| Item::Table(Table::new()));
260
261    let root_table = root
262        .as_table_mut()
263        .ok_or_else(|| ManifestError::InvalidSectionType {
264            path: path.to_path_buf(),
265            section: root_key.to_string(),
266        })?;
267
268    let metadata = root_table
269        .entry("metadata")
270        .or_insert_with(|| Item::Table(Table::new()));
271
272    let metadata_table =
273        metadata
274            .as_table_mut()
275            .ok_or_else(|| ManifestError::InvalidSectionType {
276                path: path.to_path_buf(),
277                section: format!("{root_key}.metadata"),
278            })?;
279
280    let changeset = metadata_table
281        .entry("changeset")
282        .or_insert_with(|| Item::Table(Table::new()));
283
284    let changeset_table =
285        changeset
286            .as_table_mut()
287            .ok_or_else(|| ManifestError::InvalidSectionType {
288                path: path.to_path_buf(),
289                section: format!("{root_key}.metadata.changeset"),
290            })?;
291
292    changeset_table.set_implicit(true);
293
294    Ok(changeset_table)
295}
296
297fn populate_changeset_table(changeset_table: &mut Table, config: &InitConfig) {
298    if let Some(commit) = config.commit {
299        changeset_table.insert("commit", value(commit));
300    }
301
302    if let Some(tags) = config.tags {
303        changeset_table.insert("tags", value(tags));
304    }
305
306    if let Some(keep_changesets) = config.keep_changesets {
307        changeset_table.insert("keep-changesets", value(keep_changesets));
308    }
309
310    if let Some(tag_format) = config.tag_format {
311        changeset_table.insert("tag-format", value(tag_format.as_str()));
312    }
313
314    if let Some(changelog) = config.changelog {
315        changeset_table.insert("changelog", value(changelog.as_str()));
316    }
317
318    if let Some(comparison_links) = config.comparison_links {
319        changeset_table.insert("comparison-links", value(comparison_links.as_str()));
320    }
321
322    if let Some(zero_version_behavior) = config.zero_version_behavior {
323        changeset_table.insert(
324            "zero-version-behavior",
325            value(zero_version_behavior.as_str()),
326        );
327    }
328
329    if let Some(ref dependency_bump_changelog_template) = config.dependency_bump_changelog_template
330    {
331        changeset_table.insert(
332            "dependency-bump-changelog-template",
333            value(dependency_bump_changelog_template.as_str()),
334        );
335    }
336
337    if let Some(ref base_branch) = config.base_branch {
338        changeset_table.insert("base-branch", value(base_branch.as_str()));
339    }
340
341    if let Some(none_bump_behavior) = config.none_bump_behavior {
342        changeset_table.insert("none-bump-behavior", value(none_bump_behavior.as_str()));
343    }
344
345    if let Some(ref none_bump_promote_message_template) = config.none_bump_promote_message_template
346    {
347        changeset_table.insert(
348            "none-bump-promote-message-template",
349            value(none_bump_promote_message_template.as_str()),
350        );
351    }
352
353    if let Some(ref commit_title_template) = config.commit_title_template {
354        changeset_table.insert(
355            "commit-title-template",
356            value(commit_title_template.as_str()),
357        );
358    }
359
360    if let Some(changes_in_body) = config.changes_in_body {
361        changeset_table.insert("changes-in-body", value(changes_in_body));
362    }
363
364    if let Some(ref comparison_links_template) = config.comparison_links_template {
365        changeset_table.insert(
366            "comparison-links-template",
367            value(comparison_links_template.as_str()),
368        );
369    }
370
371    if let Some(ref ignored_files) = config.ignored_files
372        && !ignored_files.is_empty()
373    {
374        let mut arr = toml_edit::Array::new();
375        for pattern in ignored_files {
376            arr.push(pattern.as_str());
377        }
378        changeset_table.insert("ignored-files", Item::Value(toml_edit::Value::Array(arr)));
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn write_version_updates_package_version() {
388        let toml = r#"
389[package]
390name = "test-crate"
391version = "1.0.0"
392"#;
393        let dir = tempfile::tempdir().expect("create temp dir");
394        let path = dir.path().join("Cargo.toml");
395        std::fs::write(&path, toml).expect("write test file");
396
397        write_version(&path, &Version::new(2, 0, 0)).expect("write version");
398
399        let result = read_version(&path).expect("read version");
400        assert_eq!(result, Version::new(2, 0, 0));
401    }
402
403    #[test]
404    fn write_version_converts_inherited_to_literal() {
405        let toml = r#"
406[package]
407name = "test-crate"
408version.workspace = true
409"#;
410        let dir = tempfile::tempdir().expect("create temp dir");
411        let path = dir.path().join("Cargo.toml");
412        std::fs::write(&path, toml).expect("write test file");
413
414        write_version(&path, &Version::new(1, 5, 0)).expect("write version");
415
416        let result = read_version(&path).expect("read version");
417        assert_eq!(result, Version::new(1, 5, 0));
418
419        let content = std::fs::read_to_string(&path).expect("read file");
420        assert!(content.contains(r#"version = "1.5.0""#));
421        assert!(!content.contains("version.workspace"));
422    }
423
424    #[test]
425    fn write_version_preserves_comments() {
426        let toml = r#"# Package configuration
427[package]
428name = "test-crate"
429# Version comment
430version = "1.0.0"
431# After version comment
432edition = "2021"
433"#;
434        let dir = tempfile::tempdir().expect("create temp dir");
435        let path = dir.path().join("Cargo.toml");
436        std::fs::write(&path, toml).expect("write test file");
437
438        write_version(&path, &Version::new(2, 0, 0)).expect("write version");
439
440        let content = std::fs::read_to_string(&path).expect("read file");
441        assert!(content.contains("# Package configuration"));
442        assert!(content.contains("# After version comment"));
443    }
444
445    #[test]
446    fn remove_workspace_version_removes_field() {
447        let toml = r#"
448[workspace]
449members = ["crates/*"]
450
451[workspace.package]
452version = "1.0.0"
453edition = "2021"
454"#;
455        let dir = tempfile::tempdir().expect("create temp dir");
456        let path = dir.path().join("Cargo.toml");
457        std::fs::write(&path, toml).expect("write test file");
458
459        remove_workspace_version(&path).expect("remove workspace version");
460
461        let content = std::fs::read_to_string(&path).expect("read file");
462        assert!(!content.contains(r#"version = "1.0.0""#));
463        assert!(content.contains(r#"edition = "2021""#));
464    }
465
466    #[test]
467    fn remove_workspace_version_preserves_other_fields() {
468        let toml = r#"
469[workspace]
470members = ["crates/*"]
471
472[workspace.package]
473version = "1.0.0"
474edition = "2021"
475license = "MIT"
476"#;
477        let dir = tempfile::tempdir().expect("create temp dir");
478        let path = dir.path().join("Cargo.toml");
479        std::fs::write(&path, toml).expect("write test file");
480
481        remove_workspace_version(&path).expect("remove workspace version");
482
483        let content = std::fs::read_to_string(&path).expect("read file");
484        assert!(content.contains(r#"edition = "2021""#));
485        assert!(content.contains(r#"license = "MIT""#));
486        assert!(content.contains(r#"members = ["crates/*"]"#));
487    }
488
489    #[test]
490    fn verify_version_succeeds_when_matching() {
491        let toml = r#"
492[package]
493name = "test-crate"
494version = "1.2.3"
495"#;
496        let dir = tempfile::tempdir().expect("create temp dir");
497        let path = dir.path().join("Cargo.toml");
498        std::fs::write(&path, toml).expect("write test file");
499
500        verify_version(&path, &Version::new(1, 2, 3)).expect("verify version");
501    }
502
503    #[test]
504    fn verify_version_fails_when_mismatched() {
505        let toml = r#"
506[package]
507name = "test-crate"
508version = "1.0.0"
509"#;
510        let dir = tempfile::tempdir().expect("create temp dir");
511        let path = dir.path().join("Cargo.toml");
512        std::fs::write(&path, toml).expect("write test file");
513
514        let result = verify_version(&path, &Version::new(2, 0, 0));
515        assert!(matches!(
516            result,
517            Err(ManifestError::VerificationFailed { .. })
518        ));
519    }
520
521    #[test]
522    fn write_metadata_creates_workspace_section() {
523        let toml = r#"
524[workspace]
525members = ["crates/*"]
526"#;
527        let dir = tempfile::tempdir().expect("create temp dir");
528        let path = dir.path().join("Cargo.toml");
529        std::fs::write(&path, toml).expect("write test file");
530
531        let config = InitConfig {
532            commit: Some(true),
533            ..Default::default()
534        };
535
536        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
537
538        let content = std::fs::read_to_string(&path).expect("read file");
539        assert!(content.contains("[workspace.metadata.changeset]"));
540        assert!(content.contains("commit = true"));
541    }
542
543    #[test]
544    fn write_metadata_creates_package_section() {
545        let toml = r#"
546[package]
547name = "test-crate"
548version = "1.0.0"
549"#;
550        let dir = tempfile::tempdir().expect("create temp dir");
551        let path = dir.path().join("Cargo.toml");
552        std::fs::write(&path, toml).expect("write test file");
553
554        let config = InitConfig {
555            tags: Some(true),
556            ..Default::default()
557        };
558
559        write_metadata_section(&path, MetadataSection::Package, &config).expect("write metadata");
560
561        let content = std::fs::read_to_string(&path).expect("read file");
562        assert!(content.contains("[package.metadata.changeset]"));
563        assert!(content.contains("tags = true"));
564    }
565
566    #[test]
567    fn write_metadata_preserves_existing_content() {
568        let toml = r#"# Workspace configuration
569[workspace]
570# Members list
571members = ["crates/*"]
572
573[workspace.package]
574edition = "2021"
575"#;
576        let dir = tempfile::tempdir().expect("create temp dir");
577        let path = dir.path().join("Cargo.toml");
578        std::fs::write(&path, toml).expect("write test file");
579
580        let config = InitConfig {
581            commit: Some(true),
582            ..Default::default()
583        };
584
585        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
586
587        let content = std::fs::read_to_string(&path).expect("read file");
588        assert!(content.contains("# Workspace configuration"));
589        assert!(content.contains("# Members list"));
590        assert!(content.contains(r#"members = ["crates/*"]"#));
591        assert!(content.contains(r#"edition = "2021""#));
592    }
593
594    #[test]
595    fn write_metadata_updates_existing_section() {
596        let toml = r#"
597[workspace]
598members = ["crates/*"]
599
600[workspace.metadata.changeset]
601commit = false
602tags = false
603"#;
604        let dir = tempfile::tempdir().expect("create temp dir");
605        let path = dir.path().join("Cargo.toml");
606        std::fs::write(&path, toml).expect("write test file");
607
608        let config = InitConfig {
609            commit: Some(true),
610            tags: Some(true),
611            ..Default::default()
612        };
613
614        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
615
616        let content = std::fs::read_to_string(&path).expect("read file");
617        assert!(content.contains("commit = true"));
618        assert!(content.contains("tags = true"));
619        assert!(!content.contains("commit = false"));
620        assert!(!content.contains("tags = false"));
621    }
622
623    #[test]
624    fn write_metadata_creates_nested_hierarchy() {
625        let toml = r#"
626[workspace]
627members = ["crates/*"]
628"#;
629        let dir = tempfile::tempdir().expect("create temp dir");
630        let path = dir.path().join("Cargo.toml");
631        std::fs::write(&path, toml).expect("write test file");
632
633        let config = InitConfig {
634            commit: Some(true),
635            tags: Some(true),
636            ..Default::default()
637        };
638
639        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
640
641        let content = std::fs::read_to_string(&path).expect("read file");
642        assert!(content.contains("[workspace.metadata.changeset]"));
643        assert!(content.contains("commit = true"));
644        assert!(content.contains("tags = true"));
645    }
646
647    #[test]
648    fn write_metadata_merges_with_existing_metadata() {
649        let toml = r#"
650[workspace]
651members = ["crates/*"]
652
653[workspace.metadata.other]
654key = "value"
655"#;
656        let dir = tempfile::tempdir().expect("create temp dir");
657        let path = dir.path().join("Cargo.toml");
658        std::fs::write(&path, toml).expect("write test file");
659
660        let config = InitConfig {
661            commit: Some(true),
662            ..Default::default()
663        };
664
665        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
666
667        let content = std::fs::read_to_string(&path).expect("read file");
668        assert!(content.contains("[workspace.metadata.other]"));
669        assert!(content.contains(r#"key = "value""#));
670        assert!(content.contains("[workspace.metadata.changeset]"));
671        assert!(content.contains("commit = true"));
672    }
673
674    #[test]
675    fn write_metadata_handles_all_config_options() {
676        use crate::config::{
677            ChangelogLocation, ComparisonLinks, NoneBumpBehavior, TagFormat, ZeroVersionBehavior,
678        };
679
680        let toml = r#"
681[workspace]
682members = ["crates/*"]
683"#;
684        let dir = tempfile::tempdir().expect("create temp dir");
685        let path = dir.path().join("Cargo.toml");
686        std::fs::write(&path, toml).expect("write test file");
687
688        let config = InitConfig {
689            commit: Some(true),
690            tags: Some(true),
691            keep_changesets: Some(false),
692            tag_format: Some(TagFormat::CratePrefixed),
693            changelog: Some(ChangelogLocation::PerPackage),
694            comparison_links: Some(ComparisonLinks::Enabled),
695            zero_version_behavior: Some(ZeroVersionBehavior::AutoPromoteOnMajor),
696            dependency_bump_changelog_template: None,
697            base_branch: None,
698            none_bump_behavior: Some(NoneBumpBehavior::Disallow),
699            none_bump_promote_message_template: Some("My message".to_string()),
700            commit_title_template: Some("Release {new-version}".to_string()),
701            changes_in_body: Some(true),
702            comparison_links_template: Some(
703                "https://github.com/org/repo/compare/{base}...{target}".to_string(),
704            ),
705            ignored_files: Some(vec!["*.lock".to_string(), "docs/**".to_string()]),
706        };
707
708        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
709
710        let content = std::fs::read_to_string(&path).expect("read file");
711        assert!(content.contains("commit = true"));
712        assert!(content.contains("tags = true"));
713        assert!(content.contains("keep-changesets = false"));
714        assert!(content.contains(r#"tag-format = "crate-prefixed""#));
715        assert!(content.contains(r#"changelog = "per-package""#));
716        assert!(content.contains(r#"comparison-links = "enabled""#));
717        assert!(content.contains(r#"zero-version-behavior = "auto-promote-on-major""#));
718        assert!(content.contains(r#"none-bump-behavior = "disallow""#));
719        assert!(content.contains(r#"none-bump-promote-message-template = "My message""#));
720        assert!(content.contains(r#"commit-title-template = "Release {new-version}""#));
721        assert!(content.contains("changes-in-body = true"));
722        assert!(content.contains(
723            r#"comparison-links-template = "https://github.com/org/repo/compare/{base}...{target}""#
724        ));
725        assert!(content.contains(r#"ignored-files = ["*.lock", "docs/**"]"#));
726    }
727
728    #[test]
729    fn write_metadata_skips_none_values() {
730        let toml = r#"
731[workspace]
732members = ["crates/*"]
733"#;
734        let dir = tempfile::tempdir().expect("create temp dir");
735        let path = dir.path().join("Cargo.toml");
736        std::fs::write(&path, toml).expect("write test file");
737
738        let config = InitConfig {
739            commit: Some(true),
740            ..Default::default()
741        };
742
743        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
744
745        let content = std::fs::read_to_string(&path).expect("read file");
746        assert!(content.contains("commit = true"));
747        assert!(!content.contains("tags"));
748        assert!(!content.contains("keep-changesets"));
749        assert!(!content.contains("tag-format"));
750        assert!(!content.contains("changelog"));
751        assert!(!content.contains("comparison-links"));
752        assert!(!content.contains("zero-version-behavior"));
753    }
754
755    #[test]
756    fn write_metadata_writes_correct_enum_values() {
757        use crate::config::{ChangelogLocation, ComparisonLinks, TagFormat, ZeroVersionBehavior};
758
759        let toml = r#"
760[workspace]
761members = ["crates/*"]
762"#;
763        let dir = tempfile::tempdir().expect("create temp dir");
764        let path = dir.path().join("Cargo.toml");
765        std::fs::write(&path, toml).expect("write test file");
766
767        let config = InitConfig {
768            tag_format: Some(TagFormat::VersionOnly),
769            changelog: Some(ChangelogLocation::Root),
770            comparison_links: Some(ComparisonLinks::Auto),
771            zero_version_behavior: Some(ZeroVersionBehavior::EffectiveMinor),
772            ..Default::default()
773        };
774
775        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
776
777        let content = std::fs::read_to_string(&path).expect("read file");
778        assert!(content.contains(r#"tag-format = "version-only""#));
779        assert!(content.contains(r#"changelog = "root""#));
780        assert!(content.contains(r#"comparison-links = "auto""#));
781        assert!(content.contains(r#"zero-version-behavior = "effective-minor""#));
782    }
783
784    #[test]
785    fn write_metadata_empty_config_does_not_modify_file() {
786        let toml = r#"
787[workspace]
788members = ["crates/*"]
789"#;
790        let dir = tempfile::tempdir().expect("create temp dir");
791        let path = dir.path().join("Cargo.toml");
792        std::fs::write(&path, toml).expect("write test file");
793
794        let config = InitConfig::default();
795
796        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
797
798        let content = std::fs::read_to_string(&path).expect("read file");
799        assert!(!content.contains("metadata"));
800        assert!(!content.contains("changeset"));
801    }
802
803    #[test]
804    fn update_dep_version_updates_workspace_deps() {
805        let toml = r#"
806[workspace]
807members = ["crates/*"]
808
809[workspace.dependencies]
810my-crate = { path = "crates/my-crate", version = "1.0.0" }
811"#;
812        let dir = tempfile::tempdir().expect("create temp dir");
813        let path = dir.path().join("Cargo.toml");
814        std::fs::write(&path, toml).expect("write test file");
815
816        let result =
817            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
818        assert!(result);
819
820        let content = std::fs::read_to_string(&path).expect("read file");
821        assert!(content.contains(r#"version = "2.0.0""#));
822        assert!(!content.contains(r#"version = "1.0.0""#));
823    }
824
825    #[test]
826    fn update_dep_version_updates_regular_deps() {
827        let toml = r#"
828[package]
829name = "other-crate"
830version = "0.1.0"
831
832[dependencies]
833my-crate = { path = "../my-crate", version = "1.0.0" }
834"#;
835        let dir = tempfile::tempdir().expect("create temp dir");
836        let path = dir.path().join("Cargo.toml");
837        std::fs::write(&path, toml).expect("write test file");
838
839        let result =
840            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
841        assert!(result);
842
843        let content = std::fs::read_to_string(&path).expect("read file");
844        assert!(content.contains(r#"version = "2.0.0""#));
845    }
846
847    #[test]
848    fn update_dep_version_updates_dev_deps() {
849        let toml = r#"
850[package]
851name = "other-crate"
852version = "0.1.0"
853
854[dev-dependencies]
855my-crate = { path = "../my-crate", version = "1.0.0" }
856"#;
857        let dir = tempfile::tempdir().expect("create temp dir");
858        let path = dir.path().join("Cargo.toml");
859        std::fs::write(&path, toml).expect("write test file");
860
861        let result =
862            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
863        assert!(result);
864
865        let content = std::fs::read_to_string(&path).expect("read file");
866        assert!(content.contains(r#"version = "2.0.0""#));
867    }
868
869    #[test]
870    fn update_dep_version_updates_build_deps() {
871        let toml = r#"
872[package]
873name = "other-crate"
874version = "0.1.0"
875
876[build-dependencies]
877my-crate = { path = "../my-crate", version = "1.0.0" }
878"#;
879        let dir = tempfile::tempdir().expect("create temp dir");
880        let path = dir.path().join("Cargo.toml");
881        std::fs::write(&path, toml).expect("write test file");
882
883        let result =
884            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
885        assert!(result);
886
887        let content = std::fs::read_to_string(&path).expect("read file");
888        assert!(content.contains(r#"version = "2.0.0""#));
889    }
890
891    #[test]
892    fn update_dep_version_skips_workspace_true() {
893        let toml = r#"
894[package]
895name = "other-crate"
896version = "0.1.0"
897
898[dependencies]
899my-crate = { workspace = true }
900"#;
901        let dir = tempfile::tempdir().expect("create temp dir");
902        let path = dir.path().join("Cargo.toml");
903        std::fs::write(&path, toml).expect("write test file");
904
905        let result =
906            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
907        assert!(!result);
908
909        let content = std::fs::read_to_string(&path).expect("read file");
910        assert!(content.contains("workspace = true"));
911        assert!(!content.contains(r#"version = "2.0.0""#));
912    }
913
914    #[test]
915    fn update_dep_version_skips_no_version_key() {
916        let toml = r#"
917[package]
918name = "other-crate"
919version = "0.1.0"
920
921[dependencies]
922my-crate = { path = "../my-crate" }
923"#;
924        let dir = tempfile::tempdir().expect("create temp dir");
925        let path = dir.path().join("Cargo.toml");
926        std::fs::write(&path, toml).expect("write test file");
927
928        let result =
929            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
930        assert!(!result);
931
932        let content = std::fs::read_to_string(&path).expect("read file");
933        assert!(!content.contains(r#"version = "2.0.0""#));
934    }
935
936    #[test]
937    fn update_dep_version_skips_missing_dep() {
938        let toml = r#"
939[package]
940name = "other-crate"
941version = "0.1.0"
942
943[dependencies]
944some-other = "1.0.0"
945"#;
946        let dir = tempfile::tempdir().expect("create temp dir");
947        let path = dir.path().join("Cargo.toml");
948        std::fs::write(&path, toml).expect("write test file");
949
950        let result =
951            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
952        assert!(!result);
953    }
954
955    #[test]
956    fn update_dep_version_preserves_formatting() {
957        let toml = r#"# Root manifest
958[workspace]
959members = ["crates/*"]
960
961# Workspace deps
962[workspace.dependencies]
963my-crate = { path = "crates/my-crate", version = "1.0.0" }
964"#;
965        let dir = tempfile::tempdir().expect("create temp dir");
966        let path = dir.path().join("Cargo.toml");
967        std::fs::write(&path, toml).expect("write test file");
968
969        update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
970
971        let content = std::fs::read_to_string(&path).expect("read file");
972        assert!(content.contains("# Root manifest"));
973        assert!(content.contains("# Workspace deps"));
974    }
975
976    #[test]
977    fn update_dep_version_updates_simple_string() {
978        let toml = r#"
979[package]
980name = "other-crate"
981version = "0.1.0"
982
983[dependencies]
984my-crate = "1.0.0"
985"#;
986        let dir = tempfile::tempdir().expect("create temp dir");
987        let path = dir.path().join("Cargo.toml");
988        std::fs::write(&path, toml).expect("write test file");
989
990        let result =
991            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
992        assert!(result);
993
994        let content = std::fs::read_to_string(&path).expect("read file");
995        assert!(content.contains(r#"my-crate = "2.0.0""#));
996    }
997
998    #[test]
999    fn write_metadata_serializes_dependency_bump_changelog_template() {
1000        let toml = r#"
1001[workspace]
1002members = ["crates/*"]
1003"#;
1004        let dir = tempfile::tempdir().expect("create temp dir");
1005        let path = dir.path().join("Cargo.toml");
1006        std::fs::write(&path, toml).expect("write test file");
1007
1008        let config = InitConfig {
1009            dependency_bump_changelog_template: Some(
1010                "Updated dependency `{dependency}` to v{version}".to_string(),
1011            ),
1012            ..Default::default()
1013        };
1014
1015        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1016
1017        let content = std::fs::read_to_string(&path).expect("read file");
1018        assert!(content.contains("[workspace.metadata.changeset]"));
1019        assert!(content.contains(
1020            r#"dependency-bump-changelog-template = "Updated dependency `{dependency}` to v{version}""#
1021        ));
1022    }
1023
1024    #[test]
1025    fn update_dep_version_updates_multiple_sections() {
1026        let toml = r#"
1027[package]
1028name = "other-crate"
1029version = "0.1.0"
1030
1031[dependencies]
1032my-crate = { path = "../my-crate", version = "1.0.0" }
1033
1034[dev-dependencies]
1035my-crate = { path = "../my-crate", version = "1.0.0" }
1036"#;
1037        let dir = tempfile::tempdir().expect("create temp dir");
1038        let path = dir.path().join("Cargo.toml");
1039        std::fs::write(&path, toml).expect("write test file");
1040
1041        let result =
1042            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1043        assert!(result);
1044
1045        let content = std::fs::read_to_string(&path).expect("read file");
1046        assert!(!content.contains(r#"version = "1.0.0""#));
1047        assert_eq!(content.matches(r#"version = "2.0.0""#).count(), 2);
1048    }
1049
1050    #[test]
1051    fn update_dep_version_returns_true_on_change() {
1052        let toml = r#"
1053[workspace.dependencies]
1054my-crate = { path = "crates/my-crate", version = "1.0.0" }
1055"#;
1056        let dir = tempfile::tempdir().expect("create temp dir");
1057        let path = dir.path().join("Cargo.toml");
1058        std::fs::write(&path, toml).expect("write test file");
1059
1060        let changed =
1061            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1062        assert!(changed);
1063
1064        let not_changed = update_dependency_version(&path, "nonexistent", &Version::new(2, 0, 0))
1065            .expect("update");
1066        assert!(!not_changed);
1067    }
1068
1069    #[test]
1070    fn write_metadata_serializes_base_branch() {
1071        let toml = r#"
1072[workspace]
1073members = ["crates/*"]
1074"#;
1075        let dir = tempfile::tempdir().expect("create temp dir");
1076        let path = dir.path().join("Cargo.toml");
1077        std::fs::write(&path, toml).expect("write test file");
1078
1079        let config = InitConfig {
1080            base_branch: Some("develop".to_string()),
1081            ..Default::default()
1082        };
1083
1084        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1085
1086        let content = std::fs::read_to_string(&path).expect("read file");
1087        assert!(content.contains("[workspace.metadata.changeset]"));
1088        assert!(content.contains(r#"base-branch = "develop""#));
1089    }
1090
1091    #[test]
1092    fn write_metadata_serializes_none_bump_behavior() {
1093        use crate::config::NoneBumpBehavior;
1094
1095        let toml = r#"
1096[workspace]
1097members = ["crates/*"]
1098"#;
1099        let dir = tempfile::tempdir().expect("create temp dir");
1100        let path = dir.path().join("Cargo.toml");
1101        std::fs::write(&path, toml).expect("write test file");
1102
1103        let config = InitConfig {
1104            none_bump_behavior: Some(NoneBumpBehavior::Disallow),
1105            ..Default::default()
1106        };
1107
1108        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1109
1110        let content = std::fs::read_to_string(&path).expect("read file");
1111        assert!(content.contains("[workspace.metadata.changeset]"));
1112        assert!(content.contains(r#"none-bump-behavior = "disallow""#));
1113    }
1114
1115    #[test]
1116    fn write_metadata_serializes_none_bump_promote_message_template() {
1117        let toml = r#"
1118[workspace]
1119members = ["crates/*"]
1120"#;
1121        let dir = tempfile::tempdir().expect("create temp dir");
1122        let path = dir.path().join("Cargo.toml");
1123        std::fs::write(&path, toml).expect("write test file");
1124
1125        let config = InitConfig {
1126            none_bump_promote_message_template: Some("chore: internal refactor".to_string()),
1127            ..Default::default()
1128        };
1129
1130        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1131
1132        let content = std::fs::read_to_string(&path).expect("read file");
1133        assert!(content.contains("[workspace.metadata.changeset]"));
1134        assert!(
1135            content.contains(r#"none-bump-promote-message-template = "chore: internal refactor""#)
1136        );
1137    }
1138
1139    #[test]
1140    fn write_metadata_serializes_commit_title_template() {
1141        let toml = r#"
1142[workspace]
1143members = ["crates/*"]
1144"#;
1145        let dir = tempfile::tempdir().expect("create temp dir");
1146        let path = dir.path().join("Cargo.toml");
1147        std::fs::write(&path, toml).expect("write test file");
1148
1149        let config = InitConfig {
1150            commit_title_template: Some("Release {new-version}".to_string()),
1151            ..Default::default()
1152        };
1153
1154        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1155
1156        let content = std::fs::read_to_string(&path).expect("read file");
1157        assert!(content.contains(r#"commit-title-template = "Release {new-version}""#));
1158    }
1159
1160    #[test]
1161    fn write_metadata_serializes_changes_in_body() {
1162        let toml = r#"
1163[workspace]
1164members = ["crates/*"]
1165"#;
1166        let dir = tempfile::tempdir().expect("create temp dir");
1167        let path = dir.path().join("Cargo.toml");
1168        std::fs::write(&path, toml).expect("write test file");
1169
1170        let config = InitConfig {
1171            changes_in_body: Some(false),
1172            ..Default::default()
1173        };
1174
1175        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1176
1177        let content = std::fs::read_to_string(&path).expect("read file");
1178        assert!(content.contains("changes-in-body = false"));
1179    }
1180
1181    #[test]
1182    fn write_metadata_serializes_comparison_links_template() {
1183        let toml = r#"
1184[workspace]
1185members = ["crates/*"]
1186"#;
1187        let dir = tempfile::tempdir().expect("create temp dir");
1188        let path = dir.path().join("Cargo.toml");
1189        std::fs::write(&path, toml).expect("write test file");
1190
1191        let config = InitConfig {
1192            comparison_links_template: Some(
1193                "https://github.com/{repository}/compare/{base}...{target}".to_string(),
1194            ),
1195            ..Default::default()
1196        };
1197
1198        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1199
1200        let content = std::fs::read_to_string(&path).expect("read file");
1201        assert!(content.contains(
1202            r#"comparison-links-template = "https://github.com/{repository}/compare/{base}...{target}""#
1203        ));
1204    }
1205
1206    #[test]
1207    fn write_metadata_serializes_ignored_files() {
1208        let toml = r#"
1209[workspace]
1210members = ["crates/*"]
1211"#;
1212        let dir = tempfile::tempdir().expect("create temp dir");
1213        let path = dir.path().join("Cargo.toml");
1214        std::fs::write(&path, toml).expect("write test file");
1215
1216        let config = InitConfig {
1217            ignored_files: Some(vec!["*.md".to_string(), "docs/**".to_string()]),
1218            ..Default::default()
1219        };
1220
1221        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1222
1223        let content = std::fs::read_to_string(&path).expect("read file");
1224        assert!(content.contains(r#"ignored-files = ["*.md", "docs/**"]"#));
1225    }
1226
1227    #[test]
1228    fn write_metadata_skips_empty_ignored_files() {
1229        let toml = r#"
1230[workspace]
1231members = ["crates/*"]
1232"#;
1233        let dir = tempfile::tempdir().expect("create temp dir");
1234        let path = dir.path().join("Cargo.toml");
1235        std::fs::write(&path, toml).expect("write test file");
1236
1237        let config = InitConfig {
1238            ignored_files: Some(vec![]),
1239            commit: Some(true),
1240            ..Default::default()
1241        };
1242
1243        write_metadata_section(&path, MetadataSection::Workspace, &config).expect("write metadata");
1244
1245        let content = std::fs::read_to_string(&path).expect("read file");
1246        assert!(!content.contains("ignored-files"));
1247        assert!(content.contains("commit = true"));
1248    }
1249
1250    #[test]
1251    fn update_dep_version_updates_target_deps() {
1252        let toml = r#"
1253[package]
1254name = "other-crate"
1255version = "0.1.0"
1256
1257[target.'cfg(target_os = "linux")'.dependencies]
1258my-crate = { path = "../my-crate", version = "1.0.0" }
1259"#;
1260        let dir = tempfile::tempdir().expect("create temp dir");
1261        let path = dir.path().join("Cargo.toml");
1262        std::fs::write(&path, toml).expect("write test file");
1263
1264        let result =
1265            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1266        assert!(result);
1267
1268        let content = std::fs::read_to_string(&path).expect("read file");
1269        assert!(content.contains(r#"version = "2.0.0""#));
1270        assert!(!content.contains(r#"version = "1.0.0""#));
1271    }
1272
1273    #[test]
1274    fn update_dep_version_updates_target_dev_deps() {
1275        let toml = r#"
1276[package]
1277name = "other-crate"
1278version = "0.1.0"
1279
1280[target.'cfg(target_os = "linux")'.dev-dependencies]
1281my-crate = { path = "../my-crate", version = "1.0.0" }
1282"#;
1283        let dir = tempfile::tempdir().expect("create temp dir");
1284        let path = dir.path().join("Cargo.toml");
1285        std::fs::write(&path, toml).expect("write test file");
1286
1287        let result =
1288            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1289        assert!(result);
1290
1291        let content = std::fs::read_to_string(&path).expect("read file");
1292        assert!(content.contains(r#"version = "2.0.0""#));
1293    }
1294
1295    #[test]
1296    fn update_dep_version_updates_target_build_deps() {
1297        let toml = r#"
1298[package]
1299name = "other-crate"
1300version = "0.1.0"
1301
1302[target.'cfg(target_os = "linux")'.build-dependencies]
1303my-crate = { path = "../my-crate", version = "1.0.0" }
1304"#;
1305        let dir = tempfile::tempdir().expect("create temp dir");
1306        let path = dir.path().join("Cargo.toml");
1307        std::fs::write(&path, toml).expect("write test file");
1308
1309        let result =
1310            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1311        assert!(result);
1312
1313        let content = std::fs::read_to_string(&path).expect("read file");
1314        assert!(content.contains(r#"version = "2.0.0""#));
1315    }
1316
1317    #[test]
1318    fn update_dep_version_updates_multiple_targets() {
1319        let toml = r#"
1320[package]
1321name = "other-crate"
1322version = "0.1.0"
1323
1324[target.'cfg(target_os = "linux")'.dependencies]
1325my-crate = { path = "../my-crate", version = "1.0.0" }
1326
1327[target.'cfg(target_os = "windows")'.dependencies]
1328my-crate = { path = "../my-crate", version = "1.0.0" }
1329"#;
1330        let dir = tempfile::tempdir().expect("create temp dir");
1331        let path = dir.path().join("Cargo.toml");
1332        std::fs::write(&path, toml).expect("write test file");
1333
1334        let result =
1335            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1336        assert!(result);
1337
1338        let content = std::fs::read_to_string(&path).expect("read file");
1339        assert!(!content.contains(r#"version = "1.0.0""#));
1340        assert_eq!(content.matches(r#"version = "2.0.0""#).count(), 2);
1341    }
1342
1343    #[test]
1344    fn update_dep_version_skips_target_workspace_true() {
1345        let toml = r#"
1346[package]
1347name = "other-crate"
1348version = "0.1.0"
1349
1350[target.'cfg(target_os = "linux")'.dependencies]
1351my-crate = { workspace = true }
1352"#;
1353        let dir = tempfile::tempdir().expect("create temp dir");
1354        let path = dir.path().join("Cargo.toml");
1355        std::fs::write(&path, toml).expect("write test file");
1356
1357        let result =
1358            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1359        assert!(!result);
1360
1361        let content = std::fs::read_to_string(&path).expect("read file");
1362        assert!(content.contains("workspace = true"));
1363        assert!(!content.contains(r#"version = "2.0.0""#));
1364    }
1365
1366    #[test]
1367    fn update_dep_version_skips_target_no_version_key() {
1368        let toml = r#"
1369[package]
1370name = "other-crate"
1371version = "0.1.0"
1372
1373[target.'cfg(target_os = "linux")'.dependencies]
1374my-crate = { path = "../my-crate" }
1375"#;
1376        let dir = tempfile::tempdir().expect("create temp dir");
1377        let path = dir.path().join("Cargo.toml");
1378        std::fs::write(&path, toml).expect("write test file");
1379
1380        let result =
1381            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1382        assert!(!result);
1383
1384        let content = std::fs::read_to_string(&path).expect("read file");
1385        assert!(!content.contains(r#"version = "2.0.0""#));
1386    }
1387
1388    #[test]
1389    fn update_dep_version_updates_target_and_regular() {
1390        let toml = r#"
1391[package]
1392name = "other-crate"
1393version = "0.1.0"
1394
1395[dependencies]
1396my-crate = { path = "../my-crate", version = "1.0.0" }
1397
1398[target.'cfg(target_os = "linux")'.dependencies]
1399my-crate = { path = "../my-crate", version = "1.0.0" }
1400"#;
1401        let dir = tempfile::tempdir().expect("create temp dir");
1402        let path = dir.path().join("Cargo.toml");
1403        std::fs::write(&path, toml).expect("write test file");
1404
1405        let result =
1406            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1407        assert!(result);
1408
1409        let content = std::fs::read_to_string(&path).expect("read file");
1410        assert!(!content.contains(r#"version = "1.0.0""#));
1411        assert_eq!(content.matches(r#"version = "2.0.0""#).count(), 2);
1412    }
1413
1414    #[test]
1415    fn update_dep_version_preserves_target_formatting() {
1416        let toml = r#"# Package manifest
1417[package]
1418name = "other-crate"
1419version = "0.1.0"
1420
1421# Linux-specific deps
1422[target.'cfg(target_os = "linux")'.dependencies]
1423my-crate = { path = "../my-crate", version = "1.0.0" }
1424"#;
1425        let dir = tempfile::tempdir().expect("create temp dir");
1426        let path = dir.path().join("Cargo.toml");
1427        std::fs::write(&path, toml).expect("write test file");
1428
1429        update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1430
1431        let content = std::fs::read_to_string(&path).expect("read file");
1432        assert!(content.contains("# Package manifest"));
1433        assert!(content.contains("# Linux-specific deps"));
1434        assert!(content.contains(r#"version = "2.0.0""#));
1435    }
1436
1437    #[test]
1438    fn update_dep_version_updates_target_simple_string() {
1439        let toml = r#"
1440[package]
1441name = "other-crate"
1442version = "0.1.0"
1443
1444[target.'cfg(target_os = "linux")'.dependencies]
1445my-crate = "1.0.0"
1446"#;
1447        let dir = tempfile::tempdir().expect("create temp dir");
1448        let path = dir.path().join("Cargo.toml");
1449        std::fs::write(&path, toml).expect("write test file");
1450
1451        let result =
1452            update_dependency_version(&path, "my-crate", &Version::new(2, 0, 0)).expect("update");
1453        assert!(result);
1454
1455        let content = std::fs::read_to_string(&path).expect("read file");
1456        assert!(content.contains(r#"my-crate = "2.0.0""#));
1457    }
1458}