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 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
136pub 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}