sampo_core/
prerelease.rs

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