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