sampo_core/
prerelease.rs

1use crate::discover_workspace;
2use crate::errors::{Result, SampoError};
3use crate::manifest::{ManifestMetadata, update_manifest_versions};
4use crate::release::{parse_version_string, regenerate_lockfile, restore_prerelease_changesets};
5use crate::types::{CrateInfo, Workspace};
6use semver::{BuildMetadata, Prerelease};
7use std::collections::{BTreeMap, BTreeSet};
8use std::fs;
9use std::path::Path;
10
11/// Represents a version change applied to a package manifest.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct VersionChange {
14    pub name: String,
15    pub old_version: String,
16    pub new_version: String,
17}
18
19/// Enter pre-release mode for the selected packages with the provided label.
20pub fn enter_prerelease(
21    root: &Path,
22    packages: &[String],
23    label: &str,
24) -> Result<Vec<VersionChange>> {
25    let workspace = discover_workspace(root)?;
26    let targets = resolve_targets(&workspace, packages)?;
27    let prerelease = validate_label(label)?;
28
29    let (changes, new_versions) = plan_enter_updates(&targets, &prerelease)?;
30    if new_versions.is_empty() {
31        return Ok(Vec::new());
32    }
33
34    apply_version_updates(&workspace, &new_versions)?;
35    Ok(changes)
36}
37
38/// Exit pre-release mode for the selected packages, restoring stable versions.
39pub fn exit_prerelease(root: &Path, packages: &[String]) -> Result<Vec<VersionChange>> {
40    let workspace = discover_workspace(root)?;
41    let targets = resolve_targets(&workspace, packages)?;
42
43    let (changes, new_versions) = plan_exit_updates(&targets)?;
44    if new_versions.is_empty() {
45        return Ok(Vec::new());
46    }
47
48    apply_version_updates(&workspace, &new_versions)?;
49    Ok(changes)
50}
51
52/// Restore any preserved changesets from a prior pre-release phase back into the
53/// workspace changeset directory.
54///
55/// Returns the number of files moved. When no preserved changesets are present,
56/// the function behaves as a no-op.
57pub fn restore_preserved_changesets(root: &Path) -> Result<usize> {
58    let prerelease_dir = root.join(".sampo").join("prerelease");
59    if !prerelease_dir.exists() {
60        return Ok(0);
61    }
62
63    let changesets_dir = root.join(".sampo").join("changesets");
64    let mut preserved = 0usize;
65
66    for entry in fs::read_dir(&prerelease_dir)? {
67        let entry = entry?;
68        let path = entry.path();
69        if !path.is_file() {
70            continue;
71        }
72        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
73            continue;
74        }
75        preserved += 1;
76    }
77
78    if preserved == 0 {
79        return Ok(0);
80    }
81
82    restore_prerelease_changesets(&prerelease_dir, &changesets_dir)?;
83    Ok(preserved)
84}
85
86fn resolve_targets<'a>(
87    workspace: &'a Workspace,
88    packages: &[String],
89) -> Result<Vec<&'a CrateInfo>> {
90    if packages.is_empty() {
91        return Err(SampoError::Prerelease(
92            "At least one package must be specified.".to_string(),
93        ));
94    }
95
96    let mut lookup: BTreeMap<&str, &CrateInfo> = BTreeMap::new();
97    for info in &workspace.members {
98        lookup.insert(info.name.as_str(), info);
99    }
100
101    let mut seen: BTreeSet<&str> = BTreeSet::new();
102    let mut targets = Vec::new();
103
104    for name in packages {
105        let trimmed = name.trim();
106        if trimmed.is_empty() {
107            return Err(SampoError::Prerelease(
108                "Package names cannot be empty.".to_string(),
109            ));
110        }
111        if !seen.insert(trimmed) {
112            continue;
113        }
114        let info = lookup.get(trimmed).ok_or_else(|| {
115            SampoError::NotFound(format!("Package '{}' not found in workspace", trimmed))
116        })?;
117        targets.push(*info);
118    }
119
120    targets.sort_by(|a, b| a.name.cmp(&b.name));
121    Ok(targets)
122}
123
124fn validate_label(label: &str) -> Result<Prerelease> {
125    let trimmed = label.trim();
126    if trimmed.is_empty() {
127        return Err(SampoError::Prerelease(
128            "Pre-release label cannot be empty.".to_string(),
129        ));
130    }
131
132    let has_non_numeric = trimmed
133        .split('.')
134        .any(|segment| segment.chars().any(|ch| !ch.is_ascii_digit()));
135    if !has_non_numeric {
136        return Err(SampoError::Prerelease(
137            "Pre-release label must contain at least one non-numeric identifier.".to_string(),
138        ));
139    }
140
141    Prerelease::new(trimmed).map_err(|err| {
142        SampoError::Prerelease(format!("Invalid pre-release label '{}': {err}", trimmed))
143    })
144}
145
146fn plan_enter_updates(
147    targets: &[&CrateInfo],
148    prerelease: &Prerelease,
149) -> Result<(Vec<VersionChange>, BTreeMap<String, String>)> {
150    let mut changes = Vec::new();
151    let mut new_versions: BTreeMap<String, String> = BTreeMap::new();
152
153    for info in targets {
154        let version = parse_version_string(&info.version).map_err(|err| {
155            SampoError::Prerelease(format!(
156                "Invalid semantic version for package '{}': {}",
157                info.name, err
158            ))
159        })?;
160
161        let mut base = version.clone();
162        base.pre = Prerelease::EMPTY;
163        base.build = BuildMetadata::EMPTY;
164
165        let mut updated = base.clone();
166        updated.pre = prerelease.clone();
167        let new_version = updated.to_string();
168
169        if new_version == info.version {
170            continue;
171        }
172
173        new_versions.insert(info.name.clone(), new_version.clone());
174        changes.push(VersionChange {
175            name: info.name.clone(),
176            old_version: info.version.clone(),
177            new_version,
178        });
179    }
180
181    Ok((changes, new_versions))
182}
183
184fn plan_exit_updates(
185    targets: &[&CrateInfo],
186) -> Result<(Vec<VersionChange>, BTreeMap<String, String>)> {
187    let mut changes = Vec::new();
188    let mut new_versions: BTreeMap<String, String> = BTreeMap::new();
189
190    for info in targets {
191        let version = parse_version_string(&info.version).map_err(|err| {
192            SampoError::Prerelease(format!(
193                "Invalid semantic version for package '{}': {}",
194                info.name, err
195            ))
196        })?;
197
198        if version.pre.is_empty() {
199            continue;
200        }
201
202        let mut stable = version.clone();
203        stable.pre = Prerelease::EMPTY;
204        stable.build = BuildMetadata::EMPTY;
205        let new_version = stable.to_string();
206
207        if new_version == info.version {
208            continue;
209        }
210
211        new_versions.insert(info.name.clone(), new_version.clone());
212        changes.push(VersionChange {
213            name: info.name.clone(),
214            old_version: info.version.clone(),
215            new_version,
216        });
217    }
218
219    Ok((changes, new_versions))
220}
221
222fn apply_version_updates(
223    workspace: &Workspace,
224    new_versions: &BTreeMap<String, String>,
225) -> Result<()> {
226    let manifest_metadata = ManifestMetadata::load(workspace).map_err(|err| match err {
227        SampoError::Release(msg) => SampoError::Prerelease(msg),
228        other => other,
229    })?;
230
231    for info in &workspace.members {
232        let manifest_path = info.path.join("Cargo.toml");
233        let original = fs::read_to_string(&manifest_path)?;
234        let new_pkg_version = new_versions.get(&info.name).map(|s| s.as_str());
235        let (updated, _deps) = update_manifest_versions(
236            &manifest_path,
237            &original,
238            new_pkg_version,
239            new_versions,
240            Some(&manifest_metadata),
241        )?;
242
243        if updated != original {
244            fs::write(&manifest_path, updated)?;
245        }
246    }
247
248    if workspace.root.join("Cargo.lock").exists() {
249        regenerate_lockfile(&workspace.root).map_err(|err| match err {
250            SampoError::Release(msg) => SampoError::Prerelease(msg),
251            other => other,
252        })?;
253    }
254
255    Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use tempfile::tempdir;
262
263    fn init_workspace() -> tempfile::TempDir {
264        let temp = tempdir().unwrap();
265        let root = temp.path();
266
267        fs::create_dir_all(root.join("crates/foo")).unwrap();
268        fs::create_dir_all(root.join("crates/bar")).unwrap();
269
270        fs::write(
271            root.join("Cargo.toml"),
272            "[workspace]\nmembers=[\"crates/*\"]\n",
273        )
274        .unwrap();
275
276        write_manifest(&root.join("crates/foo"), "foo", "0.1.0");
277        write_manifest(&root.join("crates/bar"), "bar", "0.1.0");
278
279        temp
280    }
281
282    fn write_manifest(path: &Path, name: &str, version: &str) {
283        fs::create_dir_all(path.join("src")).unwrap();
284        fs::write(
285            path.join("Cargo.toml"),
286            format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n"),
287        )
288        .unwrap();
289        fs::write(path.join("src/lib.rs"), "pub fn __sampo_test_marker() {}\n").unwrap();
290    }
291
292    fn append_dependency(path: &Path, dep: &str, dep_version: &str) {
293        let manifest_path = path.join("Cargo.toml");
294        let current = fs::read_to_string(&manifest_path).unwrap();
295        fs::write(
296            &manifest_path,
297            format!(
298                "{}\n[dependencies]\n{dep} = {{ path = \"../{dep}\", version = \"{dep_version}\" }}\n",
299                current.trim_end()
300            ),
301        )
302        .unwrap();
303    }
304
305    #[test]
306    fn enter_sets_prerelease_label_and_updates_dependents() {
307        let temp = init_workspace();
308        let root = temp.path();
309
310        write_manifest(&root.join("crates/foo"), "foo", "1.2.3");
311        write_manifest(&root.join("crates/bar"), "bar", "0.1.0");
312        append_dependency(&root.join("crates/bar"), "foo", "1.2.3");
313
314        let updates = enter_prerelease(root, &[String::from("foo")], "alpha").unwrap();
315        assert_eq!(
316            updates,
317            vec![VersionChange {
318                name: "foo".to_string(),
319                old_version: "1.2.3".to_string(),
320                new_version: "1.2.3-alpha".to_string(),
321            }]
322        );
323
324        let foo_manifest = fs::read_to_string(root.join("crates/foo/Cargo.toml")).unwrap();
325        assert!(
326            foo_manifest.contains("version = \"1.2.3-alpha\"")
327                || foo_manifest.contains("version=\"1.2.3-alpha\"")
328        );
329
330        let bar_manifest = fs::read_to_string(root.join("crates/bar/Cargo.toml")).unwrap();
331        assert!(
332            bar_manifest.contains("version = \"1.2.3-alpha\"")
333                || bar_manifest.contains("version=\"1.2.3-alpha\"")
334        );
335    }
336
337    #[test]
338    fn enter_switches_between_labels() {
339        let temp = init_workspace();
340        let root = temp.path();
341
342        write_manifest(&root.join("crates/foo"), "foo", "1.0.0-beta.3");
343
344        let updates = enter_prerelease(root, &[String::from("foo")], "alpha").unwrap();
345        assert_eq!(
346            updates,
347            vec![VersionChange {
348                name: "foo".to_string(),
349                old_version: "1.0.0-beta.3".to_string(),
350                new_version: "1.0.0-alpha".to_string(),
351            }]
352        );
353
354        let foo_manifest = fs::read_to_string(root.join("crates/foo/Cargo.toml")).unwrap();
355        assert!(foo_manifest.contains("1.0.0-alpha"));
356    }
357
358    #[test]
359    fn enter_rejects_numeric_only_label() {
360        let temp = init_workspace();
361        let root = temp.path();
362
363        write_manifest(&root.join("crates/foo"), "foo", "0.1.0");
364
365        let err = enter_prerelease(root, &[String::from("foo")], "123").unwrap_err();
366        match err {
367            SampoError::Prerelease(msg) => {
368                assert!(msg.contains("non-numeric"));
369            }
370            other => panic!("unexpected error: {other:?}"),
371        }
372    }
373
374    #[test]
375    fn exit_clears_prerelease_and_updates_dependents() {
376        let temp = init_workspace();
377        let root = temp.path();
378
379        write_manifest(&root.join("crates/foo"), "foo", "2.3.4-alpha.5");
380        write_manifest(&root.join("crates/bar"), "bar", "0.2.0");
381        append_dependency(&root.join("crates/bar"), "foo", "2.3.4-alpha.5");
382
383        let updates = exit_prerelease(root, &[String::from("foo")]).unwrap();
384        assert_eq!(
385            updates,
386            vec![VersionChange {
387                name: "foo".to_string(),
388                old_version: "2.3.4-alpha.5".to_string(),
389                new_version: "2.3.4".to_string(),
390            }]
391        );
392
393        let foo_manifest = fs::read_to_string(root.join("crates/foo/Cargo.toml")).unwrap();
394        assert!(
395            foo_manifest.contains("version = \"2.3.4\"")
396                || foo_manifest.contains("version=\"2.3.4\"")
397        );
398
399        let bar_manifest = fs::read_to_string(root.join("crates/bar/Cargo.toml")).unwrap();
400        assert!(
401            bar_manifest.contains("version = \"2.3.4\"")
402                || bar_manifest.contains("version=\"2.3.4\"")
403        );
404    }
405
406    #[test]
407    fn restore_preserved_changesets_moves_files() {
408        let temp = init_workspace();
409        let root = temp.path();
410
411        let prerelease_dir = root.join(".sampo/prerelease");
412        fs::create_dir_all(&prerelease_dir).unwrap();
413        fs::write(prerelease_dir.join("change.md"), "---\nfoo: minor\n---\n").unwrap();
414
415        let restored = restore_preserved_changesets(root).unwrap();
416        assert_eq!(restored, 1);
417
418        let changesets_dir = root.join(".sampo/changesets");
419        let restored_entries = fs::read_dir(&changesets_dir)
420            .unwrap()
421            .map(|entry| entry.unwrap().path())
422            .collect::<Vec<_>>();
423        assert_eq!(restored_entries.len(), 1);
424        assert!(
425            restored_entries[0]
426                .file_name()
427                .unwrap()
428                .to_string_lossy()
429                .starts_with("change")
430        );
431
432        let remaining = fs::read_dir(&prerelease_dir).unwrap().collect::<Vec<_>>();
433        assert!(remaining.is_empty());
434    }
435}