Skip to main content

changeset_manifest/
additional_packages.rs

1use std::path::{Path, PathBuf};
2
3use changeset_core::{AdditionalPackageDeclaration, ManifestFormat};
4use toml_edit::{ArrayOfTables, Item, Table, Value, value};
5
6use crate::config::MetadataSection;
7use crate::error::ManifestError;
8use crate::reader::read_document;
9use crate::writer::navigate_to_changeset_table;
10
11pub struct AdditionalPackageUpdate {
12    pub path: Option<PathBuf>,
13    pub influence: Option<Vec<String>>,
14    pub manifest_file_path: Option<PathBuf>,
15    pub manifest_format: Option<ManifestFormat>,
16    pub manifest_version_field_path: Option<String>,
17}
18
19/// # Errors
20///
21/// Returns an error if the manifest file cannot be read or written, or if the
22/// `additional-packages` key exists but is not an array-of-tables.
23pub fn add_additional_package(
24    path: &Path,
25    section: MetadataSection,
26    declaration: &AdditionalPackageDeclaration,
27) -> Result<(), ManifestError> {
28    let mut doc = read_document(path)?;
29    let changeset_table = navigate_to_changeset_table(&mut doc, section, path)?;
30
31    let aot = changeset_table
32        .entry("additional-packages")
33        .or_insert(Item::ArrayOfTables(ArrayOfTables::new()));
34
35    let Item::ArrayOfTables(aot) = aot else {
36        return Err(ManifestError::InvalidSectionType {
37            path: path.to_path_buf(),
38            section: "additional-packages".to_string(),
39        });
40    };
41
42    let mut table = Table::new();
43    table.insert("name", value(declaration.name().as_str()));
44    table.insert("path", value(declaration.path().to_string_lossy().as_ref()));
45
46    let mut influence_arr = toml_edit::Array::new();
47    for glob in declaration.influence() {
48        influence_arr.push(glob.as_str());
49    }
50    table.insert("influence", Item::Value(Value::Array(influence_arr)));
51
52    let mut manifest_table = Table::new();
53    manifest_table.insert(
54        "file-path",
55        value(
56            declaration
57                .manifest()
58                .file_path()
59                .to_string_lossy()
60                .as_ref(),
61        ),
62    );
63    manifest_table.insert(
64        "format",
65        value(declaration.manifest().format().to_string().as_str()),
66    );
67    manifest_table.insert(
68        "version-field-path",
69        value(declaration.manifest().version_field_path().as_str()),
70    );
71    table.insert("manifest", Item::Table(manifest_table));
72
73    aot.push(table);
74
75    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
76        path: path.to_path_buf(),
77        source,
78    })
79}
80
81/// Returns `true` if the entry was found and removed, `false` if no entry matched `name`.
82///
83/// # Errors
84///
85/// Returns an error if the manifest file cannot be read or written.
86pub fn remove_additional_package(
87    path: &Path,
88    section: MetadataSection,
89    name: &str,
90) -> Result<bool, ManifestError> {
91    let mut doc = read_document(path)?;
92    let changeset_table = navigate_to_changeset_table(&mut doc, section, path)?;
93
94    let Some(aot_item) = changeset_table.get_mut("additional-packages") else {
95        return Ok(false);
96    };
97
98    let Item::ArrayOfTables(aot) = aot_item else {
99        return Ok(false);
100    };
101
102    let original_len = aot.len();
103    let indices_to_remove: Vec<usize> = aot
104        .iter()
105        .enumerate()
106        .filter(|(_, t)| {
107            t.get("name")
108                .and_then(Item::as_str)
109                .is_some_and(|n| n == name)
110        })
111        .map(|(i, _)| i)
112        .collect();
113
114    if indices_to_remove.is_empty() {
115        return Ok(false);
116    }
117
118    for i in indices_to_remove.into_iter().rev() {
119        aot.remove(i);
120    }
121
122    let removed = aot.len() < original_len;
123    if removed {
124        std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
125            path: path.to_path_buf(),
126            source,
127        })?;
128    }
129
130    Ok(removed)
131}
132
133/// Returns `true` if the entry was found and updated, `false` if no entry matched `name`.
134///
135/// # Errors
136///
137/// Returns an error if the manifest file cannot be read or written, or if the manifest
138/// sub-table is not a table type.
139pub fn update_additional_package(
140    path: &Path,
141    section: MetadataSection,
142    name: &str,
143    updates: &AdditionalPackageUpdate,
144) -> Result<bool, ManifestError> {
145    let mut doc = read_document(path)?;
146    let changeset_table = navigate_to_changeset_table(&mut doc, section, path)?;
147
148    let Some(aot_item) = changeset_table.get_mut("additional-packages") else {
149        return Ok(false);
150    };
151
152    let Item::ArrayOfTables(aot) = aot_item else {
153        return Ok(false);
154    };
155
156    let Some(table) = aot.iter_mut().find(|t| {
157        t.get("name")
158            .and_then(Item::as_str)
159            .is_some_and(|n| n == name)
160    }) else {
161        return Ok(false);
162    };
163
164    if let Some(ref new_path) = updates.path {
165        table.insert("path", value(new_path.to_string_lossy().as_ref()));
166    }
167
168    if let Some(ref new_influence) = updates.influence {
169        let mut arr = toml_edit::Array::new();
170        for glob in new_influence {
171            arr.push(glob.as_str());
172        }
173        table.insert("influence", Item::Value(Value::Array(arr)));
174    }
175
176    if updates.manifest_file_path.is_some()
177        || updates.manifest_format.is_some()
178        || updates.manifest_version_field_path.is_some()
179    {
180        let manifest_item = table
181            .entry("manifest")
182            .or_insert_with(|| Item::Table(Table::new()));
183
184        let manifest_table =
185            manifest_item
186                .as_table_mut()
187                .ok_or_else(|| ManifestError::InvalidSectionType {
188                    path: path.to_path_buf(),
189                    section: "additional-packages[].manifest".to_string(),
190                })?;
191
192        if let Some(ref new_file_path) = updates.manifest_file_path {
193            manifest_table.insert("file-path", value(new_file_path.to_string_lossy().as_ref()));
194        }
195
196        if let Some(new_format) = updates.manifest_format {
197            manifest_table.insert("format", value(new_format.to_string().as_str()));
198        }
199
200        if let Some(ref new_version_field_path) = updates.manifest_version_field_path {
201            manifest_table.insert("version-field-path", value(new_version_field_path.as_str()));
202        }
203    }
204
205    std::fs::write(path, doc.to_string()).map_err(|source| ManifestError::Write {
206        path: path.to_path_buf(),
207        source,
208    })?;
209
210    Ok(true)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use changeset_core::{AdditionalPackageManifest, ManifestFormat};
217    use std::path::PathBuf;
218
219    fn make_declaration(name: &str) -> AdditionalPackageDeclaration {
220        AdditionalPackageDeclaration::new(
221            name.to_string(),
222            PathBuf::from(format!("charts/{name}")),
223            vec![format!("charts/{name}/**")],
224            AdditionalPackageManifest::new(
225                PathBuf::from(format!("charts/{name}/Chart.yaml")),
226                ManifestFormat::Yaml,
227                "version".to_string(),
228            ),
229            Vec::new(),
230        )
231    }
232
233    fn write_temp_toml(content: &str) -> (tempfile::TempDir, PathBuf) {
234        let dir = tempfile::tempdir().expect("create temp dir");
235        let path = dir.path().join("Cargo.toml");
236        std::fs::write(&path, content).expect("write test file");
237        (dir, path)
238    }
239
240    #[test]
241    fn add_creates_array_of_tables_entry() {
242        let (_dir, path) = write_temp_toml(
243            r#"
244[workspace]
245members = ["crates/*"]
246"#,
247        );
248        let decl = make_declaration("my-chart");
249
250        add_additional_package(&path, MetadataSection::Workspace, &decl)
251            .expect("add should succeed");
252
253        let content = std::fs::read_to_string(&path).expect("read file");
254        assert!(content.contains("[[workspace.metadata.changeset.additional-packages]]"));
255        assert!(content.contains(r#"name = "my-chart""#));
256    }
257
258    #[test]
259    fn add_appends_to_existing_array() {
260        let (_dir, path) = write_temp_toml(
261            r#"
262[workspace]
263members = ["crates/*"]
264"#,
265        );
266
267        add_additional_package(
268            &path,
269            MetadataSection::Workspace,
270            &make_declaration("chart-a"),
271        )
272        .expect("add first");
273        add_additional_package(
274            &path,
275            MetadataSection::Workspace,
276            &make_declaration("chart-b"),
277        )
278        .expect("add second");
279
280        let content = std::fs::read_to_string(&path).expect("read file");
281        assert!(content.contains(r#"name = "chart-a""#));
282        assert!(content.contains(r#"name = "chart-b""#));
283    }
284
285    #[test]
286    fn add_preserves_existing_comments() {
287        let (_dir, path) = write_temp_toml(
288            r#"# Workspace config
289[workspace]
290# Members
291members = ["crates/*"]
292"#,
293        );
294        let decl = make_declaration("my-chart");
295
296        add_additional_package(&path, MetadataSection::Workspace, &decl)
297            .expect("add should succeed");
298
299        let content = std::fs::read_to_string(&path).expect("read file");
300        assert!(content.contains("# Workspace config"));
301        assert!(content.contains("# Members"));
302    }
303
304    #[test]
305    fn add_creates_nested_manifest_table() {
306        let (_dir, path) = write_temp_toml(
307            r#"
308[workspace]
309members = ["crates/*"]
310"#,
311        );
312        let decl = AdditionalPackageDeclaration::new(
313            "my-chart".to_string(),
314            PathBuf::from("charts/my-chart"),
315            vec![],
316            AdditionalPackageManifest::new(
317                PathBuf::from("charts/my-chart/Chart.yaml"),
318                ManifestFormat::Yaml,
319                "version".to_string(),
320            ),
321            Vec::new(),
322        );
323
324        add_additional_package(&path, MetadataSection::Workspace, &decl)
325            .expect("add should succeed");
326
327        let content = std::fs::read_to_string(&path).expect("read file");
328        assert!(content.contains(r#"file-path = "charts/my-chart/Chart.yaml""#));
329        assert!(content.contains(r#"format = "yaml""#));
330        assert!(content.contains(r#"version-field-path = "version""#));
331    }
332
333    #[test]
334    fn add_serializes_influence_as_array() {
335        let (_dir, path) = write_temp_toml(
336            r#"
337[workspace]
338members = ["crates/*"]
339"#,
340        );
341        let decl = AdditionalPackageDeclaration::new(
342            "my-chart".to_string(),
343            PathBuf::from("charts/my-chart"),
344            vec!["charts/my-chart/**".to_string(), "helm/**".to_string()],
345            AdditionalPackageManifest::new(
346                PathBuf::from("charts/my-chart/Chart.yaml"),
347                ManifestFormat::Yaml,
348                "version".to_string(),
349            ),
350            Vec::new(),
351        );
352
353        add_additional_package(&path, MetadataSection::Workspace, &decl)
354            .expect("add should succeed");
355
356        let content = std::fs::read_to_string(&path).expect("read file");
357        assert!(content.contains("influence"));
358        assert!(content.contains(r#""charts/my-chart/**""#));
359        assert!(content.contains(r#""helm/**""#));
360    }
361
362    #[test]
363    fn remove_by_name_returns_true() {
364        let (_dir, path) = write_temp_toml(
365            r#"
366[workspace]
367members = ["crates/*"]
368"#,
369        );
370        add_additional_package(
371            &path,
372            MetadataSection::Workspace,
373            &make_declaration("my-chart"),
374        )
375        .expect("add should succeed");
376
377        let result = remove_additional_package(&path, MetadataSection::Workspace, "my-chart")
378            .expect("remove should succeed");
379
380        assert!(result);
381        let content = std::fs::read_to_string(&path).expect("read file");
382        assert!(!content.contains(r#"name = "my-chart""#));
383    }
384
385    #[test]
386    fn remove_nonexistent_returns_false() {
387        let (_dir, path) = write_temp_toml(
388            r#"
389[workspace]
390members = ["crates/*"]
391"#,
392        );
393
394        let result = remove_additional_package(&path, MetadataSection::Workspace, "nonexistent")
395            .expect("remove should succeed");
396
397        assert!(!result);
398    }
399
400    #[test]
401    fn remove_preserves_other_entries() {
402        let (_dir, path) = write_temp_toml(
403            r#"
404[workspace]
405members = ["crates/*"]
406"#,
407        );
408        add_additional_package(
409            &path,
410            MetadataSection::Workspace,
411            &make_declaration("chart-a"),
412        )
413        .expect("add first");
414        add_additional_package(
415            &path,
416            MetadataSection::Workspace,
417            &make_declaration("chart-b"),
418        )
419        .expect("add second");
420
421        remove_additional_package(&path, MetadataSection::Workspace, "chart-a")
422            .expect("remove should succeed");
423
424        let content = std::fs::read_to_string(&path).expect("read file");
425        assert!(!content.contains(r#"name = "chart-a""#));
426        assert!(content.contains(r#"name = "chart-b""#));
427    }
428
429    #[test]
430    fn remove_preserves_comments() {
431        let (_dir, path) = write_temp_toml(
432            r#"# Workspace config
433[workspace]
434# Members comment
435members = ["crates/*"]
436"#,
437        );
438        add_additional_package(
439            &path,
440            MetadataSection::Workspace,
441            &make_declaration("chart-a"),
442        )
443        .expect("add");
444
445        remove_additional_package(&path, MetadataSection::Workspace, "chart-a").expect("remove");
446
447        let content = std::fs::read_to_string(&path).expect("read file");
448        assert!(content.contains("# Workspace config"));
449        assert!(content.contains("# Members comment"));
450    }
451
452    #[test]
453    fn update_modifies_path_field() {
454        let (_dir, path) = write_temp_toml(
455            r#"
456[workspace]
457members = ["crates/*"]
458"#,
459        );
460        add_additional_package(
461            &path,
462            MetadataSection::Workspace,
463            &make_declaration("my-chart"),
464        )
465        .expect("add");
466
467        let updates = AdditionalPackageUpdate {
468            path: Some(PathBuf::from("new/chart/path")),
469            influence: None,
470            manifest_file_path: None,
471            manifest_format: None,
472            manifest_version_field_path: None,
473        };
474        let result =
475            update_additional_package(&path, MetadataSection::Workspace, "my-chart", &updates)
476                .expect("update should succeed");
477
478        assert!(result);
479        let content = std::fs::read_to_string(&path).expect("read file");
480        assert!(content.contains(r#"path = "new/chart/path""#));
481    }
482
483    #[test]
484    fn update_modifies_influence() {
485        let (_dir, path) = write_temp_toml(
486            r#"
487[workspace]
488members = ["crates/*"]
489"#,
490        );
491        add_additional_package(
492            &path,
493            MetadataSection::Workspace,
494            &make_declaration("my-chart"),
495        )
496        .expect("add");
497
498        let updates = AdditionalPackageUpdate {
499            path: None,
500            influence: Some(vec!["new/pattern/**".to_string()]),
501            manifest_file_path: None,
502            manifest_format: None,
503            manifest_version_field_path: None,
504        };
505        update_additional_package(&path, MetadataSection::Workspace, "my-chart", &updates)
506            .expect("update should succeed");
507
508        let content = std::fs::read_to_string(&path).expect("read file");
509        assert!(content.contains(r#""new/pattern/**""#));
510    }
511
512    #[test]
513    fn update_modifies_manifest_fields() {
514        let (_dir, path) = write_temp_toml(
515            r#"
516[workspace]
517members = ["crates/*"]
518"#,
519        );
520        add_additional_package(
521            &path,
522            MetadataSection::Workspace,
523            &make_declaration("my-chart"),
524        )
525        .expect("add");
526
527        let updates = AdditionalPackageUpdate {
528            path: None,
529            influence: None,
530            manifest_file_path: None,
531            manifest_format: Some(ManifestFormat::Json),
532            manifest_version_field_path: Some("info.version".to_string()),
533        };
534        update_additional_package(&path, MetadataSection::Workspace, "my-chart", &updates)
535            .expect("update should succeed");
536
537        let content = std::fs::read_to_string(&path).expect("read file");
538        assert!(content.contains(r#"format = "json""#));
539        assert!(content.contains(r#"version-field-path = "info.version""#));
540    }
541
542    #[test]
543    fn update_nonexistent_returns_false() {
544        let (_dir, path) = write_temp_toml(
545            r#"
546[workspace]
547members = ["crates/*"]
548"#,
549        );
550
551        let updates = AdditionalPackageUpdate {
552            path: Some(PathBuf::from("somewhere")),
553            influence: None,
554            manifest_file_path: None,
555            manifest_format: None,
556            manifest_version_field_path: None,
557        };
558        let result =
559            update_additional_package(&path, MetadataSection::Workspace, "nonexistent", &updates)
560                .expect("update should succeed");
561
562        assert!(!result);
563    }
564
565    #[test]
566    fn update_preserves_comments() {
567        let (_dir, path) = write_temp_toml(
568            r#"# My project
569[workspace]
570# Workspace members
571members = ["crates/*"]
572"#,
573        );
574        add_additional_package(
575            &path,
576            MetadataSection::Workspace,
577            &make_declaration("my-chart"),
578        )
579        .expect("add");
580
581        let updates = AdditionalPackageUpdate {
582            path: Some(PathBuf::from("new/path")),
583            influence: None,
584            manifest_file_path: None,
585            manifest_format: None,
586            manifest_version_field_path: None,
587        };
588        update_additional_package(&path, MetadataSection::Workspace, "my-chart", &updates)
589            .expect("update");
590
591        let content = std::fs::read_to_string(&path).expect("read file");
592        assert!(content.contains("# My project"));
593        assert!(content.contains("# Workspace members"));
594    }
595
596    #[test]
597    fn workspace_and_package_sections() {
598        let (_dir, path) = write_temp_toml(
599            r#"
600[package]
601name = "my-crate"
602version = "0.1.0"
603"#,
604        );
605        let decl = make_declaration("my-chart");
606
607        add_additional_package(&path, MetadataSection::Package, &decl).expect("add should succeed");
608
609        let content = std::fs::read_to_string(&path).expect("read file");
610        assert!(content.contains("[[package.metadata.changeset.additional-packages]]"));
611        assert!(!content.contains("[[workspace.metadata.changeset.additional-packages]]"));
612    }
613}