Skip to main content

callisto_format/
pre.rs

1use callisto_model::{Version, VersionGrammar};
2use indexmap::IndexMap;
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7#[serde(rename_all = "camelCase")]
8pub struct PreState {
9    pub mode: PreMode,
10    pub tag: String,
11    #[schemars(with = "std::collections::BTreeMap<String, Version>")]
12    pub initial_versions: IndexMap<String, Version>,
13    pub changesets: Vec<String>,
14}
15
16impl PreState {
17    pub fn entering(tag: impl Into<String>, initial_versions: impl IntoIterator<Item = (String, Version)>) -> Self {
18        let mut map = IndexMap::new();
19        for (pkg, ver) in initial_versions {
20            map.entry(pkg).or_insert(ver);
21        }
22        PreState {
23            mode: PreMode::Pre,
24            tag: tag.into(),
25            initial_versions: map,
26            changesets: Vec::new(),
27        }
28    }
29
30    pub fn exiting(mut self) -> Self {
31        self.mode = PreMode::Exit;
32        self
33    }
34}
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(rename_all = "lowercase")]
38pub enum PreMode {
39    Pre,
40    Exit,
41}
42
43pub fn parse_pre_json(input: &str) -> Result<PreState, PreJsonError> {
44    let clean_input = input.strip_prefix('\u{FEFF}').unwrap_or(input);
45    let val: serde_json::Value =
46        serde_json::from_str(clean_input).map_err(|e| PreJsonError::Malformed { message: e.to_string() })?;
47
48    let obj = val.as_object().ok_or_else(|| PreJsonError::Malformed {
49        message: "expected a JSON object".to_string(),
50    })?;
51
52    let mode_val = obj.get("mode").ok_or(PreJsonError::MissingField { field: "mode" })?;
53    let mode_str = mode_val
54        .as_str()
55        .ok_or(PreJsonError::WrongFieldType { field: "mode" })?;
56    let mode = match mode_str {
57        "pre" => PreMode::Pre,
58        "exit" => PreMode::Exit,
59        _ => {
60            return Err(PreJsonError::InvalidMode {
61                found: mode_str.to_string(),
62            })
63        }
64    };
65
66    let tag_val = obj.get("tag").ok_or(PreJsonError::MissingField { field: "tag" })?;
67    let tag = tag_val
68        .as_str()
69        .ok_or(PreJsonError::WrongFieldType { field: "tag" })?
70        .to_string();
71
72    let init_val = obj.get("initialVersions").ok_or(PreJsonError::MissingField {
73        field: "initialVersions",
74    })?;
75    let init_obj = init_val.as_object().ok_or(PreJsonError::WrongFieldType {
76        field: "initialVersions",
77    })?;
78
79    let mut initial_versions = IndexMap::new();
80    for (pkg, v_val) in init_obj {
81        let v_str = v_val.as_str().ok_or(PreJsonError::WrongFieldType {
82            field: "initialVersions",
83        })?;
84        let ver = Version::parse(v_str, VersionGrammar::SemVer)
85            .or_else(|_| Version::parse(v_str, VersionGrammar::Pep440))
86            .map_err(|source| PreJsonError::InvalidInitialVersion {
87                package: pkg.clone(),
88                raw: v_str.to_string(),
89                source,
90            })?;
91        initial_versions.insert(pkg.clone(), ver);
92    }
93
94    let cs_val = obj
95        .get("changesets")
96        .ok_or(PreJsonError::MissingField { field: "changesets" })?;
97    let cs_arr = cs_val
98        .as_array()
99        .ok_or(PreJsonError::WrongFieldType { field: "changesets" })?;
100
101    let mut changesets = Vec::new();
102    for (index, c_val) in cs_arr.iter().enumerate() {
103        let c_str = c_val.as_str().ok_or(PreJsonError::InvalidChangesetId { index })?;
104        changesets.push(c_str.to_string());
105    }
106
107    Ok(PreState {
108        mode,
109        tag,
110        initial_versions,
111        changesets,
112    })
113}
114
115pub fn write_pre_json(state: &PreState) -> String {
116    let mut map = IndexMap::new();
117    map.insert("mode".to_string(), serde_json::to_value(state.mode).unwrap());
118    map.insert("tag".to_string(), serde_json::to_value(&state.tag).unwrap());
119
120    let mut init_map = IndexMap::new();
121    for (pkg, ver) in &state.initial_versions {
122        init_map.insert(pkg.clone(), serde_json::to_value(ver).unwrap());
123    }
124    map.insert("initialVersions".to_string(), serde_json::to_value(init_map).unwrap());
125    map.insert(
126        "changesets".to_string(),
127        serde_json::to_value(&state.changesets).unwrap(),
128    );
129
130    let mut out = serde_json::to_string_pretty(&map).unwrap();
131    out.push('\n');
132    out
133}
134
135#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum PreJsonError {
138    #[error("pre.json is not a valid JSON object: {message}")]
139    Malformed { message: String },
140
141    #[error("pre.json is missing required field {field:?}")]
142    MissingField { field: &'static str },
143
144    #[error("pre.json field {field:?} has the wrong type")]
145    WrongFieldType { field: &'static str },
146
147    #[error("pre.json has mode {found:?}, expected \"pre\" or \"exit\"")]
148    InvalidMode { found: String },
149
150    #[error("pre.json initialVersions[{package:?}] = {raw:?} is not a valid version: {source}")]
151    InvalidInitialVersion {
152        package: String,
153        raw: String,
154        #[source]
155        source: callisto_model::VersionParseError,
156    },
157
158    #[error("pre.json changesets[{index}] is not a string")]
159    InvalidChangesetId { index: usize },
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn pre_json_round_trips() {
168        let json = r#"{
169  "mode": "pre",
170  "tag": "next",
171  "initialVersions": {
172    "foo": "1.0.0"
173  },
174  "changesets": [
175    "cool-dragons-fly"
176  ]
177}
178"#;
179        let state = parse_pre_json(json).unwrap();
180        assert_eq!(state.tag, "next");
181        let written = write_pre_json(&state);
182        assert_eq!(written, json);
183    }
184
185    #[test]
186    fn pre_json_accepts_pep440_initial_versions() {
187        // pre.json files from Python/PyPI workspaces store PEP 440 version
188        // strings (e.g. "0.3.2a1") in initialVersions. parse_pre_json must
189        // accept them; rejecting them would make pre-mode unusable for Python
190        // packages entirely.
191        let json = r#"{
192  "mode": "pre",
193  "tag": "beta",
194  "initialVersions": {
195    "my-python-pkg": "0.3.2a1"
196  },
197  "changesets": []
198}
199"#;
200        let state = parse_pre_json(json).unwrap();
201        assert_eq!(state.initial_versions["my-python-pkg"].raw(), "0.3.2a1");
202    }
203
204    #[test]
205    fn pre_json_round_trips_pep440_version() {
206        // The PEP 440 initial version string must survive a write → parse cycle
207        // unchanged so that pre.json files are idempotent.
208        let state = PreState::entering(
209            "beta",
210            [("pkg".to_string(), {
211                callisto_model::Version::parse("1.0.0a1", callisto_model::VersionGrammar::Pep440).unwrap()
212            })],
213        );
214        let written = write_pre_json(&state);
215        let reparsed = parse_pre_json(&written).unwrap();
216        assert_eq!(reparsed.initial_versions["pkg"].raw(), "1.0.0a1");
217    }
218}