1use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
4use crate::cli::ui::Loader;
5use crate::commands::cli_session::{
6 fetch_linear_api_key_from_dashboard, post_version_activity, CliVersionActivityPayload,
7 VersionActivityLinearInitiative,
8};
9use crate::commands::publish::run_publish_command_with_progress_prefix;
10use crate::commands::PublishCommandOptions;
11use crate::config::{
12 global_xbp_paths, load_package_name_files_registry, load_versioning_files_registry,
13 resolve_github_oauth2_key, resolve_global_linear_release_config, resolve_linear_api_key,
14 resolve_openrouter_api_key, PackageNameLookup,
15};
16use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
17use crate::strategies::{
18 resolve_config_paths_for_runtime, DeploymentConfig, ServiceConfig, XbpConfig,
19};
20use crate::utils::{
21 command_exists, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
22 parse_config_with_auto_heal, parse_github_repo_from_remote_url, redact_remote_url_credentials,
23 resolve_env_placeholders,
24};
25use colored::Colorize;
26use dialoguer::{theme::ColorfulTheme, Select};
27use regex::Regex;
28use semver::Version;
29use serde::{Deserialize, Serialize};
30use serde_json::Value as JsonValue;
31use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
32use std::collections::HashMap;
33use std::collections::{BTreeMap, BTreeSet};
34use std::env;
35use std::fs;
36use std::io::IsTerminal;
37use std::path::{Path, PathBuf};
38use std::process::Command;
39use toml::Value as TomlValue;
40
41#[path = "version/github_release.rs"]
42mod github_release;
43#[path = "version/release_docs.rs"]
44mod release_docs;
45#[path = "version/release_linear.rs"]
46mod release_linear;
47#[path = "version/release_notes.rs"]
48mod release_notes;
49#[path = "version/workspace_release.rs"]
50mod workspace_release;
51
52use github_release::{
53 create_github_release, get_github_release_by_tag, update_github_release,
54 upload_github_release_asset, GithubReleaseInput, GithubReleaseResult, GithubReleaseTagResponse,
55};
56use release_docs::sync_release_docs;
57use release_linear::{
58 publish_release_to_linear_initiatives, resolve_linear_release_config,
59 LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
60};
61use release_notes::{generate_release_notes, ReleaseNotesRequest};
62pub use workspace_release::{
63 run_version_workspace_command, WorkspacePublishRunOptions, WorkspaceVersionCheckOptions,
64 WorkspaceVersionCommand, WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions,
65 WorkspaceVersionValidateOptions,
66};
67
68#[derive(Clone, Debug)]
69struct VersionObservation {
70 location: String,
71 version: Version,
72}
73
74#[derive(Clone, Debug)]
75struct GitTagObservation {
76 version: Version,
77 raw_tags: Vec<String>,
78}
79
80#[derive(Clone, Debug)]
81struct RegistryVersionObservation {
82 registry: String,
83 package_name: String,
84 source_file: String,
85 latest: Option<Version>,
86 raw_version: Option<String>,
87 note: Option<String>,
88}
89
90#[derive(Clone, Debug)]
91struct ResolvedRegistryPath {
92 relative: String,
93 absolute: PathBuf,
94 cargo_package_override: Option<String>,
95}
96
97#[derive(Clone, Debug)]
98struct WorkspacePrimaryCargoTarget {
99 manifest_relative: String,
100 manifest_absolute: PathBuf,
101 package_name: String,
102}
103
104#[derive(Clone, Debug)]
105enum VersionScope {
106 Repository,
107 Crate {
108 crate_root: PathBuf,
109 crate_relative_root: String,
110 package_name: String,
111 tag_prefix: String,
112 },
113 Service {
114 service_root: PathBuf,
115 service_relative_root: String,
116 service_name: String,
117 tag_prefix: String,
118 cargo_package_name: Option<String>,
119 version_targets: Vec<String>,
120 },
121}
122
123#[derive(Default, Debug)]
124struct VersionReport {
125 worktree: Vec<VersionObservation>,
126 head: Vec<VersionObservation>,
127 local_tags: Vec<GitTagObservation>,
128 remote_tags: Vec<GitTagObservation>,
129 registry_versions: Vec<RegistryVersionObservation>,
130 dirty_files: Vec<String>,
131 warnings: Vec<String>,
132}
133
134const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
135
136#[derive(Clone, Debug, Default, Deserialize, Serialize)]
137struct VersionChangeGuardRegistry {
138 #[serde(default)]
139 entries: BTreeMap<String, VersionChangeGuardEntry>,
140}
141
142#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
143struct VersionChangeGuardEntry {
144 #[serde(default)]
145 pending_version_change_count: usize,
146 #[serde(default)]
147 head_commit: Option<String>,
148}
149
150#[derive(Clone, Debug, Default, PartialEq, Eq)]
151struct GitWorktreeState {
152 is_dirty: bool,
153 head_commit: Option<String>,
154}
155
156impl VersionReport {
157 fn highest_worktree(&self) -> Option<Version> {
158 self.worktree
159 .iter()
160 .map(|entry| entry.version.clone())
161 .max()
162 }
163
164 fn highest_head(&self) -> Option<Version> {
165 self.head.iter().map(|entry| entry.version.clone()).max()
166 }
167
168 fn highest_local_tag(&self) -> Option<Version> {
169 self.local_tags
170 .iter()
171 .map(|entry| entry.version.clone())
172 .max()
173 }
174
175 fn highest_remote_tag(&self) -> Option<Version> {
176 self.remote_tags
177 .iter()
178 .map(|entry| entry.version.clone())
179 .max()
180 }
181
182 fn highest_git(&self) -> Option<Version> {
183 self.highest_remote_tag()
184 .or_else(|| self.highest_local_tag())
185 }
186
187 fn highest_registry(&self) -> Option<Version> {
188 self.registry_versions
189 .iter()
190 .filter_map(|entry| entry.latest.clone())
191 .max()
192 }
193
194 fn highest_available(&self) -> Version {
195 self.highest_worktree()
196 .into_iter()
197 .chain(self.highest_head())
198 .chain(self.highest_git())
199 .chain(self.highest_registry())
200 .max()
201 .unwrap_or_else(default_version)
202 }
203
204 fn divergent_versions(&self) -> Vec<Version> {
205 let mut versions = BTreeSet::new();
206 for entry in &self.worktree {
207 versions.insert(entry.version.clone());
208 }
209 for entry in &self.head {
210 versions.insert(entry.version.clone());
211 }
212 for entry in &self.local_tags {
213 versions.insert(entry.version.clone());
214 }
215 for entry in &self.remote_tags {
216 versions.insert(entry.version.clone());
217 }
218 for entry in &self.registry_versions {
219 if let Some(version) = &entry.latest {
220 versions.insert(version.clone());
221 }
222 }
223 versions.into_iter().collect()
224 }
225}
226
227pub async fn run_version_command(
228 target: Option<String>,
229 git_only: bool,
230 _debug: bool,
231) -> Result<(), String> {
232 if git_only && target.is_some() {
233 return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
234 }
235
236 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
237 let project_root: PathBuf = resolve_project_root();
238 let version_scope: VersionScope =
239 resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
240 let registry: Vec<String> = load_versioning_files_registry()?;
241
242 if git_only {
243 print_git_versions(&project_root, &version_scope)?;
244 return Ok(());
245 }
246
247 match target.as_deref() {
248 None => {
249 let mut report: VersionReport =
250 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
251 match load_package_name_files_registry() {
252 Ok(lookups) => {
253 report.registry_versions = collect_registry_versions(
254 &project_root,
255 &invocation_dir,
256 &lookups,
257 &version_scope,
258 &mut report.warnings,
259 )
260 .await;
261 }
262 Err(err) => report.warnings.push(err),
263 }
264 print_version_report(&project_root, &report);
265 Ok(())
266 }
267 Some(bump_target @ ("major" | "minor" | "patch")) => {
268 enforce_version_change_guard(&project_root)?;
269 let current: Version = resolve_current_version_for_bump(
270 &project_root,
271 &invocation_dir,
272 ®istry,
273 &version_scope,
274 );
275 let next: Version = bump_version(¤t, bump_target);
276 let updated_paths = write_version_to_configured_files_with_paths(
277 &project_root,
278 &invocation_dir,
279 ®istry,
280 &version_scope,
281 &next,
282 )?;
283 let updated = updated_paths.len();
284 println!(
285 "Updated {} version file(s) from {} to {}.",
286 updated, current, next
287 );
288 auto_commit_command_paths(
289 &project_root,
290 updated_paths,
291 format!("chore(version): update version to {}", next),
292 "xbp version",
293 )
294 .await;
295 record_version_change_guard(&project_root)?;
296 sync_cli_version_write_activity(
297 &project_root,
298 &version_scope,
299 &next,
300 format!(
301 "Updated {} version file(s) from {} to {}.",
302 updated, current, next
303 ),
304 )
305 .await;
306 Ok(())
307 }
308 Some(explicit) => {
309 enforce_version_change_guard(&project_root)?;
310 if let Some((package_name, version)) = parse_package_version_target(explicit)? {
311 let updated_paths = write_package_version_to_configured_files_with_paths(
312 &project_root,
313 &invocation_dir,
314 ®istry,
315 &version_scope,
316 &package_name,
317 &version,
318 )?;
319 let updated = updated_paths.len();
320 println!(
321 "Updated {} file(s) for package `{}` to {}.",
322 updated, package_name, version
323 );
324 auto_commit_command_paths(
325 &project_root,
326 updated_paths,
327 format!("chore(version): set {} to {}", package_name, version),
328 "xbp version",
329 )
330 .await;
331 record_version_change_guard(&project_root)?;
332 sync_cli_version_write_activity(
333 &project_root,
334 &version_scope,
335 &version,
336 format!(
337 "Updated {} file(s) for package `{}` to {}.",
338 updated, package_name, version
339 ),
340 )
341 .await;
342 } else {
343 let version: Version = parse_version(explicit)?;
344 let updated_paths = write_version_to_configured_files_with_paths(
345 &project_root,
346 &invocation_dir,
347 ®istry,
348 &version_scope,
349 &version,
350 )?;
351 let updated = updated_paths.len();
352 println!("Updated {} version file(s) to {}.", updated, version);
353 auto_commit_command_paths(
354 &project_root,
355 updated_paths,
356 format!("chore(version): update version to {}", version),
357 "xbp version",
358 )
359 .await;
360 record_version_change_guard(&project_root)?;
361 sync_cli_version_write_activity(
362 &project_root,
363 &version_scope,
364 &version,
365 format!("Updated {} version file(s) to {}.", updated, version),
366 )
367 .await;
368 }
369 Ok(())
370 }
371 }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum ReleaseLatestPolicy {
376 True,
377 False,
378 Legacy,
379}
380
381impl ReleaseLatestPolicy {
382 pub(crate) fn as_github_api_value(self) -> &'static str {
383 match self {
384 Self::True => "true",
385 Self::False => "false",
386 Self::Legacy => "legacy",
387 }
388 }
389}
390
391#[derive(Debug, Clone)]
392pub struct VersionReleaseOptions {
393 pub explicit_version: Option<String>,
394 pub allow_dirty: bool,
395 pub title: Option<String>,
396 pub notes: Option<String>,
397 pub notes_file: Option<PathBuf>,
398 pub draft: bool,
399 pub prerelease: bool,
400 pub publish: bool,
401 pub force: bool,
402 pub latest_policy: ReleaseLatestPolicy,
403}
404
405struct ReleaseWorkflowSummary {
406 version: Version,
407 tag_name: String,
408 release_url: String,
409 uploaded_openapi_asset: Option<String>,
410 release_title: String,
411 release_notes: String,
412 repository_owner: String,
413 repository_name: String,
414 scope_kind: String,
415 scope_label: String,
416 published_initiatives: Vec<PublishedLinearInitiative>,
417 release_branch: Option<String>,
418}
419
420pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
421 let loader = Loader::start("Publishing release");
422 let result: Result<ReleaseWorkflowSummary, String> = async {
423 let VersionReleaseOptions {
424 explicit_version,
425 allow_dirty,
426 title,
427 notes,
428 notes_file,
429 draft,
430 prerelease,
431 publish,
432 force,
433 latest_policy,
434 } = options;
435
436 if notes.is_some() && notes_file.is_some() {
437 return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
438 }
439
440 if !command_exists("git") {
441 return Err(
442 "Git is required for `xbp version release`, but it is not installed.".to_string(),
443 );
444 }
445
446 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
447 let project_root: PathBuf = resolve_project_root();
448 let version_scope: VersionScope =
449 resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
450 let sync_explicit_version = explicit_version.is_some();
451 let total_steps = 8usize + usize::from(sync_explicit_version) + usize::from(publish);
452 let mut step = 1usize;
453
454 loader.update(&format!(
455 "[{}/{}] Validating git state and resolving release target",
456 step, total_steps
457 ));
458 if !allow_dirty {
459 let dirty: Vec<String> = git_dirty_entries(&project_root)?;
460 if !dirty.is_empty() {
461 let preview = dirty.into_iter().take(8).collect::<Vec<_>>().join(", ");
462 return Err(format!(
463 "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
464 preview
465 ));
466 }
467 }
468
469 let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
470 let (version, parsed_tag_name) = parse_release_version_target(&raw)?;
471 (
472 version.clone(),
473 scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
474 )
475 } else {
476 let registry: Vec<String> = load_versioning_files_registry()?;
477 let report: VersionReport =
478 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
479 let release_version: Version = report.highest_available();
480 let tag_name: String = default_release_tag_name(&version_scope, &release_version);
481 (release_version, tag_name)
482 };
483
484 if sync_explicit_version {
485 step += 1;
486 loader.update(&format!(
487 "[{}/{}] Syncing configured version files",
488 step, total_steps
489 ));
490 let registry: Vec<String> = load_versioning_files_registry()?;
491 let updated_paths = sync_version_to_configured_files_with_paths(
492 &project_root,
493 &invocation_dir,
494 ®istry,
495 &version_scope,
496 &release_version,
497 )?;
498 if !updated_paths.is_empty() {
499 auto_commit_command_paths(
500 &project_root,
501 updated_paths,
502 format!("chore(version): update version to {}", release_version),
503 "xbp version release",
504 )
505 .await;
506 }
507 }
508
509 ensure_remote_exists(&project_root, "origin")?;
510 let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
511 let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
512
513 if publish {
514 step += 1;
515 loader.update(&format!(
516 "[{}/{}] Publishing configured packages",
517 step, total_steps
518 ));
519 let publish_target_filter =
520 resolve_release_publish_target_filter(&invocation_dir, &version_scope)?;
521 run_publish_command_with_progress_prefix(
522 PublishCommandOptions {
523 dry_run: false,
524 allow_dirty,
525 force,
526 target: publish_target_filter,
527 manifest_path: None,
528 expected_version: Some(release_version.to_string()),
529 },
530 &loader,
531 format!("[{}/{}]", step, total_steps),
532 )
533 .await?;
534 }
535
536 step += 1;
537 loader.update(&format!(
538 "[{}/{}] Resolving GitHub repository and auth",
539 step, total_steps
540 ));
541 let origin_url: String = git_remote_url(&project_root, "origin")?;
542 let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
543 format!(
544 "Origin remote is not a GitHub repository URL: `{}`. Use a GitHub origin like `https://github.com/<owner>/<repo>.git` or `git@github.com:<owner>/<repo>.git` and keep tokens in `GITHUB_TOKEN`/`xbp config github set-key` instead of embedding them in the remote URL.",
545 redact_remote_url_credentials(&origin_url)
546 )
547 })?;
548
549 let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
550 "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
551 .to_string()
552 })?;
553 let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
554 Some(key)
555 } else {
556 fetch_linear_api_key_from_dashboard().await?
557 };
558 let release_title: String = title.unwrap_or_else(|| {
559 default_release_title(
560 &release_version,
561 release_title_subject(&version_scope, &repo),
562 )
563 });
564 let release_branch_config =
565 resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
566
567 step += 1;
568 loader.update(&format!(
569 "[{}/{}] Generating release notes",
570 step, total_steps
571 ));
572 let release_notes_body: String = if let Some(path) = notes_file {
573 fs::read_to_string(&path).map_err(|e| {
574 format!(
575 "Failed to read release notes file {}: {}",
576 path.display(),
577 e
578 )
579 })?
580 } else if let Some(body) = notes {
581 body
582 } else {
583 generate_release_notes(&ReleaseNotesRequest {
584 project_root: &project_root,
585 release_title: &release_title,
586 current_tag_name: &tag_name,
587 owner: &owner,
588 repo: &repo,
589 github_token: &github_token,
590 linear_api_key: linear_api_key.as_deref(),
591 openrouter_api_key: resolve_openrouter_api_key().as_deref(),
592 path_filter: release_notes_scope_path(&version_scope).as_deref(),
593 })
594 .await?
595 };
596 let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
597
598 step += 1;
599 loader.update(&format!(
600 "[{}/{}] Creating and pushing release tag",
601 step, total_steps
602 ));
603 let tag_message: String = format!("Release {}", tag_name);
604 let target_commitish: String = git_head_commitish(&project_root)?;
605 let created_release_branch = if let Some(branch_config) = &release_branch_config {
606 Some(ensure_release_branch(
607 &project_root,
608 branch_config,
609 &release_version,
610 &tag_name,
611 &target_commitish,
612 )?)
613 } else {
614 None
615 };
616 if !tag_exists_local {
617 run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])?;
618 }
619 if !tag_exists_remote {
620 run_git_command(&project_root, &["push", "origin", &tag_name])?;
621 }
622
623 let release_input: GithubReleaseInput = GithubReleaseInput {
624 owner: owner.clone(),
625 repo: repo.clone(),
626 token: github_token,
627 tag_name: tag_name.clone(),
628 target_commitish,
629 title: release_title,
630 notes: release_notes,
631 draft,
632 prerelease,
633 latest_policy,
634 };
635
636 step += 1;
637 loader.update(&format!(
638 "[{}/{}] Publishing GitHub release",
639 step, total_steps
640 ));
641 let release_result: GithubReleaseResult = match create_github_release(&release_input).await {
642 Ok(result) => result,
643 Err(create_error) => {
644 let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
645 format!(
646 "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
647 create_error, tag_name, e
648 )
649 })?;
650
651 let Some(existing_release) = existing_release else {
652 return Err(format!(
653 "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
654 create_error, tag_name
655 ));
656 };
657
658 let needs_update: bool = existing_release.prerelease.unwrap_or(false)
659 != release_input.prerelease
660 || existing_release.draft.unwrap_or(false) != release_input.draft
661 || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
662
663 if needs_update {
664 update_github_release(&release_input, existing_release.id)
665 .await
666 .map_err(|e| {
667 format!(
668 "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
669 create_error, tag_name, e
670 )
671 })?
672 } else {
673 GithubReleaseResult {
674 id: existing_release.id,
675 html_url: existing_release.html_url.unwrap_or_else(|| {
676 format!(
677 "https://github.com/{}/{}/releases/tag/{}",
678 release_input.owner, release_input.repo, release_input.tag_name
679 )
680 }),
681 }
682 }
683 }
684 };
685 let release_url = release_result.html_url.clone();
686
687 step += 1;
688 loader.update(&format!(
689 "[{}/{}] Publishing release integrations",
690 step, total_steps
691 ));
692 let openapi_path =
693 resolve_release_openapi_spec(&project_root, &invocation_dir, &version_scope);
694 let linear_release_config =
695 resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
696 let openapi_release_input = release_input.clone();
697 let linear_release_input = release_input.clone();
698 let openapi_project_root = project_root.clone();
699 let openapi_tag_name = tag_name.clone();
700 let linear_tag_name = tag_name.clone();
701 let linear_release_url = release_url.clone();
702 let linear_api_key_for_release = linear_api_key.clone();
703
704 let openapi_future = async move {
705 if let Some(openapi_path) = openapi_path {
706 upload_github_release_asset(
707 &openapi_release_input,
708 release_result.id,
709 &openapi_path,
710 )
711 .await
712 .map_err(|e| {
713 format!(
714 "Release `{}` was published, but uploading OpenAPI asset `{}` failed: {}",
715 openapi_tag_name,
716 openapi_path.display(),
717 e
718 )
719 })?;
720 Ok(Some(
721 openapi_path
722 .strip_prefix(&openapi_project_root)
723 .map(normalized_relative_path)
724 .unwrap_or_else(|_| normalized_relative_path(&openapi_path)),
725 ))
726 } else {
727 Ok(None)
728 }
729 };
730
731 let linear_future = async move {
732 if let Some(linear_release_config) = linear_release_config {
733 let linear_api_key: String = linear_api_key_for_release.ok_or_else(|| {
734 "A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key` or save it in the dashboard settings."
735 .to_string()
736 })?;
737 publish_release_to_linear_initiatives(&LinearReleasePublishInput {
738 api_key: linear_api_key,
739 initiative_ids: linear_release_config.initiative_ids,
740 organization_name: linear_release_config.organization_name,
741 health: linear_release_config.health,
742 release_title: linear_release_input.title.clone(),
743 release_tag: linear_release_input.tag_name.clone(),
744 release_url: linear_release_url,
745 release_notes: linear_release_input.notes.clone(),
746 })
747 .await
748 .map_err(|e| {
749 format!(
750 "Release `{}` was published, but publishing to configured Linear initiatives failed: {}",
751 linear_tag_name, e
752 )
753 })
754 } else {
755 Ok(Vec::new())
756 }
757 };
758
759 let (uploaded_openapi_asset, published_initiatives) =
760 tokio::try_join!(openapi_future, linear_future)?;
761
762 step += 1;
763 loader.update(&format!(
764 "[{}/{}] Syncing release docs",
765 step, total_steps
766 ));
767 let release_doc_paths = sync_release_docs(&project_root, &owner, &repo)?;
768 step += 1;
769 loader.update(&format!(
770 "[{}/{}] Auto-committing release docs",
771 step, total_steps
772 ));
773 auto_commit_command_paths(
774 &project_root,
775 release_doc_paths,
776 format!("docs(release): sync release docs for {}", tag_name),
777 "xbp version release",
778 )
779 .await;
780 let summary = ReleaseWorkflowSummary {
781 version: release_version.clone(),
782 tag_name,
783 release_url,
784 uploaded_openapi_asset,
785 release_title: release_input.title.clone(),
786 release_notes: release_input.notes.clone(),
787 repository_owner: owner.clone(),
788 repository_name: repo.clone(),
789 scope_kind: version_scope_kind(&version_scope).to_string(),
790 scope_label: version_scope_label(&version_scope, &repo),
791 published_initiatives,
792 release_branch: created_release_branch,
793 };
794 sync_cli_release_activity(&summary).await;
795
796 Ok(summary)
797 }
798 .await;
799
800 match result {
801 Ok(summary) => {
802 loader.success_with(&format!("Published {}", summary.tag_name));
803 println!("Released {} successfully.", summary.tag_name);
804 println!("GitHub release: {}", summary.release_url);
805 if let Some(openapi_asset) = summary.uploaded_openapi_asset {
806 println!("Uploaded OpenAPI asset: {}", openapi_asset);
807 }
808 if !summary.published_initiatives.is_empty() {
809 println!(
810 "Published release update to Linear initiative(s): {}",
811 summary
812 .published_initiatives
813 .iter()
814 .map(|initiative| initiative.name.as_str())
815 .collect::<Vec<_>>()
816 .join(", ")
817 );
818 }
819 if let Some(release_branch) = summary.release_branch {
820 println!("Release branch: {}", release_branch);
821 }
822 println!("Updated release docs: CHANGELOG.md and SECURITY.md");
823 Ok(())
824 }
825 Err(error) => {
826 loader.fail(&error);
827 Err(error)
828 }
829 }
830}
831
832pub async fn print_version() {
834 println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
835}
836
837fn resolve_project_root() -> PathBuf {
838 let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
839
840 if let Some(root) = git_repository_root(&cwd) {
841 return root;
842 }
843
844 if let Some(found) = find_xbp_config_upwards(&cwd) {
845 return found.project_root;
846 }
847
848 cwd
849}
850
851fn collect_version_report(
852 project_root: &Path,
853 invocation_dir: &Path,
854 registry: &[String],
855 version_scope: &VersionScope,
856) -> VersionReport {
857 let mut report: VersionReport = VersionReport::default();
858 report.worktree = collect_local_versions(
859 project_root,
860 invocation_dir,
861 registry,
862 version_scope,
863 &mut report.warnings,
864 );
865 match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
866 Ok(entries) => report.head = entries,
867 Err(err) => report.warnings.push(err),
868 }
869 match collect_git_versions(project_root, version_scope) {
870 Ok(tags) => report.local_tags = tags,
871 Err(err) => report.warnings.push(err),
872 }
873 match collect_remote_git_versions(project_root, "origin", version_scope) {
874 Ok(tags) => report.remote_tags = tags,
875 Err(err) => report.warnings.push(err),
876 }
877 match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
878 Ok(files) => report.dirty_files = files,
879 Err(err) => report.warnings.push(err),
880 }
881 report
882}
883
884fn resolve_current_version_for_bump(
885 project_root: &Path,
886 invocation_dir: &Path,
887 registry: &[String],
888 version_scope: &VersionScope,
889) -> Version {
890 let mut _warnings = Vec::new();
891 let local_versions = collect_local_versions(
892 project_root,
893 invocation_dir,
894 registry,
895 version_scope,
896 &mut _warnings,
897 );
898 let head_versions =
899 collect_head_versions(project_root, invocation_dir, registry, version_scope)
900 .unwrap_or_default();
901 let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
902
903 local_versions
904 .iter()
905 .map(|entry| entry.version.clone())
906 .chain(head_versions.iter().map(|entry| entry.version.clone()))
907 .chain(local_tags.iter().map(|entry| entry.version.clone()))
908 .max()
909 .unwrap_or_else(default_version)
910}
911
912async fn auto_commit_command_paths(
913 project_root: &Path,
914 paths: Vec<PathBuf>,
915 message: String,
916 action_label: &'static str,
917) {
918 match commit_paths(AutoCommitRequest {
919 project_root,
920 paths,
921 message,
922 action_label,
923 })
924 .await
925 {
926 Ok(AutoCommitResult::Committed(_)) => {}
927 Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
928 Err(e) => print_skip(action_label, &e),
929 }
930}
931
932async fn collect_registry_versions(
933 project_root: &Path,
934 invocation_dir: &Path,
935 lookups: &[PackageNameLookup],
936 version_scope: &VersionScope,
937 warnings: &mut Vec<String>,
938) -> Vec<RegistryVersionObservation> {
939 let mut entries: Vec<RegistryVersionObservation> = Vec::new();
940 let mut seen: BTreeSet<String> = BTreeSet::new();
941 let client: reqwest::Client = reqwest::Client::new();
942
943 for lookup in lookups {
944 let dedupe_key: String = format!(
945 "{}|{}|{}|{}",
946 lookup.file, lookup.format, lookup.key, lookup.registry
947 );
948 if !seen.insert(dedupe_key) {
949 continue;
950 }
951
952 let source_file = resolve_registry_relative_path(
953 project_root,
954 invocation_dir,
955 version_scope,
956 &lookup.file,
957 );
958 let path = project_root.join(&source_file);
959 if !path.exists() {
960 continue;
961 }
962
963 let content: String = match fs::read_to_string(&path) {
964 Ok(content) => content,
965 Err(err) => {
966 warnings.push(format!("Failed to read {}: {}", path.display(), err));
967 continue;
968 }
969 };
970
971 let package_name: String = match read_package_name_from_lookup(lookup, &content) {
972 Ok(Some(value)) => value,
973 Ok(None) => continue,
974 Err(err) => {
975 warnings.push(format!("{}: {}", source_file, err));
976 continue;
977 }
978 };
979
980 let (latest, raw_version, note) =
981 match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
982 Ok(version) => {
983 let parsed: Option<Version> = parse_version(&version).ok();
984 let note: Option<String> = if parsed.is_none() {
985 Some(format!("Non-semver registry version: {}", version))
986 } else {
987 None
988 };
989 (parsed, Some(version), note)
990 }
991 Err(err) => (None, None, Some(err)),
992 };
993
994 entries.push(RegistryVersionObservation {
995 registry: lookup.registry.clone(),
996 package_name,
997 source_file,
998 latest,
999 raw_version,
1000 note,
1001 });
1002 }
1003
1004 entries.sort_by(|a, b| {
1005 a.registry
1006 .cmp(&b.registry)
1007 .then_with(|| a.package_name.cmp(&b.package_name))
1008 });
1009 entries
1010}
1011
1012fn read_package_name_from_lookup(
1013 lookup: &PackageNameLookup,
1014 content: &str,
1015) -> Result<Option<String>, String> {
1016 let key_parts: Vec<&str> = lookup
1017 .key
1018 .split('.')
1019 .map(|part| part.trim())
1020 .filter(|part| !part.is_empty())
1021 .collect();
1022 if key_parts.is_empty() {
1023 return Err("Lookup key cannot be empty".to_string());
1024 }
1025
1026 let format: String = lookup.format.trim().to_ascii_lowercase();
1027 match format.as_str() {
1028 "json" => {
1029 let value: JsonValue = serde_json::from_str(content)
1030 .map_err(|e| format!("Failed to parse JSON: {}", e))?;
1031 Ok(json_lookup_string(&value, &key_parts))
1032 }
1033 "yaml" | "yml" => {
1034 let value: YamlValue = serde_yaml::from_str(content)
1035 .map_err(|e| format!("Failed to parse YAML: {}", e))?;
1036 Ok(yaml_lookup_string(&value, &key_parts))
1037 }
1038 "toml" => {
1039 let value: TomlValue =
1040 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1041 Ok(toml_lookup_string(&value, &key_parts))
1042 }
1043 other => Err(format!("Unsupported lookup format `{}`", other)),
1044 }
1045}
1046
1047async fn fetch_registry_latest_version(
1048 client: &reqwest::Client,
1049 registry: &str,
1050 package_name: &str,
1051) -> Result<String, String> {
1052 let normalized_registry: String = registry.trim().to_ascii_lowercase();
1053 match normalized_registry.as_str() {
1054 "npm" => fetch_npm_latest_version(client, package_name).await,
1055 "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
1056 _ => Err(format!("Unsupported registry `{}`", registry)),
1057 }
1058}
1059
1060#[derive(Debug, Deserialize)]
1061struct NpmLatestResponse {
1062 version: String,
1063}
1064
1065async fn fetch_npm_latest_version(
1066 client: &reqwest::Client,
1067 package_name: &str,
1068) -> Result<String, String> {
1069 let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
1070 .map_err(|e| format!("Failed to build npm URL: {}", e))?;
1071 {
1072 let mut segments = url
1073 .path_segments_mut()
1074 .map_err(|_| "Failed to compose npm URL segments".to_string())?;
1075 segments.push(package_name);
1076 segments.push("latest");
1077 }
1078
1079 let response: reqwest::Response = client
1080 .get(url)
1081 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1082 .send()
1083 .await
1084 .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
1085
1086 if !response.status().is_success() {
1087 return Err(format!(
1088 "npm lookup for {} returned status {}",
1089 package_name,
1090 response.status()
1091 ));
1092 }
1093
1094 let payload: NpmLatestResponse = response
1095 .json()
1096 .await
1097 .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
1098 Ok(payload.version)
1099}
1100
1101#[derive(Debug, Deserialize)]
1102struct CratesIoResponse {
1103 #[serde(rename = "crate")]
1104 crate_meta: CratesIoMeta,
1105}
1106
1107#[derive(Debug, Deserialize)]
1108struct CratesIoMeta {
1109 newest_version: String,
1110}
1111
1112async fn fetch_crates_latest_version(
1113 client: &reqwest::Client,
1114 package_name: &str,
1115) -> Result<String, String> {
1116 let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
1117 .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
1118 {
1119 let mut segments = url
1120 .path_segments_mut()
1121 .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
1122 segments.push(package_name);
1123 }
1124
1125 let response: reqwest::Response = client
1126 .get(url)
1127 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1128 .send()
1129 .await
1130 .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
1131
1132 if !response.status().is_success() {
1133 return Err(format!(
1134 "crates.io lookup for {} returned status {}",
1135 package_name,
1136 response.status()
1137 ));
1138 }
1139
1140 let payload: CratesIoResponse = response.json().await.map_err(|e| {
1141 format!(
1142 "Failed to parse crates.io response for {}: {}",
1143 package_name, e
1144 )
1145 })?;
1146 Ok(payload.crate_meta.newest_version)
1147}
1148
1149fn collect_local_versions(
1150 project_root: &Path,
1151 invocation_dir: &Path,
1152 registry: &[String],
1153 version_scope: &VersionScope,
1154 warnings: &mut Vec<String>,
1155) -> Vec<VersionObservation> {
1156 let mut observed = Vec::new();
1157
1158 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1159 let path: &PathBuf = &entry.absolute;
1160 if !path.exists() {
1161 continue;
1162 }
1163
1164 match read_version_from_resolved_path(&entry) {
1165 Ok(Some(version)) => {
1166 if let Ok(parsed) = parse_version(&version) {
1167 observed.push(VersionObservation {
1168 location: entry.relative.clone(),
1169 version: parsed,
1170 });
1171 } else {
1172 warnings.push(format!("Ignoring non-semver version in {}", path.display()));
1173 }
1174 }
1175 Ok(None) => {}
1176 Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
1177 }
1178 }
1179
1180 observed.sort_by(|a, b| a.location.cmp(&b.location));
1181 observed
1182}
1183
1184fn collect_git_versions(
1185 project_root: &Path,
1186 version_scope: &VersionScope,
1187) -> Result<Vec<GitTagObservation>, String> {
1188 if !command_exists("git") {
1189 return Err("Git is not installed; skipping git tag inspection.".to_string());
1190 }
1191
1192 let output: std::process::Output = Command::new("git")
1193 .current_dir(project_root)
1194 .args(["tag", "--list"])
1195 .output()
1196 .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
1197
1198 if !output.status.success() {
1199 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1200 if stderr.is_empty() {
1201 return Err("`git tag --list` failed in the current directory.".to_string());
1202 }
1203 return Err(format!("`git tag --list` failed: {}", stderr));
1204 }
1205
1206 Ok(parse_local_git_tag_output_for_scope(
1207 &String::from_utf8_lossy(&output.stdout),
1208 version_scope,
1209 ))
1210}
1211
1212fn collect_remote_git_versions(
1213 project_root: &Path,
1214 remote: &str,
1215 version_scope: &VersionScope,
1216) -> Result<Vec<GitTagObservation>, String> {
1217 if !command_exists("git") {
1218 return Err("Git is not installed; skipping remote tag inspection.".to_string());
1219 }
1220
1221 let output: std::process::Output = Command::new("git")
1222 .current_dir(project_root)
1223 .args(["ls-remote", "--tags", remote])
1224 .output()
1225 .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
1226
1227 if !output.status.success() {
1228 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1229 if stderr.is_empty() {
1230 return Err(format!("`git ls-remote --tags {}` failed.", remote));
1231 }
1232 return Err(format!(
1233 "`git ls-remote --tags {}` failed: {}",
1234 remote, stderr
1235 ));
1236 }
1237
1238 Ok(parse_remote_git_tag_output_for_scope(
1239 &String::from_utf8_lossy(&output.stdout),
1240 version_scope,
1241 ))
1242}
1243
1244fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
1245 let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
1246
1247 if tags.is_empty() {
1248 println!("No semantic git tags found in {}.", project_root.display());
1249 return Ok(());
1250 }
1251
1252 println!("Git versions from `git tag --list`:");
1253 for tag in tags {
1254 if tag.raw_tags.len() > 1 {
1255 println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
1256 } else {
1257 println!(" {}", tag.version);
1258 }
1259 }
1260
1261 Ok(())
1262}
1263
1264fn print_version_observations(
1265 title: &str,
1266 entries: &[VersionObservation],
1267 dirty_files: Option<&[String]>,
1268) {
1269 println!();
1270 println!("{}", title.bright_cyan().bold());
1271 println!("{}", "─".repeat(72).bright_black());
1272
1273 if entries.is_empty() {
1274 println!(" {}", "none found".dimmed());
1275 return;
1276 }
1277
1278 let Some(highest) = highest_version_observation(entries) else {
1279 println!(" {}", "none found".dimmed());
1280 return;
1281 };
1282
1283 let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
1284 let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
1285 println!(
1286 " {:<28} {} ({}/{})",
1287 "latest".bright_white(),
1288 highest.to_string().bright_green().bold(),
1289 latest_count,
1290 entries.len()
1291 );
1292
1293 if stale_entries.is_empty() {
1294 return;
1295 }
1296
1297 println!(" {}", "stale entries".bright_yellow().bold());
1298 for entry in stale_entries {
1299 let dirty: bool = dirty_files
1300 .map(|files| files.iter().any(|file| file == &entry.location))
1301 .unwrap_or(false);
1302 let dirty_suffix: String = if dirty {
1303 format!(" {}", "modified".bright_magenta())
1304 } else {
1305 String::new()
1306 };
1307
1308 println!(
1309 " {:<28} {}{}",
1310 entry.location.bright_white(),
1311 entry.version.to_string().bright_green(),
1312 dirty_suffix
1313 );
1314 }
1315}
1316
1317fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
1318 println!();
1319 println!("{}", title.bright_cyan().bold());
1320 println!("{}", "─".repeat(72).bright_black());
1321
1322 if tags.is_empty() {
1323 println!(" {}", "none found".dimmed());
1324 return;
1325 }
1326
1327 let latest = &tags[0];
1328 if latest.raw_tags.len() > 1 {
1329 println!(
1330 " {:<20} {}",
1331 latest.version.to_string().bright_green().bold(),
1332 latest.raw_tags.join(", ").dimmed()
1333 );
1334 } else {
1335 println!(" {}", latest.version.to_string().bright_green().bold());
1336 }
1337
1338 if tags.len() > 1 {
1339 println!(
1340 " {:<20} {}",
1341 "older tags".bright_white(),
1342 format!("{} hidden", tags.len() - 1).dimmed()
1343 );
1344 }
1345}
1346
1347fn collect_head_versions(
1348 project_root: &Path,
1349 invocation_dir: &Path,
1350 registry: &[String],
1351 version_scope: &VersionScope,
1352) -> Result<Vec<VersionObservation>, String> {
1353 if !command_exists("git") {
1354 return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
1355 }
1356
1357 let head_check = Command::new("git")
1358 .current_dir(project_root)
1359 .args(["rev-parse", "--verify", "HEAD"])
1360 .output()
1361 .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
1362
1363 if !head_check.status.success() {
1364 return Ok(Vec::new());
1365 }
1366
1367 let mut observed = Vec::new();
1368 let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
1369
1370 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1371 let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
1372 continue;
1373 };
1374
1375 match read_version_from_blob_with_override(
1376 &entry.relative,
1377 &content,
1378 cargo_toml_content.as_deref(),
1379 entry.cargo_package_override.as_deref(),
1380 ) {
1381 Ok(Some(version)) => {
1382 if let Ok(parsed) = parse_version(&version) {
1383 observed.push(VersionObservation {
1384 location: entry.relative.clone(),
1385 version: parsed,
1386 });
1387 }
1388 }
1389 Ok(None) => {}
1390 Err(_) => {}
1391 }
1392 }
1393
1394 observed.sort_by(|a, b| a.location.cmp(&b.location));
1395 Ok(observed)
1396}
1397
1398fn collect_dirty_version_files(
1399 project_root: &Path,
1400 invocation_dir: &Path,
1401 registry: &[String],
1402 version_scope: &VersionScope,
1403) -> Result<Vec<String>, String> {
1404 if !command_exists("git") {
1405 return Err("Git is not installed; skipping worktree status inspection.".to_string());
1406 }
1407
1408 let mut args = vec!["status", "--porcelain", "--"];
1409 let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1410 for entry in &resolved {
1411 args.push(entry.relative.as_str());
1412 }
1413
1414 let output = Command::new("git")
1415 .current_dir(project_root)
1416 .args(&args)
1417 .output()
1418 .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
1419
1420 if !output.status.success() {
1421 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1422 if stderr.is_empty() {
1423 return Err("`git status --porcelain` failed.".to_string());
1424 }
1425 return Err(format!("`git status --porcelain` failed: {}", stderr));
1426 }
1427
1428 let mut dirty = Vec::new();
1429 for line in String::from_utf8_lossy(&output.stdout).lines() {
1430 if line.len() < 4 {
1431 continue;
1432 }
1433 let path = line[3..].trim();
1434 if !path.is_empty() {
1435 dirty.push(path.replace('\\', "/"));
1436 }
1437 }
1438
1439 dirty.sort();
1440 dirty.dedup();
1441 Ok(dirty)
1442}
1443
1444fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
1445 let output = Command::new("git")
1446 .current_dir(project_root)
1447 .args(["show", &format!("HEAD:{}", relative)])
1448 .output()
1449 .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
1450
1451 if !output.status.success() {
1452 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1453 if stderr.is_empty() {
1454 return Err(format!("{} is not present in HEAD", relative));
1455 }
1456 return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
1457 }
1458
1459 String::from_utf8(output.stdout)
1460 .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
1461}
1462
1463fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1464 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1465 for line in output.lines() {
1466 let tag = line.trim();
1467 if tag.is_empty() {
1468 continue;
1469 }
1470 if let Ok(version) = parse_version(tag) {
1471 by_version.entry(version).or_default().push(tag.to_string());
1472 }
1473 }
1474 git_tag_map_to_vec(by_version)
1475}
1476
1477fn parse_local_git_tag_output_for_scope(
1478 output: &str,
1479 version_scope: &VersionScope,
1480) -> Vec<GitTagObservation> {
1481 match version_scope {
1482 VersionScope::Repository => parse_local_git_tag_output(output),
1483 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
1484 parse_scoped_git_tag_output(output, tag_prefix)
1485 }
1486 }
1487}
1488
1489fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1490 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1491 for line in output.lines() {
1492 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1493 let tag = reference
1494 .strip_prefix("refs/tags/")
1495 .unwrap_or(reference)
1496 .trim_end_matches("^{}")
1497 .trim();
1498
1499 if tag.is_empty() {
1500 continue;
1501 }
1502 if let Ok(version) = parse_version(tag) {
1503 by_version.entry(version).or_default().push(tag.to_string());
1504 }
1505 }
1506 git_tag_map_to_vec(by_version)
1507}
1508
1509fn parse_remote_git_tag_output_for_scope(
1510 output: &str,
1511 version_scope: &VersionScope,
1512) -> Vec<GitTagObservation> {
1513 match version_scope {
1514 VersionScope::Repository => parse_remote_git_tag_output(output),
1515 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
1516 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1517 for line in output.lines() {
1518 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1519 let tag = reference
1520 .strip_prefix("refs/tags/")
1521 .unwrap_or(reference)
1522 .trim_end_matches("^{}")
1523 .trim();
1524
1525 if tag.is_empty() {
1526 continue;
1527 }
1528 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1529 by_version.entry(version).or_default().push(tag.to_string());
1530 }
1531 }
1532 git_tag_map_to_vec(by_version)
1533 }
1534 }
1535}
1536
1537fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
1538 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1539 for line in output.lines() {
1540 let tag = line.trim();
1541 if tag.is_empty() {
1542 continue;
1543 }
1544 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1545 by_version.entry(version).or_default().push(tag.to_string());
1546 }
1547 }
1548 git_tag_map_to_vec(by_version)
1549}
1550
1551fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
1552 let mut versions: Vec<GitTagObservation> = by_version
1553 .into_iter()
1554 .map(|(version, mut raw_tags)| {
1555 raw_tags.sort();
1556 raw_tags.dedup();
1557 GitTagObservation { version, raw_tags }
1558 })
1559 .collect();
1560 versions.sort_by(|a, b| b.version.cmp(&a.version));
1561 versions
1562}
1563
1564#[cfg(test)]
1565fn read_version_from_blob(
1566 relative: &str,
1567 content: &str,
1568 cargo_toml_content: Option<&str>,
1569) -> Result<Option<String>, String> {
1570 read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
1571}
1572
1573fn read_version_from_blob_with_override(
1574 relative: &str,
1575 content: &str,
1576 cargo_toml_content: Option<&str>,
1577 cargo_package_override: Option<&str>,
1578) -> Result<Option<String>, String> {
1579 let file_name = Path::new(relative)
1580 .file_name()
1581 .and_then(|n| n.to_str())
1582 .unwrap_or_default();
1583
1584 match file_name {
1585 "README.md" => read_readme_version_from_content(content),
1586 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1587 read_openapi_version_from_content(content)
1588 }
1589 "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
1590 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1591 | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
1592 "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
1593 "Cargo.toml" => read_cargo_toml_version_from_content(content),
1594 "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
1595 content,
1596 cargo_toml_content,
1597 cargo_package_override,
1598 ),
1599 "pyproject.toml" => read_pyproject_version_from_content(content),
1600 "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
1601 "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
1602 "pom.xml" => {
1603 read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
1604 }
1605 "build.gradle" | "build.gradle.kts" => {
1606 read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1607 }
1608 "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
1609 _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
1610 Some("json") => read_json_root_version_from_content(content),
1611 Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
1612 Some("toml") => read_toml_root_version_from_content(content),
1613 Some("md") => read_readme_version_from_content(content),
1614 _ => Ok(None),
1615 },
1616 }
1617}
1618
1619fn print_version_report(project_root: &Path, report: &VersionReport) {
1620 let dirty_suffix = if report.dirty_files.is_empty() {
1621 "clean".green().to_string()
1622 } else {
1623 format!("dirty ({})", report.dirty_files.len())
1624 .bright_magenta()
1625 .to_string()
1626 };
1627
1628 println!(
1629 "\n{} {}",
1630 "Version Summary".bright_cyan().bold(),
1631 project_root.display().to_string().bright_white()
1632 );
1633 println!("{}", "─".repeat(72).bright_black());
1634 println!(
1635 "{:<20} {}",
1636 "Highest available".bright_white(),
1637 report.highest_available().to_string().bright_green().bold()
1638 );
1639 println!(
1640 "{:<20} {}",
1641 "Worktree".bright_white(),
1642 report
1643 .highest_worktree()
1644 .unwrap_or_else(default_version)
1645 .to_string()
1646 .bright_yellow()
1647 );
1648 println!(
1649 "{:<20} {}",
1650 "Committed HEAD".bright_white(),
1651 report
1652 .highest_head()
1653 .map(|v| v.to_string())
1654 .unwrap_or_else(|| "none".dimmed().to_string())
1655 );
1656 println!(
1657 "{:<20} {}",
1658 "GitHub tags".bright_white(),
1659 report
1660 .highest_remote_tag()
1661 .map(|v| v.to_string())
1662 .unwrap_or_else(|| "none".dimmed().to_string())
1663 );
1664 println!(
1665 "{:<20} {}",
1666 "Registry latest".bright_white(),
1667 report
1668 .highest_registry()
1669 .map(|v| v.to_string())
1670 .unwrap_or_else(|| "none".dimmed().to_string())
1671 );
1672 println!(
1673 "{:<20} {}",
1674 "Local tags".bright_white(),
1675 report
1676 .highest_local_tag()
1677 .map(|v| v.to_string())
1678 .unwrap_or_else(|| "none".dimmed().to_string())
1679 );
1680 println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
1681
1682 print_version_observations(
1683 "Worktree version files",
1684 &report.worktree,
1685 Some(&report.dirty_files),
1686 );
1687 print_version_observations("Committed HEAD version files", &report.head, None);
1688 print_registry_observations("Published package versions", &report.registry_versions);
1689 print_git_tag_observations("GitHub tags", &report.remote_tags);
1690 print_git_tag_observations("Local git tags", &report.local_tags);
1691
1692 let divergent = report.divergent_versions();
1693 let highest = report.highest_available();
1694 let outdated: Vec<_> = divergent
1695 .into_iter()
1696 .filter(|version| version != &highest)
1697 .collect();
1698 println!();
1699 println!("{}", "Divergence".bright_cyan().bold());
1700 println!("{}", "─".repeat(72).bright_black());
1701 println!(
1702 " {:<20} {}",
1703 "latest target".bright_white(),
1704 highest.to_string().bright_green().bold()
1705 );
1706 if !outdated.is_empty() {
1707 for version in outdated {
1708 println!(
1709 " {} {}",
1710 "•".bright_yellow(),
1711 version.to_string().bright_yellow()
1712 );
1713 }
1714 println!();
1715 println!(
1716 "{} {}",
1717 "Fix local files with".bright_white(),
1718 format!("xbp version {}", highest).black().on_bright_green()
1719 );
1720 } else {
1721 println!(" {}", "all relevant sources are aligned".green());
1722 }
1723
1724 if !report.warnings.is_empty() {
1725 println!();
1726 println!("{}", "Warnings".bright_yellow().bold());
1727 println!("{}", "─".repeat(72).bright_black());
1728 for warning in &report.warnings {
1729 println!(" {} {}", "!".bright_yellow(), warning);
1730 }
1731 }
1732}
1733
1734fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
1735 entries.iter().map(|entry| entry.version.clone()).max()
1736}
1737
1738fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
1739 let Some(highest) = highest_version_observation(entries) else {
1740 return Vec::new();
1741 };
1742
1743 entries
1744 .iter()
1745 .filter(|entry| entry.version < highest)
1746 .collect()
1747}
1748
1749fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
1750 println!();
1751 println!("{}", title.bright_cyan().bold());
1752 println!("{}", "─".repeat(72).bright_black());
1753
1754 if entries.is_empty() {
1755 println!(" {}", "none found".dimmed());
1756 return;
1757 }
1758
1759 for entry in entries {
1760 let latest_display = match (&entry.latest, &entry.raw_version) {
1761 (Some(version), _) => version.to_string().bright_green().to_string(),
1762 (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
1763 (None, None) => "unavailable".dimmed().to_string(),
1764 };
1765
1766 let note = entry
1767 .note
1768 .as_ref()
1769 .map(|value| format!(" {}", value.bright_yellow()))
1770 .unwrap_or_default();
1771
1772 println!(
1773 " {:<9} {:<28} {:<16} {}{}",
1774 entry.registry.bright_white(),
1775 entry.package_name.bright_white(),
1776 latest_display,
1777 entry.source_file.dimmed(),
1778 note
1779 );
1780 }
1781}
1782
1783#[cfg(test)]
1784fn write_version_to_configured_files(
1785 project_root: &Path,
1786 invocation_dir: &Path,
1787 registry: &[String],
1788 version_scope: &VersionScope,
1789 version: &Version,
1790) -> Result<usize, String> {
1791 write_version_to_configured_files_with_paths(
1792 project_root,
1793 invocation_dir,
1794 registry,
1795 version_scope,
1796 version,
1797 )
1798 .map(|paths| paths.len())
1799}
1800
1801fn write_version_to_configured_files_with_paths(
1802 project_root: &Path,
1803 invocation_dir: &Path,
1804 registry: &[String],
1805 version_scope: &VersionScope,
1806 version: &Version,
1807) -> Result<Vec<PathBuf>, String> {
1808 write_version_to_configured_files_with_paths_internal(
1809 project_root,
1810 invocation_dir,
1811 registry,
1812 version_scope,
1813 version,
1814 false,
1815 )
1816}
1817
1818fn sync_version_to_configured_files_with_paths(
1819 project_root: &Path,
1820 invocation_dir: &Path,
1821 registry: &[String],
1822 version_scope: &VersionScope,
1823 version: &Version,
1824) -> Result<Vec<PathBuf>, String> {
1825 write_version_to_configured_files_with_paths_internal(
1826 project_root,
1827 invocation_dir,
1828 registry,
1829 version_scope,
1830 version,
1831 true,
1832 )
1833}
1834
1835fn write_version_to_configured_files_with_paths_internal(
1836 project_root: &Path,
1837 invocation_dir: &Path,
1838 registry: &[String],
1839 version_scope: &VersionScope,
1840 version: &Version,
1841 allow_noop_when_targets_exist: bool,
1842) -> Result<Vec<PathBuf>, String> {
1843 let mut updated = 0usize;
1844 let mut matched_targets = 0usize;
1845 let mut updated_paths = Vec::new();
1846 let mut errors = Vec::new();
1847
1848 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1849 let path = &entry.absolute;
1850 if !path.exists() {
1851 continue;
1852 }
1853 matched_targets += 1;
1854
1855 match write_version_to_resolved_path(&entry, version) {
1856 Ok(true) => {
1857 updated += 1;
1858 updated_paths.push(path.clone());
1859 }
1860 Ok(false) => {}
1861 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
1862 }
1863 }
1864
1865 if matched_targets == 0 && errors.is_empty() {
1866 return Err("No configured version files were found to update.".to_string());
1867 }
1868
1869 if !errors.is_empty() {
1870 return Err(format!(
1871 "Updated {} file(s), but some version targets failed:\n{}",
1872 updated,
1873 errors.join("\n")
1874 ));
1875 }
1876
1877 if updated == 0 && allow_noop_when_targets_exist {
1878 return Ok(updated_paths);
1879 }
1880
1881 Ok(updated_paths)
1882}
1883
1884fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
1885 let file_name = path
1886 .file_name()
1887 .and_then(|n| n.to_str())
1888 .unwrap_or_default();
1889
1890 match file_name {
1891 "README.md" => read_readme_version(path),
1892 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1893 read_openapi_version(path)
1894 }
1895 "openapi.json" | "swagger.json" => read_json_openapi_version(path),
1896 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1897 | "xbp.json" => read_json_root_version(path),
1898 "deno.json" => read_json_root_version(path),
1899 "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
1900 "Cargo.toml" => read_cargo_toml_version(path),
1901 "Cargo.lock" => read_cargo_lock_version(path),
1902 "pyproject.toml" => read_pyproject_version(path),
1903 "Chart.yaml" => read_yaml_root_version(path, "version"),
1904 "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
1905 "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
1906 "build.gradle" | "build.gradle.kts" => {
1907 read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1908 }
1909 "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
1910 _ => match path.extension().and_then(|ext| ext.to_str()) {
1911 Some("json") => read_json_root_version(path),
1912 Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
1913 Some("toml") => read_toml_root_version(path),
1914 Some("md") => read_readme_version(path),
1915 _ => Ok(None),
1916 },
1917 }
1918}
1919
1920fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
1921 let path = &entry.absolute;
1922 let file_name = path
1923 .file_name()
1924 .and_then(|n| n.to_str())
1925 .unwrap_or_default();
1926
1927 if file_name == "Cargo.lock" {
1928 if let Some(package_name) = entry.cargo_package_override.as_deref() {
1929 return read_cargo_lock_version_for_package(path, package_name);
1930 }
1931 }
1932
1933 read_version_from_path(path)
1934}
1935
1936fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
1937 let file_name = path
1938 .file_name()
1939 .and_then(|n| n.to_str())
1940 .unwrap_or_default();
1941
1942 match file_name {
1943 "README.md" => write_readme_version(path, version).map(|_| true),
1944 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1945 write_openapi_version(path, version).map(|_| true)
1946 }
1947 "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
1948 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1949 | "xbp.json" => write_json_root_version(path, version).map(|_| true),
1950 "deno.json" => write_json_root_version(path, version).map(|_| true),
1951 "deno.jsonc" => {
1952 write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
1953 }
1954 "Cargo.toml" => write_cargo_toml_version(path, version),
1955 "Cargo.lock" => write_cargo_lock_version(path, version),
1956 "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
1957 "Chart.yaml" => write_chart_version(path, version).map(|_| true),
1958 "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
1959 "pom.xml" => {
1960 write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
1961 }
1962 "build.gradle" | "build.gradle.kts" => {
1963 write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
1964 .map(|_| true)
1965 }
1966 "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
1967 _ => match path.extension().and_then(|ext| ext.to_str()) {
1968 Some("json") => write_json_root_version(path, version).map(|_| true),
1969 Some("yaml") | Some("yml") => {
1970 write_yaml_root_version(path, "version", version).map(|_| true)
1971 }
1972 Some("toml") => write_toml_root_version(path, version).map(|_| true),
1973 Some("md") => write_readme_version(path, version).map(|_| true),
1974 _ => Err("Unsupported version file type".to_string()),
1975 },
1976 }
1977}
1978
1979fn write_version_to_resolved_path(
1980 entry: &ResolvedRegistryPath,
1981 version: &Version,
1982) -> Result<bool, String> {
1983 let path = &entry.absolute;
1984 let file_name = path
1985 .file_name()
1986 .and_then(|n| n.to_str())
1987 .unwrap_or_default();
1988
1989 if file_name == "Cargo.lock" {
1990 if let Some(package_name) = entry.cargo_package_override.as_deref() {
1991 return write_cargo_lock_version_for_package(path, Some(package_name), version);
1992 }
1993 }
1994
1995 write_version_to_path(path, version)
1996}
1997
1998fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
1999 let value: JsonValue =
2000 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2001 Ok(value
2002 .get("version")
2003 .and_then(JsonValue::as_str)
2004 .map(|value| value.to_string()))
2005}
2006
2007fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
2008 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2009 read_json_root_version_from_content(&content)
2010}
2011
2012fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
2013 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2014 let mut value: JsonValue =
2015 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2016
2017 let object = value
2018 .as_object_mut()
2019 .ok_or_else(|| "Expected a JSON object".to_string())?;
2020 object.insert(
2021 "version".to_string(),
2022 JsonValue::String(version.to_string()),
2023 );
2024
2025 fs::write(
2026 path,
2027 serde_json::to_string_pretty(&value)
2028 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
2029 )
2030 .map_err(|e| format!("Failed to write file: {}", e))
2031}
2032
2033fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
2034 let value: YamlValue =
2035 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2036 Ok(yaml_get_string(&value, key))
2037}
2038
2039fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
2040 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2041 read_yaml_root_version_from_content(&content, key)
2042}
2043
2044fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
2045 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2046 let mut value: YamlValue =
2047 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2048
2049 let mapping = yaml_root_mapping_mut(&mut value)?;
2050 mapping.insert(
2051 YamlValue::String(key.to_string()),
2052 YamlValue::String(version.to_string()),
2053 );
2054
2055 fs::write(
2056 path,
2057 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2058 )
2059 .map_err(|e| format!("Failed to write file: {}", e))
2060}
2061
2062fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
2063 let value: YamlValue =
2064 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2065
2066 let info = yaml_get_mapping(&value, "info");
2067 Ok(info.and_then(|mapping| {
2068 mapping
2069 .get(YamlValue::String("version".to_string()))
2070 .and_then(YamlValue::as_str)
2071 .map(|value| value.to_string())
2072 }))
2073}
2074
2075fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
2076 let content: String =
2077 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2078 read_openapi_version_from_content(&content)
2079}
2080
2081fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
2082 let value: JsonValue =
2083 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2084 Ok(value
2085 .get("info")
2086 .and_then(JsonValue::as_object)
2087 .and_then(|info| info.get("version"))
2088 .and_then(JsonValue::as_str)
2089 .map(|value| value.to_string()))
2090}
2091
2092fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
2093 let content: String =
2094 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2095 read_json_openapi_version_from_content(&content)
2096}
2097
2098fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2099 let content: String =
2100 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2101 let mut value: YamlValue =
2102 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2103
2104 let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2105 let info_key: YamlValue = YamlValue::String("info".to_string());
2106 if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
2107 root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
2108 }
2109
2110 let info: &mut YamlMapping = root
2111 .get_mut(&info_key)
2112 .and_then(YamlValue::as_mapping_mut)
2113 .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
2114 info.insert(
2115 YamlValue::String("version".to_string()),
2116 YamlValue::String(version.to_string()),
2117 );
2118
2119 fs::write(
2120 path,
2121 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2122 )
2123 .map_err(|e| format!("Failed to write file: {}", e))
2124}
2125
2126fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2127 let content: String =
2128 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2129 let mut value: JsonValue =
2130 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2131
2132 let root = value
2133 .as_object_mut()
2134 .ok_or_else(|| "Expected a JSON object".to_string())?;
2135 let info = root
2136 .entry("info".to_string())
2137 .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
2138 let info_object = info
2139 .as_object_mut()
2140 .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
2141 info_object.insert(
2142 "version".to_string(),
2143 JsonValue::String(version.to_string()),
2144 );
2145
2146 fs::write(
2147 path,
2148 serde_json::to_string_pretty(&value)
2149 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
2150 )
2151 .map_err(|e| format!("Failed to write file: {}", e))
2152}
2153
2154fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
2155 let value: TomlValue =
2156 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2157 Ok(value
2158 .get("version")
2159 .and_then(TomlValue::as_str)
2160 .map(|value| value.to_string()))
2161}
2162
2163fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
2164 let content: String =
2165 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2166 read_toml_root_version_from_content(&content)
2167}
2168
2169fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
2170 let content: String =
2171 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2172 let mut value: TomlValue =
2173 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2174 let table = value
2175 .as_table_mut()
2176 .ok_or_else(|| "Expected a TOML table".to_string())?;
2177 table.insert(
2178 "version".to_string(),
2179 TomlValue::String(version.to_string()),
2180 );
2181 fs::write(
2182 path,
2183 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2184 )
2185 .map_err(|e| format!("Failed to write file: {}", e))
2186}
2187
2188fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
2189 let value: TomlValue =
2190 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2191 Ok(value
2192 .get("package")
2193 .and_then(TomlValue::as_table)
2194 .and_then(|package| package.get("version"))
2195 .and_then(TomlValue::as_str)
2196 .map(|value| value.to_string()))
2197}
2198
2199fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
2200 let content: String =
2201 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2202 read_cargo_toml_version_from_content(&content)
2203}
2204
2205fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
2206 let content: String =
2207 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2208 let mut value: TomlValue =
2209 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2210
2211 let Some(package) = value.get_mut("package").and_then(TomlValue::as_table_mut) else {
2212 return Ok(false);
2213 };
2214 package.insert(
2215 "version".to_string(),
2216 TomlValue::String(version.to_string()),
2217 );
2218
2219 fs::write(
2220 path,
2221 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2222 )
2223 .map_err(|e| format!("Failed to write file: {}", e))?;
2224
2225 Ok(true)
2226}
2227
2228fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
2229 let value: TomlValue =
2230 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2231
2232 let project_version = value
2233 .get("project")
2234 .and_then(TomlValue::as_table)
2235 .and_then(|project| project.get("version"))
2236 .and_then(TomlValue::as_str);
2237
2238 let poetry_version = value
2239 .get("tool")
2240 .and_then(TomlValue::as_table)
2241 .and_then(|tool| tool.get("poetry"))
2242 .and_then(TomlValue::as_table)
2243 .and_then(|poetry| poetry.get("version"))
2244 .and_then(TomlValue::as_str);
2245
2246 Ok(project_version
2247 .or(poetry_version)
2248 .map(|value| value.to_string()))
2249}
2250
2251fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
2252 let content: String =
2253 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2254 read_pyproject_version_from_content(&content)
2255}
2256
2257fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
2258 let content: String =
2259 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2260 let mut value: TomlValue =
2261 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2262
2263 if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
2264 project.insert(
2265 "version".to_string(),
2266 TomlValue::String(version.to_string()),
2267 );
2268 } else if let Some(poetry) = value
2269 .get_mut("tool")
2270 .and_then(TomlValue::as_table_mut)
2271 .and_then(|tool| tool.get_mut("poetry"))
2272 .and_then(TomlValue::as_table_mut)
2273 {
2274 poetry.insert(
2275 "version".to_string(),
2276 TomlValue::String(version.to_string()),
2277 );
2278 } else {
2279 let table: &mut toml::map::Map<String, TomlValue> = value
2280 .as_table_mut()
2281 .ok_or_else(|| "Expected a TOML table".to_string())?;
2282 table.insert(
2283 "version".to_string(),
2284 TomlValue::String(version.to_string()),
2285 );
2286 }
2287
2288 fs::write(
2289 path,
2290 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2291 )
2292 .map_err(|e| format!("Failed to write file: {}", e))
2293}
2294
2295fn read_cargo_lock_version_from_content(
2296 content: &str,
2297 cargo_toml_content: Option<&str>,
2298) -> Result<Option<String>, String> {
2299 read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
2300}
2301
2302fn read_cargo_lock_version_from_content_with_package(
2303 content: &str,
2304 cargo_toml_content: Option<&str>,
2305 package_name_override: Option<&str>,
2306) -> Result<Option<String>, String> {
2307 let value: TomlValue =
2308 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2309 let package_name = if let Some(package_name_override) = package_name_override {
2310 package_name_override.trim().to_string()
2311 } else {
2312 let cargo_toml_content = cargo_toml_content
2313 .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
2314 cargo_package_name_from_content(cargo_toml_content)?
2315 };
2316
2317 Ok(value
2318 .get("package")
2319 .and_then(TomlValue::as_array)
2320 .and_then(|packages| {
2321 packages.iter().find_map(|package| {
2322 let table = package.as_table()?;
2323 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2324 table
2325 .get("version")
2326 .and_then(TomlValue::as_str)
2327 .map(|value| value.to_string())
2328 } else {
2329 None
2330 }
2331 })
2332 }))
2333}
2334
2335fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
2336 let content: String =
2337 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2338 let cargo_toml: String = fs::read_to_string(
2339 path.parent()
2340 .unwrap_or_else(|| Path::new("."))
2341 .join("Cargo.toml"),
2342 )
2343 .map_err(|e| format!("Failed to read file: {}", e))?;
2344 read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
2345}
2346
2347fn read_cargo_lock_version_for_package(
2348 path: &Path,
2349 package_name: &str,
2350) -> Result<Option<String>, String> {
2351 let content: String =
2352 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2353 read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
2354}
2355
2356fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
2357 write_cargo_lock_version_for_package(path, None, version)
2358}
2359
2360fn write_cargo_lock_version_for_package(
2361 path: &Path,
2362 package_name_override: Option<&str>,
2363 version: &Version,
2364) -> Result<bool, String> {
2365 let content: String =
2366 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2367 let mut value: TomlValue =
2368 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2369 let package_name = if let Some(package_name_override) = package_name_override {
2370 package_name_override.trim().to_string()
2371 } else {
2372 let Some(package_name) = cargo_package_name(path)? else {
2373 return Ok(false);
2374 };
2375 package_name
2376 };
2377
2378 let packages: &mut Vec<TomlValue> = value
2379 .get_mut("package")
2380 .and_then(TomlValue::as_array_mut)
2381 .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
2382
2383 let mut updated = false;
2384 for package in packages {
2385 if let Some(table) = package.as_table_mut() {
2386 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2387 table.insert(
2388 "version".to_string(),
2389 TomlValue::String(version.to_string()),
2390 );
2391 updated = true;
2392 }
2393 }
2394 }
2395
2396 if !updated {
2397 return Err(format!(
2398 "Could not find package `{}` in Cargo.lock",
2399 package_name
2400 ));
2401 }
2402
2403 fs::write(
2404 path,
2405 toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2406 )
2407 .map_err(|e| format!("Failed to write file: {}", e))?;
2408
2409 Ok(true)
2410}
2411
2412fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
2413 let cargo_toml: PathBuf = path
2414 .parent()
2415 .unwrap_or_else(|| Path::new("."))
2416 .join("Cargo.toml");
2417 let content: String = fs::read_to_string(&cargo_toml)
2418 .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
2419 cargo_package_name_from_content_optional(&content)
2420}
2421
2422fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
2423 cargo_package_name_from_content_optional(content)?
2424 .ok_or_else(|| "Could not determine Cargo package name".to_string())
2425}
2426
2427fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
2428 let value: TomlValue =
2429 toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
2430 Ok(value
2431 .get("package")
2432 .and_then(TomlValue::as_table)
2433 .and_then(|package| package.get("name"))
2434 .and_then(TomlValue::as_str)
2435 .map(|value| value.to_string()))
2436}
2437
2438fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
2439 read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2440}
2441
2442fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
2443 let content: String =
2444 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2445 read_readme_version_from_content(&content)
2446}
2447
2448fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
2449 let content: String =
2450 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2451 let marker: String = format!("current version: `{}`", version);
2452 let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2453 .map_err(|e| format!("Failed to build README regex: {}", e))?;
2454
2455 let updated: String = if regex.is_match(&content) {
2456 regex.replace(&content, marker.as_str()).to_string()
2457 } else if let Some(first_break) = content.find('\n') {
2458 let mut next = String::new();
2459 next.push_str(&content[..=first_break]);
2460 next.push('\n');
2461 next.push_str(&marker);
2462 next.push('\n');
2463 next.push_str(&content[first_break + 1..]);
2464 next
2465 } else {
2466 format!("{}\n\n{}\n", content, marker)
2467 };
2468
2469 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2470}
2471
2472fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
2473 let content: String =
2474 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2475 read_regex_version_from_content(&content, pattern)
2476}
2477
2478fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
2479 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2480 Ok(regex
2481 .captures(content)
2482 .and_then(|captures| captures.get(1))
2483 .map(|matched| matched.as_str().trim().to_string()))
2484}
2485
2486fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
2487 let content: String =
2488 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2489 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2490
2491 if !regex.is_match(&content) {
2492 return Err("No version pattern found".to_string());
2493 }
2494
2495 let updated: String = regex
2496 .replace(&content, |caps: ®ex::Captures<'_>| {
2497 caps[0].replace(&caps[1], &version.to_string())
2498 })
2499 .to_string();
2500 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2501}
2502
2503#[cfg(test)]
2504fn write_package_version_to_configured_files(
2505 project_root: &Path,
2506 invocation_dir: &Path,
2507 registry: &[String],
2508 version_scope: &VersionScope,
2509 package_name: &str,
2510 version: &Version,
2511) -> Result<usize, String> {
2512 write_package_version_to_configured_files_with_paths(
2513 project_root,
2514 invocation_dir,
2515 registry,
2516 version_scope,
2517 package_name,
2518 version,
2519 )
2520 .map(|paths| paths.len())
2521}
2522
2523fn write_package_version_to_configured_files_with_paths(
2524 project_root: &Path,
2525 invocation_dir: &Path,
2526 registry: &[String],
2527 version_scope: &VersionScope,
2528 package_name: &str,
2529 version: &Version,
2530) -> Result<Vec<PathBuf>, String> {
2531 let mut updated: usize = 0usize;
2532 let mut updated_paths = Vec::new();
2533 let mut errors: Vec<String> = Vec::new();
2534
2535 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2536 let path: &PathBuf = &entry.absolute;
2537 if !path.exists() {
2538 continue;
2539 }
2540
2541 match write_package_version_to_path(path, package_name, version) {
2542 Ok(true) => {
2543 updated += 1;
2544 updated_paths.push(path.clone());
2545 }
2546 Ok(false) => {}
2547 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
2548 }
2549 }
2550
2551 if updated == 0 && errors.is_empty() {
2552 return Err(format!(
2553 "No configured TOML files contained package assignment `{}`.",
2554 package_name
2555 ));
2556 }
2557
2558 if !errors.is_empty() {
2559 return Err(format!(
2560 "Updated {} file(s), but some package version targets failed:\n{}",
2561 updated,
2562 errors.join("\n")
2563 ));
2564 }
2565
2566 Ok(updated_paths)
2567}
2568
2569fn write_package_version_to_path(
2570 path: &Path,
2571 package_name: &str,
2572 version: &Version,
2573) -> Result<bool, String> {
2574 let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
2575 if !is_toml {
2576 return Ok(false);
2577 }
2578
2579 let content: String =
2580 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2581 let (updated, changed) =
2582 rewrite_toml_package_assignment_versions(&content, package_name, version)?;
2583 if changed {
2584 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
2585 }
2586 Ok(changed)
2587}
2588
2589fn rewrite_toml_package_assignment_versions(
2590 content: &str,
2591 package_name: &str,
2592 version: &Version,
2593) -> Result<(String, bool), String> {
2594 let escaped_name: String = regex::escape(package_name);
2595 let inline_pattern: String = format!(
2596 r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
2597 escaped_name
2598 );
2599 let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
2600
2601 let inline_regex: Regex =
2602 Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
2603 let string_regex: Regex =
2604 Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
2605
2606 let replacement: String = version.to_string();
2607
2608 let after_inline: String = inline_regex
2609 .replace_all(content, |caps: ®ex::Captures<'_>| {
2610 format!("{}{}{}", &caps[1], replacement, &caps[3])
2611 })
2612 .to_string();
2613 let after_string: String = string_regex
2614 .replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
2615 format!("{}{}{}", &caps[1], replacement, &caps[3])
2616 })
2617 .to_string();
2618
2619 Ok((after_string.clone(), after_string != content))
2620}
2621
2622fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
2623 let content: String =
2624 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2625 let mut value: YamlValue =
2626 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2627 let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2628 mapping.insert(
2629 YamlValue::String("version".to_string()),
2630 YamlValue::String(version.to_string()),
2631 );
2632 if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
2633 mapping.insert(
2634 YamlValue::String("appVersion".to_string()),
2635 YamlValue::String(version.to_string()),
2636 );
2637 }
2638 fs::write(
2639 path,
2640 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2641 )
2642 .map_err(|e| format!("Failed to write file: {}", e))
2643}
2644
2645fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
2646 value
2647 .as_mapping_mut()
2648 .ok_or_else(|| "Expected a YAML mapping".to_string())
2649}
2650
2651fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
2652 value
2653 .as_mapping()
2654 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2655 .and_then(YamlValue::as_mapping)
2656}
2657
2658fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
2659 value
2660 .as_mapping()
2661 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2662 .and_then(YamlValue::as_str)
2663 .map(|value| value.to_string())
2664}
2665
2666fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
2667 let mut current: &JsonValue = value;
2668 for part in key_parts {
2669 current = current.get(*part)?;
2670 }
2671 current.as_str().map(|value| value.to_string())
2672}
2673
2674fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
2675 let mut current = value;
2676 for part in key_parts {
2677 let mapping = current.as_mapping()?;
2678 current = mapping.get(YamlValue::String((*part).to_string()))?;
2679 }
2680 current.as_str().map(|value| value.to_string())
2681}
2682
2683fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
2684 let mut current = value;
2685 for part in key_parts {
2686 current = current.get(*part)?;
2687 }
2688 current.as_str().map(|value| value.to_string())
2689}
2690
2691fn parse_version(input: &str) -> Result<Version, String> {
2692 let trimmed: &str = input.trim();
2693 let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
2694 Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
2695}
2696
2697fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
2698 let trimmed = input.trim();
2699 if trimmed.is_empty() {
2700 return Err("Release version cannot be empty.".to_string());
2701 }
2702
2703 if let Ok(version) = parse_version(trimmed) {
2704 return Ok((version.clone(), format!("v{}", version)));
2705 }
2706
2707 let prefixed = Regex::new(
2708 r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
2709 )
2710 .map_err(|e| format!("Failed to build release target parser: {}", e))?;
2711
2712 if let Some(captures) = prefixed.captures(trimmed) {
2713 let semver = captures
2714 .name("semver")
2715 .map(|m| m.as_str())
2716 .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
2717 let version = Version::parse(semver)
2718 .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
2719 return Ok((version, trimmed.to_string()));
2720 }
2721
2722 Err(format!(
2723 "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
2724 input
2725 ))
2726}
2727
2728fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
2729 let Some((raw_package, raw_version)) = input.split_once('=') else {
2730 return Ok(None);
2731 };
2732
2733 let package_name = raw_package.trim();
2734 if package_name.is_empty() {
2735 return Ok(None);
2736 }
2737
2738 let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
2739 .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
2740 if !package_name_regex.is_match(package_name) {
2741 return Err(format!(
2742 "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
2743 input
2744 ));
2745 }
2746
2747 let version = parse_version(raw_version.trim())?;
2748 Ok(Some((package_name.to_string(), version)))
2749}
2750
2751fn bump_version(current: &Version, kind: &str) -> Version {
2752 let mut next = current.clone();
2753 match kind {
2754 "major" => {
2755 next.major += 1;
2756 next.minor = 0;
2757 next.patch = 0;
2758 next.pre = semver::Prerelease::EMPTY;
2759 next.build = semver::BuildMetadata::EMPTY;
2760 }
2761 "minor" => {
2762 next.minor += 1;
2763 next.patch = 0;
2764 next.pre = semver::Prerelease::EMPTY;
2765 next.build = semver::BuildMetadata::EMPTY;
2766 }
2767 _ => {
2768 next.patch += 1;
2769 next.pre = semver::Prerelease::EMPTY;
2770 next.build = semver::BuildMetadata::EMPTY;
2771 }
2772 }
2773 next
2774}
2775
2776fn default_version() -> Version {
2777 Version::new(0, 1, 0)
2778}
2779
2780fn resolve_registry_paths(
2781 project_root: &Path,
2782 invocation_dir: &Path,
2783 registry: &[String],
2784 version_scope: &VersionScope,
2785) -> Vec<ResolvedRegistryPath> {
2786 let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
2787 let mut seen: BTreeSet<String> = BTreeSet::new();
2788 let workspace_primary_target = match version_scope {
2789 VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
2790 VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
2791 };
2792
2793 for relative in registry {
2794 match version_scope {
2795 VersionScope::Repository => {
2796 if *relative == *"Cargo.lock" {
2797 let resolved_relative = resolve_registry_relative_path(
2798 project_root,
2799 invocation_dir,
2800 version_scope,
2801 relative,
2802 );
2803 if !seen.insert(resolved_relative.clone()) {
2804 continue;
2805 }
2806
2807 resolved.push(ResolvedRegistryPath {
2808 absolute: project_root.join(&resolved_relative),
2809 relative: resolved_relative,
2810 cargo_package_override: workspace_primary_target
2811 .as_ref()
2812 .map(|target| target.package_name.clone()),
2813 });
2814 continue;
2815 }
2816
2817 if *relative == *"Cargo.toml" {
2818 if let Some(target) = workspace_primary_target.as_ref() {
2819 if !seen.insert(target.manifest_relative.clone()) {
2820 continue;
2821 }
2822
2823 resolved.push(ResolvedRegistryPath {
2824 absolute: target.manifest_absolute.clone(),
2825 relative: target.manifest_relative.clone(),
2826 cargo_package_override: None,
2827 });
2828 continue;
2829 }
2830 }
2831
2832 let resolved_relative = resolve_registry_relative_path(
2833 project_root,
2834 invocation_dir,
2835 version_scope,
2836 relative,
2837 );
2838 if !seen.insert(resolved_relative.clone()) {
2839 continue;
2840 }
2841
2842 resolved.push(ResolvedRegistryPath {
2843 absolute: project_root.join(&resolved_relative),
2844 relative: resolved_relative,
2845 cargo_package_override: None,
2846 });
2847 }
2848 VersionScope::Crate {
2849 crate_root,
2850 package_name,
2851 ..
2852 } => {
2853 if *relative == *"Cargo.lock" {
2854 let cargo_lock = project_root.join("Cargo.lock");
2855 if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
2856 resolved.push(ResolvedRegistryPath {
2857 absolute: cargo_lock,
2858 relative: "Cargo.lock".to_string(),
2859 cargo_package_override: Some(package_name.clone()),
2860 });
2861 }
2862 continue;
2863 }
2864
2865 let preferred = crate_root.join(relative);
2866 if !preferred.exists() {
2867 continue;
2868 }
2869 let Ok(stripped) = preferred.strip_prefix(project_root) else {
2870 continue;
2871 };
2872 let resolved_relative = normalized_relative_path(stripped);
2873 if !seen.insert(resolved_relative.clone()) {
2874 continue;
2875 }
2876
2877 resolved.push(ResolvedRegistryPath {
2878 absolute: preferred,
2879 relative: resolved_relative,
2880 cargo_package_override: None,
2881 });
2882 }
2883 VersionScope::Service {
2884 service_root,
2885 cargo_package_name,
2886 ..
2887 } => {
2888 if *relative == *"Cargo.lock" {
2889 let local_cargo_lock = service_root.join("Cargo.lock");
2890 if local_cargo_lock.exists() {
2891 let relative_path = local_cargo_lock
2892 .strip_prefix(project_root)
2893 .ok()
2894 .map(normalized_relative_path)
2895 .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
2896 if seen.insert(relative_path.clone()) {
2897 resolved.push(ResolvedRegistryPath {
2898 absolute: local_cargo_lock,
2899 relative: relative_path,
2900 cargo_package_override: cargo_package_name.clone(),
2901 });
2902 }
2903 } else if let Some(package_name) = cargo_package_name.as_ref() {
2904 let workspace_cargo_lock = project_root.join("Cargo.lock");
2905 if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
2906 resolved.push(ResolvedRegistryPath {
2907 absolute: workspace_cargo_lock,
2908 relative: "Cargo.lock".to_string(),
2909 cargo_package_override: Some(package_name.clone()),
2910 });
2911 }
2912 }
2913 continue;
2914 }
2915
2916 let preferred = service_root.join(relative);
2917 if !preferred.exists() {
2918 continue;
2919 }
2920 let Ok(stripped) = preferred.strip_prefix(project_root) else {
2921 continue;
2922 };
2923 let resolved_relative = normalized_relative_path(stripped);
2924 if !seen.insert(resolved_relative.clone()) {
2925 continue;
2926 }
2927
2928 resolved.push(ResolvedRegistryPath {
2929 absolute: preferred,
2930 relative: resolved_relative,
2931 cargo_package_override: None,
2932 });
2933 }
2934 }
2935 }
2936
2937 for configured_path in
2938 resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
2939 {
2940 if seen.insert(configured_path.relative.clone()) {
2941 resolved.push(configured_path);
2942 }
2943 }
2944
2945 resolved
2946}
2947
2948fn resolve_configured_version_target_paths(
2949 project_root: &Path,
2950 invocation_dir: &Path,
2951 version_scope: &VersionScope,
2952) -> Vec<ResolvedRegistryPath> {
2953 let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
2954 return Vec::new();
2955 };
2956
2957 let service_targets = match version_scope {
2958 VersionScope::Service {
2959 version_targets, ..
2960 } if !version_targets.is_empty() => Some(version_targets.clone()),
2961 _ => None,
2962 };
2963
2964 let manifest_paths = if let Some(service_targets) = &service_targets {
2965 service_targets
2966 .iter()
2967 .cloned()
2968 .map(PathBuf::from)
2969 .collect::<Vec<_>>()
2970 } else {
2971 let mut targets = config
2972 .version_targets
2973 .into_iter()
2974 .map(PathBuf::from)
2975 .collect::<Vec<_>>();
2976 targets.extend(
2977 config
2978 .publish
2979 .into_iter()
2980 .flat_map(|publish| [publish.npm, publish.crates])
2981 .flatten()
2982 .filter_map(|target| target.manifest_path)
2983 .map(PathBuf::from),
2984 );
2985 targets
2986 };
2987
2988 let scope_root = match version_scope {
2989 VersionScope::Repository => None,
2990 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
2991 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
2992 };
2993
2994 manifest_paths
2995 .into_iter()
2996 .filter_map(|manifest_path| {
2997 let absolute = if manifest_path.is_absolute() {
2998 manifest_path
2999 } else {
3000 config_root.join(manifest_path)
3001 };
3002 if !absolute.exists() {
3003 return None;
3004 }
3005 if let Some(scope_root) = scope_root {
3006 if !absolute.starts_with(scope_root) {
3007 return None;
3008 }
3009 }
3010 let relative = absolute
3011 .strip_prefix(project_root)
3012 .ok()
3013 .map(normalized_relative_path)
3014 .unwrap_or_else(|| normalized_relative_path(&absolute));
3015 Some(ResolvedRegistryPath {
3016 relative,
3017 absolute,
3018 cargo_package_override: None,
3019 })
3020 })
3021 .collect()
3022}
3023
3024fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
3025 let found = find_xbp_config_upwards(invocation_dir)?;
3026 let config_path = if found.kind == "json" {
3027 maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
3028 .ok()
3029 .flatten()
3030 .unwrap_or_else(|| found.config_path.clone())
3031 } else {
3032 found.config_path.clone()
3033 };
3034
3035 let kind = if config_path
3036 .extension()
3037 .and_then(|ext| ext.to_str())
3038 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
3039 .unwrap_or(false)
3040 {
3041 "yaml"
3042 } else {
3043 "json"
3044 };
3045
3046 let content = fs::read_to_string(&config_path).ok()?;
3047 let (mut config, _healed): (XbpConfig, Option<String>) =
3048 parse_config_with_auto_heal(&content, kind).ok()?;
3049 resolve_config_paths_for_runtime(&mut config, &found.project_root);
3050 Some((found.project_root, config))
3051}
3052
3053fn resolve_registry_relative_path(
3054 project_root: &Path,
3055 invocation_dir: &Path,
3056 version_scope: &VersionScope,
3057 relative: &str,
3058) -> String {
3059 if let Some(scope_root) = version_scope_root(version_scope) {
3060 let preferred = scope_root.join(relative);
3061 if preferred.exists() {
3062 if let Ok(stripped) = preferred.strip_prefix(project_root) {
3063 return normalized_relative_path(stripped);
3064 }
3065 }
3066 return relative.replace('\\', "/");
3067 }
3068
3069 if relative == "Cargo.toml" {
3070 if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
3071 return target.manifest_relative;
3072 }
3073 }
3074
3075 let preferred: PathBuf = invocation_dir.join(relative);
3076 if preferred.exists() {
3077 if let Ok(stripped) = preferred.strip_prefix(project_root) {
3078 return normalized_relative_path(stripped);
3079 }
3080 }
3081
3082 relative.replace('\\', "/")
3083}
3084
3085fn resolve_version_scope_with_prompt(
3086 project_root: &Path,
3087 invocation_dir: &Path,
3088) -> Result<VersionScope, String> {
3089 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
3090 if let Some(scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
3091 return Ok(scope);
3092 }
3093
3094 if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
3095 {
3096 return prompt_for_version_scope_selection(&service_scopes);
3097 }
3098
3099 Ok(resolve_version_scope(project_root, invocation_dir))
3100}
3101
3102fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
3103 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
3104 if let Some(service_scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
3105 return service_scope;
3106 }
3107
3108 if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
3109 return crate_scope;
3110 }
3111
3112 VersionScope::Repository
3113}
3114
3115fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
3116 let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
3117 return Vec::new();
3118 };
3119 let Some(services) = config.services else {
3120 return Vec::new();
3121 };
3122
3123 let mut scopes = services
3124 .into_iter()
3125 .filter_map(|service| build_service_scope(project_root, service))
3126 .collect::<Vec<_>>();
3127 scopes.sort_by(|left, right| {
3128 version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
3129 });
3130 scopes
3131}
3132
3133fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
3134 let version_targets = service
3135 .version_targets
3136 .clone()
3137 .unwrap_or_default()
3138 .into_iter()
3139 .map(normalized_path_string)
3140 .collect::<Vec<_>>();
3141 let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
3142 let service_relative_root = service_root
3143 .strip_prefix(project_root)
3144 .ok()
3145 .map(normalized_relative_path)
3146 .filter(|value| !value.is_empty())
3147 .unwrap_or_else(|| ".".to_string());
3148 let cargo_package_name =
3149 resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
3150 let tag_identity = cargo_package_name
3151 .clone()
3152 .unwrap_or_else(|| service.name.clone());
3153
3154 Some(VersionScope::Service {
3155 service_root,
3156 service_relative_root,
3157 service_name: service.name,
3158 tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
3159 cargo_package_name,
3160 version_targets,
3161 })
3162}
3163
3164fn resolve_service_scope_root(
3165 project_root: &Path,
3166 service: &ServiceConfig,
3167 version_targets: &[String],
3168) -> Option<PathBuf> {
3169 if !version_targets.is_empty() {
3170 return common_parent_from_targets(project_root, version_targets);
3171 }
3172
3173 service.root_directory.as_ref().map(|root| {
3174 let path = PathBuf::from(root);
3175 if path.is_absolute() {
3176 path
3177 } else {
3178 project_root.join(path)
3179 }
3180 })
3181}
3182
3183fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
3184 let mut parents = version_targets.iter().filter_map(|target| {
3185 let path = PathBuf::from(target);
3186 let absolute = if path.is_absolute() {
3187 path
3188 } else {
3189 project_root.join(path)
3190 };
3191 absolute.parent().map(Path::to_path_buf)
3192 });
3193
3194 let mut common = parents.next()?;
3195 for parent in parents {
3196 while !parent.starts_with(&common) {
3197 common = common.parent()?.to_path_buf();
3198 }
3199 }
3200 Some(common)
3201}
3202
3203fn resolve_service_scope_cargo_package_name(
3204 project_root: &Path,
3205 service_root: &Path,
3206 version_targets: &[String],
3207) -> Option<String> {
3208 let mut cargo_paths = version_targets
3209 .iter()
3210 .filter(|target| {
3211 Path::new(target)
3212 .file_name()
3213 .and_then(|value| value.to_str())
3214 == Some("Cargo.toml")
3215 })
3216 .map(|target| {
3217 let path = PathBuf::from(target);
3218 if path.is_absolute() {
3219 path
3220 } else {
3221 project_root.join(path)
3222 }
3223 })
3224 .collect::<Vec<_>>();
3225 if cargo_paths.is_empty() {
3226 cargo_paths.push(service_root.join("Cargo.toml"));
3227 }
3228
3229 cargo_paths.into_iter().find_map(|path| {
3230 let content = fs::read_to_string(path).ok()?;
3231 cargo_package_name_from_content_optional(&content)
3232 .ok()
3233 .flatten()
3234 })
3235}
3236
3237fn select_matching_service_scope(
3238 scopes: &[VersionScope],
3239 invocation_dir: &Path,
3240) -> Option<VersionScope> {
3241 scopes
3242 .iter()
3243 .filter_map(|scope| {
3244 let root = version_scope_root(scope)?;
3245 if invocation_dir.starts_with(root) {
3246 Some((root.components().count(), scope.clone()))
3247 } else {
3248 None
3249 }
3250 })
3251 .max_by_key(|(depth, _)| *depth)
3252 .map(|(_, scope)| scope)
3253}
3254
3255fn prompt_for_version_scope_selection(
3256 service_scopes: &[VersionScope],
3257) -> Result<VersionScope, String> {
3258 let mut items = service_scopes
3259 .iter()
3260 .map(version_scope_prompt_label)
3261 .collect::<Vec<_>>();
3262 items.push("Repository (all configured version targets)".to_string());
3263
3264 let selection = Select::with_theme(&ColorfulTheme::default())
3265 .with_prompt("Choose the project/service to version or release")
3266 .items(&items)
3267 .default(0)
3268 .interact_opt()
3269 .map_err(|e| format!("Prompt failed: {}", e))?;
3270
3271 match selection {
3272 Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
3273 _ => Ok(VersionScope::Repository),
3274 }
3275}
3276
3277fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
3278 let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
3279 let cargo_toml = crate_root.join("Cargo.toml");
3280 let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
3281 let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
3282 let crate_relative_root = crate_root
3283 .strip_prefix(project_root)
3284 .ok()
3285 .map(normalized_relative_path)?;
3286
3287 Some(VersionScope::Crate {
3288 crate_root,
3289 crate_relative_root,
3290 tag_prefix: format!("{}-", package_name),
3291 package_name,
3292 })
3293}
3294
3295fn resolve_release_openapi_spec(
3296 project_root: &Path,
3297 invocation_dir: &Path,
3298 version_scope: &VersionScope,
3299) -> Option<PathBuf> {
3300 let mut roots: Vec<PathBuf> = Vec::new();
3301 if let Some(scope_root) = version_scope_root(version_scope) {
3302 roots.push(scope_root.to_path_buf());
3303 } else if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
3304 roots.push(crate_root);
3305 }
3306 roots.push(project_root.to_path_buf());
3307
3308 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
3309 for root in roots {
3310 if !seen.insert(root.clone()) {
3311 continue;
3312 }
3313
3314 for file_name in [
3315 "openapi.yaml",
3316 "openapi.yml",
3317 "openapi.json",
3318 "swagger.yaml",
3319 "swagger.yml",
3320 "swagger.json",
3321 ] {
3322 let path = root.join(file_name);
3323 if path.is_file() {
3324 return Some(path);
3325 }
3326 }
3327 }
3328
3329 None
3330}
3331
3332fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
3333 let crates_root = project_root.join("crates");
3334 let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
3335 let mut components = relative.components();
3336 let crate_name = components.next()?;
3337 Some(crates_root.join(crate_name.as_os_str()))
3338}
3339
3340fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
3341 match version_scope {
3342 VersionScope::Repository => None,
3343 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
3344 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
3345 }
3346}
3347
3348fn normalized_path_string(path: String) -> String {
3349 path.replace('\\', "/")
3350}
3351
3352fn slugify_scope_name(value: &str) -> String {
3353 let mut slug = value
3354 .chars()
3355 .map(|ch| {
3356 if ch.is_ascii_alphanumeric() {
3357 ch.to_ascii_lowercase()
3358 } else {
3359 '-'
3360 }
3361 })
3362 .collect::<String>();
3363 while slug.contains("--") {
3364 slug = slug.replace("--", "-");
3365 }
3366 slug.trim_matches('-').to_string()
3367}
3368
3369fn resolve_workspace_primary_cargo_target(
3370 project_root: &Path,
3371) -> Option<WorkspacePrimaryCargoTarget> {
3372 let workspace_manifest = project_root.join("Cargo.toml");
3373 let content = fs::read_to_string(&workspace_manifest).ok()?;
3374 let value: TomlValue = toml::from_str(&content).ok()?;
3375 if value.get("package").and_then(TomlValue::as_table).is_some() {
3376 return None;
3377 }
3378
3379 let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
3380 let mut candidate_roots = workspace
3381 .get("default-members")
3382 .and_then(TomlValue::as_array)
3383 .map(|members| {
3384 members
3385 .iter()
3386 .filter_map(TomlValue::as_str)
3387 .map(str::trim)
3388 .filter(|member| !member.is_empty() && !member.contains('*'))
3389 .map(str::to_string)
3390 .collect::<Vec<_>>()
3391 })
3392 .unwrap_or_default();
3393
3394 if candidate_roots.is_empty() {
3395 let members = workspace
3396 .get("members")
3397 .and_then(TomlValue::as_array)
3398 .map(|members| {
3399 members
3400 .iter()
3401 .filter_map(TomlValue::as_str)
3402 .map(str::trim)
3403 .filter(|member| !member.is_empty() && !member.contains('*'))
3404 .map(str::to_string)
3405 .collect::<Vec<_>>()
3406 })
3407 .unwrap_or_default();
3408 if members.len() == 1 {
3409 candidate_roots = members;
3410 }
3411 }
3412
3413 candidate_roots.sort();
3414 candidate_roots.dedup();
3415 if candidate_roots.len() != 1 {
3416 return None;
3417 }
3418
3419 let crate_relative_root = candidate_roots.into_iter().next()?;
3420 let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
3421 let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
3422 let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
3423
3424 Some(WorkspacePrimaryCargoTarget {
3425 manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
3426 manifest_absolute,
3427 package_name,
3428 })
3429}
3430
3431fn resolve_release_publish_target_filter(
3432 invocation_dir: &Path,
3433 version_scope: &VersionScope,
3434) -> Result<Option<String>, String> {
3435 let VersionScope::Service {
3436 service_name,
3437 service_root,
3438 version_targets,
3439 ..
3440 } = version_scope
3441 else {
3442 return Ok(None);
3443 };
3444
3445 let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
3446 return Ok(None);
3447 };
3448 let Some(publish) = config.publish else {
3449 return Ok(None);
3450 };
3451
3452 let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
3453 let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
3454 .into_iter()
3455 .filter_map(|(kind, target)| {
3456 let target = target?;
3457 if target.enabled.unwrap_or(true) {
3458 Some((kind, target))
3459 } else {
3460 None
3461 }
3462 })
3463 .collect::<Vec<_>>();
3464
3465 if enabled_targets.is_empty() {
3466 return Ok(None);
3467 }
3468
3469 let matched = enabled_targets
3470 .iter()
3471 .filter_map(|(kind, target)| {
3472 let manifest_path = target.manifest_path.as_ref()?;
3473 let absolute = if Path::new(manifest_path).is_absolute() {
3474 PathBuf::from(manifest_path)
3475 } else {
3476 project_root.join(manifest_path)
3477 };
3478 let relative = absolute
3479 .strip_prefix(&project_root)
3480 .ok()
3481 .map(normalized_relative_path)
3482 .unwrap_or_else(|| normalized_relative_path(&absolute));
3483
3484 if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
3485 {
3486 Some((*kind).to_string())
3487 } else {
3488 None
3489 }
3490 })
3491 .collect::<Vec<_>>();
3492
3493 if matched.is_empty() {
3494 return Err(format!(
3495 "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
3496 service_name
3497 ));
3498 }
3499
3500 if matched.len() == enabled_targets.len() {
3501 if matched.len() == 1 {
3502 return Ok(matched.into_iter().next());
3503 }
3504 return Ok(None);
3505 }
3506
3507 if matched.len() == 1 {
3508 return Ok(matched.into_iter().next());
3509 }
3510
3511 Err(format!(
3512 "Service `{}` matches multiple publish targets while other publish targets are also enabled. Narrow the publish config before using `xbp version release --publish` for this service.",
3513 service_name
3514 ))
3515}
3516
3517async fn resolve_effective_linear_release_config(
3518 project_root: &Path,
3519 invocation_dir: &Path,
3520) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
3521 let global_config = resolve_global_linear_release_config();
3522 let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
3523 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
3524 config.linear.and_then(|linear| linear.release)
3525 } else {
3526 None
3527 };
3528
3529 Ok(resolve_linear_release_config(global_config, project_config)
3530 .map(|config| resolve_linear_release_placeholders(project_root, config)))
3531}
3532
3533fn resolve_linear_release_placeholders(
3534 project_root: &Path,
3535 mut config: ResolvedLinearReleaseConfig,
3536) -> ResolvedLinearReleaseConfig {
3537 let mut env_map = HashMap::new();
3538 for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
3539 env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
3540 }
3541 if let Some(organization_name) = config.organization_name.clone() {
3542 env_map.insert("organization_name".to_string(), organization_name);
3543 }
3544
3545 let resolved = resolve_env_placeholders(project_root, &env_map);
3546 config.initiative_ids = config
3547 .initiative_ids
3548 .iter()
3549 .enumerate()
3550 .map(|(index, initiative_id)| {
3551 resolved
3552 .get(&format!("initiative_id_{}", index))
3553 .cloned()
3554 .unwrap_or_else(|| initiative_id.clone())
3555 })
3556 .map(|value| value.trim().to_string())
3557 .filter(|value| !value.is_empty())
3558 .collect();
3559 config.organization_name = resolved
3560 .get("organization_name")
3561 .cloned()
3562 .or(config.organization_name)
3563 .map(|value| value.trim().to_string())
3564 .filter(|value| !value.is_empty());
3565 config
3566}
3567
3568async fn resolve_project_github_release_branch_config(
3569 _project_root: &Path,
3570 invocation_dir: &Path,
3571) -> Result<Option<GitHubReleaseBranchSettings>, String> {
3572 if let Some(found) = find_xbp_config_upwards(invocation_dir) {
3573 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
3574 Ok(config.github_release_branch_settings())
3575 } else {
3576 Ok(None)
3577 }
3578}
3579
3580fn normalized_relative_path(path: &Path) -> String {
3581 path.to_string_lossy().replace('\\', "/")
3582}
3583
3584fn git_repository_root(dir: &Path) -> Option<PathBuf> {
3585 if !command_exists("git") {
3586 return None;
3587 }
3588
3589 let output: std::process::Output = Command::new("git")
3590 .current_dir(dir)
3591 .args(["rev-parse", "--show-toplevel"])
3592 .output()
3593 .ok()?;
3594
3595 if !output.status.success() {
3596 return None;
3597 }
3598
3599 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
3600 if root.is_empty() {
3601 None
3602 } else {
3603 Some(PathBuf::from(root))
3604 }
3605}
3606
3607fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
3608 let output: std::process::Output = Command::new("git")
3609 .current_dir(project_root)
3610 .args(args)
3611 .output()
3612 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
3613
3614 if !output.status.success() {
3615 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
3616 if stderr.is_empty() {
3617 return Err(format!(
3618 "`git {}` failed with status {}",
3619 args.join(" "),
3620 output.status
3621 ));
3622 }
3623 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
3624 }
3625
3626 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
3627}
3628
3629fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
3630 let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
3631 Ok(output
3632 .lines()
3633 .map(|line| line.trim())
3634 .filter(|line| !line.is_empty())
3635 .map(|line| line.to_string())
3636 .collect())
3637}
3638
3639fn version_change_guard_state_path() -> Result<PathBuf, String> {
3640 let paths = global_xbp_paths()?;
3641 Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
3642}
3643
3644fn version_change_guard_repo_key(project_root: &Path) -> String {
3645 fs::canonicalize(project_root)
3646 .unwrap_or_else(|_| project_root.to_path_buf())
3647 .to_string_lossy()
3648 .replace('\\', "/")
3649}
3650
3651fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
3652 if !path.exists() {
3653 return Ok(VersionChangeGuardRegistry::default());
3654 }
3655
3656 let content = fs::read_to_string(path).map_err(|e| {
3657 format!(
3658 "Failed to read version-change guard state {}: {}",
3659 path.display(),
3660 e
3661 )
3662 })?;
3663
3664 Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
3665}
3666
3667fn save_version_change_guard_registry(
3668 path: &Path,
3669 registry: &VersionChangeGuardRegistry,
3670) -> Result<(), String> {
3671 if let Some(parent) = path.parent() {
3672 fs::create_dir_all(parent).map_err(|e| {
3673 format!(
3674 "Failed to create guard state directory {}: {}",
3675 parent.display(),
3676 e
3677 )
3678 })?;
3679 }
3680
3681 let content = serde_yaml::to_string(registry)
3682 .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
3683 fs::write(path, content).map_err(|e| {
3684 format!(
3685 "Failed to write version-change guard state {}: {}",
3686 path.display(),
3687 e
3688 )
3689 })
3690}
3691
3692fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
3693 if !command_exists("git") {
3694 return Ok(None);
3695 }
3696
3697 let status_output: std::process::Output = Command::new("git")
3698 .current_dir(project_root)
3699 .args(["status", "--porcelain"])
3700 .output()
3701 .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
3702 if !status_output.status.success() {
3703 return Ok(None);
3704 }
3705
3706 let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
3707 .lines()
3708 .any(|line| !line.trim().is_empty());
3709
3710 let head_output: std::process::Output = Command::new("git")
3711 .current_dir(project_root)
3712 .args(["rev-parse", "HEAD"])
3713 .output()
3714 .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
3715 let head_commit: Option<String> = if head_output.status.success() {
3716 let value: String = String::from_utf8_lossy(&head_output.stdout)
3717 .trim()
3718 .to_string();
3719 if value.is_empty() {
3720 None
3721 } else {
3722 Some(value)
3723 }
3724 } else {
3725 None
3726 };
3727
3728 Ok(Some(GitWorktreeState {
3729 is_dirty,
3730 head_commit,
3731 }))
3732}
3733
3734fn should_clear_version_change_guard(
3735 entry: &VersionChangeGuardEntry,
3736 state: &GitWorktreeState,
3737) -> bool {
3738 if entry.pending_version_change_count == 0 {
3739 return true;
3740 }
3741 if !state.is_dirty {
3742 return true;
3743 }
3744
3745 match (&entry.head_commit, &state.head_commit) {
3746 (Some(previous), Some(current)) => previous != current,
3747 (Some(_), None) => true,
3748 _ => false,
3749 }
3750}
3751
3752fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
3753 let Some(state) = git_worktree_state(project_root)? else {
3754 return Ok(());
3755 };
3756
3757 let state_path: PathBuf = version_change_guard_state_path()?;
3758 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3759 let repo_key: String = version_change_guard_repo_key(project_root);
3760 let mut changed = false;
3761
3762 if let Some(entry) = registry.entries.get(&repo_key).cloned() {
3763 if should_clear_version_change_guard(&entry, &state) {
3764 registry.entries.remove(&repo_key);
3765 changed = true;
3766 }
3767 }
3768
3769 if changed {
3770 save_version_change_guard_registry(&state_path, ®istry)?;
3771 }
3772
3773 if state.is_dirty {
3774 if let Some(entry) = registry.entries.get(&repo_key) {
3775 if entry.pending_version_change_count >= 1 {
3776 return Err(format!(
3777 "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
3778 entry.pending_version_change_count,
3779 state_path.display()
3780 ));
3781 }
3782 }
3783 }
3784
3785 Ok(())
3786}
3787
3788fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
3789 let Some(state) = git_worktree_state(project_root)? else {
3790 return Ok(());
3791 };
3792
3793 let state_path: PathBuf = version_change_guard_state_path()?;
3794 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3795 let repo_key: String = version_change_guard_repo_key(project_root);
3796
3797 if state.is_dirty {
3798 registry.entries.insert(
3799 repo_key,
3800 VersionChangeGuardEntry {
3801 pending_version_change_count: 1,
3802 head_commit: state.head_commit,
3803 },
3804 );
3805 } else {
3806 registry.entries.remove(&repo_key);
3807 }
3808
3809 save_version_change_guard_registry(&state_path, ®istry)
3810}
3811
3812fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
3813 let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
3814 Ok(!output.trim().is_empty())
3815}
3816
3817fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
3818 let remotes: String = run_git_command(project_root, &["remote"])?;
3819 let exists: bool = remotes.lines().any(|line| line.trim() == remote);
3820 if exists {
3821 Ok(())
3822 } else {
3823 Err(format!(
3824 "Git remote `{}` is not configured for this repository.",
3825 remote
3826 ))
3827 }
3828}
3829
3830fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
3831 run_git_command(project_root, &["remote", "get-url", remote])
3832}
3833
3834fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
3835 let query: String = format!("refs/tags/{}", tag);
3836 let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
3837 Ok(!output.trim().is_empty())
3838}
3839
3840fn git_head_commitish(project_root: &Path) -> Result<String, String> {
3841 let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
3842 if commitish.is_empty() {
3843 Err("Unable to resolve HEAD commit for release target.".to_string())
3844 } else {
3845 Ok(commitish)
3846 }
3847}
3848
3849fn render_release_branch_name(
3850 naming_template: &str,
3851 release_version: &Version,
3852 tag_name: &str,
3853) -> Result<String, String> {
3854 let branch_name = naming_template
3855 .replace("${GITHUB_VERSION}", &release_version.to_string())
3856 .replace("${GITHUB_TAG}", tag_name);
3857 let branch_name = branch_name.trim();
3858 if branch_name.is_empty() {
3859 return Err(
3860 "GitHub release branch naming template resolved to an empty branch name.".to_string(),
3861 );
3862 }
3863 Ok(branch_name.to_string())
3864}
3865
3866fn git_local_branch_commit(
3867 project_root: &Path,
3868 branch_name: &str,
3869) -> Result<Option<String>, String> {
3870 let output = Command::new("git")
3871 .current_dir(project_root)
3872 .args([
3873 "rev-parse",
3874 "--verify",
3875 &format!("refs/heads/{}", branch_name),
3876 ])
3877 .output()
3878 .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
3879 if !output.status.success() {
3880 return Ok(None);
3881 }
3882 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
3883 if commit.is_empty() {
3884 Ok(None)
3885 } else {
3886 Ok(Some(commit))
3887 }
3888}
3889
3890fn git_remote_branch_commit(
3891 project_root: &Path,
3892 remote: &str,
3893 branch_name: &str,
3894) -> Result<Option<String>, String> {
3895 let output = run_git_command(
3896 project_root,
3897 &[
3898 "ls-remote",
3899 "--heads",
3900 remote,
3901 &format!("refs/heads/{}", branch_name),
3902 ],
3903 )?;
3904 let commit = output
3905 .split_whitespace()
3906 .next()
3907 .map(str::trim)
3908 .filter(|value| !value.is_empty())
3909 .map(str::to_string);
3910 Ok(commit)
3911}
3912
3913fn ensure_release_branch(
3914 project_root: &Path,
3915 branch_config: &GitHubReleaseBranchSettings,
3916 release_version: &Version,
3917 tag_name: &str,
3918 target_commitish: &str,
3919) -> Result<String, String> {
3920 let branch_name =
3921 render_release_branch_name(&branch_config.naming_template, release_version, tag_name)?;
3922
3923 if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
3924 if existing_local_commit != target_commitish {
3925 return Err(format!(
3926 "Configured release branch `{}` already exists locally at {}, expected {}.",
3927 branch_name, existing_local_commit, target_commitish
3928 ));
3929 }
3930 } else {
3931 run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
3932 }
3933
3934 if let Some(existing_remote_commit) =
3935 git_remote_branch_commit(project_root, "origin", &branch_name)?
3936 {
3937 if existing_remote_commit != target_commitish {
3938 return Err(format!(
3939 "Configured release branch `{}` already exists on origin at {}, expected {}.",
3940 branch_name, existing_remote_commit, target_commitish
3941 ));
3942 }
3943 } else {
3944 run_git_command(
3945 project_root,
3946 &[
3947 "push",
3948 "origin",
3949 &format!("{}:refs/heads/{}", target_commitish, branch_name),
3950 ],
3951 )?;
3952 }
3953
3954 Ok(branch_name)
3955}
3956
3957fn release_tag_family(tag_name: &str) -> String {
3958 if tag_name.starts_with('v')
3959 && tag_name
3960 .chars()
3961 .nth(1)
3962 .map(|ch| ch.is_ascii_digit())
3963 .unwrap_or(false)
3964 {
3965 return "v".to_string();
3966 }
3967
3968 let mut family: String = String::new();
3969 for ch in tag_name.chars() {
3970 if ch.is_ascii_digit() {
3971 break;
3972 }
3973 family.push(ch);
3974 }
3975 family
3976}
3977
3978fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
3979 if family == "v" {
3980 return parse_version(tag).ok();
3981 }
3982
3983 tag.strip_prefix(family)
3984 .and_then(|rest| parse_version(rest).ok())
3985}
3986
3987fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
3988 let range: String = format!("{}..HEAD", tag);
3989 run_git_command(project_root, &["rev-list", "--count", &range])
3990 .ok()
3991 .and_then(|raw| raw.trim().parse::<usize>().ok())
3992}
3993
3994fn previous_release_tag(
3995 project_root: &Path,
3996 current_tag_name: &str,
3997) -> Result<Option<String>, String> {
3998 let family: String = release_tag_family(current_tag_name);
3999 let tag_pattern: String = format!("{}*", family);
4000 let merged_tags: String = run_git_command(
4001 project_root,
4002 &["tag", "--merged", "HEAD", "--list", &tag_pattern],
4003 )?;
4004
4005 let mut best: Option<(usize, String)> = None;
4006 for raw in merged_tags.lines() {
4007 let tag = raw.trim();
4008 if tag.is_empty() || tag == current_tag_name {
4009 continue;
4010 }
4011 if parse_release_family_version(tag, &family).is_none() {
4012 continue;
4013 }
4014
4015 let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
4016 continue;
4017 };
4018 if distance == 0 {
4019 continue;
4020 }
4021
4022 match &best {
4023 None => best = Some((distance, tag.to_string())),
4024 Some((best_distance, _)) if distance < *best_distance => {
4025 best = Some((distance, tag.to_string()))
4026 }
4027 _ => {}
4028 }
4029 }
4030
4031 Ok(best.map(|(_, tag)| tag))
4032}
4033
4034fn default_release_title(version: &Version, repo: &str) -> String {
4035 format!("{} - {}", version, repo)
4036}
4037
4038fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
4039 match version_scope {
4040 VersionScope::Repository => repo,
4041 VersionScope::Crate { package_name, .. } => package_name.as_str(),
4042 VersionScope::Service { service_name, .. } => service_name.as_str(),
4043 }
4044}
4045
4046fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
4047 match version_scope {
4048 VersionScope::Repository => "repository",
4049 VersionScope::Crate { .. } => "crate",
4050 VersionScope::Service { .. } => "service",
4051 }
4052}
4053
4054fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
4055 match version_scope {
4056 VersionScope::Repository => repo.to_string(),
4057 VersionScope::Crate { package_name, .. } => package_name.clone(),
4058 VersionScope::Service { service_name, .. } => service_name.clone(),
4059 }
4060}
4061
4062fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
4063 match version_scope {
4064 VersionScope::Repository => "Repository".to_string(),
4065 VersionScope::Crate {
4066 package_name,
4067 crate_relative_root,
4068 ..
4069 } => format!("{} ({})", package_name, crate_relative_root),
4070 VersionScope::Service {
4071 service_name,
4072 service_relative_root,
4073 ..
4074 } => format!("{} ({})", service_name, service_relative_root),
4075 }
4076}
4077
4078fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
4079 match version_scope {
4080 VersionScope::Repository => format!("v{}", version),
4081 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
4082 format!("{}{}", tag_prefix, version)
4083 }
4084 }
4085}
4086
4087fn scoped_release_tag_name(
4088 version_scope: &VersionScope,
4089 version: &Version,
4090 parsed_tag_name: &str,
4091) -> String {
4092 match version_scope {
4093 VersionScope::Repository => parsed_tag_name.to_string(),
4094 VersionScope::Crate { .. } | VersionScope::Service { .. } => {
4095 if release_tag_family(parsed_tag_name) == "v" {
4096 default_release_tag_name(version_scope, version)
4097 } else {
4098 parsed_tag_name.to_string()
4099 }
4100 }
4101 }
4102}
4103
4104fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
4105 match version_scope {
4106 VersionScope::Repository => None,
4107 VersionScope::Crate {
4108 crate_relative_root,
4109 ..
4110 } => Some(crate_relative_root.clone()),
4111 VersionScope::Service {
4112 service_relative_root,
4113 ..
4114 } if service_relative_root == "." => None,
4115 VersionScope::Service {
4116 service_relative_root,
4117 ..
4118 } => Some(service_relative_root.clone()),
4119 }
4120}
4121
4122fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
4123 let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
4124 return (None, None);
4125 };
4126 let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
4127 return (None, None);
4128 };
4129
4130 (Some(owner), Some(repo))
4131}
4132
4133fn published_initiatives_to_activity(
4134 initiatives: &[PublishedLinearInitiative],
4135) -> Vec<VersionActivityLinearInitiative> {
4136 initiatives
4137 .iter()
4138 .map(|initiative| VersionActivityLinearInitiative {
4139 id: initiative.id.clone(),
4140 name: initiative.name.clone(),
4141 url: initiative.url.clone(),
4142 })
4143 .collect()
4144}
4145
4146async fn sync_cli_version_write_activity(
4147 project_root: &Path,
4148 version_scope: &VersionScope,
4149 version: &Version,
4150 message: String,
4151) {
4152 let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
4153 let scope_label = version_scope_label(
4154 version_scope,
4155 repository_name.as_deref().unwrap_or_else(|| {
4156 project_root
4157 .file_name()
4158 .and_then(|value| value.to_str())
4159 .unwrap_or("repository")
4160 }),
4161 );
4162
4163 let payload = CliVersionActivityPayload {
4164 command_kind: "version".to_string(),
4165 repository_owner,
4166 repository_name,
4167 scope_kind: version_scope_kind(version_scope).to_string(),
4168 scope_label,
4169 version: version.to_string(),
4170 tag_name: None,
4171 title: None,
4172 release_url: None,
4173 message_markdown: Some(message),
4174 published_initiatives: Vec::new(),
4175 };
4176
4177 if let Err(error) = post_version_activity(&payload).await {
4178 eprintln!("Warning: {}", error);
4179 }
4180}
4181
4182async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
4183 let payload = CliVersionActivityPayload {
4184 command_kind: "version_release".to_string(),
4185 repository_owner: Some(summary.repository_owner.clone()),
4186 repository_name: Some(summary.repository_name.clone()),
4187 scope_kind: summary.scope_kind.clone(),
4188 scope_label: summary.scope_label.clone(),
4189 version: summary.version.to_string(),
4190 tag_name: Some(summary.tag_name.clone()),
4191 title: Some(summary.release_title.clone()),
4192 release_url: Some(summary.release_url.clone()),
4193 message_markdown: Some(summary.release_notes.clone()),
4194 published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
4195 };
4196
4197 if let Err(error) = post_version_activity(&payload).await {
4198 eprintln!("Warning: {}", error);
4199 }
4200}
4201
4202fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
4203 let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
4204 let mut rendered_notes: String = notes.trim_end().to_string();
4205 if !rendered_notes.is_empty() {
4206 rendered_notes.push('\n');
4207 }
4208 rendered_notes.push_str("Release label: ");
4209 rendered_notes.push_str(release_label);
4210 rendered_notes.push('\n');
4211 rendered_notes.push_str("Generated by XBP ");
4212 rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
4213 rendered_notes
4214}
4215
4216#[cfg(test)]
4217mod tests {
4218 use super::github_release::{
4219 github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
4220 github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
4221 github_release_update_endpoint,
4222 };
4223 use super::release_docs::{
4224 release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
4225 };
4226 use super::release_notes::{
4227 build_fallback_sections, collect_linear_issue_identifiers,
4228 deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
4229 LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
4230 };
4231 use super::{
4232 append_release_label_footer, bump_version, cargo_package_name, default_release_tag_name,
4233 default_release_title, highest_version_observation, parse_github_repo_from_remote_url,
4234 parse_local_git_tag_output, parse_local_git_tag_output_for_scope,
4235 parse_package_version_target, parse_release_version_target, parse_remote_git_tag_output,
4236 parse_version, read_cargo_lock_version, read_cargo_lock_version_for_package,
4237 read_cargo_toml_version, read_json_openapi_version, read_json_root_version,
4238 read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
4239 read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
4240 read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
4241 render_release_branch_name, resolve_configured_version_target_paths,
4242 resolve_linear_release_placeholders, resolve_release_openapi_spec,
4243 resolve_release_publish_target_filter, resolve_version_scope,
4244 rewrite_toml_package_assignment_versions, should_clear_version_change_guard,
4245 stale_version_observations, sync_version_to_configured_files_with_paths,
4246 write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
4247 write_json_openapi_version, write_json_root_version, write_openapi_version,
4248 write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
4249 write_regex_version, write_toml_root_version, write_version_to_configured_files,
4250 write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
4251 VersionObservation, VersionScope,
4252 };
4253
4254 use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
4255 use crate::config::PackageNameLookup;
4256 use semver::Version;
4257 use std::collections::BTreeMap;
4258 use std::fs;
4259 use std::path::{Path, PathBuf};
4260 use std::time::{SystemTime, UNIX_EPOCH};
4261
4262 fn temp_dir(label: &str) -> PathBuf {
4263 let nanos: u128 = SystemTime::now()
4264 .duration_since(UNIX_EPOCH)
4265 .expect("time")
4266 .as_nanos();
4267 let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
4268 fs::create_dir_all(&dir).expect("create temp dir");
4269 dir
4270 }
4271
4272 fn write_multi_service_config(dir: &Path) {
4273 let xbp_dir = dir.join(".xbp");
4274 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
4275 fs::write(
4276 xbp_dir.join("xbp.yaml"),
4277 r#"project_name: xbp
4278version: 10.30.0
4279port: 3000
4280build_dir: ./
4281services:
4282 - name: cli
4283 target: rust
4284 branch: main
4285 port: 8080
4286 root_directory: ./
4287 version_targets:
4288 - crates/cli/Cargo.toml
4289 - name: web
4290 target: nextjs
4291 branch: main
4292 port: 3001
4293 root_directory: apps/web
4294 version_targets:
4295 - apps/web/package.json
4296publish:
4297 npm:
4298 enabled: true
4299 manifest_path: apps/web/package.json
4300 crates:
4301 enabled: true
4302 manifest_path: crates/cli/Cargo.toml
4303version_targets:
4304 - crates/cli/Cargo.toml
4305 - apps/web/package.json
4306"#,
4307 )
4308 .expect("write xbp config");
4309 }
4310
4311 #[test]
4312 fn parses_prefixed_semver() {
4313 assert_eq!(
4314 parse_version("v1.2.3").expect("version"),
4315 Version::new(1, 2, 3)
4316 );
4317 }
4318
4319 #[test]
4320 fn rejects_invalid_semver() {
4321 let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
4322 assert!(error.contains("Invalid semantic version"));
4323 }
4324
4325 #[test]
4326 fn release_target_parser_supports_plain_semver() {
4327 let (version, tag_name) =
4328 parse_release_version_target("1.2.3-alpha.1").expect("release target");
4329 assert_eq!(version.major, 1);
4330 assert_eq!(version.minor, 2);
4331 assert_eq!(version.patch, 3);
4332 assert_eq!(version.pre.as_str(), "alpha.1");
4333 assert_eq!(tag_name, "v1.2.3-alpha.1");
4334 }
4335
4336 #[test]
4337 fn release_target_parser_supports_prefixed_semver() {
4338 let (version, tag_name) =
4339 parse_release_version_target("studio-0.3.2-alpha").expect("release target");
4340 assert_eq!(version.major, 0);
4341 assert_eq!(version.minor, 3);
4342 assert_eq!(version.patch, 2);
4343 assert_eq!(version.pre.as_str(), "alpha");
4344 assert_eq!(tag_name, "studio-0.3.2-alpha");
4345 }
4346
4347 #[test]
4348 fn bumps_versions_correctly() {
4349 let base: Version = Version::new(0, 1, 0);
4350 assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
4351 assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
4352 assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
4353 }
4354
4355 #[test]
4356 fn version_change_guard_clears_when_worktree_is_clean() {
4357 let entry = VersionChangeGuardEntry {
4358 pending_version_change_count: 1,
4359 head_commit: Some("abc123".to_string()),
4360 };
4361 let state = GitWorktreeState {
4362 is_dirty: false,
4363 head_commit: Some("abc123".to_string()),
4364 };
4365 assert!(should_clear_version_change_guard(&entry, &state));
4366 }
4367
4368 #[test]
4369 fn version_change_guard_clears_when_head_changes() {
4370 let entry = VersionChangeGuardEntry {
4371 pending_version_change_count: 1,
4372 head_commit: Some("abc123".to_string()),
4373 };
4374 let state = GitWorktreeState {
4375 is_dirty: true,
4376 head_commit: Some("def456".to_string()),
4377 };
4378 assert!(should_clear_version_change_guard(&entry, &state));
4379 }
4380
4381 #[test]
4382 fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
4383 let entry = VersionChangeGuardEntry {
4384 pending_version_change_count: 1,
4385 head_commit: Some("abc123".to_string()),
4386 };
4387 let state = GitWorktreeState {
4388 is_dirty: true,
4389 head_commit: Some("abc123".to_string()),
4390 };
4391 assert!(!should_clear_version_change_guard(&entry, &state));
4392 }
4393
4394 #[test]
4395 fn render_release_branch_name_replaces_supported_tokens() {
4396 let branch = render_release_branch_name(
4397 "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
4398 &Version::new(10, 27, 0),
4399 "v10.27.0",
4400 )
4401 .expect("branch name");
4402
4403 assert_eq!(branch, "releases/10.27.0/v10.27.0");
4404 }
4405
4406 #[test]
4407 fn resolve_linear_release_placeholders_reads_env_files() {
4408 let temp_dir = std::env::temp_dir().join(format!(
4409 "xbp-linear-release-placeholders-{}",
4410 std::time::SystemTime::now()
4411 .duration_since(std::time::UNIX_EPOCH)
4412 .expect("time")
4413 .as_nanos()
4414 ));
4415 fs::create_dir_all(&temp_dir).expect("temp dir");
4416 fs::write(
4417 temp_dir.join(".env.local"),
4418 "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
4419 )
4420 .expect("env file");
4421
4422 let resolved = resolve_linear_release_placeholders(
4423 &temp_dir,
4424 ResolvedLinearReleaseConfig {
4425 initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
4426 organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
4427 health: "on_track".to_string(),
4428 },
4429 );
4430
4431 assert_eq!(
4432 resolved.initiative_ids,
4433 vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
4434 );
4435 assert_eq!(
4436 resolved.organization_name.as_deref(),
4437 Some("suits-formations")
4438 );
4439
4440 let _ = fs::remove_dir_all(temp_dir);
4441 }
4442
4443 #[test]
4444 fn version_change_guard_clears_when_pending_count_is_zero() {
4445 let entry = VersionChangeGuardEntry {
4446 pending_version_change_count: 0,
4447 head_commit: Some("abc123".to_string()),
4448 };
4449 let state = GitWorktreeState {
4450 is_dirty: true,
4451 head_commit: Some("abc123".to_string()),
4452 };
4453 assert!(should_clear_version_change_guard(&entry, &state));
4454 }
4455
4456 #[test]
4457 fn parse_package_version_target_supports_assignment_syntax() {
4458 let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
4459 .expect("parse")
4460 .expect("target");
4461 assert_eq!(parsed.0, "demo_pkg".to_string());
4462 assert_eq!(parsed.1, Version::new(1, 2, 3));
4463 }
4464
4465 #[test]
4466 fn parse_package_version_target_rejects_invalid_package_names() {
4467 let error: String = parse_package_version_target("bad package=1.2.3")
4468 .expect_err("invalid package target should fail");
4469 assert!(error.contains("Invalid package target"));
4470 }
4471
4472 #[test]
4473 fn parse_package_version_target_returns_none_without_assignment() {
4474 assert!(parse_package_version_target("1.2.3")
4475 .expect("parse")
4476 .is_none());
4477 }
4478
4479 #[test]
4480 fn parse_package_version_target_returns_none_for_empty_package_name() {
4481 assert!(parse_package_version_target(" =1.2.3")
4482 .expect("parse")
4483 .is_none());
4484 }
4485
4486 #[test]
4487 fn bumping_clears_prerelease_and_build_metadata() {
4488 let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
4489 assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
4490 assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
4491 assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
4492 }
4493
4494 #[test]
4495 fn cargo_toml_adapter_reads_and_writes() {
4496 let dir: PathBuf = temp_dir("cargo");
4497 let path: PathBuf = dir.join("Cargo.toml");
4498 fs::write(
4499 &path,
4500 r#"[package]
4501 name = "xbp"
4502 version = "1.0.0"
4503 "#,
4504 )
4505 .expect("write Cargo.toml");
4506
4507 assert_eq!(
4508 read_cargo_toml_version(&path).expect("read"),
4509 Some("1.0.0".to_string())
4510 );
4511
4512 write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
4513 assert_eq!(
4514 read_version_from_path(&path).expect("read"),
4515 Some("1.1.0".to_string())
4516 );
4517
4518 let _ = fs::remove_dir_all(dir);
4519 }
4520
4521 #[test]
4522 fn json_root_adapter_reads_and_writes() {
4523 let dir: PathBuf = temp_dir("json");
4524 let path: PathBuf = dir.join("package.json");
4525 fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
4526
4527 assert_eq!(
4528 read_json_root_version(&path).expect("read"),
4529 Some("1.4.0".to_string())
4530 );
4531
4532 write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
4533 assert_eq!(
4534 read_version_from_path(&path).expect("read"),
4535 Some("1.5.0".to_string())
4536 );
4537
4538 let _ = fs::remove_dir_all(dir);
4539 }
4540
4541 #[test]
4542 fn yaml_root_adapter_reads_and_writes() {
4543 let dir: PathBuf = temp_dir("yaml");
4544 let path: PathBuf = dir.join("xbp.yaml");
4545 fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
4546
4547 assert_eq!(
4548 read_yaml_root_version(&path, "version").expect("read"),
4549 Some("0.2.0".to_string())
4550 );
4551
4552 write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
4553 assert_eq!(
4554 read_version_from_path(&path).expect("read"),
4555 Some("0.3.0".to_string())
4556 );
4557
4558 let _ = fs::remove_dir_all(dir);
4559 }
4560
4561 #[test]
4562 fn toml_root_adapter_reads_and_writes() {
4563 let dir: PathBuf = temp_dir("toml");
4564 let path: PathBuf = dir.join("config.toml");
4565 fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
4566
4567 assert_eq!(
4568 read_toml_root_version(&path).expect("read"),
4569 Some("3.1.4".to_string())
4570 );
4571
4572 write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
4573 assert_eq!(
4574 read_toml_root_version(&path).expect("read"),
4575 Some("3.2.0".to_string())
4576 );
4577
4578 let _ = fs::remove_dir_all(dir);
4579 }
4580
4581 #[test]
4582 fn openapi_adapter_reads_and_writes_nested_version() {
4583 let dir: PathBuf = temp_dir("openapi");
4584 let path: PathBuf = dir.join("openapi.yaml");
4585 fs::write(
4586 &path,
4587 "openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
4588 )
4589 .expect("write openapi");
4590
4591 assert_eq!(
4592 read_openapi_version(&path).expect("read"),
4593 Some("1.2.3".to_string())
4594 );
4595
4596 write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
4597 assert_eq!(
4598 read_openapi_version(&path).expect("read"),
4599 Some("2.0.0".to_string())
4600 );
4601
4602 let _ = fs::remove_dir_all(dir);
4603 }
4604
4605 #[test]
4606 fn openapi_writer_creates_missing_info_mapping() {
4607 let dir: PathBuf = temp_dir("openapi-missing-info");
4608 let path: PathBuf = dir.join("openapi.yaml");
4609 fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
4610
4611 write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
4612 assert_eq!(
4613 read_openapi_version(&path).expect("read"),
4614 Some("4.0.0".to_string())
4615 );
4616
4617 let _ = fs::remove_dir_all(dir);
4618 }
4619
4620 #[test]
4621 fn json_openapi_adapter_reads_and_writes_nested_version() {
4622 let dir: PathBuf = temp_dir("openapi-json");
4623 let path: PathBuf = dir.join("openapi.json");
4624 fs::write(
4625 &path,
4626 r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
4627 )
4628 .expect("write openapi json");
4629
4630 assert_eq!(
4631 read_json_openapi_version(&path).expect("read"),
4632 Some("1.2.3".to_string())
4633 );
4634
4635 write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
4636 assert_eq!(
4637 read_json_openapi_version(&path).expect("read"),
4638 Some("2.1.0".to_string())
4639 );
4640
4641 let _ = fs::remove_dir_all(dir);
4642 }
4643
4644 #[test]
4645 fn json_openapi_writer_creates_missing_info_object() {
4646 let dir: PathBuf = temp_dir("openapi-json-missing-info");
4647 let path: PathBuf = dir.join("openapi.json");
4648 fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
4649
4650 write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
4651 assert_eq!(
4652 read_json_openapi_version(&path).expect("read"),
4653 Some("4.0.0".to_string())
4654 );
4655
4656 let _ = fs::remove_dir_all(dir);
4657 }
4658
4659 #[test]
4660 fn pyproject_reader_prefers_project_version() {
4661 let dir: PathBuf = temp_dir("pyproject-project");
4662 let path: PathBuf = dir.join("pyproject.toml");
4663 fs::write(
4664 &path,
4665 "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
4666 )
4667 .expect("write pyproject");
4668
4669 assert_eq!(
4670 read_pyproject_version(&path).expect("read"),
4671 Some("0.8.0".to_string())
4672 );
4673
4674 let _ = fs::remove_dir_all(dir);
4675 }
4676
4677 #[test]
4678 fn pyproject_reader_falls_back_to_poetry_version() {
4679 let dir: PathBuf = temp_dir("pyproject-poetry");
4680 let path: PathBuf = dir.join("pyproject.toml");
4681 fs::write(
4682 &path,
4683 "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
4684 )
4685 .expect("write pyproject");
4686
4687 assert_eq!(
4688 read_pyproject_version(&path).expect("read"),
4689 Some("1.9.0".to_string())
4690 );
4691
4692 let _ = fs::remove_dir_all(dir);
4693 }
4694
4695 #[test]
4696 fn pyproject_writer_updates_project_table() {
4697 let dir: PathBuf = temp_dir("pyproject-write-project");
4698 let path: PathBuf = dir.join("pyproject.toml");
4699 fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
4700 .expect("write pyproject");
4701
4702 write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
4703 assert_eq!(
4704 read_pyproject_version(&path).expect("read"),
4705 Some("1.1.0".to_string())
4706 );
4707
4708 let _ = fs::remove_dir_all(dir);
4709 }
4710
4711 #[test]
4712 fn pyproject_writer_updates_poetry_table() {
4713 let dir: PathBuf = temp_dir("pyproject-write-poetry");
4714 let path: PathBuf = dir.join("pyproject.toml");
4715 fs::write(
4716 &path,
4717 "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
4718 )
4719 .expect("write pyproject");
4720
4721 write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
4722 assert_eq!(
4723 read_pyproject_version(&path).expect("read"),
4724 Some("2.1.0".to_string())
4725 );
4726
4727 let _ = fs::remove_dir_all(dir);
4728 }
4729
4730 #[test]
4731 fn cargo_lock_reader_and_writer_follow_package_name() {
4732 let dir: PathBuf = temp_dir("cargo-lock");
4733 let cargo_toml: PathBuf = dir.join("Cargo.toml");
4734 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4735 fs::write(
4736 &cargo_toml,
4737 r#"[package]
4738 name = "xbp"
4739 version = "1.0.0"
4740 "#,
4741 )
4742 .expect("write Cargo.toml");
4743 fs::write(
4744 &cargo_lock,
4745 r#"version = 4
4746
4747 [[package]]
4748 name = "xbp"
4749 version = "1.0.0"
4750
4751 [[package]]
4752 name = "other"
4753 version = "9.9.9"
4754 "#,
4755 )
4756 .expect("write Cargo.lock");
4757
4758 assert_eq!(
4759 read_cargo_lock_version(&cargo_lock).expect("read"),
4760 Some("1.0.0".to_string())
4761 );
4762
4763 write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
4764 assert_eq!(
4765 read_cargo_lock_version(&cargo_lock).expect("read"),
4766 Some("1.0.1".to_string())
4767 );
4768
4769 let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
4770 assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
4771
4772 let _ = fs::remove_dir_all(dir);
4773 }
4774
4775 #[test]
4776 fn cargo_lock_writer_errors_when_package_missing() {
4777 let dir: PathBuf = temp_dir("cargo-lock-missing");
4778 fs::write(
4779 dir.join("Cargo.toml"),
4780 "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
4781 )
4782 .expect("write Cargo.toml");
4783 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4784 fs::write(
4785 &cargo_lock,
4786 "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
4787 )
4788 .expect("write Cargo.lock");
4789
4790 let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
4791 .expect_err("missing package should fail");
4792 assert!(error.contains("Could not find package `xbp`"));
4793
4794 let _ = fs::remove_dir_all(dir);
4795 }
4796
4797 #[test]
4798 fn cargo_package_name_reads_package_section() {
4799 let dir: PathBuf = temp_dir("cargo-package-name");
4800 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4801 fs::write(
4802 dir.join("Cargo.toml"),
4803 "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
4804 )
4805 .expect("write Cargo.toml");
4806 fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
4807
4808 assert_eq!(
4809 cargo_package_name(&cargo_lock).expect("name"),
4810 Some("xbp-cli".to_string())
4811 );
4812
4813 let _ = fs::remove_dir_all(dir);
4814 }
4815
4816 #[test]
4817 fn cargo_toml_writer_skips_workspace_manifest_without_package() {
4818 let dir: PathBuf = temp_dir("cargo-workspace-manifest");
4819 let path: PathBuf = dir.join("Cargo.toml");
4820 fs::write(
4821 &path,
4822 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
4823 )
4824 .expect("write Cargo.toml");
4825
4826 let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
4827 assert!(!changed);
4828 assert_eq!(
4829 fs::read_to_string(&path).expect("read Cargo.toml"),
4830 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n"
4831 );
4832
4833 let _ = fs::remove_dir_all(dir);
4834 }
4835
4836 #[test]
4837 fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
4838 let dir: PathBuf = temp_dir("workspace-cargo-skip");
4839 fs::write(
4840 dir.join("Cargo.toml"),
4841 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
4842 )
4843 .expect("write Cargo.toml");
4844 fs::write(
4845 dir.join("Cargo.lock"),
4846 "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
4847 )
4848 .expect("write Cargo.lock");
4849 fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
4850 .expect("write README");
4851
4852 let updated = write_version_to_configured_files(
4853 &dir,
4854 &dir,
4855 &[
4856 "Cargo.toml".to_string(),
4857 "Cargo.lock".to_string(),
4858 "README.md".to_string(),
4859 ],
4860 &VersionScope::Repository,
4861 &Version::new(1, 1, 0),
4862 )
4863 .expect("write versions");
4864
4865 assert_eq!(updated, 1);
4866 assert_eq!(
4867 read_readme_version(&dir.join("README.md")).expect("read"),
4868 Some("1.1.0".to_string())
4869 );
4870
4871 let _ = fs::remove_dir_all(dir);
4872 }
4873
4874 #[test]
4875 fn repository_scope_prefers_workspace_default_member_manifest() {
4876 let dir: PathBuf = temp_dir("workspace-default-member-path");
4877 let crate_dir: PathBuf = dir.join("crates").join("cli");
4878 fs::create_dir_all(&crate_dir).expect("create crate dir");
4879 fs::write(
4880 dir.join("Cargo.toml"),
4881 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4882 )
4883 .expect("write workspace cargo");
4884 fs::write(
4885 crate_dir.join("Cargo.toml"),
4886 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4887 )
4888 .expect("write crate cargo");
4889
4890 let resolved = super::resolve_registry_relative_path(
4891 &dir,
4892 &dir,
4893 &VersionScope::Repository,
4894 "Cargo.toml",
4895 );
4896
4897 assert_eq!(resolved, "crates/cli/Cargo.toml");
4898
4899 let _ = fs::remove_dir_all(dir);
4900 }
4901
4902 #[test]
4903 fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
4904 let dir: PathBuf = temp_dir("workspace-default-member-writer");
4905 let crate_dir: PathBuf = dir.join("crates").join("cli");
4906 fs::create_dir_all(&crate_dir).expect("create crate dir");
4907 fs::write(
4908 dir.join("Cargo.toml"),
4909 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4910 )
4911 .expect("write workspace cargo");
4912 fs::write(
4913 crate_dir.join("Cargo.toml"),
4914 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4915 )
4916 .expect("write crate cargo");
4917 fs::write(
4918 dir.join("Cargo.lock"),
4919 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
4920 )
4921 .expect("write cargo lock");
4922 fs::write(
4923 dir.join("README.md"),
4924 "# XBP\n\ncurrent version: `10.21.0`\n",
4925 )
4926 .expect("write readme");
4927
4928 let updated = write_version_to_configured_files(
4929 &dir,
4930 &dir,
4931 &[
4932 "Cargo.toml".to_string(),
4933 "Cargo.lock".to_string(),
4934 "README.md".to_string(),
4935 ],
4936 &VersionScope::Repository,
4937 &Version::new(10, 22, 0),
4938 )
4939 .expect("write versions");
4940
4941 assert_eq!(updated, 3);
4942 assert_eq!(
4943 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
4944 Some("10.22.0".to_string())
4945 );
4946 assert_eq!(
4947 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
4948 Some("10.22.0".to_string())
4949 );
4950 assert_eq!(
4951 read_readme_version(&dir.join("README.md")).expect("read readme"),
4952 Some("10.22.0".to_string())
4953 );
4954
4955 let _ = fs::remove_dir_all(dir);
4956 }
4957
4958 #[test]
4959 fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
4960 let dir: PathBuf = temp_dir("publish-manifest-version-target");
4961 let package_dir = dir.join("packages").join("heroui");
4962 let xbp_dir = dir.join(".xbp");
4963 fs::create_dir_all(&package_dir).expect("create package dir");
4964 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
4965
4966 fs::write(
4967 xbp_dir.join("xbp.yaml"),
4968 r#"project_name: athena-auth-ui
4969version: 0.3.1
4970port: 4000
4971build_dir: ./
4972publish:
4973 npm:
4974 enabled: true
4975 working_directory: packages/heroui
4976 manifest_path: packages/heroui/package.json
4977"#,
4978 )
4979 .expect("write xbp config");
4980 fs::write(
4981 dir.join("package.json"),
4982 r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
4983 )
4984 .expect("write root package");
4985 fs::write(
4986 package_dir.join("package.json"),
4987 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
4988 )
4989 .expect("write package manifest");
4990
4991 let updated = write_version_to_configured_files(
4992 &dir,
4993 &dir,
4994 &["package.json".to_string()],
4995 &VersionScope::Repository,
4996 &Version::new(0, 3, 1),
4997 )
4998 .expect("write versions");
4999
5000 assert_eq!(updated, 2);
5001 assert_eq!(
5002 read_json_root_version(&dir.join("package.json")).expect("read root package"),
5003 Some("0.3.1".to_string())
5004 );
5005 assert_eq!(
5006 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
5007 Some("0.3.1".to_string())
5008 );
5009
5010 let _ = fs::remove_dir_all(dir);
5011 }
5012
5013 #[test]
5014 fn configured_writer_updates_version_targets_from_xbp_config() {
5015 let dir: PathBuf = temp_dir("explicit-version-targets");
5016 let app_dir = dir.join("apps").join("web");
5017 let cli_dir = dir.join("crates").join("cli");
5018 let xbp_dir = dir.join(".xbp");
5019 fs::create_dir_all(&app_dir).expect("create app dir");
5020 fs::create_dir_all(&cli_dir).expect("create cli dir");
5021 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
5022
5023 fs::write(
5024 xbp_dir.join("xbp.yaml"),
5025 r#"project_name: xbp
5026version: 10.30.0
5027port: 3000
5028build_dir: ./
5029version_targets:
5030 - crates/cli/Cargo.toml
5031 - apps/web/package.json
5032"#,
5033 )
5034 .expect("write xbp config");
5035 fs::write(
5036 cli_dir.join("Cargo.toml"),
5037 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
5038 )
5039 .expect("write cli Cargo.toml");
5040 fs::write(
5041 app_dir.join("package.json"),
5042 r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
5043 )
5044 .expect("write app package");
5045
5046 let updated = write_version_to_configured_files(
5047 &dir,
5048 &dir,
5049 &[".xbp/xbp.yaml".to_string()],
5050 &VersionScope::Repository,
5051 &Version::new(10, 30, 0),
5052 )
5053 .expect("write versions");
5054
5055 assert_eq!(updated, 3);
5056 assert_eq!(
5057 read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
5058 Some("10.30.0".to_string())
5059 );
5060 assert_eq!(
5061 read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
5062 Some("10.30.0".to_string())
5063 );
5064 assert_eq!(
5065 read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
5066 Some("10.30.0".to_string())
5067 );
5068
5069 let _ = fs::remove_dir_all(dir);
5070 }
5071
5072 #[test]
5073 fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
5074 let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
5075 let package_dir = dir.join("packages").join("heroui");
5076 let xbp_dir = dir.join(".xbp");
5077 fs::create_dir_all(&package_dir).expect("create package dir");
5078 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
5079
5080 fs::write(
5081 xbp_dir.join("xbp.yaml"),
5082 r#"project_name: athena-auth-ui
5083version: 0.3.0
5084port: 4000
5085build_dir: ./
5086publish:
5087 npm:
5088 enabled: true
5089 working_directory: packages/heroui
5090 manifest_path: packages/heroui/package.json
5091"#,
5092 )
5093 .expect("write xbp config");
5094 fs::write(
5095 dir.join("package.json"),
5096 r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
5097 )
5098 .expect("write root package");
5099 fs::write(
5100 package_dir.join("package.json"),
5101 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
5102 )
5103 .expect("write package manifest");
5104
5105 let _updated_paths = sync_version_to_configured_files_with_paths(
5106 &dir,
5107 &dir,
5108 &["package.json".to_string()],
5109 &VersionScope::Repository,
5110 &Version::new(0, 3, 0),
5111 )
5112 .expect("sync versions");
5113
5114 assert_eq!(
5115 read_json_root_version(&dir.join("package.json")).expect("read root package"),
5116 Some("0.3.0".to_string())
5117 );
5118 assert_eq!(
5119 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
5120 Some("0.3.0".to_string())
5121 );
5122
5123 let _ = fs::remove_dir_all(dir);
5124 }
5125
5126 #[test]
5127 fn readme_adapter_updates_current_version_marker() {
5128 let dir: PathBuf = temp_dir("readme");
5129 let path: PathBuf = dir.join("README.md");
5130 fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
5131
5132 write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
5133 assert_eq!(
5134 read_readme_version(&path).expect("read"),
5135 Some("1.2.0".to_string())
5136 );
5137
5138 let _ = fs::remove_dir_all(dir);
5139 }
5140
5141 #[test]
5142 fn readme_writer_inserts_marker_when_missing() {
5143 let dir: PathBuf = temp_dir("readme-insert");
5144 let path: PathBuf = dir.join("README.md");
5145 fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
5146
5147 write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
5148 let content: String = fs::read_to_string(&path).expect("read readme");
5149 assert!(content.contains("current version: `3.0.0`"));
5150
5151 let _ = fs::remove_dir_all(dir);
5152 }
5153
5154 #[test]
5155 fn regex_adapter_reads_and_writes_versions() {
5156 let dir: PathBuf = temp_dir("regex");
5157 let path: PathBuf = dir.join("build.gradle");
5158 fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
5159
5160 assert_eq!(
5161 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
5162 Some("5.4.3".to_string())
5163 );
5164
5165 write_regex_version(
5166 &path,
5167 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
5168 &Version::new(5, 5, 0),
5169 )
5170 .expect("write");
5171
5172 assert_eq!(
5173 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
5174 Some("5.5.0".to_string())
5175 );
5176
5177 let _ = fs::remove_dir_all(dir);
5178 }
5179
5180 #[test]
5181 fn regex_writer_errors_without_matching_pattern() {
5182 let dir: PathBuf = temp_dir("regex-miss");
5183 let path: PathBuf = dir.join("build.gradle");
5184 fs::write(&path, "group = 'demo'\n").expect("write gradle");
5185
5186 let error: String = write_regex_version(
5187 &path,
5188 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
5189 &Version::new(1, 0, 0),
5190 )
5191 .expect_err("missing version should fail");
5192 assert!(error.contains("No version pattern found"));
5193
5194 let _ = fs::remove_dir_all(dir);
5195 }
5196
5197 #[test]
5198 fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
5199 let original: &str = r#"[dependencies]
5200 serde = "1.0.219"
5201 tokio = { version = "1.44.1", features = ["full"] }
5202 "#;
5203
5204 let (updated, changed) =
5205 rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
5206 .expect("rewrite");
5207 assert!(changed);
5208 assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
5209
5210 let (updated, changed) =
5211 rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
5212 .expect("rewrite");
5213 assert!(changed);
5214 assert!(updated.contains(r#"serde = "1.1.0""#));
5215 }
5216
5217 #[test]
5218 fn package_version_writer_updates_registry_toml_targets() {
5219 let dir: PathBuf = temp_dir("package-version-registry");
5220 let cargo_toml: PathBuf = dir.join("Cargo.toml");
5221 fs::write(
5222 &cargo_toml,
5223 r#"[package]
5224 name = "demo"
5225 version = "0.1.0"
5226
5227 [dependencies]
5228 serde = "1.0.219"
5229 tokio = { version = "1.44.1", features = ["full"] }
5230 "#,
5231 )
5232 .expect("write Cargo.toml");
5233
5234 let updated: usize = write_package_version_to_configured_files(
5235 &dir,
5236 &dir,
5237 &["Cargo.toml".to_string()],
5238 &VersionScope::Repository,
5239 "tokio",
5240 &Version::new(1, 45, 1),
5241 )
5242 .expect("update package assignment");
5243 assert_eq!(updated, 1);
5244
5245 let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
5246 assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
5247
5248 let _ = fs::remove_dir_all(dir);
5249 }
5250
5251 #[test]
5252 fn package_version_writer_errors_when_package_assignment_not_found() {
5253 let dir: PathBuf = temp_dir("package-version-missing");
5254 let cargo_toml: PathBuf = dir.join("Cargo.toml");
5255 fs::write(
5256 &cargo_toml,
5257 r#"[package]
5258 name = "demo"
5259 version = "0.1.0"
5260
5261 [dependencies]
5262 serde = "1.0.219"
5263 "#,
5264 )
5265 .expect("write Cargo.toml");
5266
5267 let error: String = write_package_version_to_configured_files(
5268 &dir,
5269 &dir,
5270 &["Cargo.toml".to_string()],
5271 &VersionScope::Repository,
5272 "tokio",
5273 &Version::new(1, 45, 1),
5274 )
5275 .expect_err("missing package assignment should fail");
5276 assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
5277
5278 let _ = fs::remove_dir_all(dir);
5279 }
5280
5281 #[test]
5282 fn chart_writer_updates_app_version_when_present() {
5283 let dir: PathBuf = temp_dir("chart");
5284 let path: PathBuf = dir.join("Chart.yaml");
5285 fs::write(
5286 &path,
5287 "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
5288 )
5289 .expect("write chart");
5290
5291 write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
5292 let content: String = fs::read_to_string(&path).expect("read chart");
5293 assert!(content.contains("version: 0.2.0"));
5294 assert!(content.contains("appVersion: 0.2.0"));
5295
5296 let _ = fs::remove_dir_all(dir);
5297 }
5298
5299 #[test]
5300 fn configured_file_writer_deduplicates_registry_entries() {
5301 let dir: PathBuf = temp_dir("dedupe");
5302 let readme: PathBuf = dir.join("README.md");
5303 fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
5304
5305 let updated: usize = write_version_to_configured_files(
5306 &dir,
5307 &dir,
5308 &[
5309 "README.md".to_string(),
5310 "README.md".to_string(),
5311 "missing.md".to_string(),
5312 ],
5313 &VersionScope::Repository,
5314 &Version::new(1, 1, 0),
5315 )
5316 .expect("write versions");
5317
5318 assert_eq!(updated, 1);
5319 assert_eq!(
5320 read_readme_version(&readme).expect("read"),
5321 Some("1.1.0".to_string())
5322 );
5323
5324 let _ = fs::remove_dir_all(dir);
5325 }
5326
5327 #[test]
5328 fn configured_file_writer_prefers_invocation_directory_targets() {
5329 let dir: PathBuf = temp_dir("invocation-precedence");
5330 let app_dir: PathBuf = dir.join("apps").join("web");
5331 fs::create_dir_all(&app_dir).expect("create app dir");
5332
5333 let root_package: PathBuf = dir.join("package.json");
5334 let app_package: PathBuf = app_dir.join("package.json");
5335 fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
5336 .expect("write root package");
5337 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
5338 .expect("write app package");
5339
5340 let updated: usize = write_version_to_configured_files(
5341 &dir,
5342 &app_dir,
5343 &["package.json".to_string()],
5344 &VersionScope::Repository,
5345 &Version::new(2, 14, 0),
5346 )
5347 .expect("write versions");
5348 assert_eq!(updated, 1);
5349
5350 assert_eq!(
5351 read_json_root_version(&root_package).expect("read root"),
5352 Some("9.9.9".to_string())
5353 );
5354 assert_eq!(
5355 read_json_root_version(&app_package).expect("read app"),
5356 Some("2.14.0".to_string())
5357 );
5358
5359 let _ = fs::remove_dir_all(dir);
5360 }
5361
5362 #[test]
5363 fn resolve_version_scope_detects_crate_scoped_invocation() {
5364 let dir: PathBuf = temp_dir("crate-scope");
5365 let crate_dir: PathBuf = dir.join("crates").join("alpha");
5366 let nested_dir: PathBuf = crate_dir.join("src");
5367 fs::create_dir_all(&nested_dir).expect("create nested dir");
5368 fs::write(
5369 crate_dir.join("Cargo.toml"),
5370 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
5371 )
5372 .expect("write Cargo.toml");
5373
5374 let scope = resolve_version_scope(&dir, &nested_dir);
5375 match scope {
5376 VersionScope::Crate {
5377 package_name,
5378 crate_relative_root,
5379 tag_prefix,
5380 ..
5381 } => {
5382 assert_eq!(package_name, "alpha-crate");
5383 assert_eq!(crate_relative_root, "crates/alpha");
5384 assert_eq!(tag_prefix, "alpha-crate-");
5385 }
5386 _ => panic!("expected crate scope"),
5387 }
5388
5389 let _ = fs::remove_dir_all(dir);
5390 }
5391
5392 #[test]
5393 fn resolve_version_scope_detects_service_scoped_invocation() {
5394 let dir: PathBuf = temp_dir("service-scope");
5395 let service_dir: PathBuf = dir.join("apps").join("web");
5396 let nested_dir: PathBuf = service_dir.join("src");
5397 let cli_dir: PathBuf = dir.join("crates").join("cli");
5398 fs::create_dir_all(&nested_dir).expect("create nested dir");
5399 fs::create_dir_all(&cli_dir).expect("create cli dir");
5400 write_multi_service_config(&dir);
5401 fs::write(
5402 service_dir.join("package.json"),
5403 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
5404 )
5405 .expect("write package");
5406 fs::write(
5407 cli_dir.join("Cargo.toml"),
5408 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
5409 )
5410 .expect("write cargo");
5411
5412 let scope = resolve_version_scope(&dir, &nested_dir);
5413 match scope {
5414 VersionScope::Service {
5415 service_name,
5416 service_relative_root,
5417 tag_prefix,
5418 ..
5419 } => {
5420 assert_eq!(service_name, "web");
5421 assert_eq!(service_relative_root, "apps/web");
5422 assert_eq!(tag_prefix, "web-");
5423 }
5424 _ => panic!("expected service scope"),
5425 }
5426
5427 let _ = fs::remove_dir_all(dir);
5428 }
5429
5430 #[test]
5431 fn service_scoped_configured_targets_only_include_selected_service() {
5432 let dir: PathBuf = temp_dir("service-targets");
5433 let service_dir: PathBuf = dir.join("apps").join("web");
5434 let nested_dir: PathBuf = service_dir.join("src");
5435 let cli_dir: PathBuf = dir.join("crates").join("cli");
5436 fs::create_dir_all(&nested_dir).expect("create nested dir");
5437 fs::create_dir_all(&cli_dir).expect("create cli dir");
5438 write_multi_service_config(&dir);
5439 fs::write(
5440 service_dir.join("package.json"),
5441 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
5442 )
5443 .expect("write package");
5444 fs::write(
5445 cli_dir.join("Cargo.toml"),
5446 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
5447 )
5448 .expect("write cargo");
5449
5450 let scope = resolve_version_scope(&dir, &nested_dir);
5451 let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
5452 assert_eq!(targets.len(), 1);
5453 assert_eq!(targets[0].relative, "apps/web/package.json");
5454
5455 let _ = fs::remove_dir_all(dir);
5456 }
5457
5458 #[test]
5459 fn service_scoped_release_publish_filter_selects_matching_target() {
5460 let dir: PathBuf = temp_dir("service-publish-filter");
5461 let service_dir: PathBuf = dir.join("apps").join("web");
5462 let nested_dir: PathBuf = service_dir.join("src");
5463 let cli_dir: PathBuf = dir.join("crates").join("cli");
5464 fs::create_dir_all(&nested_dir).expect("create nested dir");
5465 fs::create_dir_all(&cli_dir).expect("create cli dir");
5466 write_multi_service_config(&dir);
5467 fs::write(
5468 service_dir.join("package.json"),
5469 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
5470 )
5471 .expect("write package");
5472 fs::write(
5473 cli_dir.join("Cargo.toml"),
5474 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
5475 )
5476 .expect("write cargo");
5477
5478 let scope = resolve_version_scope(&dir, &nested_dir);
5479 let publish_target =
5480 resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
5481 assert_eq!(publish_target.as_deref(), Some("npm"));
5482
5483 let _ = fs::remove_dir_all(dir);
5484 }
5485
5486 #[test]
5487 fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
5488 let dir: PathBuf = temp_dir("crate-writer");
5489 let crate_dir: PathBuf = dir.join("crates").join("alpha");
5490 fs::create_dir_all(&crate_dir).expect("create crate dir");
5491 fs::write(
5492 crate_dir.join("Cargo.toml"),
5493 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
5494 )
5495 .expect("write crate Cargo.toml");
5496 fs::write(
5497 dir.join("Cargo.lock"),
5498 "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
5499 )
5500 .expect("write Cargo.lock");
5501 fs::write(
5502 dir.join("README.md"),
5503 "# root\n\ncurrent version: `9.9.9`\n",
5504 )
5505 .expect("write root readme");
5506
5507 let scope = resolve_version_scope(&dir, &crate_dir);
5508 let updated = write_version_to_configured_files(
5509 &dir,
5510 &crate_dir,
5511 &[
5512 "Cargo.toml".to_string(),
5513 "Cargo.lock".to_string(),
5514 "README.md".to_string(),
5515 ],
5516 &scope,
5517 &Version::new(1, 3, 0),
5518 )
5519 .expect("write versions");
5520
5521 assert_eq!(updated, 2);
5522 assert_eq!(
5523 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
5524 Some("1.3.0".to_string())
5525 );
5526 assert_eq!(
5527 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
5528 .expect("read cargo lock"),
5529 Some("1.3.0".to_string())
5530 );
5531 assert_eq!(
5532 read_readme_version(&dir.join("README.md")).expect("read readme"),
5533 Some("9.9.9".to_string())
5534 );
5535
5536 let _ = fs::remove_dir_all(dir);
5537 }
5538
5539 #[test]
5540 fn release_openapi_resolution_prefers_crate_scope() {
5541 let dir: PathBuf = temp_dir("release-openapi-crate");
5542 let crate_dir: PathBuf = dir.join("crates").join("monitor");
5543 let nested_dir: PathBuf = crate_dir.join("src");
5544 fs::create_dir_all(&nested_dir).expect("create nested dir");
5545 fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
5546 let crate_openapi: PathBuf = crate_dir.join("openapi.json");
5547 fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
5548
5549 let scope = resolve_version_scope(&dir, &nested_dir);
5550 let resolved =
5551 resolve_release_openapi_spec(&dir, &nested_dir, &scope).expect("crate-scoped openapi");
5552 assert_eq!(resolved, crate_openapi);
5553
5554 let _ = fs::remove_dir_all(dir);
5555 }
5556
5557 #[test]
5558 fn release_openapi_resolution_falls_back_to_repo_root() {
5559 let dir: PathBuf = temp_dir("release-openapi-root");
5560 let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
5561 fs::create_dir_all(&crate_dir).expect("create crate dir");
5562 let root_openapi: PathBuf = dir.join("openapi.json");
5563 fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
5564
5565 let scope = resolve_version_scope(&dir, &dir);
5566 let resolved =
5567 resolve_release_openapi_spec(&dir, &crate_dir, &scope).expect("repo root openapi");
5568 assert_eq!(resolved, root_openapi);
5569
5570 let _ = fs::remove_dir_all(dir);
5571 }
5572
5573 #[test]
5574 fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
5575 let dir: PathBuf = temp_dir("invocation-dedupe");
5576 let app_dir: PathBuf = dir.join("apps").join("web");
5577 fs::create_dir_all(&app_dir).expect("create app dir");
5578
5579 let app_package: PathBuf = app_dir.join("package.json");
5580 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
5581 .expect("write app package");
5582
5583 let updated: usize = write_version_to_configured_files(
5584 &dir,
5585 &app_dir,
5586 &[
5587 "package.json".to_string(),
5588 "apps/web/package.json".to_string(),
5589 ],
5590 &VersionScope::Repository,
5591 &Version::new(2, 14, 0),
5592 )
5593 .expect("write versions");
5594 assert_eq!(updated, 1);
5595
5596 assert_eq!(
5597 read_json_root_version(&app_package).expect("read app"),
5598 Some("2.14.0".to_string())
5599 );
5600
5601 let _ = fs::remove_dir_all(dir);
5602 }
5603
5604 #[test]
5605 fn configured_file_writer_errors_when_no_targets_exist() {
5606 let dir: PathBuf = temp_dir("no-targets");
5607 let error: String = write_version_to_configured_files(
5608 &dir,
5609 &dir,
5610 &["missing.toml".to_string()],
5611 &VersionScope::Repository,
5612 &Version::new(1, 0, 0),
5613 )
5614 .expect_err("missing targets should fail");
5615
5616 assert!(error.contains("No configured version files were found"));
5617
5618 let _ = fs::remove_dir_all(dir);
5619 }
5620
5621 #[test]
5622 fn remote_git_tag_parser_deduplicates_peeled_refs() {
5623 let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
5624 "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
5625 );
5626
5627 assert_eq!(parsed.len(), 2);
5628 assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
5629 assert_eq!(
5630 parsed[1].version,
5631 Version::parse("0.1.7-exp").expect("version")
5632 );
5633 assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
5634 }
5635
5636 #[test]
5637 fn local_git_tag_parser_normalizes_prefixed_versions() {
5638 let parsed: Vec<crate::commands::version::GitTagObservation> =
5639 parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
5640
5641 assert_eq!(parsed.len(), 2);
5642 assert_eq!(parsed[0].version, Version::new(1, 0, 0));
5643 assert_eq!(
5644 parsed[0].raw_tags,
5645 vec!["1.0.0".to_string(), "v1.0.0".to_string()]
5646 );
5647 }
5648
5649 #[test]
5650 fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
5651 let scope = VersionScope::Crate {
5652 crate_root: PathBuf::from("/tmp/crates/alpha"),
5653 crate_relative_root: "crates/alpha".to_string(),
5654 package_name: "alpha-crate".to_string(),
5655 tag_prefix: "alpha-crate-".to_string(),
5656 };
5657
5658 let parsed = parse_local_git_tag_output_for_scope(
5659 "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
5660 &scope,
5661 );
5662
5663 assert_eq!(parsed.len(), 2);
5664 assert_eq!(parsed[0].version, Version::new(1, 2, 0));
5665 assert_eq!(parsed[1].version, Version::new(1, 0, 0));
5666 }
5667
5668 #[test]
5669 fn crate_scoped_release_tags_default_to_package_prefix() {
5670 let scope = VersionScope::Crate {
5671 crate_root: PathBuf::from("/tmp/crates/alpha"),
5672 crate_relative_root: "crates/alpha".to_string(),
5673 package_name: "alpha-crate".to_string(),
5674 tag_prefix: "alpha-crate-".to_string(),
5675 };
5676
5677 assert_eq!(
5678 default_release_tag_name(&scope, &Version::new(1, 2, 3)),
5679 "alpha-crate-1.2.3"
5680 );
5681 }
5682
5683 #[test]
5684 fn blob_reader_handles_head_readme_versions() {
5685 assert_eq!(
5686 read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
5687 .expect("read"),
5688 Some("0.4.0".to_string())
5689 );
5690 }
5691
5692 #[test]
5693 fn blob_reader_handles_head_cargo_lock_versions() {
5694 let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
5695 let cargo_lock: &str =
5696 "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
5697
5698 assert_eq!(
5699 read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
5700 Some("0.2.0".to_string())
5701 );
5702 }
5703
5704 #[test]
5705 fn package_name_lookup_reads_json_name_for_npm() {
5706 let lookup: PackageNameLookup = PackageNameLookup {
5707 file: "package.json".to_string(),
5708 format: "json".to_string(),
5709 key: "name".to_string(),
5710 registry: "npm".to_string(),
5711 };
5712
5713 assert_eq!(
5714 read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
5715 .expect("read"),
5716 Some("@xylex/athena-mcp".to_string())
5717 );
5718 }
5719
5720 #[test]
5721 fn package_name_lookup_reads_toml_nested_package_name() {
5722 let lookup: PackageNameLookup = PackageNameLookup {
5723 file: "Cargo.toml".to_string(),
5724 format: "toml".to_string(),
5725 key: "package.name".to_string(),
5726 registry: "crates.io".to_string(),
5727 };
5728
5729 assert_eq!(
5730 read_package_name_from_lookup(
5731 &lookup,
5732 "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
5733 )
5734 .expect("read"),
5735 Some("athena-mcp".to_string())
5736 );
5737 }
5738
5739 #[test]
5740 fn package_name_lookup_errors_on_unknown_format() {
5741 let lookup: PackageNameLookup = PackageNameLookup {
5742 file: "meta.txt".to_string(),
5743 format: "ini".to_string(),
5744 key: "name".to_string(),
5745 registry: "npm".to_string(),
5746 };
5747
5748 let error = read_package_name_from_lookup(&lookup, "name=demo")
5749 .expect_err("unsupported format should fail");
5750 assert!(error.contains("Unsupported lookup format"));
5751 }
5752
5753 #[test]
5754 fn highest_version_observation_returns_max_version() {
5755 let entries: Vec<VersionObservation> = vec![
5756 VersionObservation {
5757 location: "README.md".to_string(),
5758 version: Version::new(1, 0, 0),
5759 },
5760 VersionObservation {
5761 location: "Cargo.toml".to_string(),
5762 version: Version::new(1, 2, 0),
5763 },
5764 ];
5765
5766 assert_eq!(
5767 highest_version_observation(&entries).expect("max version"),
5768 Version::new(1, 2, 0)
5769 );
5770 }
5771
5772 #[test]
5773 fn stale_version_observations_only_returns_outdated_entries() {
5774 let entries: Vec<VersionObservation> = vec![
5775 VersionObservation {
5776 location: "README.md".to_string(),
5777 version: Version::new(1, 1, 0),
5778 },
5779 VersionObservation {
5780 location: "Cargo.toml".to_string(),
5781 version: Version::new(1, 2, 0),
5782 },
5783 VersionObservation {
5784 location: "openapi.yaml".to_string(),
5785 version: Version::new(1, 0, 5),
5786 },
5787 ];
5788
5789 let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
5790 assert_eq!(stale.len(), 2);
5791 assert!(stale.iter().any(|entry| entry.location == "README.md"));
5792 assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
5793 assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
5794 }
5795
5796 #[test]
5797 fn parses_github_remote_urls() {
5798 assert_eq!(
5799 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
5800 Some(("xylex-group".to_string(), "xbp".to_string()))
5801 );
5802 assert_eq!(
5803 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
5804 Some(("xylex-group".to_string(), "xbp".to_string()))
5805 );
5806 assert_eq!(
5807 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
5808 Some(("xylex-group".to_string(), "xbp".to_string()))
5809 );
5810 assert_eq!(
5811 parse_github_repo_from_remote_url(
5812 "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
5813 ),
5814 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
5815 );
5816 assert_eq!(
5817 parse_github_repo_from_remote_url(
5818 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
5819 ),
5820 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
5821 );
5822 assert_eq!(
5823 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
5824 None
5825 );
5826 }
5827
5828 #[test]
5829 fn redacts_credentials_in_remote_urls() {
5830 let redacted = redact_remote_url_credentials(
5831 "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
5832 );
5833 assert!(redacted.contains("REDACTED"));
5834 assert!(!redacted.contains("ghp_secretvalue"));
5835
5836 let username_only = redact_remote_url_credentials(
5837 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
5838 );
5839 assert!(username_only.contains("REDACTED@github.com"));
5840 assert!(!username_only.contains("floris-xlx@github.com"));
5841
5842 let ssh_remote =
5843 redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
5844 assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
5845 }
5846
5847 #[test]
5848 fn builds_github_release_urls_with_encoded_tag_segments() {
5849 let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
5850 assert_eq!(
5851 create_url.as_str(),
5852 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
5853 );
5854
5855 let lookup_url =
5856 github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
5857 .expect("url");
5858 assert_eq!(
5859 lookup_url.as_str(),
5860 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
5861 );
5862
5863 let update_url =
5864 github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
5865 assert_eq!(
5866 update_url.as_str(),
5867 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
5868 );
5869
5870 let lookup_with_special_tag = github_release_by_tag_endpoint(
5871 "SuitsBooks",
5872 "suits-invoicing",
5873 "release candidate/v0.0.1+build",
5874 )
5875 .expect("url");
5876 assert_eq!(
5877 lookup_with_special_tag.as_str(),
5878 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
5879 );
5880 }
5881
5882 #[test]
5883 fn builds_github_release_asset_urls() {
5884 let list_url =
5885 github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
5886 assert_eq!(
5887 list_url.as_str(),
5888 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
5889 );
5890
5891 let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
5892 .expect("url");
5893 assert_eq!(
5894 delete_url.as_str(),
5895 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
5896 );
5897
5898 let upload_url = github_release_asset_upload_endpoint(
5899 "SuitsBooks",
5900 "suits-invoicing",
5901 42,
5902 "openapi spec.json",
5903 )
5904 .expect("url");
5905 assert_eq!(
5906 upload_url.as_str(),
5907 "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
5908 );
5909 }
5910
5911 #[test]
5912 fn maps_release_latest_policy_to_github_api_values() {
5913 assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
5914 assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
5915 assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
5916 }
5917
5918 #[test]
5919 fn release_channel_from_semver_prerelease_labels() {
5920 let stable = Version::parse("3.6.2").expect("version");
5921 let nightly = Version::parse("3.6.2-nightly.1").expect("version");
5922 let experimental = Version::parse("0.1.1-alpha.1").expect("version");
5923 assert_eq!(release_channel(&stable), "stable");
5924 assert_eq!(release_channel(&nightly), "nightly");
5925 assert_eq!(release_channel(&experimental), "experimental");
5926 }
5927
5928 #[test]
5929 fn renders_release_docs_from_entries() {
5930 let entries = vec![
5931 ReleaseDocEntry {
5932 tag: "v3.6.2".to_string(),
5933 version: Version::parse("3.6.2").expect("version"),
5934 date: "2026-04-27".to_string(),
5935 },
5936 ReleaseDocEntry {
5937 tag: "docs-0.1.1-alpha.1".to_string(),
5938 version: Version::parse("0.1.1-alpha.1").expect("version"),
5939 date: "2026-04-20".to_string(),
5940 },
5941 ];
5942 let changelog = render_changelog("xylex-group", "athena", &entries);
5943 assert!(changelog.contains("## [3.6.2]"));
5944 assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
5945 assert!(changelog.contains("Release channel: stable"));
5946 assert!(changelog.contains("Release channel: experimental"));
5947
5948 let security = render_security_policy(&entries);
5949 assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
5950 assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
5951 }
5952
5953 #[test]
5954 fn formats_release_commit_lines_with_sha_and_pr_links() {
5955 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
5956 let formatted =
5957 format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
5958 .expect("formatted line");
5959
5960 assert_eq!(
5961 formatted,
5962 "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
5963 );
5964 }
5965
5966 #[test]
5967 fn formats_release_commit_lines_with_linear_links_when_available() {
5968 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
5969 let issue_infos = BTreeMap::from([(
5970 "SUI-1336".to_string(),
5971 LinearIssueInfo {
5972 title: "Release flow".to_string(),
5973 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
5974 },
5975 )]);
5976 let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
5977 .expect("formatted line");
5978
5979 assert_eq!(
5980 formatted,
5981 "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Fix release flow for [SUI-1336](https://linear.app/suitsbooks/issue/SUI-1336/release-flow) ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
5982 );
5983 }
5984
5985 #[test]
5986 fn renders_release_notes_in_requested_layout() {
5987 let commits = vec![
5988 ReleaseCommitEntry {
5989 full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
5990 short_sha: "abcdef1".to_string(),
5991 subject: "Improve release docs (#42)".to_string(),
5992 date: "2026-05-24".to_string(),
5993 },
5994 ReleaseCommitEntry {
5995 full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
5996 short_sha: "fedcba9".to_string(),
5997 subject: "Fix release flow for SUI-1336".to_string(),
5998 date: "2026-05-25".to_string(),
5999 },
6000 ];
6001 let pull_request_infos = BTreeMap::from([(
6002 "42".to_string(),
6003 super::release_notes::GithubPullRequestInfo {
6004 title: "Improve release docs".to_string(),
6005 url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
6006 },
6007 )]);
6008 let issue_infos = BTreeMap::from([(
6009 "SUI-1336".to_string(),
6010 LinearIssueInfo {
6011 title: "Release flow".to_string(),
6012 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
6013 },
6014 )]);
6015 let sections = build_fallback_sections(&commits);
6016 let rendered = render_release_notes(&ReleaseNotesRenderInput {
6017 release_title: "1.7.0 - athena-auth",
6018 current_tag_name: "v1.7.0",
6019 owner: "xylex-group",
6020 repo: "athena-auth",
6021 previous_tag: Some("v1.6.0"),
6022 sections: §ions,
6023 commit_entries: &commits,
6024 pull_request_infos: &pull_request_infos,
6025 linear_issue_infos: &issue_infos,
6026 });
6027
6028 assert_eq!(
6029 rendered,
6030 "# [1.7.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.7.0) - [athena-auth](https://github.com/xylex-group/athena-auth)\n\n## What's Changed\n\nComparing changes since [v1.6.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.6.0).\n\n### Documentation & Tooling\n\nDocumentation and tooling changes grouped around dependency updates and developer guidance.\n\n- [abcdef1](https://github.com/xylex-group/athena-auth/commit/abcdef1234567890abcdef1234567890abcdef12) Updated documentation around release docs.\n- [#42](https://github.com/xylex-group/athena-auth/pull/42) Improve release docs\n\n### Maintenance\n\nGeneral maintenance changes grouped into the main release summary.\n\n- [fedcba9](https://github.com/xylex-group/athena-auth/commit/fedcba9876543210fedcba9876543210fedcba98) Improved general behavior through release flow.\n- [SUI-1336](https://linear.app/suitsbooks/issue/SUI-1336/release-flow) Release flow\n\n---\n\nRelease: [v1.7.0](https://github.com/xylex-group/athena-auth/releases/tag/v1.7.0)"
6031 );
6032 }
6033
6034 #[test]
6035 fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
6036 let commits = vec![
6037 ReleaseCommitEntry {
6038 full_sha: "a".repeat(40),
6039 short_sha: "aaaaaaa".to_string(),
6040 subject: "Fix SUI-1336 and SUI-1440".to_string(),
6041 date: "2026-05-24".to_string(),
6042 },
6043 ReleaseCommitEntry {
6044 full_sha: "b".repeat(40),
6045 short_sha: "bbbbbbb".to_string(),
6046 subject: "Touch SUI-1336 again".to_string(),
6047 date: "2026-05-25".to_string(),
6048 },
6049 ];
6050
6051 assert_eq!(
6052 collect_linear_issue_identifiers(&commits),
6053 vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
6054 );
6055 }
6056
6057 #[test]
6058 fn release_title_defaults_to_version_and_repo() {
6059 assert_eq!(
6060 default_release_title(&Version::new(1, 7, 0), "athena-auth"),
6061 "1.7.0 - athena-auth"
6062 );
6063 }
6064
6065 #[test]
6066 fn deduplicates_release_commit_entries_by_exact_subject() {
6067 let commits = vec![
6068 ReleaseCommitEntry {
6069 full_sha: "a".repeat(40),
6070 short_sha: "aaaaaaa".to_string(),
6071 subject: "Improve release docs".to_string(),
6072 date: "2026-05-24".to_string(),
6073 },
6074 ReleaseCommitEntry {
6075 full_sha: "b".repeat(40),
6076 short_sha: "bbbbbbb".to_string(),
6077 subject: "Improve release docs".to_string(),
6078 date: "2026-05-25".to_string(),
6079 },
6080 ];
6081
6082 let deduplicated = deduplicate_release_commit_entries(&commits);
6083 assert_eq!(deduplicated.len(), 1);
6084 assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
6085 }
6086
6087 #[test]
6088 fn fallback_sections_collapse_related_commit_themes() {
6089 let commits = vec![
6090 ReleaseCommitEntry {
6091 full_sha: "a".repeat(40),
6092 short_sha: "chat001".to_string(),
6093 subject: "Add optimistic chat retries".to_string(),
6094 date: "2026-06-01".to_string(),
6095 },
6096 ReleaseCommitEntry {
6097 full_sha: "b".repeat(40),
6098 short_sha: "chat002".to_string(),
6099 subject: "Persist deleted-message state in chat".to_string(),
6100 date: "2026-06-01".to_string(),
6101 },
6102 ReleaseCommitEntry {
6103 full_sha: "c".repeat(40),
6104 short_sha: "file001".to_string(),
6105 subject: "Fix upload UTF-8 audit retry handling".to_string(),
6106 date: "2026-06-01".to_string(),
6107 },
6108 ReleaseCommitEntry {
6109 full_sha: "d".repeat(40),
6110 short_sha: "ath001".to_string(),
6111 subject: "Migrate form progress routes to Athena".to_string(),
6112 date: "2026-06-01".to_string(),
6113 },
6114 ReleaseCommitEntry {
6115 full_sha: "e".repeat(40),
6116 short_sha: "ath002".to_string(),
6117 subject: "Update Athena models and package wiring".to_string(),
6118 date: "2026-06-01".to_string(),
6119 },
6120 ];
6121
6122 let sections = build_fallback_sections(&commits);
6123 assert_eq!(sections.len(), 3);
6124 assert_eq!(sections[0].title, "Cases & Communication");
6125 assert!(!sections[0].summary.is_empty());
6126 assert_eq!(sections[0].bullets.len(), 2);
6127 assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
6128 assert!(sections[0].bullets[0].summary.contains("chat"));
6129 assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
6130 assert!(sections[0].bullets[1].summary.contains("deleted-message"));
6131 assert_eq!(sections[1].title, "Reliability");
6132 assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
6133 assert_eq!(sections[2].title, "Athena Migration");
6134 assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
6135 }
6136
6137 #[test]
6138 fn appends_release_label_footer_for_pre_release() {
6139 let with_label = append_release_label_footer("# Release", true);
6140 assert_eq!(
6141 with_label,
6142 format!(
6143 "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
6144 env!("CARGO_PKG_VERSION")
6145 )
6146 );
6147 }
6148}