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#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct VersionChange {
16 pub name: String,
17 pub old_version: String,
18 pub new_version: String,
19}
20
21pub 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
40pub 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
54pub 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}