Skip to main content

changeset_manifest/
external.rs

1use std::path::Path;
2
3use changeset_core::types::ManifestFormat;
4use jsonc_parser::ParseOptions;
5use jsonc_parser::cst::{CstInputValue, CstObject, CstRootNode};
6use semver::Version;
7use toml_edit::DocumentMut;
8use yaml_edit::Document;
9
10use crate::error::ManifestError;
11
12/// # Errors
13///
14/// Returns an error if the manifest cannot be read, updated, or written.
15pub fn write_external_version(
16    path: &Path,
17    format: ManifestFormat,
18    version_field_path: &str,
19    version: &Version,
20) -> Result<(), ManifestError> {
21    let content = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
22        path: path.to_path_buf(),
23        source,
24    })?;
25
26    let new_content = match format {
27        ManifestFormat::Toml => write_toml_version(&content, path, version_field_path, version)?,
28        ManifestFormat::Yaml => write_yaml_version(&content, path, version_field_path, version)?,
29        ManifestFormat::Json => write_json_version(&content, path, version_field_path, version)?,
30    };
31
32    std::fs::write(path, new_content).map_err(|source| ManifestError::Write {
33        path: path.to_path_buf(),
34        source,
35    })
36}
37
38/// # Errors
39///
40/// Returns an error if the manifest cannot be read, updated, or written.
41pub fn restore_external_version(
42    path: &Path,
43    format: ManifestFormat,
44    version_field_path: &str,
45    version_str: &str,
46) -> Result<(), ManifestError> {
47    let content = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
48        path: path.to_path_buf(),
49        source,
50    })?;
51
52    let new_content = match format {
53        ManifestFormat::Toml => {
54            restore_toml_version(&content, path, version_field_path, version_str)?
55        }
56        ManifestFormat::Yaml => {
57            restore_yaml_version(&content, path, version_field_path, version_str)?
58        }
59        ManifestFormat::Json => {
60            restore_json_version(&content, path, version_field_path, version_str)?
61        }
62    };
63
64    std::fs::write(path, new_content).map_err(|source| ManifestError::Write {
65        path: path.to_path_buf(),
66        source,
67    })
68}
69
70/// # Errors
71///
72/// Returns an error if the manifest cannot be read or the version field path
73/// does not resolve to a string value.
74pub fn read_external_version_string(
75    path: &Path,
76    format: ManifestFormat,
77    version_field_path: &str,
78) -> Result<String, ManifestError> {
79    let content = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
80        path: path.to_path_buf(),
81        source,
82    })?;
83
84    match format {
85        ManifestFormat::Toml => read_toml_version(&content, path, version_field_path),
86        ManifestFormat::Yaml => read_yaml_version(&content, path, version_field_path),
87        ManifestFormat::Json => read_json_version(&content, path, version_field_path),
88    }
89}
90
91/// # Errors
92///
93/// Returns `ManifestError::VerificationFailed` if the version does not match the expected value.
94pub fn verify_external_version(
95    path: &Path,
96    format: ManifestFormat,
97    version_field_path: &str,
98    expected: &Version,
99) -> Result<(), ManifestError> {
100    let content = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
101        path: path.to_path_buf(),
102        source,
103    })?;
104
105    let version_str = match format {
106        ManifestFormat::Toml => read_toml_version(&content, path, version_field_path)?,
107        ManifestFormat::Yaml => read_yaml_version(&content, path, version_field_path)?,
108        ManifestFormat::Json => read_json_version(&content, path, version_field_path)?,
109    };
110
111    let actual =
112        version_str
113            .parse::<Version>()
114            .map_err(|source| ManifestError::InvalidVersion {
115                path: path.to_path_buf(),
116                version: version_str,
117                source,
118            })?;
119
120    if actual != *expected {
121        return Err(ManifestError::VerificationFailed {
122            path: path.to_path_buf(),
123            expected: expected.to_string(),
124            actual: actual.to_string(),
125        });
126    }
127
128    Ok(())
129}
130
131fn write_toml_version(
132    content: &str,
133    path: &Path,
134    version_field_path: &str,
135    version: &Version,
136) -> Result<String, ManifestError> {
137    let mut doc = content
138        .parse::<DocumentMut>()
139        .map_err(|source| ManifestError::Parse {
140            path: path.to_path_buf(),
141            source,
142        })?;
143
144    let segments: Vec<&str> = version_field_path.split('.').collect();
145    let (leaf_key, parent_segments) =
146        segments
147            .split_last()
148            .ok_or_else(|| ManifestError::VersionPathNotFound {
149                path: path.to_path_buf(),
150                version_field_path: version_field_path.to_string(),
151            })?;
152
153    let mut current = doc.as_item_mut();
154    for segment in parent_segments {
155        current = current
156            .get_mut(*segment)
157            .ok_or_else(|| ManifestError::VersionPathNotFound {
158                path: path.to_path_buf(),
159                version_field_path: version_field_path.to_string(),
160            })?;
161    }
162
163    let table = current
164        .as_table_like_mut()
165        .ok_or_else(|| ManifestError::VersionPathNotFound {
166            path: path.to_path_buf(),
167            version_field_path: version_field_path.to_string(),
168        })?;
169
170    if table.get(leaf_key).is_none() {
171        return Err(ManifestError::VersionPathNotFound {
172            path: path.to_path_buf(),
173            version_field_path: version_field_path.to_string(),
174        });
175    }
176
177    table.insert(leaf_key, toml_edit::value(version.to_string()));
178
179    Ok(doc.to_string())
180}
181
182fn read_toml_version(
183    content: &str,
184    path: &Path,
185    version_field_path: &str,
186) -> Result<String, ManifestError> {
187    let doc = content
188        .parse::<DocumentMut>()
189        .map_err(|source| ManifestError::Parse {
190            path: path.to_path_buf(),
191            source,
192        })?;
193
194    let mut current = doc.as_item();
195    for segment in version_field_path.split('.') {
196        current = current
197            .get(segment)
198            .ok_or_else(|| ManifestError::VersionPathNotFound {
199                path: path.to_path_buf(),
200                version_field_path: version_field_path.to_string(),
201            })?;
202    }
203
204    current
205        .as_str()
206        .map(String::from)
207        .ok_or_else(|| ManifestError::VersionNotString {
208            path: path.to_path_buf(),
209            version_field_path: version_field_path.to_string(),
210        })
211}
212
213fn write_yaml_version(
214    content: &str,
215    path: &Path,
216    version_field_path: &str,
217    version: &Version,
218) -> Result<String, ManifestError> {
219    let doc = content
220        .parse::<Document>()
221        .map_err(|source| ManifestError::YamlParse {
222            path: path.to_path_buf(),
223            source,
224        })?;
225
226    let segments: Vec<&str> = version_field_path.split('.').collect();
227    let (leaf_key, parent_segments) =
228        segments
229            .split_last()
230            .ok_or_else(|| ManifestError::VersionPathNotFound {
231                path: path.to_path_buf(),
232                version_field_path: version_field_path.to_string(),
233            })?;
234
235    let mapping = doc
236        .as_mapping()
237        .ok_or_else(|| ManifestError::VersionPathNotFound {
238            path: path.to_path_buf(),
239            version_field_path: version_field_path.to_string(),
240        })?;
241
242    let mut current_mapping = mapping;
243    for segment in parent_segments {
244        current_mapping = current_mapping.get_mapping(*segment).ok_or_else(|| {
245            ManifestError::VersionPathNotFound {
246                path: path.to_path_buf(),
247                version_field_path: version_field_path.to_string(),
248            }
249        })?;
250    }
251
252    if !current_mapping.contains_key(*leaf_key) {
253        return Err(ManifestError::VersionPathNotFound {
254            path: path.to_path_buf(),
255            version_field_path: version_field_path.to_string(),
256        });
257    }
258
259    current_mapping.set(*leaf_key, version.to_string().as_str());
260
261    Ok(doc.to_string())
262}
263
264fn read_yaml_version(
265    content: &str,
266    path: &Path,
267    version_field_path: &str,
268) -> Result<String, ManifestError> {
269    let doc = content
270        .parse::<Document>()
271        .map_err(|source| ManifestError::YamlParse {
272            path: path.to_path_buf(),
273            source,
274        })?;
275
276    let segments: Vec<&str> = version_field_path.split('.').collect();
277    let (leaf_key, parent_segments) =
278        segments
279            .split_last()
280            .ok_or_else(|| ManifestError::VersionPathNotFound {
281                path: path.to_path_buf(),
282                version_field_path: version_field_path.to_string(),
283            })?;
284
285    let mapping = doc
286        .as_mapping()
287        .ok_or_else(|| ManifestError::VersionPathNotFound {
288            path: path.to_path_buf(),
289            version_field_path: version_field_path.to_string(),
290        })?;
291
292    let mut current_mapping = mapping;
293    for segment in parent_segments {
294        current_mapping = current_mapping.get_mapping(*segment).ok_or_else(|| {
295            ManifestError::VersionPathNotFound {
296                path: path.to_path_buf(),
297                version_field_path: version_field_path.to_string(),
298            }
299        })?;
300    }
301
302    let node =
303        current_mapping
304            .get(*leaf_key)
305            .ok_or_else(|| ManifestError::VersionPathNotFound {
306                path: path.to_path_buf(),
307                version_field_path: version_field_path.to_string(),
308            })?;
309
310    node.as_scalar()
311        .map(yaml_edit::Scalar::as_string)
312        .ok_or_else(|| ManifestError::VersionNotString {
313            path: path.to_path_buf(),
314            version_field_path: version_field_path.to_string(),
315        })
316}
317
318fn navigate_json_to_object(
319    root_obj: &CstObject,
320    path: &Path,
321    version_field_path: &str,
322    parent_segments: &[&str],
323) -> Result<CstObject, ManifestError> {
324    let mut current = root_obj.clone();
325    for segment in parent_segments {
326        current =
327            current
328                .object_value(segment)
329                .ok_or_else(|| ManifestError::VersionPathNotFound {
330                    path: path.to_path_buf(),
331                    version_field_path: version_field_path.to_string(),
332                })?;
333    }
334    Ok(current)
335}
336
337fn write_json_version(
338    content: &str,
339    path: &Path,
340    version_field_path: &str,
341    version: &Version,
342) -> Result<String, ManifestError> {
343    let root = CstRootNode::parse(content, &ParseOptions::default()).map_err(|source| {
344        ManifestError::JsonParse {
345            path: path.to_path_buf(),
346            source,
347        }
348    })?;
349
350    let root_obj = root
351        .object_value()
352        .ok_or_else(|| ManifestError::VersionPathNotFound {
353            path: path.to_path_buf(),
354            version_field_path: version_field_path.to_string(),
355        })?;
356
357    let segments: Vec<&str> = version_field_path.split('.').collect();
358    let (leaf_key, parent_segments) =
359        segments
360            .split_last()
361            .ok_or_else(|| ManifestError::VersionPathNotFound {
362                path: path.to_path_buf(),
363                version_field_path: version_field_path.to_string(),
364            })?;
365
366    let target_obj = navigate_json_to_object(&root_obj, path, version_field_path, parent_segments)?;
367
368    let prop = target_obj
369        .get(leaf_key)
370        .ok_or_else(|| ManifestError::VersionPathNotFound {
371            path: path.to_path_buf(),
372            version_field_path: version_field_path.to_string(),
373        })?;
374
375    prop.set_value(CstInputValue::String(version.to_string()));
376
377    Ok(root.to_string())
378}
379
380fn read_json_version(
381    content: &str,
382    path: &Path,
383    version_field_path: &str,
384) -> Result<String, ManifestError> {
385    let root = CstRootNode::parse(content, &ParseOptions::default()).map_err(|source| {
386        ManifestError::JsonParse {
387            path: path.to_path_buf(),
388            source,
389        }
390    })?;
391
392    let root_obj = root
393        .object_value()
394        .ok_or_else(|| ManifestError::VersionPathNotFound {
395            path: path.to_path_buf(),
396            version_field_path: version_field_path.to_string(),
397        })?;
398
399    let segments: Vec<&str> = version_field_path.split('.').collect();
400    let (leaf_key, parent_segments) =
401        segments
402            .split_last()
403            .ok_or_else(|| ManifestError::VersionPathNotFound {
404                path: path.to_path_buf(),
405                version_field_path: version_field_path.to_string(),
406            })?;
407
408    let target_obj = navigate_json_to_object(&root_obj, path, version_field_path, parent_segments)?;
409
410    let prop = target_obj
411        .get(leaf_key)
412        .ok_or_else(|| ManifestError::VersionPathNotFound {
413            path: path.to_path_buf(),
414            version_field_path: version_field_path.to_string(),
415        })?;
416
417    let node = prop
418        .value()
419        .ok_or_else(|| ManifestError::VersionNotString {
420            path: path.to_path_buf(),
421            version_field_path: version_field_path.to_string(),
422        })?;
423
424    let string_lit = node
425        .as_string_lit()
426        .ok_or_else(|| ManifestError::VersionNotString {
427            path: path.to_path_buf(),
428            version_field_path: version_field_path.to_string(),
429        })?;
430
431    string_lit
432        .decoded_value()
433        .map_err(|source| ManifestError::JsonStringDecode {
434            path: path.to_path_buf(),
435            source,
436        })
437}
438
439fn restore_toml_version(
440    content: &str,
441    path: &Path,
442    version_field_path: &str,
443    version_str: &str,
444) -> Result<String, ManifestError> {
445    let mut doc = content
446        .parse::<DocumentMut>()
447        .map_err(|source| ManifestError::Parse {
448            path: path.to_path_buf(),
449            source,
450        })?;
451
452    let segments: Vec<&str> = version_field_path.split('.').collect();
453    let (leaf_key, parent_segments) =
454        segments
455            .split_last()
456            .ok_or_else(|| ManifestError::VersionPathNotFound {
457                path: path.to_path_buf(),
458                version_field_path: version_field_path.to_string(),
459            })?;
460
461    let mut current = doc.as_item_mut();
462    for segment in parent_segments {
463        current = current
464            .get_mut(*segment)
465            .ok_or_else(|| ManifestError::VersionPathNotFound {
466                path: path.to_path_buf(),
467                version_field_path: version_field_path.to_string(),
468            })?;
469    }
470
471    let table = current
472        .as_table_like_mut()
473        .ok_or_else(|| ManifestError::VersionPathNotFound {
474            path: path.to_path_buf(),
475            version_field_path: version_field_path.to_string(),
476        })?;
477
478    if table.get(leaf_key).is_none() {
479        return Err(ManifestError::VersionPathNotFound {
480            path: path.to_path_buf(),
481            version_field_path: version_field_path.to_string(),
482        });
483    }
484
485    table.insert(leaf_key, toml_edit::value(version_str));
486
487    Ok(doc.to_string())
488}
489
490fn restore_yaml_version(
491    content: &str,
492    path: &Path,
493    version_field_path: &str,
494    version_str: &str,
495) -> Result<String, ManifestError> {
496    let doc = content
497        .parse::<Document>()
498        .map_err(|source| ManifestError::YamlParse {
499            path: path.to_path_buf(),
500            source,
501        })?;
502
503    let segments: Vec<&str> = version_field_path.split('.').collect();
504    let (leaf_key, parent_segments) =
505        segments
506            .split_last()
507            .ok_or_else(|| ManifestError::VersionPathNotFound {
508                path: path.to_path_buf(),
509                version_field_path: version_field_path.to_string(),
510            })?;
511
512    let mapping = doc
513        .as_mapping()
514        .ok_or_else(|| ManifestError::VersionPathNotFound {
515            path: path.to_path_buf(),
516            version_field_path: version_field_path.to_string(),
517        })?;
518
519    let mut current_mapping = mapping;
520    for segment in parent_segments {
521        current_mapping = current_mapping.get_mapping(*segment).ok_or_else(|| {
522            ManifestError::VersionPathNotFound {
523                path: path.to_path_buf(),
524                version_field_path: version_field_path.to_string(),
525            }
526        })?;
527    }
528
529    if !current_mapping.contains_key(*leaf_key) {
530        return Err(ManifestError::VersionPathNotFound {
531            path: path.to_path_buf(),
532            version_field_path: version_field_path.to_string(),
533        });
534    }
535
536    current_mapping.set(*leaf_key, version_str);
537
538    Ok(doc.to_string())
539}
540
541fn restore_json_version(
542    content: &str,
543    path: &Path,
544    version_field_path: &str,
545    version_str: &str,
546) -> Result<String, ManifestError> {
547    let root = CstRootNode::parse(content, &ParseOptions::default()).map_err(|source| {
548        ManifestError::JsonParse {
549            path: path.to_path_buf(),
550            source,
551        }
552    })?;
553
554    let root_obj = root
555        .object_value()
556        .ok_or_else(|| ManifestError::VersionPathNotFound {
557            path: path.to_path_buf(),
558            version_field_path: version_field_path.to_string(),
559        })?;
560
561    let segments: Vec<&str> = version_field_path.split('.').collect();
562    let (leaf_key, parent_segments) =
563        segments
564            .split_last()
565            .ok_or_else(|| ManifestError::VersionPathNotFound {
566                path: path.to_path_buf(),
567                version_field_path: version_field_path.to_string(),
568            })?;
569
570    let target_obj = navigate_json_to_object(&root_obj, path, version_field_path, parent_segments)?;
571
572    let prop = target_obj
573        .get(leaf_key)
574        .ok_or_else(|| ManifestError::VersionPathNotFound {
575            path: path.to_path_buf(),
576            version_field_path: version_field_path.to_string(),
577        })?;
578
579    prop.set_value(CstInputValue::String(version_str.to_string()));
580
581    Ok(root.to_string())
582}
583
584#[cfg(test)]
585mod tests {
586    use semver::Version;
587    use tempfile::NamedTempFile;
588
589    use super::*;
590
591    #[test]
592    fn writes_toml_version_preserving_comments() {
593        let content = "# package info\n[package]\nname = \"foo\"\nversion = \"1.0.0\"\n# after version\nedition = \"2024\"\n";
594        let file = NamedTempFile::new().expect("create temp file");
595        std::fs::write(file.path(), content).expect("write file");
596
597        write_external_version(
598            file.path(),
599            ManifestFormat::Toml,
600            "package.version",
601            &Version::new(2, 0, 0),
602        )
603        .expect("write version");
604
605        let result = std::fs::read_to_string(file.path()).expect("read file");
606        assert!(result.contains("# package info"));
607        assert!(result.contains("# after version"));
608        assert!(result.contains(r#"version = "2.0.0""#));
609    }
610
611    #[test]
612    fn writes_yaml_version_preserving_comments() {
613        let content = "name: my-chart # chart name\nversion: \"1.0.0\" # current version\n";
614        let file = NamedTempFile::new().expect("create temp file");
615        std::fs::write(file.path(), content).expect("write file");
616
617        write_external_version(
618            file.path(),
619            ManifestFormat::Yaml,
620            "version",
621            &Version::new(2, 0, 0),
622        )
623        .expect("write version");
624
625        let result = std::fs::read_to_string(file.path()).expect("read file");
626        assert!(result.contains("# chart name"));
627        assert!(result.contains("2.0.0"));
628    }
629
630    #[test]
631    fn writes_json_version_preserving_formatting() {
632        let content = "{\n  \"version\": \"1.0.0\",\n  \"name\": \"my-pkg\"\n}\n";
633        let file = NamedTempFile::new().expect("create temp file");
634        std::fs::write(file.path(), content).expect("write file");
635
636        write_external_version(
637            file.path(),
638            ManifestFormat::Json,
639            "version",
640            &Version::new(2, 0, 0),
641        )
642        .expect("write version");
643
644        let result = std::fs::read_to_string(file.path()).expect("read file");
645        assert!(result.contains("  \"version\""));
646        assert!(result.contains("\"2.0.0\""));
647    }
648
649    #[test]
650    fn writes_jsonc_version_preserving_comments() {
651        let content = "{\n  // version field\n  \"version\": \"1.0.0\"\n}\n";
652        let file = NamedTempFile::new().expect("create temp file");
653        std::fs::write(file.path(), content).expect("write file");
654
655        write_external_version(
656            file.path(),
657            ManifestFormat::Json,
658            "version",
659            &Version::new(2, 0, 0),
660        )
661        .expect("write version");
662
663        let result = std::fs::read_to_string(file.path()).expect("read file");
664        assert!(result.contains("// version field"));
665        assert!(result.contains("\"2.0.0\""));
666    }
667
668    #[test]
669    fn writes_yaml_version_flat_path() {
670        let content = "version: \"1.0.0\"\nname: my-chart\n";
671        let file = NamedTempFile::new().expect("create temp file");
672        std::fs::write(file.path(), content).expect("write file");
673
674        write_external_version(
675            file.path(),
676            ManifestFormat::Yaml,
677            "version",
678            &Version::new(3, 1, 4),
679        )
680        .expect("write version");
681
682        let result = std::fs::read_to_string(file.path()).expect("read file");
683        assert!(result.contains("3.1.4"));
684    }
685
686    #[test]
687    fn writes_toml_version_nested_path() {
688        let content = "[package]\nname = \"my-crate\"\nversion = \"1.0.0\"\n";
689        let file = NamedTempFile::new().expect("create temp file");
690        std::fs::write(file.path(), content).expect("write file");
691
692        write_external_version(
693            file.path(),
694            ManifestFormat::Toml,
695            "package.version",
696            &Version::new(1, 2, 3),
697        )
698        .expect("write version");
699
700        let result = std::fs::read_to_string(file.path()).expect("read file");
701        assert!(result.contains(r#"version = "1.2.3""#));
702    }
703
704    #[test]
705    fn writes_json_version_nested_path() {
706        let content = "{\n  \"metadata\": {\n    \"version\": \"1.0.0\"\n  }\n}\n";
707        let file = NamedTempFile::new().expect("create temp file");
708        std::fs::write(file.path(), content).expect("write file");
709
710        write_external_version(
711            file.path(),
712            ManifestFormat::Json,
713            "metadata.version",
714            &Version::new(2, 0, 0),
715        )
716        .expect("write version");
717
718        let result = std::fs::read_to_string(file.path()).expect("read file");
719        assert!(result.contains("\"2.0.0\""));
720    }
721
722    #[test]
723    fn verifies_matching_version() {
724        let content = "{\n  \"version\": \"1.2.3\"\n}\n";
725        let file = NamedTempFile::new().expect("create temp file");
726        std::fs::write(file.path(), content).expect("write file");
727
728        verify_external_version(
729            file.path(),
730            ManifestFormat::Json,
731            "version",
732            &Version::new(1, 2, 3),
733        )
734        .expect("verify version");
735    }
736
737    #[test]
738    fn verifies_returns_error_on_mismatch() {
739        let content = "{\n  \"version\": \"1.0.0\"\n}\n";
740        let file = NamedTempFile::new().expect("create temp file");
741        std::fs::write(file.path(), content).expect("write file");
742
743        let result = verify_external_version(
744            file.path(),
745            ManifestFormat::Json,
746            "version",
747            &Version::new(2, 0, 0),
748        );
749        assert!(matches!(
750            result,
751            Err(ManifestError::VerificationFailed { .. })
752        ));
753    }
754
755    #[test]
756    fn returns_error_for_missing_version_field_path() {
757        let content = "{\n  \"name\": \"my-pkg\"\n}\n";
758        let file = NamedTempFile::new().expect("create temp file");
759        std::fs::write(file.path(), content).expect("write file");
760
761        let result = write_external_version(
762            file.path(),
763            ManifestFormat::Json,
764            "version",
765            &Version::new(1, 0, 0),
766        );
767        assert!(matches!(
768            result,
769            Err(ManifestError::VersionPathNotFound { .. })
770        ));
771    }
772}