standard_version/
project.rs1use std::sync::LazyLock;
8
9use crate::version_file::{VersionFile, VersionFileError};
10
11static JSON_VERSION_RE: LazyLock<regex::Regex> =
13 LazyLock::new(|| regex::Regex::new(r#""version"\s*:\s*"([^"]+)""#).expect("valid regex"));
14
15#[derive(Debug, Clone, Copy)]
23pub struct ProjectTomlVersionFile;
24
25impl VersionFile for ProjectTomlVersionFile {
26 fn name(&self) -> &str {
27 "project.toml"
28 }
29
30 fn filenames(&self) -> &[&str] {
31 &["project.toml"]
32 }
33
34 fn detect(&self, content: &str) -> bool {
35 for line in content.lines() {
36 let trimmed = line.trim();
37 if trimmed.starts_with('[') {
38 return false;
39 }
40 if trimmed.starts_with("version") && trimmed.contains('=') {
41 return true;
42 }
43 }
44 false
45 }
46
47 fn read_version(&self, content: &str) -> Option<String> {
48 for line in content.lines() {
49 let trimmed = line.trim();
50 if trimmed.starts_with('[') {
51 return None;
52 }
53 if trimmed.starts_with("version")
54 && let Some(eq_pos) = trimmed.find('=')
55 {
56 let value = trimmed[eq_pos + 1..].trim();
57 return Some(value.trim_matches('"').to_string());
58 }
59 }
60 None
61 }
62
63 fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError> {
64 let mut result = String::new();
65 let mut replaced = false;
66
67 for line in content.lines() {
68 let trimmed = line.trim();
69 if !replaced
70 && !trimmed.starts_with('[')
71 && trimmed.starts_with("version")
72 && let Some(eq_pos) = line.find('=')
73 {
74 let prefix = &line[..=eq_pos];
75 result.push_str(prefix);
76 result.push_str(&format!(" \"{new_version}\""));
77 result.push('\n');
78 replaced = true;
79 continue;
80 }
81 result.push_str(line);
82 result.push('\n');
83 }
84
85 if !replaced {
86 return Err(VersionFileError::NoVersionField);
87 }
88 if !content.ends_with('\n') && result.ends_with('\n') {
89 result.pop();
90 }
91 Ok(result)
92 }
93}
94
95#[derive(Debug, Clone, Copy)]
103pub struct ProjectJsonVersionFile;
104
105impl VersionFile for ProjectJsonVersionFile {
106 fn name(&self) -> &str {
107 "project.json"
108 }
109
110 fn filenames(&self) -> &[&str] {
111 &["project.json"]
112 }
113
114 fn detect(&self, content: &str) -> bool {
115 JSON_VERSION_RE.is_match(content)
116 }
117
118 fn read_version(&self, content: &str) -> Option<String> {
119 JSON_VERSION_RE
120 .captures(content)
121 .and_then(|caps| caps.get(1))
122 .map(|m| m.as_str().to_string())
123 }
124
125 fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError> {
126 let re = &*JSON_VERSION_RE;
127 if !re.is_match(content) {
128 return Err(VersionFileError::NoVersionField);
129 }
130 let mut replaced = false;
131 let result = re.replace(content, |caps: ®ex::Captures<'_>| {
132 if replaced {
133 return caps[0].to_string();
134 }
135 replaced = true;
136 let full = &caps[0];
137 let version_start = caps.get(1).unwrap().start() - caps.get(0).unwrap().start();
138 let version_end = caps.get(1).unwrap().end() - caps.get(0).unwrap().start();
139 format!(
140 "{}{}{}",
141 &full[..version_start],
142 new_version,
143 &full[version_end..],
144 )
145 });
146 Ok(result.into_owned())
147 }
148}
149
150#[derive(Debug, Clone, Copy)]
158pub struct ProjectYamlVersionFile;
159
160impl VersionFile for ProjectYamlVersionFile {
161 fn name(&self) -> &str {
162 "project.yaml"
163 }
164
165 fn filenames(&self) -> &[&str] {
166 &["project.yaml"]
167 }
168
169 fn detect(&self, content: &str) -> bool {
170 content
171 .lines()
172 .any(|line| line.starts_with("version:") && line.len() > "version:".len())
173 }
174
175 fn read_version(&self, content: &str) -> Option<String> {
176 for line in content.lines() {
177 if let Some(value) = line.strip_prefix("version:") {
178 let value = value.trim().trim_matches('"').trim_matches('\'');
179 if !value.is_empty() {
180 return Some(value.to_string());
181 }
182 }
183 }
184 None
185 }
186
187 fn write_version(&self, content: &str, new_version: &str) -> Result<String, VersionFileError> {
188 let mut result = String::new();
189 let mut replaced = false;
190
191 for line in content.lines() {
192 if !replaced && line.starts_with("version:") {
193 result.push_str(&format!("version: \"{new_version}\""));
194 result.push('\n');
195 replaced = true;
196 continue;
197 }
198 result.push_str(line);
199 result.push('\n');
200 }
201
202 if !replaced {
203 return Err(VersionFileError::NoVersionField);
204 }
205 if !content.ends_with('\n') && result.ends_with('\n') {
206 result.pop();
207 }
208 Ok(result)
209 }
210}
211
212#[cfg(test)]
217mod tests {
218 use super::*;
219
220 const TOML: &str = r#"name = "io.driftsys.myapp"
223description = "My application"
224version = "0.1.0"
225license = "MIT"
226"#;
227
228 const TOML_NO_VERSION: &str = "name = \"io.driftsys.myapp\"\n";
229
230 const TOML_VERSION_IN_SECTION: &str = r#"name = "io.driftsys.myapp"
231
232[metadata]
233version = "0.1.0"
234"#;
235
236 #[test]
237 fn toml_detect() {
238 assert!(ProjectTomlVersionFile.detect(TOML));
239 }
240
241 #[test]
242 fn toml_detect_no_version() {
243 assert!(!ProjectTomlVersionFile.detect(TOML_NO_VERSION));
244 }
245
246 #[test]
247 fn toml_detect_ignores_section() {
248 assert!(!ProjectTomlVersionFile.detect(TOML_VERSION_IN_SECTION));
249 }
250
251 #[test]
252 fn toml_read() {
253 assert_eq!(
254 ProjectTomlVersionFile.read_version(TOML),
255 Some("0.1.0".to_string()),
256 );
257 }
258
259 #[test]
260 fn toml_write() {
261 let result = ProjectTomlVersionFile.write_version(TOML, "2.0.0").unwrap();
262 assert!(result.contains("version = \"2.0.0\""));
263 assert!(result.contains("license = \"MIT\""));
264 }
265
266 #[test]
267 fn toml_write_no_version_errors() {
268 assert!(
269 ProjectTomlVersionFile
270 .write_version(TOML_NO_VERSION, "1.0.0")
271 .is_err()
272 );
273 }
274
275 const JSON: &str = r#"{
278 "name": "io.driftsys.myapp",
279 "version": "0.1.0",
280 "description": "My application"
281}
282"#;
283
284 const JSON_NO_VERSION: &str = r#"{
285 "name": "io.driftsys.myapp"
286}
287"#;
288
289 #[test]
290 fn json_detect() {
291 assert!(ProjectJsonVersionFile.detect(JSON));
292 }
293
294 #[test]
295 fn json_detect_no_version() {
296 assert!(!ProjectJsonVersionFile.detect(JSON_NO_VERSION));
297 }
298
299 #[test]
300 fn json_read() {
301 assert_eq!(
302 ProjectJsonVersionFile.read_version(JSON),
303 Some("0.1.0".to_string()),
304 );
305 }
306
307 #[test]
308 fn json_write() {
309 let result = ProjectJsonVersionFile.write_version(JSON, "2.0.0").unwrap();
310 assert!(result.contains(r#""version": "2.0.0""#));
311 assert!(result.contains(r#""name": "io.driftsys.myapp""#));
312 }
313
314 const YAML: &str = "name: io.driftsys.myapp\nversion: \"0.1.0\"\nlicense: MIT\n";
317 const YAML_UNQUOTED: &str = "name: io.driftsys.myapp\nversion: 0.1.0\nlicense: MIT\n";
318 const YAML_NO_VERSION: &str = "name: io.driftsys.myapp\nlicense: MIT\n";
319
320 #[test]
321 fn yaml_detect() {
322 assert!(ProjectYamlVersionFile.detect(YAML));
323 }
324
325 #[test]
326 fn yaml_detect_unquoted() {
327 assert!(ProjectYamlVersionFile.detect(YAML_UNQUOTED));
328 }
329
330 #[test]
331 fn yaml_detect_no_version() {
332 assert!(!ProjectYamlVersionFile.detect(YAML_NO_VERSION));
333 }
334
335 #[test]
336 fn yaml_read_quoted() {
337 assert_eq!(
338 ProjectYamlVersionFile.read_version(YAML),
339 Some("0.1.0".to_string()),
340 );
341 }
342
343 #[test]
344 fn yaml_read_unquoted() {
345 assert_eq!(
346 ProjectYamlVersionFile.read_version(YAML_UNQUOTED),
347 Some("0.1.0".to_string()),
348 );
349 }
350
351 #[test]
352 fn yaml_write() {
353 let result = ProjectYamlVersionFile.write_version(YAML, "2.0.0").unwrap();
354 assert!(result.contains("version: \"2.0.0\""));
355 assert!(result.contains("license: MIT"));
356 }
357
358 #[test]
359 fn yaml_write_no_version_errors() {
360 assert!(
361 ProjectYamlVersionFile
362 .write_version(YAML_NO_VERSION, "1.0.0")
363 .is_err()
364 );
365 }
366}