Skip to main content

standard_version/
project.rs

1//! Project manifest version file engines.
2//!
3//! Supports `project.toml`, `project.json`, and `project.yaml` — the
4//! driftsys project manifest format. Each file has a top-level `version`
5//! field. No ecosystem tooling; always uses native string manipulation.
6
7use std::sync::LazyLock;
8
9use crate::version_file::{VersionFile, VersionFileError};
10
11/// Regex matching `"version": "..."` in JSON (same as `json.rs`).
12static JSON_VERSION_RE: LazyLock<regex::Regex> =
13    LazyLock::new(|| regex::Regex::new(r#""version"\s*:\s*"([^"]+)""#).expect("valid regex"));
14
15// ---------------------------------------------------------------------------
16// project.toml
17// ---------------------------------------------------------------------------
18
19/// Version file engine for `project.toml`.
20///
21/// Detects a top-level `version = "..."` (before any `[section]` header).
22#[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// ---------------------------------------------------------------------------
96// project.json
97// ---------------------------------------------------------------------------
98
99/// Version file engine for `project.json`.
100///
101/// Uses regex matching to support both strict JSON and JSONC with comments.
102#[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: &regex::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// ---------------------------------------------------------------------------
151// project.yaml
152// ---------------------------------------------------------------------------
153
154/// Version file engine for `project.yaml`.
155///
156/// Detects a top-level `version:` field (not indented).
157#[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// ---------------------------------------------------------------------------
213// Tests
214// ---------------------------------------------------------------------------
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    // === project.toml ===
221
222    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    // === project.json ===
276
277    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    // === project.yaml ===
315
316    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}