Skip to main content

semifold_resolver/
utils.rs

1use std::{
2    cmp::max,
3    path::{Path, PathBuf},
4};
5
6use semver::Version;
7
8use crate::{
9    changeset::{BumpLevel, Changeset},
10    config::{CommandConfig, ReleaseChannel},
11    error::ResolveError,
12};
13
14pub fn find_at_parent(
15    path_name: &str,
16    starts_at: &Path,
17    ends_at: Option<&Path>,
18) -> Option<PathBuf> {
19    let mut current_path = starts_at;
20    loop {
21        if ends_at.is_some_and(|ends_at| current_path == ends_at) {
22            break None;
23        } else {
24            let config_path = current_path.join(path_name);
25            if config_path.exists() {
26                break Some(config_path);
27            }
28        }
29        if let Some(parent_path) = current_path.parent() {
30            current_path = parent_path;
31        } else {
32            break None;
33        }
34    }
35}
36
37pub fn list_files<F: Fn(&Path) -> bool>(
38    path: &Path,
39    filter: F,
40) -> Result<Vec<PathBuf>, ResolveError> {
41    let mut files = Vec::new();
42    for entry in std::fs::read_dir(path)? {
43        let path = entry?.path();
44        if path.is_file() && filter(&path) {
45            files.push(path);
46        }
47    }
48    Ok(files)
49}
50
51fn bump_stable_base(version: &mut Version, level: BumpLevel) {
52    match level {
53        BumpLevel::Major => {
54            version.major += 1;
55            version.minor = 0;
56            version.patch = 0;
57        }
58        BumpLevel::Minor => {
59            version.minor += 1;
60            version.patch = 0;
61        }
62        BumpLevel::Patch => version.patch += 1,
63        BumpLevel::Unchanged => {}
64    }
65}
66
67fn set_named_channel(
68    version: &mut Version,
69    channel: &str,
70    sequence: u64,
71) -> Result<(), ResolveError> {
72    version.pre = semver::Prerelease::new(&format!("{channel}.{sequence}"))?;
73    Ok(())
74}
75
76fn advance_named_channel(version: &mut Version, channel: &str) -> Result<(), ResolveError> {
77    let prefix = format!("{channel}.");
78    let sequence = version
79        .pre
80        .as_str()
81        .strip_prefix(&prefix)
82        .map(|value| {
83            value
84                .parse::<u64>()
85                .map(|sequence| sequence + 1)
86                .map_err(|error| ResolveError::InvalidVersion {
87                    version: version.to_string(),
88                    reason: error.to_string(),
89                })
90        })
91        .transpose()?
92        .unwrap_or(0);
93    set_named_channel(version, channel, sequence)
94}
95
96pub fn bump_version<'a>(
97    version: &'a mut Version,
98    level: BumpLevel,
99    channel: &ReleaseChannel,
100) -> Result<&'a mut Version, ResolveError> {
101    match channel {
102        ReleaseChannel::Stable => {
103            if version.pre.is_empty() {
104                bump_stable_base(version, level);
105            } else {
106                // If the version is a pre-release, bumping semantic version resets pre-release
107                version.pre = semver::Prerelease::EMPTY;
108            }
109        }
110        ReleaseChannel::Named(name) => {
111            if level != BumpLevel::Unchanged {
112                if version.pre.is_empty() {
113                    bump_stable_base(version, level);
114                    set_named_channel(version, name, 0)?;
115                } else {
116                    advance_named_channel(version, name)?;
117                }
118            }
119        }
120    }
121    Ok(version)
122}
123
124pub fn get_bump_level(changesets: &[Changeset], package_name: &str) -> BumpLevel {
125    let mut level = BumpLevel::Unchanged;
126    for changeset in changesets {
127        changeset.packages.iter().for_each(|package| {
128            if package.name == package_name {
129                level = max(level, package.level);
130            }
131        });
132    }
133    level
134}
135
136/// Replaces a root-object JSON string field without reformatting unrelated content.
137pub fn replace_root_json_string_field(
138    content: &str,
139    field: &str,
140    replacement: &str,
141) -> Option<String> {
142    let bytes = content.as_bytes();
143    let mut index = skip_json_whitespace(bytes, 0);
144    if bytes.get(index) != Some(&b'{') {
145        return None;
146    }
147    index += 1;
148
149    loop {
150        index = skip_json_whitespace(bytes, index);
151        if bytes.get(index) == Some(&b'}') {
152            return None;
153        }
154        if bytes.get(index) != Some(&b'"') {
155            return None;
156        }
157
158        let key_start = index;
159        let key_end = scan_json_string(bytes, index)?;
160        let key = serde_json::from_str::<String>(&content[key_start..key_end]).ok()?;
161        index = skip_json_whitespace(bytes, key_end);
162        if bytes.get(index) != Some(&b':') {
163            return None;
164        }
165        index = skip_json_whitespace(bytes, index + 1);
166
167        if key == field && bytes.get(index) == Some(&b'"') {
168            let value_end = scan_json_string(bytes, index)?;
169            let replacement = serde_json::to_string(replacement).ok()?;
170            return Some(format!(
171                "{}{}{}",
172                &content[..index],
173                replacement,
174                &content[value_end..]
175            ));
176        }
177
178        index = scan_json_value(bytes, index)?;
179        index = skip_json_whitespace(bytes, index);
180        match bytes.get(index) {
181            Some(b',') => index += 1,
182            Some(b'}') => return None,
183            _ => return None,
184        }
185    }
186}
187
188fn skip_json_whitespace(bytes: &[u8], mut index: usize) -> usize {
189    while bytes
190        .get(index)
191        .is_some_and(|byte| matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
192    {
193        index += 1;
194    }
195    index
196}
197
198fn scan_json_string(bytes: &[u8], start: usize) -> Option<usize> {
199    if bytes.get(start) != Some(&b'"') {
200        return None;
201    }
202
203    let mut index = start + 1;
204    while let Some(byte) = bytes.get(index) {
205        match byte {
206            b'\\' => index += 2,
207            b'"' => return Some(index + 1),
208            _ => index += 1,
209        }
210    }
211    None
212}
213
214fn scan_json_value(bytes: &[u8], start: usize) -> Option<usize> {
215    let mut index = start;
216    let mut depth = 0usize;
217
218    while let Some(byte) = bytes.get(index) {
219        match byte {
220            b'"' => index = scan_json_string(bytes, index)?,
221            b'{' | b'[' => {
222                depth += 1;
223                index += 1;
224            }
225            b'}' | b']' if depth > 0 => {
226                depth -= 1;
227                index += 1;
228            }
229            b',' | b'}' if depth == 0 => return Some(index),
230            _ => index += 1,
231        }
232    }
233    None
234}
235
236pub fn run_command(command: &CommandConfig, cwd: &Path) -> Result<(), ResolveError> {
237    let mut cmd = std::process::Command::new(&command.command);
238    if let Some(args) = &command.args {
239        cmd.args(args);
240    }
241    cmd.current_dir(cwd);
242    cmd.envs(&command.extra_env);
243    cmd.stdout(command.stdout);
244    cmd.stderr(command.stderr);
245    let status = cmd.status()?;
246    if status.success() {
247        Ok(())
248    } else {
249        Err(ResolveError::CommandError {
250            command: command.command.clone(),
251            status,
252            code: status.code(),
253        })
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use semver::Version;
260
261    use crate::{
262        changeset::{BumpLevel, Changeset},
263        config::ReleaseChannel,
264    };
265
266    use super::{bump_version, get_bump_level, replace_root_json_string_field};
267
268    #[test]
269    fn bumps_semantic_versions() {
270        let cases = [
271            (BumpLevel::Major, "1.2.3", "2.0.0"),
272            (BumpLevel::Minor, "1.2.3", "1.3.0"),
273            (BumpLevel::Patch, "1.2.3", "1.2.4"),
274            (BumpLevel::Unchanged, "1.2.3", "1.2.3"),
275        ];
276
277        for (level, current, expected) in cases {
278            let mut version = Version::parse(current).unwrap();
279            bump_version(&mut version, level, &ReleaseChannel::Stable).unwrap();
280            assert_eq!(version, Version::parse(expected).unwrap());
281        }
282    }
283
284    #[test]
285    fn semantic_bump_finalizes_a_prerelease_without_incrementing() {
286        let mut version = Version::parse("1.2.3-beta.4").unwrap();
287
288        bump_version(&mut version, BumpLevel::Patch, &ReleaseChannel::Stable).unwrap();
289
290        assert_eq!(version, Version::parse("1.2.3").unwrap());
291    }
292
293    #[test]
294    fn named_channel_sets_a_stable_base_then_advances_or_switches() {
295        let mut version = Version::parse("1.2.3").unwrap();
296        let beta = ReleaseChannel::Named("beta".to_string());
297
298        bump_version(&mut version, BumpLevel::Major, &beta).unwrap();
299        assert_eq!(version, Version::parse("2.0.0-beta.0").unwrap());
300
301        bump_version(&mut version, BumpLevel::Major, &beta).unwrap();
302        assert_eq!(version, Version::parse("2.0.0-beta.1").unwrap());
303
304        bump_version(
305            &mut version,
306            BumpLevel::Patch,
307            &ReleaseChannel::Named("rc".to_string()),
308        )
309        .unwrap();
310        assert_eq!(version, Version::parse("2.0.0-rc.0").unwrap());
311    }
312
313    #[test]
314    fn unchanged_named_channel_does_not_advance() {
315        let mut version = Version::parse("1.2.3").unwrap();
316
317        bump_version(
318            &mut version,
319            BumpLevel::Unchanged,
320            &ReleaseChannel::Named("beta".to_string()),
321        )
322        .unwrap();
323
324        assert_eq!(version, Version::parse("1.2.3").unwrap());
325    }
326
327    #[test]
328    fn changeset_tags_do_not_change_the_release_channel() {
329        let root = std::path::Path::new(".");
330        let mut changeset = Changeset::new("feature".to_string(), root);
331        changeset.add_package(
332            "api".to_string(),
333            BumpLevel::Patch,
334            Some("breaking-change".to_string()),
335        );
336        let mut version = Version::parse("1.2.3").unwrap();
337
338        bump_version(
339            &mut version,
340            get_bump_level(&[changeset], "api"),
341            &ReleaseChannel::Named("beta".to_string()),
342        )
343        .unwrap();
344
345        assert_eq!(version, Version::parse("1.2.4-beta.0").unwrap());
346    }
347
348    #[test]
349    fn selects_the_highest_bump_for_the_requested_package() {
350        let root = std::path::Path::new(".");
351        let mut first = Changeset::new("first".to_string(), root);
352        first.add_package("api".to_string(), BumpLevel::Patch, None);
353        first.add_package("web".to_string(), BumpLevel::Major, None);
354
355        let mut second = Changeset::new("second".to_string(), root);
356        second.add_package("api".to_string(), BumpLevel::Minor, None);
357
358        assert_eq!(get_bump_level(&[first, second], "api"), BumpLevel::Minor);
359        assert_eq!(get_bump_level(&[], "api"), BumpLevel::Unchanged);
360        assert_eq!(
361            get_bump_level(&[Changeset::new("other".to_string(), root)], "unknown"),
362            BumpLevel::Unchanged
363        );
364    }
365
366    #[test]
367    fn replaces_only_the_root_json_string_field_without_reformatting() {
368        let content = concat!(
369            "{\n",
370            "  \"metadata\": { \"version\": \"unchanged\" },\n",
371            "  \"version\" : \"1.0.0\",\n",
372            "  \"custom\": [1, 2]\n",
373            "}\n"
374        );
375
376        assert_eq!(
377            replace_root_json_string_field(content, "version", "1.0.1"),
378            Some(
379                concat!(
380                    "{\n",
381                    "  \"metadata\": { \"version\": \"unchanged\" },\n",
382                    "  \"version\" : \"1.0.1\",\n",
383                    "  \"custom\": [1, 2]\n",
384                    "}\n"
385                )
386                .to_string()
387            )
388        );
389    }
390}