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, heal_project_xbp_config,
22 maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
23 parse_github_repo_from_remote_url, redact_remote_url_credentials,
24 resolve_cargo_package_version, resolve_env_placeholders, write_cargo_package_version,
25};
26use colored::Colorize;
27use dialoguer::{theme::ColorfulTheme, Confirm, Select};
28use regex::Regex;
29use semver::Version;
30use serde::{Deserialize, Serialize};
31use serde_json::Value as JsonValue;
32use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
33use std::collections::HashMap;
34use std::collections::{BTreeMap, BTreeSet};
35use std::env;
36use std::fs;
37use std::io::IsTerminal;
38use std::path::{Path, PathBuf};
39use std::process::Command;
40use toml::Value as TomlValue;
41
42#[path = "version/bump.rs"]
43mod bump;
44#[path = "version/discover_services.rs"]
45mod discover_services;
46#[path = "version/github_release.rs"]
47mod github_release;
48#[path = "version/release_docs.rs"]
49mod release_docs;
50#[path = "version/release_ledger.rs"]
51mod release_ledger;
52#[path = "version/release_linear.rs"]
53mod release_linear;
54#[path = "version/release_notes.rs"]
55mod release_notes;
56#[path = "version/workspace_release.rs"]
57mod workspace_release;
58
59pub use bump::run_version_bump_command;
60pub use discover_services::run_version_discover_services;
61use github_release::{
62 create_github_release, get_github_release_by_tag, update_github_release, GithubReleaseInput,
63 GithubReleaseResult, GithubReleaseTagResponse,
64};
65use release_docs::sync_release_docs;
66use release_ledger::{
67 find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
68 load_release_ledger, release_ledger_path, release_ledger_scope_slug, save_release_ledger,
69 ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStepStatus,
70 ReleasePublishTarget, ReleaseSelectionRecord, ReleaseVersionTarget,
71};
72use release_linear::{
73 publish_release_to_linear_initiatives, resolve_linear_release_config,
74 LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
75};
76use release_notes::{generate_release_notes, ReleaseNotesRequest};
77pub(crate) use workspace_release::{
78 inspect_workspace_version_drift, resolve_manifest_workspace_publish, sync_workspace_to_version,
79 ManifestWorkspacePublishResolution,
80};
81pub use workspace_release::{
82 run_version_workspace_command, WorkspacePublishPlanOptions, WorkspacePublishRunOptions,
83 WorkspaceVersionCheckOptions, WorkspaceVersionCommand, WorkspaceVersionCommandOptions,
84 WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
85};
86
87#[derive(Clone, Debug)]
88struct VersionObservation {
89 location: String,
90 version: Version,
91}
92
93#[derive(Clone, Debug)]
94struct GitTagObservation {
95 version: Version,
96 raw_tags: Vec<String>,
97}
98
99#[derive(Clone, Debug)]
100struct RegistryVersionObservation {
101 registry: String,
102 package_name: String,
103 source_file: String,
104 latest: Option<Version>,
105 raw_version: Option<String>,
106 published_at: Option<String>,
107 note: Option<String>,
108}
109
110#[derive(Clone, Debug)]
111struct RegistryVersionDetails {
112 version: String,
113 published_at: Option<String>,
114}
115
116#[derive(Clone, Debug, Default)]
117struct ChangedTargetSelection {
118 changed_files: Vec<String>,
119 version_targets: Vec<ResolvedRegistryPath>,
120 publish_targets: Vec<ReleasePublishTarget>,
121 baseline_source: String,
122 baseline_reference: Option<String>,
123 baseline_tag: Option<String>,
124}
125
126const LEDGER_STEP_SYNC_VERSION: &str = "sync_version";
127const LEDGER_STEP_WORKSPACE_SYNC: &str = "workspace_sync";
128const LEDGER_STEP_PUBLISH_PACKAGES: &str = "publish_packages";
129const LEDGER_STEP_PUSH_TAG: &str = "push_tag";
130const LEDGER_STEP_CREATE_GITHUB_RELEASE: &str = "github_release";
131const LEDGER_STEP_UPLOAD_OPENAPI_ASSETS: &str = "upload_openapi_assets";
132const LEDGER_STEP_PUBLISH_LINEAR: &str = "publish_linear";
133const LEDGER_STEP_SYNC_RELEASE_DOCS: &str = "sync_release_docs";
134const LEDGER_STEP_COMMIT_RELEASE_DOCS: &str = "commit_release_docs";
135
136#[derive(Clone, Debug)]
137struct ResolvedRegistryPath {
138 relative: String,
139 absolute: PathBuf,
140 cargo_package_override: Option<String>,
141}
142
143#[derive(Clone, Debug)]
144struct WorkspacePrimaryCargoTarget {
145 manifest_relative: String,
146 manifest_absolute: PathBuf,
147 package_name: String,
148}
149
150#[derive(Clone, Debug)]
151enum VersionScope {
152 Repository,
153 Crate {
154 crate_root: PathBuf,
155 crate_relative_root: String,
156 package_name: String,
157 tag_prefix: String,
158 },
159 Service {
160 service_root: PathBuf,
161 service_relative_root: String,
162 service_name: String,
163 tag_prefix: String,
164 cargo_package_name: Option<String>,
165 version_targets: Vec<String>,
166 },
167}
168
169#[derive(Default, Debug)]
170struct VersionReport {
171 worktree: Vec<VersionObservation>,
172 head: Vec<VersionObservation>,
173 local_tags: Vec<GitTagObservation>,
174 remote_tags: Vec<GitTagObservation>,
175 registry_versions: Vec<RegistryVersionObservation>,
176 dirty_files: Vec<String>,
177 release_ledger: Option<ReleaseLedger>,
178 warnings: Vec<String>,
179}
180
181const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
182
183#[derive(Clone, Debug, Default, Deserialize, Serialize)]
184struct VersionChangeGuardRegistry {
185 #[serde(default)]
186 entries: BTreeMap<String, VersionChangeGuardEntry>,
187}
188
189#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
190struct VersionChangeGuardEntry {
191 #[serde(default)]
192 pending_version_change_count: usize,
193 #[serde(default)]
194 head_commit: Option<String>,
195}
196
197#[derive(Clone, Debug, Default, PartialEq, Eq)]
198struct GitWorktreeState {
199 is_dirty: bool,
200 head_commit: Option<String>,
201}
202
203impl VersionReport {
204 fn highest_worktree(&self) -> Option<Version> {
205 self.worktree
206 .iter()
207 .map(|entry| entry.version.clone())
208 .max()
209 }
210
211 fn highest_head(&self) -> Option<Version> {
212 self.head.iter().map(|entry| entry.version.clone()).max()
213 }
214
215 fn highest_local_tag(&self) -> Option<Version> {
216 self.local_tags
217 .iter()
218 .map(|entry| entry.version.clone())
219 .max()
220 }
221
222 fn highest_remote_tag(&self) -> Option<Version> {
223 self.remote_tags
224 .iter()
225 .map(|entry| entry.version.clone())
226 .max()
227 }
228
229 fn highest_git(&self) -> Option<Version> {
230 self.highest_remote_tag()
231 .or_else(|| self.highest_local_tag())
232 }
233
234 fn highest_registry(&self) -> Option<Version> {
235 self.registry_versions
236 .iter()
237 .filter_map(|entry| entry.latest.clone())
238 .max()
239 }
240
241 fn highest_available(&self) -> Version {
242 self.highest_worktree()
243 .into_iter()
244 .chain(self.highest_head())
245 .chain(self.highest_git())
246 .chain(self.highest_registry())
247 .max()
248 .unwrap_or_else(default_version)
249 }
250
251 fn divergent_versions(&self) -> Vec<Version> {
252 let mut versions = BTreeSet::new();
253 for entry in &self.worktree {
254 versions.insert(entry.version.clone());
255 }
256 for entry in &self.head {
257 versions.insert(entry.version.clone());
258 }
259 for entry in &self.local_tags {
260 versions.insert(entry.version.clone());
261 }
262 for entry in &self.remote_tags {
263 versions.insert(entry.version.clone());
264 }
265 for entry in &self.registry_versions {
266 if let Some(version) = &entry.latest {
267 versions.insert(version.clone());
268 }
269 }
270 versions.into_iter().collect()
271 }
272}
273
274pub async fn run_version_command(
275 target: Option<String>,
276 git_only: bool,
277 _debug: bool,
278) -> Result<(), String> {
279 if git_only && target.is_some() {
280 return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
281 }
282
283 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
284 let project_root: PathBuf = resolve_project_root();
285 let version_scope: VersionScope =
286 resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
287 let registry: Vec<String> = load_versioning_files_registry()?;
288
289 if git_only {
290 print_git_versions(&project_root, &version_scope)?;
291 return Ok(());
292 }
293
294 match target.as_deref() {
295 None => {
296 let mut report: VersionReport =
297 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
298 match load_package_name_files_registry() {
299 Ok(lookups) => {
300 report.registry_versions = collect_registry_versions(
301 &project_root,
302 &invocation_dir,
303 &lookups,
304 &version_scope,
305 &mut report.warnings,
306 )
307 .await;
308 }
309 Err(err) => report.warnings.push(err),
310 }
311 print_version_report(&project_root, &report);
312 Ok(())
313 }
314 Some(bump_target @ ("major" | "minor" | "patch")) => {
315 enforce_version_change_guard(&project_root)?;
316 let repo = project_root
317 .file_name()
318 .and_then(|value| value.to_str())
319 .unwrap_or("repository")
320 .to_string();
321 let selection = resolve_changed_target_selection(
322 &project_root,
323 &invocation_dir,
324 ®istry,
325 &version_scope,
326 &repo,
327 )?;
328 let current: Version = read_highest_version_from_targets(&selection.version_targets)?
329 .unwrap_or_else(|| {
330 resolve_current_version_for_bump(
331 &project_root,
332 &invocation_dir,
333 ®istry,
334 &version_scope,
335 )
336 });
337 let next: Version = bump_version(¤t, bump_target);
338 let updated_paths =
339 write_version_to_selected_paths(&selection.version_targets, &next, false)?;
340 let updated = updated_paths.len();
341 println!(
342 "Updated {} version file(s) from {} to {}.",
343 updated, current, next
344 );
345 auto_commit_command_paths(
346 &project_root,
347 updated_paths,
348 format!("chore(version): update version to {}", next),
349 "xbp version",
350 )
351 .await;
352 record_version_change_guard(&project_root)?;
353 sync_cli_version_write_activity(
354 &project_root,
355 &version_scope,
356 &next,
357 format!(
358 "Updated {} version file(s) from {} to {}.",
359 updated, current, next
360 ),
361 )
362 .await;
363 Ok(())
364 }
365 Some(explicit) => {
366 enforce_version_change_guard(&project_root)?;
367 let repo = project_root
368 .file_name()
369 .and_then(|value| value.to_str())
370 .unwrap_or("repository")
371 .to_string();
372 let selection = resolve_changed_target_selection(
373 &project_root,
374 &invocation_dir,
375 ®istry,
376 &version_scope,
377 &repo,
378 )?;
379 if let Some((package_name, version)) = parse_package_version_target(explicit)? {
380 let updated_paths = write_package_version_to_configured_files_with_paths(
381 &project_root,
382 &invocation_dir,
383 ®istry,
384 &version_scope,
385 &package_name,
386 &version,
387 )?;
388 let updated = updated_paths.len();
389 println!(
390 "Updated {} file(s) for package `{}` to {}.",
391 updated, package_name, version
392 );
393 auto_commit_command_paths(
394 &project_root,
395 updated_paths,
396 format!("chore(version): set {} to {}", package_name, version),
397 "xbp version",
398 )
399 .await;
400 record_version_change_guard(&project_root)?;
401 sync_cli_version_write_activity(
402 &project_root,
403 &version_scope,
404 &version,
405 format!(
406 "Updated {} file(s) for package `{}` to {}.",
407 updated, package_name, version
408 ),
409 )
410 .await;
411 } else {
412 let version: Version = parse_version(explicit)?;
413 let updated_paths =
414 write_version_to_selected_paths(&selection.version_targets, &version, false)?;
415 let updated = updated_paths.len();
416 println!("Updated {} version file(s) to {}.", updated, version);
417 auto_commit_command_paths(
418 &project_root,
419 updated_paths,
420 format!("chore(version): update version to {}", version),
421 "xbp version",
422 )
423 .await;
424 record_version_change_guard(&project_root)?;
425 sync_cli_version_write_activity(
426 &project_root,
427 &version_scope,
428 &version,
429 format!("Updated {} version file(s) to {}.", updated, version),
430 )
431 .await;
432 }
433 Ok(())
434 }
435 }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ReleaseLatestPolicy {
440 True,
441 False,
442 Legacy,
443}
444
445impl ReleaseLatestPolicy {
446 pub(crate) fn as_github_api_value(self) -> &'static str {
447 match self {
448 Self::True => "true",
449 Self::False => "false",
450 Self::Legacy => "legacy",
451 }
452 }
453}
454
455#[derive(Debug, Clone)]
456pub struct VersionReleaseOptions {
457 pub explicit_version: Option<String>,
458 pub allow_dirty: bool,
459 pub title: Option<String>,
460 pub notes: Option<String>,
461 pub notes_file: Option<PathBuf>,
462 pub draft: bool,
463 pub prerelease: bool,
464 pub publish: bool,
465 pub force: bool,
466 pub latest_policy: ReleaseLatestPolicy,
467}
468
469struct ReleaseWorkflowSummary {
470 version: Version,
471 tag_name: String,
472 release_url: String,
473 uploaded_openapi_assets: Vec<String>,
474 release_title: String,
475 release_notes: String,
476 repository_owner: String,
477 repository_name: String,
478 scope_kind: String,
479 scope_label: String,
480 published_initiatives: Vec<PublishedLinearInitiative>,
481 release_branch: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485struct ReleaseOpenApiAsset {
486 source_path: PathBuf,
487 source_label: String,
488 asset_name: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
492struct DirtyWorktreeAnalysis {
493 all_entries: Vec<String>,
494 safe_entries: Vec<String>,
495 risky_entries: Vec<String>,
496}
497
498fn parse_git_status_path(line: &str) -> Option<String> {
499 let line = line.trim_end();
500 if line.len() < 3 {
501 return None;
502 }
503 let path_part = line.get(2..)?.trim_start();
504 if path_part.is_empty() {
505 return None;
506 }
507 if let Some((_old, new)) = path_part.split_once(" -> ") {
508 return Some(new.trim().replace('\\', "/"));
509 }
510 Some(path_part.replace('\\', "/"))
511}
512
513fn is_safe_dirty_path(path: &str) -> bool {
514 let normalized = path.replace('\\', "/");
515 let segments: Vec<&str> = normalized.split('/').collect();
516
517 if segments.first() == Some(&".xbp") {
518 return true;
519 }
520 if segments.contains(&"target") {
521 return true;
522 }
523 if segments.contains(&"__pycache__") {
524 return true;
525 }
526 if segments.contains(&"node_modules") || segments.contains(&".next") {
527 return true;
528 }
529 if normalized.ends_with(".pyc") || normalized.ends_with(".pyo") {
530 return true;
531 }
532
533 false
534}
535
536fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
537 let mut paths = Vec::new();
538 for entry in entries {
539 if let Some(path) = parse_git_status_path(entry) {
540 paths.push(path);
541 }
542 }
543 paths.sort();
544 paths.dedup();
545
546 let mut safe_entries = Vec::new();
547 let mut risky_entries = Vec::new();
548 for path in paths {
549 if is_safe_dirty_path(&path) {
550 safe_entries.push(path);
551 } else {
552 risky_entries.push(path);
553 }
554 }
555
556 DirtyWorktreeAnalysis {
557 all_entries: entries.to_vec(),
558 safe_entries,
559 risky_entries,
560 }
561}
562
563fn run_release_config_preflight(invocation_dir: &Path) -> Result<(), String> {
564 let Some(heal_result) = heal_project_xbp_config(invocation_dir)? else {
565 return Ok(());
566 };
567
568 println!(
569 " {} Auto-healed {}",
570 "✓".bright_green(),
571 heal_result.config_path.display()
572 );
573 for fix in &heal_result.fixes {
574 println!(" {} {}", "•".bright_cyan(), fix);
575 }
576 Ok(())
577}
578
579fn resolve_dirty_worktree_for_release(
580 project_root: &Path,
581 force: bool,
582 loader: Option<&Loader>,
583) -> Result<bool, String> {
584 let dirty = git_dirty_entries(project_root)?;
585 if dirty.is_empty() {
586 return Ok(false);
587 }
588
589 let analysis = analyze_dirty_worktree(&dirty);
590 if analysis.risky_entries.is_empty() {
591 println!(
592 " {} Allowing release with {} generated/auto-healed change(s)",
593 "i".bright_blue(),
594 analysis.safe_entries.len()
595 );
596 for path in analysis.safe_entries.iter().take(4) {
597 println!(" {} {}", "•".bright_black(), path);
598 }
599 if analysis.safe_entries.len() > 4 {
600 println!(
601 " {} … and {} more",
602 "•".bright_black(),
603 analysis.safe_entries.len() - 4
604 );
605 }
606 return Ok(true);
607 }
608
609 if force {
610 return Ok(true);
611 }
612
613 println!(
614 " {} Working tree has {} uncommitted change(s)",
615 "!".bright_yellow(),
616 analysis.risky_entries.len()
617 );
618 for path in analysis.risky_entries.iter().take(8) {
619 println!(" {} {}", "!".bright_yellow(), path);
620 }
621 if analysis.risky_entries.len() > 8 {
622 println!(
623 " {} … and {} more",
624 "!".bright_yellow(),
625 analysis.risky_entries.len() - 8
626 );
627 }
628 if !analysis.safe_entries.is_empty() {
629 println!(
630 " {} Ignoring {} generated/auto-healed path(s)",
631 "i".bright_blue(),
632 analysis.safe_entries.len()
633 );
634 }
635
636 if std::io::stdin().is_terminal() {
637 let prompt = || {
638 Confirm::with_theme(&ColorfulTheme::default())
639 .with_prompt("Continue release with uncommitted changes?")
640 .default(false)
641 .interact()
642 };
643 let proceed = if let Some(loader) = loader {
644 loader.suspend(prompt)
645 } else {
646 prompt()
647 }
648 .map_err(|e| format!("Failed to read confirmation: {}", e))?;
649 if proceed {
650 return Ok(true);
651 }
652 return Err(
653 "Release cancelled. Commit/stash changes first or pass `--allow-dirty`.".to_string(),
654 );
655 }
656
657 let preview = analysis
658 .risky_entries
659 .into_iter()
660 .take(8)
661 .collect::<Vec<_>>()
662 .join(", ");
663 Err(format!(
664 "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
665 preview
666 ))
667}
668
669async fn maybe_heal_workspace_versions_for_release(
670 project_root: &Path,
671 release_version: &Version,
672 force: bool,
673 loader: &Loader,
674) -> Result<(), String> {
675 let Some(drift) = inspect_workspace_version_drift(project_root, release_version)? else {
676 return Ok(());
677 };
678
679 println!(
680 " {} Workspace version drift detected ({} mismatch(es) for {})",
681 "!".bright_yellow(),
682 drift.drift_count,
683 drift.expected_version
684 );
685 for line in &drift.preview {
686 println!(" {} {}", "•".bright_yellow(), line);
687 }
688 if drift.drift_count > drift.preview.len() {
689 println!(
690 " {} … and {} more",
691 "•".bright_yellow(),
692 drift.drift_count - drift.preview.len()
693 );
694 }
695
696 let should_sync = if force {
697 true
698 } else if std::io::stdin().is_terminal() {
699 loader
700 .suspend(|| {
701 Confirm::with_theme(&ColorfulTheme::default())
702 .with_prompt(format!(
703 "Sync workspace versions to {} before publishing?",
704 release_version
705 ))
706 .default(true)
707 .interact()
708 })
709 .map_err(|e| format!("Failed to read confirmation: {}", e))?
710 } else {
711 return Err(format!(
712 "Workspace version drift detected for {}. Run `xbp version workspace sync --write --version {}` or pass `--force` to auto-sync.",
713 release_version, release_version
714 ));
715 };
716
717 if !should_sync {
718 return Err("Release cancelled. Align workspace versions before publishing.".to_string());
719 }
720
721 let changed_files = sync_workspace_to_version(project_root, release_version)?;
722 if changed_files.is_empty() {
723 println!(
724 " {} Workspace versions already aligned to {}",
725 "✓".bright_green(),
726 release_version
727 );
728 return Ok(());
729 }
730
731 println!(
732 " {} Synced workspace versions to {} ({} file(s))",
733 "✓".bright_green(),
734 release_version,
735 changed_files.len()
736 );
737 for path in changed_files.iter().take(6) {
738 println!(" {} {}", "•".bright_cyan(), path);
739 }
740 if changed_files.len() > 6 {
741 println!(
742 " {} … and {} more",
743 "•".bright_cyan(),
744 changed_files.len() - 6
745 );
746 }
747
748 let changed_paths = changed_files
749 .into_iter()
750 .map(|relative| project_root.join(relative))
751 .collect::<Vec<_>>();
752 auto_commit_command_paths(
753 project_root,
754 changed_paths,
755 format!("chore(version): sync workspace to {}", release_version),
756 "xbp version release",
757 )
758 .await;
759
760 Ok(())
761}
762
763pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
764 let loader = Loader::start("Publishing release");
765 let result: Result<ReleaseWorkflowSummary, String> = async {
766 let VersionReleaseOptions {
767 explicit_version,
768 allow_dirty,
769 title,
770 notes,
771 notes_file,
772 draft,
773 prerelease,
774 publish,
775 force,
776 latest_policy,
777 } = options;
778 let mut effective_allow_dirty = allow_dirty || force;
779
780 if notes.is_some() && notes_file.is_some() {
781 return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
782 }
783
784 if !command_exists("git") {
785 return Err(
786 "Git is required for `xbp version release`, but it is not installed.".to_string(),
787 );
788 }
789
790 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
791 let project_root: PathBuf = resolve_project_root();
792 let version_scope: VersionScope =
793 resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
794 let registry: Vec<String> = load_versioning_files_registry()?;
795 let sync_explicit_version = explicit_version.is_some();
796 let total_steps = 10usize + usize::from(sync_explicit_version) + usize::from(publish) * 2;
797 let mut step = 1usize;
798
799 loader.update(&format!(
800 "[{}/{}] Checking project configuration",
801 step, total_steps
802 ));
803 run_release_config_preflight(&invocation_dir)?;
804
805 step += 1;
806 loader.update(&format!(
807 "[{}/{}] Validating git state and resolving release target",
808 step, total_steps
809 ));
810 if !effective_allow_dirty {
811 effective_allow_dirty =
812 resolve_dirty_worktree_for_release(&project_root, force, Some(&loader))?;
813 }
814
815 let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
816 let (version, parsed_tag_name) = parse_release_version_target(raw)?;
817 (
818 version.clone(),
819 scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
820 )
821 } else {
822 let registry: Vec<String> = load_versioning_files_registry()?;
823 let report: VersionReport =
824 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
825 let release_version: Version = report.highest_available();
826 let tag_name: String = default_release_tag_name(&version_scope, &release_version);
827 (release_version, tag_name)
828 };
829
830 let repo_label = project_root
831 .file_name()
832 .and_then(|value| value.to_str())
833 .unwrap_or("repository")
834 .to_string();
835 let ledger_path =
836 current_scope_ledger_path(&project_root, &version_scope, &repo_label, &release_version);
837 let mut selection = resolve_changed_target_selection(
838 &project_root,
839 &invocation_dir,
840 ®istry,
841 &version_scope,
842 &repo_label,
843 )?;
844 let mut release_ledger = match load_release_ledger(&ledger_path)? {
845 Some(existing) if existing.version == release_version.to_string() => {
846 println!(
847 "{} {}",
848 "Resuming release ledger".bright_yellow().bold(),
849 ledger_path.display()
850 );
851 selection.version_targets = ledger_version_targets_to_resolved(&project_root, &existing);
852 if !existing.selection.publish_targets.is_empty() {
853 selection.publish_targets = existing.selection.publish_targets.clone();
854 }
855 selection.changed_files = existing.selection.changed_files.clone();
856 selection.baseline_source = existing.baseline.source.clone();
857 selection.baseline_reference = existing.baseline.reference.clone();
858 selection.baseline_tag = existing.baseline.tag.clone();
859 existing
860 }
861 _ => {
862 let ledger = initialize_release_ledger(
863 &version_scope,
864 &repo_label,
865 &release_version,
866 &tag_name,
867 None,
868 &selection,
869 );
870 save_release_ledger(&ledger_path, &ledger)?;
871 ledger
872 }
873 };
874 print_selected_release_targets(&selection);
875
876 if sync_explicit_version {
877 step += 1;
878 loader.update(&format!(
879 "[{}/{}] Syncing configured version files",
880 step, total_steps
881 ));
882 if !release_ledger.step_is_done(LEDGER_STEP_SYNC_VERSION) {
883 begin_release_step(
884 &mut release_ledger,
885 &ledger_path,
886 LEDGER_STEP_SYNC_VERSION,
887 None,
888 )?;
889 let updated_paths =
890 write_version_to_selected_paths(&selection.version_targets, &release_version, true)
891 .inspect_err(|error| {
892 let _ = fail_release_step(
893 &mut release_ledger,
894 &ledger_path,
895 LEDGER_STEP_SYNC_VERSION,
896 error,
897 );
898 })?;
899 if !updated_paths.is_empty() {
900 auto_commit_command_paths(
901 &project_root,
902 updated_paths.clone(),
903 format!("chore(version): update version to {}", release_version),
904 "xbp version release",
905 )
906 .await;
907 }
908 complete_release_step(
909 &mut release_ledger,
910 &ledger_path,
911 LEDGER_STEP_SYNC_VERSION,
912 ReleaseLedgerStepStatus::Completed,
913 Some(serde_json::json!({
914 "updated_paths": updated_paths
915 .iter()
916 .map(|path| path.display().to_string())
917 .collect::<Vec<_>>()
918 })),
919 )?;
920 }
921 }
922
923 ensure_remote_exists(&project_root, "origin")?;
924 let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
925 let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
926
927 if publish {
928 step += 1;
929 loader.update(&format!(
930 "[{}/{}] Checking workspace version alignment",
931 step, total_steps
932 ));
933 if !release_ledger.step_is_done(LEDGER_STEP_WORKSPACE_SYNC) {
934 begin_release_step(
935 &mut release_ledger,
936 &ledger_path,
937 LEDGER_STEP_WORKSPACE_SYNC,
938 None,
939 )?;
940 maybe_heal_workspace_versions_for_release(
941 &project_root,
942 &release_version,
943 force,
944 &loader,
945 )
946 .await
947 .inspect_err(|error| {
948 let _ = fail_release_step(
949 &mut release_ledger,
950 &ledger_path,
951 LEDGER_STEP_WORKSPACE_SYNC,
952 error,
953 );
954 })?;
955 complete_release_step(
956 &mut release_ledger,
957 &ledger_path,
958 LEDGER_STEP_WORKSPACE_SYNC,
959 ReleaseLedgerStepStatus::Completed,
960 None,
961 )?;
962 }
963
964 step += 1;
965 loader.update(&format!(
966 "[{}/{}] Publishing configured packages",
967 step, total_steps
968 ));
969 if !release_ledger.step_is_done(LEDGER_STEP_PUBLISH_PACKAGES) {
970 if selection.publish_targets.is_empty() {
971 complete_release_step(
972 &mut release_ledger,
973 &ledger_path,
974 LEDGER_STEP_PUBLISH_PACKAGES,
975 ReleaseLedgerStepStatus::Skipped,
976 Some(serde_json::json!({ "reason": "no selected publish targets" })),
977 )?;
978 } else if verify_selected_publish_targets(
979 &project_root,
980 &selection.publish_targets,
981 &release_version,
982 )
983 .await?
984 {
985 complete_release_step(
986 &mut release_ledger,
987 &ledger_path,
988 LEDGER_STEP_PUBLISH_PACKAGES,
989 ReleaseLedgerStepStatus::VerifiedRemote,
990 Some(serde_json::json!({
991 "targets": selection.publish_targets,
992 "version": release_version.to_string()
993 })),
994 )?;
995 } else {
996 begin_release_step(
997 &mut release_ledger,
998 &ledger_path,
999 LEDGER_STEP_PUBLISH_PACKAGES,
1000 None,
1001 )?;
1002 let publish_target_filter =
1003 publish_target_filter_from_selection(&selection.publish_targets)?;
1004 run_publish_command_with_progress_prefix(
1005 PublishCommandOptions {
1006 dry_run: false,
1007 allow_dirty: effective_allow_dirty,
1008 force,
1009 include_prereqs: true,
1010 target: publish_target_filter,
1011 manifest_path: None,
1012 expected_version: Some(release_version.to_string()),
1013 },
1014 &loader,
1015 format!("[{}/{}]", step, total_steps),
1016 )
1017 .await
1018 .inspect_err(|error| {
1019 let _ = fail_release_step(
1020 &mut release_ledger,
1021 &ledger_path,
1022 LEDGER_STEP_PUBLISH_PACKAGES,
1023 error,
1024 );
1025 })?;
1026 complete_release_step(
1027 &mut release_ledger,
1028 &ledger_path,
1029 LEDGER_STEP_PUBLISH_PACKAGES,
1030 ReleaseLedgerStepStatus::Completed,
1031 Some(serde_json::json!({
1032 "targets": selection.publish_targets,
1033 "version": release_version.to_string()
1034 })),
1035 )?;
1036 }
1037 }
1038 }
1039
1040 step += 1;
1041 loader.update(&format!(
1042 "[{}/{}] Resolving GitHub repository and auth",
1043 step, total_steps
1044 ));
1045 let origin_url: String = git_remote_url(&project_root, "origin")?;
1046 let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
1047 format!(
1048 "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.",
1049 redact_remote_url_credentials(&origin_url)
1050 )
1051 })?;
1052
1053 let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
1054 "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
1055 .to_string()
1056 })?;
1057 let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
1058 Some(key)
1059 } else {
1060 fetch_linear_api_key_from_dashboard().await?
1061 };
1062 let release_title: String = title.unwrap_or_else(|| {
1063 default_release_title(
1064 &release_version,
1065 release_title_subject(&version_scope, &repo),
1066 )
1067 });
1068 let release_branch_config =
1069 resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
1070
1071 step += 1;
1072 loader.update(&format!(
1073 "[{}/{}] Generating release notes",
1074 step, total_steps
1075 ));
1076 let release_notes_body: String = if let Some(path) = notes_file {
1077 fs::read_to_string(&path).map_err(|e| {
1078 format!(
1079 "Failed to read release notes file {}: {}",
1080 path.display(),
1081 e
1082 )
1083 })?
1084 } else if let Some(body) = notes {
1085 body
1086 } else {
1087 generate_release_notes(&ReleaseNotesRequest {
1088 project_root: &project_root,
1089 release_title: &release_title,
1090 current_tag_name: &tag_name,
1091 owner: &owner,
1092 repo: &repo,
1093 github_token: &github_token,
1094 linear_api_key: linear_api_key.as_deref(),
1095 openrouter_api_key: resolve_openrouter_api_key().as_deref(),
1096 path_filter: release_notes_scope_path(&version_scope).as_deref(),
1097 })
1098 .await?
1099 };
1100 let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
1101
1102 step += 1;
1103 loader.update(&format!(
1104 "[{}/{}] Preparing release OpenAPI assets",
1105 step, total_steps
1106 ));
1107 let release_openapi_assets = prepare_release_openapi_assets(
1108 &project_root,
1109 &invocation_dir,
1110 &version_scope,
1111 &release_version,
1112 &tag_name,
1113 )?;
1114
1115 step += 1;
1116 loader.update(&format!(
1117 "[{}/{}] Creating and pushing release tag",
1118 step, total_steps
1119 ));
1120 let tag_message: String = format!("Release {}", tag_name);
1121 let target_commitish: String = git_head_commitish(&project_root)?;
1122 let created_release_branch = if release_ledger.step_is_done(LEDGER_STEP_PUSH_TAG) {
1123 release_ledger.branch_name.clone()
1124 } else {
1125 begin_release_step(&mut release_ledger, &ledger_path, LEDGER_STEP_PUSH_TAG, None)?;
1126 let branch = if let Some(branch_config) = &release_branch_config {
1127 Some(ensure_release_branch(
1128 &project_root,
1129 branch_config,
1130 &release_version,
1131 &tag_name,
1132 &version_scope_label(&version_scope, &repo),
1133 &target_commitish,
1134 )?)
1135 } else {
1136 None
1137 };
1138 if !tag_exists_local {
1139 run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])
1140 .inspect_err(|error| {
1141 let _ = fail_release_step(
1142 &mut release_ledger,
1143 &ledger_path,
1144 LEDGER_STEP_PUSH_TAG,
1145 error,
1146 );
1147 })?;
1148 }
1149 if !tag_exists_remote {
1150 run_git_command(&project_root, &["push", "origin", &tag_name]).inspect_err(|error| {
1151 let _ = fail_release_step(
1152 &mut release_ledger,
1153 &ledger_path,
1154 LEDGER_STEP_PUSH_TAG,
1155 error,
1156 );
1157 })?;
1158 }
1159 release_ledger.branch_name = branch.clone();
1160 complete_release_step(
1161 &mut release_ledger,
1162 &ledger_path,
1163 LEDGER_STEP_PUSH_TAG,
1164 ReleaseLedgerStepStatus::Completed,
1165 Some(serde_json::json!({
1166 "tag_name": tag_name,
1167 "branch_name": branch,
1168 })),
1169 )?;
1170 branch
1171 };
1172
1173 let release_input: GithubReleaseInput = GithubReleaseInput {
1174 owner: owner.clone(),
1175 repo: repo.clone(),
1176 token: github_token,
1177 tag_name: tag_name.clone(),
1178 target_commitish,
1179 title: release_title,
1180 notes: release_notes,
1181 draft,
1182 prerelease,
1183 latest_policy,
1184 };
1185
1186 step += 1;
1187 loader.update(&format!(
1188 "[{}/{}] Publishing GitHub release",
1189 step, total_steps
1190 ));
1191 let release_result: GithubReleaseResult = if release_ledger.step_is_done(LEDGER_STEP_CREATE_GITHUB_RELEASE) {
1192 let existing_release = get_github_release_by_tag(&release_input)
1193 .await?
1194 .ok_or_else(|| "Release ledger indicates a completed GitHub release step, but the release could not be found by tag.".to_string())?;
1195 GithubReleaseResult {
1196 id: existing_release.id,
1197 html_url: existing_release.html_url.unwrap_or_else(|| {
1198 format!(
1199 "https://github.com/{}/{}/releases/tag/{}",
1200 release_input.owner, release_input.repo, release_input.tag_name
1201 )
1202 }),
1203 }
1204 } else if let Some(existing_release) = get_github_release_by_tag(&release_input).await? {
1205 complete_release_step(
1206 &mut release_ledger,
1207 &ledger_path,
1208 LEDGER_STEP_CREATE_GITHUB_RELEASE,
1209 ReleaseLedgerStepStatus::VerifiedRemote,
1210 Some(serde_json::json!({
1211 "release_id": existing_release.id,
1212 "release_url": existing_release.html_url,
1213 })),
1214 )?;
1215 GithubReleaseResult {
1216 id: existing_release.id,
1217 html_url: existing_release.html_url.unwrap_or_else(|| {
1218 format!(
1219 "https://github.com/{}/{}/releases/tag/{}",
1220 release_input.owner, release_input.repo, release_input.tag_name
1221 )
1222 }),
1223 }
1224 } else {
1225 begin_release_step(
1226 &mut release_ledger,
1227 &ledger_path,
1228 LEDGER_STEP_CREATE_GITHUB_RELEASE,
1229 None,
1230 )?;
1231 let created = match create_github_release(&release_input).await {
1232 Ok(result) => result,
1233 Err(create_error) => {
1234 let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
1235 format!(
1236 "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
1237 create_error, tag_name, e
1238 )
1239 })?;
1240
1241 let Some(existing_release) = existing_release else {
1242 let error = format!(
1243 "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
1244 create_error, tag_name
1245 );
1246 let _ = fail_release_step(
1247 &mut release_ledger,
1248 &ledger_path,
1249 LEDGER_STEP_CREATE_GITHUB_RELEASE,
1250 &error,
1251 );
1252 return Err(error);
1253 };
1254
1255 let needs_update: bool = existing_release.prerelease.unwrap_or(false)
1256 != release_input.prerelease
1257 || existing_release.draft.unwrap_or(false) != release_input.draft
1258 || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
1259
1260 if needs_update {
1261 update_github_release(&release_input, existing_release.id)
1262 .await
1263 .map_err(|e| {
1264 let error = format!(
1265 "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
1266 create_error, tag_name, e
1267 );
1268 let _ = fail_release_step(
1269 &mut release_ledger,
1270 &ledger_path,
1271 LEDGER_STEP_CREATE_GITHUB_RELEASE,
1272 &error,
1273 );
1274 error
1275 })?
1276 } else {
1277 GithubReleaseResult {
1278 id: existing_release.id,
1279 html_url: existing_release.html_url.unwrap_or_else(|| {
1280 format!(
1281 "https://github.com/{}/{}/releases/tag/{}",
1282 release_input.owner, release_input.repo, release_input.tag_name
1283 )
1284 }),
1285 }
1286 }
1287 }
1288 };
1289 complete_release_step(
1290 &mut release_ledger,
1291 &ledger_path,
1292 LEDGER_STEP_CREATE_GITHUB_RELEASE,
1293 ReleaseLedgerStepStatus::Completed,
1294 Some(serde_json::json!({
1295 "release_id": created.id,
1296 "release_url": created.html_url,
1297 })),
1298 )?;
1299 created
1300 };
1301 let release_url = release_result.html_url.clone();
1302
1303 step += 1;
1304 loader.update(&format!(
1305 "[{}/{}] Publishing release integrations",
1306 step, total_steps
1307 ));
1308 let linear_release_config =
1309 resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
1310 let uploaded_openapi_assets = if release_ledger.step_is_done(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS) {
1311 release_ledger
1312 .step(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS)
1313 .and_then(|step| step.details.get("uploaded_assets"))
1314 .and_then(serde_json::Value::as_array)
1315 .map(|values| {
1316 values
1317 .iter()
1318 .filter_map(serde_json::Value::as_str)
1319 .map(str::to_string)
1320 .collect::<Vec<_>>()
1321 })
1322 .unwrap_or_default()
1323 } else {
1324 begin_release_step(
1325 &mut release_ledger,
1326 &ledger_path,
1327 LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1328 None,
1329 )?;
1330 let mut uploaded_assets: Vec<String> = Vec::new();
1331 for asset in &release_openapi_assets {
1332 github_release::upload_github_release_asset(
1333 &release_input,
1334 release_result.id,
1335 &asset.source_path,
1336 &asset.asset_name,
1337 )
1338 .await
1339 .map_err(|e| {
1340 let error = format!(
1341 "Release `{}` was published, but uploading OpenAPI asset `{}` from `{}` failed: {}",
1342 tag_name,
1343 asset.asset_name,
1344 asset.source_label,
1345 e
1346 );
1347 let _ = fail_release_step(
1348 &mut release_ledger,
1349 &ledger_path,
1350 LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1351 &error,
1352 );
1353 error
1354 })?;
1355 uploaded_assets.push(asset.asset_name.clone());
1356 }
1357 complete_release_step(
1358 &mut release_ledger,
1359 &ledger_path,
1360 LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1361 ReleaseLedgerStepStatus::Completed,
1362 Some(serde_json::json!({ "uploaded_assets": uploaded_assets })),
1363 )?;
1364 uploaded_assets
1365 };
1366 let published_initiatives = if release_ledger.step_is_done(LEDGER_STEP_PUBLISH_LINEAR) {
1367 release_ledger
1368 .step(LEDGER_STEP_PUBLISH_LINEAR)
1369 .and_then(|step| step.details.get("initiatives"))
1370 .cloned()
1371 .map(serde_json::from_value)
1372 .transpose()
1373 .map_err(|error| format!("Failed to decode stored Linear release initiatives: {}", error))?
1374 .unwrap_or_default()
1375 } else if let Some(linear_release_config) = linear_release_config {
1376 begin_release_step(
1377 &mut release_ledger,
1378 &ledger_path,
1379 LEDGER_STEP_PUBLISH_LINEAR,
1380 None,
1381 )?;
1382 let linear_api_key: String = linear_api_key.clone().ok_or_else(|| {
1383 "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."
1384 .to_string()
1385 })?;
1386 let published = publish_release_to_linear_initiatives(&LinearReleasePublishInput {
1387 api_key: linear_api_key,
1388 initiative_ids: linear_release_config.initiative_ids,
1389 organization_name: linear_release_config.organization_name,
1390 health: linear_release_config.health,
1391 release_title: release_input.title.clone(),
1392 release_tag: release_input.tag_name.clone(),
1393 release_url: release_url.clone(),
1394 release_notes: release_input.notes.clone(),
1395 })
1396 .await
1397 .inspect_err(|error| {
1398 let _ = fail_release_step(
1399 &mut release_ledger,
1400 &ledger_path,
1401 LEDGER_STEP_PUBLISH_LINEAR,
1402 error,
1403 );
1404 })?;
1405 complete_release_step(
1406 &mut release_ledger,
1407 &ledger_path,
1408 LEDGER_STEP_PUBLISH_LINEAR,
1409 ReleaseLedgerStepStatus::Completed,
1410 Some(serde_json::json!({ "initiatives": published })),
1411 )?;
1412 published
1413 } else {
1414 complete_release_step(
1415 &mut release_ledger,
1416 &ledger_path,
1417 LEDGER_STEP_PUBLISH_LINEAR,
1418 ReleaseLedgerStepStatus::Skipped,
1419 Some(serde_json::json!({ "reason": "no linear release config" })),
1420 )?;
1421 Vec::new()
1422 };
1423
1424 step += 1;
1425 loader.update(&format!(
1426 "[{}/{}] Syncing release docs",
1427 step, total_steps
1428 ));
1429 let release_doc_paths = if release_ledger.step_is_done(LEDGER_STEP_SYNC_RELEASE_DOCS) {
1430 release_ledger
1431 .step(LEDGER_STEP_SYNC_RELEASE_DOCS)
1432 .and_then(|step| step.details.get("paths"))
1433 .and_then(serde_json::Value::as_array)
1434 .map(|paths| paths.iter().filter_map(serde_json::Value::as_str).map(PathBuf::from).collect::<Vec<_>>())
1435 .unwrap_or_default()
1436 } else {
1437 begin_release_step(
1438 &mut release_ledger,
1439 &ledger_path,
1440 LEDGER_STEP_SYNC_RELEASE_DOCS,
1441 None,
1442 )?;
1443 let paths = sync_release_docs(&project_root, &owner, &repo).inspect_err(|error| {
1444 let _ = fail_release_step(
1445 &mut release_ledger,
1446 &ledger_path,
1447 LEDGER_STEP_SYNC_RELEASE_DOCS,
1448 error,
1449 );
1450 })?;
1451 complete_release_step(
1452 &mut release_ledger,
1453 &ledger_path,
1454 LEDGER_STEP_SYNC_RELEASE_DOCS,
1455 ReleaseLedgerStepStatus::Completed,
1456 Some(serde_json::json!({
1457 "paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
1458 })),
1459 )?;
1460 paths
1461 };
1462 step += 1;
1463 loader.update(&format!(
1464 "[{}/{}] Auto-committing release docs",
1465 step, total_steps
1466 ));
1467 if !release_ledger.step_is_done(LEDGER_STEP_COMMIT_RELEASE_DOCS) {
1468 begin_release_step(
1469 &mut release_ledger,
1470 &ledger_path,
1471 LEDGER_STEP_COMMIT_RELEASE_DOCS,
1472 None,
1473 )?;
1474 auto_commit_command_paths(
1475 &project_root,
1476 release_doc_paths.clone(),
1477 format!("docs(release): sync release docs for {}", tag_name),
1478 "xbp version release",
1479 )
1480 .await;
1481 complete_release_step(
1482 &mut release_ledger,
1483 &ledger_path,
1484 LEDGER_STEP_COMMIT_RELEASE_DOCS,
1485 ReleaseLedgerStepStatus::Completed,
1486 Some(serde_json::json!({
1487 "paths": release_doc_paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
1488 })),
1489 )?;
1490 }
1491 complete_release_ledger(&mut release_ledger);
1492 save_release_ledger(&ledger_path, &release_ledger)?;
1493 let summary = ReleaseWorkflowSummary {
1494 version: release_version.clone(),
1495 tag_name,
1496 release_url,
1497 uploaded_openapi_assets,
1498 release_title: release_input.title.clone(),
1499 release_notes: release_input.notes.clone(),
1500 repository_owner: owner.clone(),
1501 repository_name: repo.clone(),
1502 scope_kind: version_scope_kind(&version_scope).to_string(),
1503 scope_label: version_scope_label(&version_scope, &repo),
1504 published_initiatives,
1505 release_branch: created_release_branch,
1506 };
1507 sync_cli_release_activity(&summary).await;
1508
1509 Ok(summary)
1510 }
1511 .await;
1512
1513 match result {
1514 Ok(summary) => {
1515 loader.success_with(&format!("Published {}", summary.tag_name));
1516 println!("Released {} successfully.", summary.tag_name);
1517 println!("GitHub release: {}", summary.release_url);
1518 if !summary.uploaded_openapi_assets.is_empty() {
1519 println!(
1520 "Uploaded OpenAPI assets: {}",
1521 summary.uploaded_openapi_assets.join(", ")
1522 );
1523 }
1524 if !summary.published_initiatives.is_empty() {
1525 println!(
1526 "Published release update to Linear initiative(s): {}",
1527 summary
1528 .published_initiatives
1529 .iter()
1530 .map(|initiative| initiative.name.as_str())
1531 .collect::<Vec<_>>()
1532 .join(", ")
1533 );
1534 }
1535 if let Some(release_branch) = summary.release_branch {
1536 println!("Release branch: {}", release_branch);
1537 }
1538 println!("Updated release docs: CHANGELOG.md and SECURITY.md");
1539 Ok(())
1540 }
1541 Err(error) => {
1542 loader.fail(&error);
1543 Err(error)
1544 }
1545 }
1546}
1547
1548pub async fn print_version() {
1550 println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
1551}
1552
1553fn resolve_project_root() -> PathBuf {
1554 let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1555 resolve_project_root_from(&cwd)
1556}
1557
1558fn resolve_project_root_from(cwd: &Path) -> PathBuf {
1559 if let Some(found) = find_xbp_config_upwards(cwd) {
1560 return found.project_root;
1561 }
1562
1563 if let Some(root) = git_repository_root(cwd) {
1564 return root;
1565 }
1566
1567 cwd.to_path_buf()
1568}
1569
1570fn current_scope_slug(version_scope: &VersionScope, repo: &str) -> String {
1571 release_ledger_scope_slug(
1572 version_scope_kind(version_scope),
1573 &version_scope_label(version_scope, repo),
1574 )
1575}
1576
1577fn current_scope_ledger_path(
1578 project_root: &Path,
1579 version_scope: &VersionScope,
1580 repo: &str,
1581 version: &Version,
1582) -> PathBuf {
1583 release_ledger_path(
1584 project_root,
1585 ¤t_scope_slug(version_scope, repo),
1586 &version.to_string(),
1587 )
1588}
1589
1590fn resolve_change_baseline(
1591 project_root: &Path,
1592 version_scope: &VersionScope,
1593 repo: &str,
1594) -> Result<(String, Option<String>, Option<String>), String> {
1595 let scope_slug = current_scope_slug(version_scope, repo);
1596 if let Some(ledger) = find_latest_successful_release_ledger(project_root, &scope_slug)? {
1597 if let Some(tag) = ledger.baseline.tag.or(Some(ledger.tag_name)) {
1598 return Ok(("release-ledger".to_string(), Some(tag.clone()), Some(tag)));
1599 }
1600 }
1601
1602 let tags = collect_remote_git_versions(project_root, "origin", version_scope)?;
1603 if let Some(latest) = tags.first() {
1604 let tag = latest
1605 .raw_tags
1606 .first()
1607 .cloned()
1608 .unwrap_or_else(|| default_release_tag_name(version_scope, &latest.version));
1609 return Ok(("github-tag".to_string(), Some(tag.clone()), Some(tag)));
1610 }
1611
1612 Ok(("initial-release".to_string(), None, None))
1613}
1614
1615fn collect_changed_files_since_reference(
1616 project_root: &Path,
1617 baseline_ref: Option<&str>,
1618) -> Result<Vec<String>, String> {
1619 let mut args = vec!["diff", "--name-only"];
1620 if let Some(reference) = baseline_ref
1621 .map(str::trim)
1622 .filter(|value| !value.is_empty())
1623 {
1624 args.push(reference);
1625 args.push("HEAD");
1626 }
1627 let output = run_git_command(project_root, &args)?;
1628 let mut changed_files = output
1629 .lines()
1630 .map(str::trim)
1631 .filter(|value| !value.is_empty())
1632 .map(str::to_string)
1633 .collect::<Vec<_>>();
1634 changed_files.sort();
1635 changed_files.dedup();
1636 Ok(changed_files)
1637}
1638
1639fn resolve_changed_target_selection(
1640 project_root: &Path,
1641 invocation_dir: &Path,
1642 registry: &[String],
1643 version_scope: &VersionScope,
1644 repo: &str,
1645) -> Result<ChangedTargetSelection, String> {
1646 let (baseline_source, baseline_reference, baseline_tag) =
1647 resolve_change_baseline(project_root, version_scope, repo)?;
1648 let changed_files =
1649 collect_changed_files_since_reference(project_root, baseline_reference.as_deref())?;
1650
1651 let all_version_targets =
1652 resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1653 if all_version_targets.is_empty() {
1654 return Err(
1655 "No configured version files were found to evaluate for release selection.".to_string(),
1656 );
1657 }
1658
1659 let version_targets = if changed_files.is_empty() || baseline_reference.is_none() {
1660 dedupe_resolved_registry_paths(&all_version_targets)
1661 } else {
1662 let selected = select_changed_version_targets(
1663 project_root,
1664 version_scope,
1665 &all_version_targets,
1666 &changed_files,
1667 );
1668 if selected.is_empty() {
1669 return Err(
1670 "No version targets matched commits since the last release baseline.".to_string(),
1671 );
1672 }
1673 selected
1674 };
1675
1676 let publish_targets = resolve_selected_publish_targets(
1677 project_root,
1678 invocation_dir,
1679 version_scope,
1680 &version_targets,
1681 changed_files.is_empty() || baseline_reference.is_none(),
1682 )?;
1683
1684 Ok(ChangedTargetSelection {
1685 changed_files,
1686 version_targets,
1687 publish_targets,
1688 baseline_source,
1689 baseline_reference,
1690 baseline_tag,
1691 })
1692}
1693
1694fn select_changed_version_targets(
1695 project_root: &Path,
1696 version_scope: &VersionScope,
1697 targets: &[ResolvedRegistryPath],
1698 changed_files: &[String],
1699) -> Vec<ResolvedRegistryPath> {
1700 if !matches!(version_scope, VersionScope::Repository) {
1701 if changed_files
1702 .iter()
1703 .any(|path| scope_matches_changed_path(project_root, version_scope, path))
1704 {
1705 return targets.to_vec();
1706 }
1707 return Vec::new();
1708 }
1709
1710 let mut selected = Vec::new();
1711 let mut shared_root_change = false;
1712 for changed_file in changed_files {
1713 let mut matched_any = false;
1714 for target in targets {
1715 if changed_file_matches_version_target(changed_file, target) {
1716 matched_any = true;
1717 if !selected
1718 .iter()
1719 .any(|existing: &ResolvedRegistryPath| existing.relative == target.relative)
1720 {
1721 selected.push(target.clone());
1722 }
1723 }
1724 }
1725 if !matched_any {
1726 shared_root_change = true;
1727 }
1728 }
1729
1730 if shared_root_change {
1731 return dedupe_resolved_registry_paths(targets);
1732 }
1733 selected
1734}
1735
1736fn dedupe_resolved_registry_paths(targets: &[ResolvedRegistryPath]) -> Vec<ResolvedRegistryPath> {
1737 let mut seen = BTreeSet::new();
1738 targets
1739 .iter()
1740 .filter(|target| seen.insert(target.relative.clone()))
1741 .cloned()
1742 .collect()
1743}
1744
1745fn changed_file_matches_version_target(changed_file: &str, target: &ResolvedRegistryPath) -> bool {
1746 let parent = Path::new(&target.relative)
1747 .parent()
1748 .map(normalized_relative_path)
1749 .unwrap_or_else(|| ".".to_string());
1750 if parent == "." || parent.is_empty() {
1751 return true;
1752 }
1753 changed_file == parent || changed_file.starts_with(&format!("{parent}/"))
1754}
1755
1756fn scope_matches_changed_path(
1757 project_root: &Path,
1758 version_scope: &VersionScope,
1759 changed_file: &str,
1760) -> bool {
1761 match version_scope {
1762 VersionScope::Repository => true,
1763 VersionScope::Crate { crate_root, .. } => {
1764 path_matches_root(project_root, crate_root, changed_file)
1765 }
1766 VersionScope::Service {
1767 service_root,
1768 version_targets,
1769 ..
1770 } => {
1771 path_matches_root(project_root, service_root, changed_file)
1772 || version_targets
1773 .iter()
1774 .any(|target| path_matches_target(changed_file, target))
1775 }
1776 }
1777}
1778
1779fn path_matches_root(project_root: &Path, scope_root: &Path, path: &str) -> bool {
1780 let relative_root = scope_root
1781 .strip_prefix(project_root)
1782 .ok()
1783 .map(normalized_relative_path)
1784 .unwrap_or_else(|| path.replace('\\', "/"));
1785
1786 path_matches_target(path, &relative_root)
1787}
1788
1789fn path_matches_target(path: &str, target: &str) -> bool {
1790 let normalized_target = target.replace('\\', "/");
1791 if normalized_target.is_empty() || normalized_target == "." {
1792 return true;
1793 }
1794 path == normalized_target || path.starts_with(&format!("{normalized_target}/"))
1795}
1796
1797fn resolve_selected_publish_targets(
1798 project_root: &Path,
1799 invocation_dir: &Path,
1800 version_scope: &VersionScope,
1801 selected_version_targets: &[ResolvedRegistryPath],
1802 select_all: bool,
1803) -> Result<Vec<ReleasePublishTarget>, String> {
1804 let Some((_, config)) = load_version_target_config(invocation_dir) else {
1805 return Ok(Vec::new());
1806 };
1807 let Some(publish) = config.publish else {
1808 return Ok(Vec::new());
1809 };
1810
1811 let selected_version_paths = selected_version_targets
1812 .iter()
1813 .map(|target| target.relative.as_str())
1814 .collect::<BTreeSet<_>>();
1815 let mut targets = Vec::new();
1816 for (kind, target) in [("npm", publish.npm), ("crates", publish.crates)] {
1817 let Some(target) = target.filter(|value| value.enabled.unwrap_or(true)) else {
1818 continue;
1819 };
1820 let manifest_path = target.manifest_path.clone().map(PathBuf::from).or_else(|| {
1821 target
1822 .working_directory
1823 .clone()
1824 .map(PathBuf::from)
1825 .map(|directory| {
1826 directory.join(if kind == "npm" {
1827 "package.json"
1828 } else {
1829 "Cargo.toml"
1830 })
1831 })
1832 });
1833 let Some(manifest_path) = manifest_path else {
1834 continue;
1835 };
1836 let relative = manifest_path
1837 .strip_prefix(project_root)
1838 .ok()
1839 .map(normalized_relative_path)
1840 .unwrap_or_else(|| normalized_relative_path(&manifest_path));
1841 let include = if select_all || selected_version_paths.contains(relative.as_str()) {
1842 true
1843 } else if matches!(version_scope, VersionScope::Service { .. }) {
1844 version_scope_root(version_scope).is_some_and(|root| manifest_path.starts_with(root))
1845 } else {
1846 false
1847 };
1848 if include {
1849 targets.push(ReleasePublishTarget {
1850 kind: kind.to_string(),
1851 manifest_path: relative,
1852 package_name: target
1853 .package_name
1854 .clone()
1855 .filter(|value| !value.trim().is_empty()),
1856 });
1857 }
1858 }
1859 Ok(targets)
1860}
1861
1862fn read_highest_version_from_targets(
1863 targets: &[ResolvedRegistryPath],
1864) -> Result<Option<Version>, String> {
1865 let mut highest = None;
1866 for target in targets {
1867 if let Some(version) = read_version_from_resolved_path(target)? {
1868 let parsed = parse_version(&version)?;
1869 if highest
1870 .as_ref()
1871 .is_none_or(|current: &Version| parsed > *current)
1872 {
1873 highest = Some(parsed);
1874 }
1875 }
1876 }
1877 Ok(highest)
1878}
1879
1880fn write_version_to_selected_paths(
1881 targets: &[ResolvedRegistryPath],
1882 version: &Version,
1883 allow_noop_when_targets_exist: bool,
1884) -> Result<Vec<PathBuf>, String> {
1885 let mut updated = 0usize;
1886 let mut matched_targets = 0usize;
1887 let mut updated_paths = Vec::new();
1888 let mut errors = Vec::new();
1889
1890 for entry in targets {
1891 let path = &entry.absolute;
1892 if !path.exists() {
1893 continue;
1894 }
1895 matched_targets += 1;
1896 match write_version_to_resolved_path(entry, version) {
1897 Ok(true) => {
1898 updated += 1;
1899 updated_paths.push(path.clone());
1900 }
1901 Ok(false) => {}
1902 Err(error) => errors.push(format!("{}: {}", path.display(), error)),
1903 }
1904 }
1905
1906 if matched_targets == 0 && errors.is_empty() {
1907 return Err("No selected version files were found to update.".to_string());
1908 }
1909 if !errors.is_empty() {
1910 return Err(format!(
1911 "Updated {} file(s), but some selected version targets failed:\n{}",
1912 updated,
1913 errors.join("\n")
1914 ));
1915 }
1916 if updated == 0 && allow_noop_when_targets_exist {
1917 return Ok(updated_paths);
1918 }
1919 Ok(updated_paths)
1920}
1921
1922fn initialize_release_ledger(
1923 version_scope: &VersionScope,
1924 repo: &str,
1925 release_version: &Version,
1926 tag_name: &str,
1927 branch_name: Option<String>,
1928 selection: &ChangedTargetSelection,
1929) -> ReleaseLedger {
1930 ReleaseLedger {
1931 scope_kind: version_scope_kind(version_scope).to_string(),
1932 scope_label: version_scope_label(version_scope, repo),
1933 scope_slug: current_scope_slug(version_scope, repo),
1934 version: release_version.to_string(),
1935 tag_name: tag_name.to_string(),
1936 branch_name,
1937 baseline: ReleaseBaselineRecord {
1938 source: selection.baseline_source.clone(),
1939 reference: selection.baseline_reference.clone(),
1940 tag: selection.baseline_tag.clone(),
1941 },
1942 selection: ReleaseSelectionRecord {
1943 changed_files: selection.changed_files.clone(),
1944 version_targets: selection
1945 .version_targets
1946 .iter()
1947 .map(|target| ReleaseVersionTarget {
1948 path: target.relative.clone(),
1949 })
1950 .collect(),
1951 publish_targets: selection.publish_targets.clone(),
1952 },
1953 status: ReleaseLedgerStatus::InProgress,
1954 steps: BTreeMap::new(),
1955 created_at: Some(chrono::Utc::now()),
1956 updated_at: Some(chrono::Utc::now()),
1957 completed_at: None,
1958 }
1959}
1960
1961fn update_ledger_step(
1962 ledger: &mut ReleaseLedger,
1963 step_name: &str,
1964 status: ReleaseLedgerStepStatus,
1965 details: Option<serde_json::Value>,
1966 last_error: Option<String>,
1967) {
1968 let now = chrono::Utc::now();
1969 let step = ledger.steps.entry(step_name.to_string()).or_default();
1970 if step.started_at.is_none() {
1971 step.started_at = Some(now);
1972 }
1973 if matches!(
1974 status,
1975 ReleaseLedgerStepStatus::Completed
1976 | ReleaseLedgerStepStatus::Failed
1977 | ReleaseLedgerStepStatus::Skipped
1978 | ReleaseLedgerStepStatus::VerifiedRemote
1979 ) {
1980 step.completed_at = Some(now);
1981 }
1982 step.status = status;
1983 if let Some(details) = details {
1984 step.details = details;
1985 }
1986 step.last_error = last_error;
1987 ledger.updated_at = Some(now);
1988 ledger.status = match step.status {
1989 ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
1990 _ => ReleaseLedgerStatus::InProgress,
1991 };
1992}
1993
1994fn complete_release_ledger(ledger: &mut ReleaseLedger) {
1995 let now = chrono::Utc::now();
1996 ledger.status = ReleaseLedgerStatus::Completed;
1997 ledger.updated_at = Some(now);
1998 ledger.completed_at = Some(now);
1999}
2000
2001fn ledger_version_targets_to_resolved(
2002 project_root: &Path,
2003 ledger: &ReleaseLedger,
2004) -> Vec<ResolvedRegistryPath> {
2005 ledger
2006 .selection
2007 .version_targets
2008 .iter()
2009 .map(|target| ResolvedRegistryPath {
2010 relative: target.path.clone(),
2011 absolute: project_root.join(&target.path),
2012 cargo_package_override: None,
2013 })
2014 .collect()
2015}
2016
2017fn print_selected_release_targets(selection: &ChangedTargetSelection) {
2018 println!("{}", "Selected release targets".bright_cyan().bold());
2019 if selection.version_targets.is_empty() {
2020 println!(" {}", "No version targets selected".dimmed());
2021 } else {
2022 println!(" {}", "Version targets".bright_white());
2023 for target in &selection.version_targets {
2024 println!(" {} {}", "•".bright_cyan(), target.relative);
2025 }
2026 }
2027 if selection.publish_targets.is_empty() {
2028 println!(" {}", "Publish targets: none".dimmed());
2029 } else {
2030 println!(" {}", "Publish targets".bright_white());
2031 for target in &selection.publish_targets {
2032 println!(
2033 " {} {} {}",
2034 "•".bright_cyan(),
2035 target.kind,
2036 target.manifest_path
2037 );
2038 }
2039 }
2040}
2041
2042fn begin_release_step(
2043 ledger: &mut ReleaseLedger,
2044 ledger_path: &Path,
2045 step_name: &str,
2046 details: Option<serde_json::Value>,
2047) -> Result<(), String> {
2048 update_ledger_step(
2049 ledger,
2050 step_name,
2051 ReleaseLedgerStepStatus::Running,
2052 details,
2053 None,
2054 );
2055 save_release_ledger(ledger_path, ledger)
2056}
2057
2058fn complete_release_step(
2059 ledger: &mut ReleaseLedger,
2060 ledger_path: &Path,
2061 step_name: &str,
2062 status: ReleaseLedgerStepStatus,
2063 details: Option<serde_json::Value>,
2064) -> Result<(), String> {
2065 update_ledger_step(ledger, step_name, status, details, None);
2066 save_release_ledger(ledger_path, ledger)
2067}
2068
2069fn fail_release_step(
2070 ledger: &mut ReleaseLedger,
2071 ledger_path: &Path,
2072 step_name: &str,
2073 error: &str,
2074) -> Result<(), String> {
2075 update_ledger_step(
2076 ledger,
2077 step_name,
2078 ReleaseLedgerStepStatus::Failed,
2079 None,
2080 Some(error.to_string()),
2081 );
2082 save_release_ledger(ledger_path, ledger)
2083}
2084
2085async fn verify_selected_publish_targets(
2086 project_root: &Path,
2087 selected_publish_targets: &[ReleasePublishTarget],
2088 expected_version: &Version,
2089) -> Result<bool, String> {
2090 if selected_publish_targets.is_empty() {
2091 return Ok(false);
2092 }
2093 let client = reqwest::Client::new();
2094 for target in selected_publish_targets {
2095 let manifest_path = project_root.join(&target.manifest_path);
2096 let package_name = target
2097 .package_name
2098 .clone()
2099 .or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
2100 .ok_or_else(|| {
2101 format!(
2102 "Could not resolve package name for selected publish target `{}`.",
2103 target.manifest_path
2104 )
2105 })?;
2106 let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
2107 if details.version.trim() != expected_version.to_string() {
2108 return Ok(false);
2109 }
2110 }
2111 Ok(true)
2112}
2113
2114fn detect_package_name_from_manifest(kind: &str, manifest_path: &Path) -> Option<String> {
2115 match kind {
2116 "npm" => {
2117 let content = fs::read_to_string(manifest_path).ok()?;
2118 let json: JsonValue = serde_json::from_str(&content).ok()?;
2119 json.get("name")
2120 .and_then(JsonValue::as_str)
2121 .map(str::trim)
2122 .filter(|value| !value.is_empty())
2123 .map(str::to_string)
2124 }
2125 "crates" => cargo_package_name(manifest_path).ok().flatten(),
2126 _ => None,
2127 }
2128}
2129
2130fn publish_target_filter_from_selection(
2131 selected_publish_targets: &[ReleasePublishTarget],
2132) -> Result<Option<String>, String> {
2133 let kinds = selected_publish_targets
2134 .iter()
2135 .map(|target| target.kind.as_str())
2136 .collect::<BTreeSet<_>>();
2137 if kinds.is_empty() || kinds.len() > 1 {
2138 return Ok(None);
2139 }
2140 let kind = kinds.iter().next().copied().unwrap_or_default();
2141 if kind.is_empty() {
2142 return Ok(None);
2143 }
2144 match kind {
2145 "npm" | "crates" => Ok(Some(kind.to_string())),
2146 other => Err(format!(
2147 "Unsupported selected publish target kind `{}` in release ledger.",
2148 other
2149 )),
2150 }
2151}
2152
2153fn collect_version_report(
2154 project_root: &Path,
2155 invocation_dir: &Path,
2156 registry: &[String],
2157 version_scope: &VersionScope,
2158) -> VersionReport {
2159 let mut report: VersionReport = VersionReport::default();
2160 let repo_label = project_root
2161 .file_name()
2162 .and_then(|value| value.to_str())
2163 .unwrap_or("repository");
2164 let scope_slug = current_scope_slug(version_scope, repo_label);
2165 report.worktree = collect_local_versions(
2166 project_root,
2167 invocation_dir,
2168 registry,
2169 version_scope,
2170 &mut report.warnings,
2171 );
2172 match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
2173 Ok(entries) => report.head = entries,
2174 Err(err) => report.warnings.push(err),
2175 }
2176 match collect_git_versions(project_root, version_scope) {
2177 Ok(tags) => report.local_tags = tags,
2178 Err(err) => report.warnings.push(err),
2179 }
2180 match collect_remote_git_versions(project_root, "origin", version_scope) {
2181 Ok(tags) => report.remote_tags = tags,
2182 Err(err) => report.warnings.push(err),
2183 }
2184 match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
2185 Ok(files) => report.dirty_files = files,
2186 Err(err) => report.warnings.push(err),
2187 }
2188 match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
2189 Ok(ledger) => report.release_ledger = ledger,
2190 Err(err) => report.warnings.push(err),
2191 }
2192 report
2193}
2194
2195fn resolve_current_version_for_bump(
2196 project_root: &Path,
2197 invocation_dir: &Path,
2198 registry: &[String],
2199 version_scope: &VersionScope,
2200) -> Version {
2201 let mut _warnings = Vec::new();
2202 let local_versions = collect_local_versions(
2203 project_root,
2204 invocation_dir,
2205 registry,
2206 version_scope,
2207 &mut _warnings,
2208 );
2209 let head_versions =
2210 collect_head_versions(project_root, invocation_dir, registry, version_scope)
2211 .unwrap_or_default();
2212 let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
2213
2214 local_versions
2215 .iter()
2216 .map(|entry| entry.version.clone())
2217 .chain(head_versions.iter().map(|entry| entry.version.clone()))
2218 .chain(local_tags.iter().map(|entry| entry.version.clone()))
2219 .max()
2220 .unwrap_or_else(default_version)
2221}
2222
2223async fn auto_commit_command_paths(
2224 project_root: &Path,
2225 paths: Vec<PathBuf>,
2226 message: String,
2227 action_label: &'static str,
2228) {
2229 match commit_paths(AutoCommitRequest {
2230 project_root,
2231 paths,
2232 message,
2233 action_label,
2234 })
2235 .await
2236 {
2237 Ok(AutoCommitResult::Committed(_)) => {}
2238 Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
2239 Err(e) => print_skip(action_label, &e),
2240 }
2241}
2242
2243async fn collect_registry_versions(
2244 project_root: &Path,
2245 invocation_dir: &Path,
2246 lookups: &[PackageNameLookup],
2247 version_scope: &VersionScope,
2248 warnings: &mut Vec<String>,
2249) -> Vec<RegistryVersionObservation> {
2250 let mut entries: Vec<RegistryVersionObservation> = Vec::new();
2251 let mut seen: BTreeSet<String> = BTreeSet::new();
2252 let client: reqwest::Client = reqwest::Client::new();
2253
2254 for lookup in lookups {
2255 let dedupe_key: String = format!(
2256 "{}|{}|{}|{}",
2257 lookup.file, lookup.format, lookup.key, lookup.registry
2258 );
2259 if !seen.insert(dedupe_key) {
2260 continue;
2261 }
2262
2263 let source_file = resolve_registry_relative_path(
2264 project_root,
2265 invocation_dir,
2266 version_scope,
2267 &lookup.file,
2268 );
2269 let path = project_root.join(&source_file);
2270 if !path.exists() {
2271 continue;
2272 }
2273
2274 let content: String = match fs::read_to_string(&path) {
2275 Ok(content) => content,
2276 Err(err) => {
2277 warnings.push(format!("Failed to read {}: {}", path.display(), err));
2278 continue;
2279 }
2280 };
2281
2282 let package_name: String = match read_package_name_from_lookup(lookup, &content) {
2283 Ok(Some(value)) => value,
2284 Ok(None) => continue,
2285 Err(err) => {
2286 warnings.push(format!("{}: {}", source_file, err));
2287 continue;
2288 }
2289 };
2290
2291 let (latest, raw_version, published_at, note) =
2292 match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
2293 Ok(details) => {
2294 let parsed: Option<Version> = parse_version(&details.version).ok();
2295 let note: Option<String> = if parsed.is_none() {
2296 Some(format!("Non-semver registry version: {}", details.version))
2297 } else {
2298 None
2299 };
2300 (parsed, Some(details.version), details.published_at, note)
2301 }
2302 Err(err) => (None, None, None, Some(err)),
2303 };
2304
2305 entries.push(RegistryVersionObservation {
2306 registry: lookup.registry.clone(),
2307 package_name,
2308 source_file,
2309 latest,
2310 raw_version,
2311 published_at,
2312 note,
2313 });
2314 }
2315
2316 entries.sort_by(|a, b| {
2317 a.registry
2318 .cmp(&b.registry)
2319 .then_with(|| a.package_name.cmp(&b.package_name))
2320 });
2321 entries
2322}
2323
2324fn read_package_name_from_lookup(
2325 lookup: &PackageNameLookup,
2326 content: &str,
2327) -> Result<Option<String>, String> {
2328 let key_parts: Vec<&str> = lookup
2329 .key
2330 .split('.')
2331 .map(|part| part.trim())
2332 .filter(|part| !part.is_empty())
2333 .collect();
2334 if key_parts.is_empty() {
2335 return Err("Lookup key cannot be empty".to_string());
2336 }
2337
2338 let format: String = lookup.format.trim().to_ascii_lowercase();
2339 match format.as_str() {
2340 "json" => {
2341 let value: JsonValue = serde_json::from_str(content)
2342 .map_err(|e| format!("Failed to parse JSON: {}", e))?;
2343 Ok(json_lookup_string(&value, &key_parts))
2344 }
2345 "yaml" | "yml" => {
2346 let value: YamlValue = serde_yaml::from_str(content)
2347 .map_err(|e| format!("Failed to parse YAML: {}", e))?;
2348 Ok(yaml_lookup_string(&value, &key_parts))
2349 }
2350 "toml" => {
2351 let value: TomlValue =
2352 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2353 Ok(toml_lookup_string(&value, &key_parts))
2354 }
2355 other => Err(format!("Unsupported lookup format `{}`", other)),
2356 }
2357}
2358
2359async fn fetch_registry_latest_version(
2360 client: &reqwest::Client,
2361 registry: &str,
2362 package_name: &str,
2363) -> Result<RegistryVersionDetails, String> {
2364 let normalized_registry: String = registry.trim().to_ascii_lowercase();
2365 match normalized_registry.as_str() {
2366 "npm" => fetch_npm_latest_version(client, package_name).await,
2367 "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
2368 _ => Err(format!("Unsupported registry `{}`", registry)),
2369 }
2370}
2371
2372#[derive(Debug, Deserialize)]
2373struct NpmLatestResponse {
2374 #[serde(rename = "dist-tags")]
2375 dist_tags: NpmDistTags,
2376 #[serde(default)]
2377 time: BTreeMap<String, String>,
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct NpmDistTags {
2382 latest: String,
2383}
2384
2385async fn fetch_npm_latest_version(
2386 client: &reqwest::Client,
2387 package_name: &str,
2388) -> Result<RegistryVersionDetails, String> {
2389 let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
2390 .map_err(|e| format!("Failed to build npm URL: {}", e))?;
2391 {
2392 let mut segments = url
2393 .path_segments_mut()
2394 .map_err(|_| "Failed to compose npm URL segments".to_string())?;
2395 segments.push(package_name);
2396 }
2397
2398 let response: reqwest::Response = client
2399 .get(url)
2400 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2401 .send()
2402 .await
2403 .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
2404
2405 if !response.status().is_success() {
2406 return Err(format!(
2407 "npm lookup for {} returned status {}",
2408 package_name,
2409 response.status()
2410 ));
2411 }
2412
2413 let payload: NpmLatestResponse = response
2414 .json()
2415 .await
2416 .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
2417 Ok(RegistryVersionDetails {
2418 published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
2419 version: payload.dist_tags.latest,
2420 })
2421}
2422
2423#[derive(Debug, Deserialize)]
2424struct CratesIoMeta {
2425 newest_version: String,
2426}
2427
2428#[derive(Debug, Deserialize)]
2429struct CratesIoVersionMeta {
2430 num: String,
2431 created_at: Option<String>,
2432}
2433
2434#[derive(Debug, Deserialize)]
2435struct CratesIoLatestResponse {
2436 #[serde(rename = "crate")]
2437 crate_meta: CratesIoMeta,
2438 #[serde(default)]
2439 versions: Vec<CratesIoVersionMeta>,
2440}
2441
2442async fn fetch_crates_latest_version(
2443 client: &reqwest::Client,
2444 package_name: &str,
2445) -> Result<RegistryVersionDetails, String> {
2446 let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
2447 .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
2448 {
2449 let mut segments = url
2450 .path_segments_mut()
2451 .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
2452 segments.push(package_name);
2453 }
2454
2455 let response: reqwest::Response = client
2456 .get(url)
2457 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2458 .send()
2459 .await
2460 .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
2461
2462 if !response.status().is_success() {
2463 return Err(format!(
2464 "crates.io lookup for {} returned status {}",
2465 package_name,
2466 response.status()
2467 ));
2468 }
2469
2470 let payload: CratesIoLatestResponse = response.json().await.map_err(|e| {
2471 format!(
2472 "Failed to parse crates.io response for {}: {}",
2473 package_name, e
2474 )
2475 })?;
2476 let published_at = payload
2477 .versions
2478 .iter()
2479 .find(|version| version.num == payload.crate_meta.newest_version)
2480 .and_then(|version| version.created_at.clone());
2481 Ok(RegistryVersionDetails {
2482 version: payload.crate_meta.newest_version,
2483 published_at,
2484 })
2485}
2486
2487fn collect_local_versions(
2488 project_root: &Path,
2489 invocation_dir: &Path,
2490 registry: &[String],
2491 version_scope: &VersionScope,
2492 warnings: &mut Vec<String>,
2493) -> Vec<VersionObservation> {
2494 let mut observed = Vec::new();
2495
2496 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2497 let path: &PathBuf = &entry.absolute;
2498 if !path.exists() {
2499 continue;
2500 }
2501
2502 match read_version_from_resolved_path(&entry) {
2503 Ok(Some(version)) => {
2504 if let Ok(parsed) = parse_version(&version) {
2505 observed.push(VersionObservation {
2506 location: entry.relative.clone(),
2507 version: parsed,
2508 });
2509 } else {
2510 warnings.push(format!("Ignoring non-semver version in {}", path.display()));
2511 }
2512 }
2513 Ok(None) => {}
2514 Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
2515 }
2516 }
2517
2518 observed.sort_by(|a, b| a.location.cmp(&b.location));
2519 observed
2520}
2521
2522fn collect_git_versions(
2523 project_root: &Path,
2524 version_scope: &VersionScope,
2525) -> Result<Vec<GitTagObservation>, String> {
2526 if !command_exists("git") {
2527 return Err("Git is not installed; skipping git tag inspection.".to_string());
2528 }
2529
2530 let output: std::process::Output = Command::new("git")
2531 .current_dir(project_root)
2532 .args(["tag", "--list"])
2533 .output()
2534 .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
2535
2536 if !output.status.success() {
2537 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2538 if stderr.is_empty() {
2539 return Err("`git tag --list` failed in the current directory.".to_string());
2540 }
2541 return Err(format!("`git tag --list` failed: {}", stderr));
2542 }
2543
2544 Ok(parse_local_git_tag_output_for_scope(
2545 &String::from_utf8_lossy(&output.stdout),
2546 version_scope,
2547 ))
2548}
2549
2550fn collect_remote_git_versions(
2551 project_root: &Path,
2552 remote: &str,
2553 version_scope: &VersionScope,
2554) -> Result<Vec<GitTagObservation>, String> {
2555 if !command_exists("git") {
2556 return Err("Git is not installed; skipping remote tag inspection.".to_string());
2557 }
2558
2559 let output: std::process::Output = Command::new("git")
2560 .current_dir(project_root)
2561 .args(["ls-remote", "--tags", remote])
2562 .output()
2563 .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
2564
2565 if !output.status.success() {
2566 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2567 if stderr.is_empty() {
2568 return Err(format!("`git ls-remote --tags {}` failed.", remote));
2569 }
2570 return Err(format!(
2571 "`git ls-remote --tags {}` failed: {}",
2572 remote, stderr
2573 ));
2574 }
2575
2576 Ok(parse_remote_git_tag_output_for_scope(
2577 &String::from_utf8_lossy(&output.stdout),
2578 version_scope,
2579 ))
2580}
2581
2582fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
2583 let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
2584
2585 if tags.is_empty() {
2586 println!("No semantic git tags found in {}.", project_root.display());
2587 return Ok(());
2588 }
2589
2590 println!("Git versions from `git tag --list`:");
2591 for tag in tags {
2592 if tag.raw_tags.len() > 1 {
2593 println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
2594 } else {
2595 println!(" {}", tag.version);
2596 }
2597 }
2598
2599 Ok(())
2600}
2601
2602fn print_version_observations(
2603 title: &str,
2604 entries: &[VersionObservation],
2605 dirty_files: Option<&[String]>,
2606) {
2607 println!();
2608 println!("{}", title.bright_cyan().bold());
2609 println!("{}", "─".repeat(72).bright_black());
2610
2611 if entries.is_empty() {
2612 println!(" {}", "none found".dimmed());
2613 return;
2614 }
2615
2616 let Some(highest) = highest_version_observation(entries) else {
2617 println!(" {}", "none found".dimmed());
2618 return;
2619 };
2620
2621 let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
2622 let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
2623 println!(
2624 " {:<28} {} ({}/{})",
2625 "latest".bright_white(),
2626 highest.to_string().bright_green().bold(),
2627 latest_count,
2628 entries.len()
2629 );
2630
2631 if stale_entries.is_empty() {
2632 return;
2633 }
2634
2635 println!(" {}", "stale entries".bright_yellow().bold());
2636 for entry in stale_entries {
2637 let dirty: bool = dirty_files
2638 .map(|files| files.iter().any(|file| file == &entry.location))
2639 .unwrap_or(false);
2640 let dirty_suffix: String = if dirty {
2641 format!(" {}", "modified".bright_magenta())
2642 } else {
2643 String::new()
2644 };
2645
2646 println!(
2647 " {:<28} {}{}",
2648 entry.location.bright_white(),
2649 entry.version.to_string().bright_green(),
2650 dirty_suffix
2651 );
2652 }
2653}
2654
2655fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
2656 println!();
2657 println!("{}", title.bright_cyan().bold());
2658 println!("{}", "─".repeat(72).bright_black());
2659
2660 if tags.is_empty() {
2661 println!(" {}", "none found".dimmed());
2662 return;
2663 }
2664
2665 let latest = &tags[0];
2666 if latest.raw_tags.len() > 1 {
2667 println!(
2668 " {:<20} {}",
2669 latest.version.to_string().bright_green().bold(),
2670 latest.raw_tags.join(", ").dimmed()
2671 );
2672 } else {
2673 println!(" {}", latest.version.to_string().bright_green().bold());
2674 }
2675
2676 if tags.len() > 1 {
2677 let older = tags
2678 .iter()
2679 .skip(1)
2680 .take(5)
2681 .map(|tag| tag.version.to_string())
2682 .collect::<Vec<_>>();
2683 println!(
2684 " {:<20} {}",
2685 "older tags".bright_white(),
2686 older.join(", ").dimmed()
2687 );
2688 if tags.len() > 6 {
2689 println!(
2690 " {:<20} {}",
2691 "older hidden".bright_white(),
2692 format!("{} more", tags.len() - 6).dimmed()
2693 );
2694 }
2695 }
2696}
2697
2698fn collect_head_versions(
2699 project_root: &Path,
2700 invocation_dir: &Path,
2701 registry: &[String],
2702 version_scope: &VersionScope,
2703) -> Result<Vec<VersionObservation>, String> {
2704 if !command_exists("git") {
2705 return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
2706 }
2707
2708 let head_check = Command::new("git")
2709 .current_dir(project_root)
2710 .args(["rev-parse", "--verify", "HEAD"])
2711 .output()
2712 .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
2713
2714 if !head_check.status.success() {
2715 return Ok(Vec::new());
2716 }
2717
2718 let mut observed = Vec::new();
2719 let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
2720
2721 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2722 let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
2723 continue;
2724 };
2725
2726 match read_version_from_blob_with_override(
2727 &entry.relative,
2728 &content,
2729 cargo_toml_content.as_deref(),
2730 entry.cargo_package_override.as_deref(),
2731 ) {
2732 Ok(Some(version)) => {
2733 if let Ok(parsed) = parse_version(&version) {
2734 observed.push(VersionObservation {
2735 location: entry.relative.clone(),
2736 version: parsed,
2737 });
2738 }
2739 }
2740 Ok(None) => {}
2741 Err(_) => {}
2742 }
2743 }
2744
2745 observed.sort_by(|a, b| a.location.cmp(&b.location));
2746 Ok(observed)
2747}
2748
2749fn collect_dirty_version_files(
2750 project_root: &Path,
2751 invocation_dir: &Path,
2752 registry: &[String],
2753 version_scope: &VersionScope,
2754) -> Result<Vec<String>, String> {
2755 if !command_exists("git") {
2756 return Err("Git is not installed; skipping worktree status inspection.".to_string());
2757 }
2758
2759 let mut args = vec!["status", "--porcelain", "--"];
2760 let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
2761 for entry in &resolved {
2762 args.push(entry.relative.as_str());
2763 }
2764
2765 let output = Command::new("git")
2766 .current_dir(project_root)
2767 .args(&args)
2768 .output()
2769 .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
2770
2771 if !output.status.success() {
2772 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2773 if stderr.is_empty() {
2774 return Err("`git status --porcelain` failed.".to_string());
2775 }
2776 return Err(format!("`git status --porcelain` failed: {}", stderr));
2777 }
2778
2779 let mut dirty = Vec::new();
2780 for line in String::from_utf8_lossy(&output.stdout).lines() {
2781 if line.len() < 4 {
2782 continue;
2783 }
2784 let path = line[3..].trim();
2785 if !path.is_empty() {
2786 dirty.push(path.replace('\\', "/"));
2787 }
2788 }
2789
2790 dirty.sort();
2791 dirty.dedup();
2792 Ok(dirty)
2793}
2794
2795fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
2796 let output = Command::new("git")
2797 .current_dir(project_root)
2798 .args(["show", &format!("HEAD:{}", relative)])
2799 .output()
2800 .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
2801
2802 if !output.status.success() {
2803 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2804 if stderr.is_empty() {
2805 return Err(format!("{} is not present in HEAD", relative));
2806 }
2807 return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
2808 }
2809
2810 String::from_utf8(output.stdout)
2811 .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
2812}
2813
2814fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2815 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2816 for line in output.lines() {
2817 let tag = line.trim();
2818 if tag.is_empty() {
2819 continue;
2820 }
2821 if let Ok(version) = parse_version(tag) {
2822 by_version.entry(version).or_default().push(tag.to_string());
2823 }
2824 }
2825 git_tag_map_to_vec(by_version)
2826}
2827
2828fn parse_local_git_tag_output_for_scope(
2829 output: &str,
2830 version_scope: &VersionScope,
2831) -> Vec<GitTagObservation> {
2832 match version_scope {
2833 VersionScope::Repository => parse_local_git_tag_output(output),
2834 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2835 parse_scoped_git_tag_output(output, tag_prefix)
2836 }
2837 }
2838}
2839
2840fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2841 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2842 for line in output.lines() {
2843 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2844 let tag = reference
2845 .strip_prefix("refs/tags/")
2846 .unwrap_or(reference)
2847 .trim_end_matches("^{}")
2848 .trim();
2849
2850 if tag.is_empty() {
2851 continue;
2852 }
2853 if let Ok(version) = parse_version(tag) {
2854 by_version.entry(version).or_default().push(tag.to_string());
2855 }
2856 }
2857 git_tag_map_to_vec(by_version)
2858}
2859
2860fn parse_remote_git_tag_output_for_scope(
2861 output: &str,
2862 version_scope: &VersionScope,
2863) -> Vec<GitTagObservation> {
2864 match version_scope {
2865 VersionScope::Repository => parse_remote_git_tag_output(output),
2866 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2867 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2868 for line in output.lines() {
2869 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2870 let tag = reference
2871 .strip_prefix("refs/tags/")
2872 .unwrap_or(reference)
2873 .trim_end_matches("^{}")
2874 .trim();
2875
2876 if tag.is_empty() {
2877 continue;
2878 }
2879 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2880 by_version.entry(version).or_default().push(tag.to_string());
2881 }
2882 }
2883 git_tag_map_to_vec(by_version)
2884 }
2885 }
2886}
2887
2888fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
2889 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2890 for line in output.lines() {
2891 let tag = line.trim();
2892 if tag.is_empty() {
2893 continue;
2894 }
2895 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2896 by_version.entry(version).or_default().push(tag.to_string());
2897 }
2898 }
2899 git_tag_map_to_vec(by_version)
2900}
2901
2902fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
2903 let mut versions: Vec<GitTagObservation> = by_version
2904 .into_iter()
2905 .map(|(version, mut raw_tags)| {
2906 raw_tags.sort();
2907 raw_tags.dedup();
2908 GitTagObservation { version, raw_tags }
2909 })
2910 .collect();
2911 versions.sort_by(|a, b| b.version.cmp(&a.version));
2912 versions
2913}
2914
2915#[cfg(test)]
2916fn read_version_from_blob(
2917 relative: &str,
2918 content: &str,
2919 cargo_toml_content: Option<&str>,
2920) -> Result<Option<String>, String> {
2921 read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
2922}
2923
2924fn read_version_from_blob_with_override(
2925 relative: &str,
2926 content: &str,
2927 cargo_toml_content: Option<&str>,
2928 cargo_package_override: Option<&str>,
2929) -> Result<Option<String>, String> {
2930 let file_name = Path::new(relative)
2931 .file_name()
2932 .and_then(|n| n.to_str())
2933 .unwrap_or_default();
2934
2935 match file_name {
2936 "README.md" => read_readme_version_from_content(content),
2937 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2938 read_openapi_version_from_content(content)
2939 }
2940 "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
2941 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2942 | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
2943 "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
2944 "Cargo.toml" => read_cargo_toml_version_from_content(content),
2945 "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
2946 content,
2947 cargo_toml_content,
2948 cargo_package_override,
2949 ),
2950 "pyproject.toml" => read_pyproject_version_from_content(content),
2951 "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
2952 "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
2953 "pom.xml" => {
2954 read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
2955 }
2956 "build.gradle" | "build.gradle.kts" => {
2957 read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
2958 }
2959 "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
2960 _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
2961 Some("json") => read_json_root_version_from_content(content),
2962 Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
2963 Some("toml") => read_toml_root_version_from_content(content),
2964 Some("md") => read_readme_version_from_content(content),
2965 _ => Ok(None),
2966 },
2967 }
2968}
2969
2970fn print_version_report(project_root: &Path, report: &VersionReport) {
2971 let dirty_suffix = if report.dirty_files.is_empty() {
2972 "clean".green().to_string()
2973 } else {
2974 format!("dirty ({})", report.dirty_files.len())
2975 .bright_magenta()
2976 .to_string()
2977 };
2978
2979 println!(
2980 "\n{} {}",
2981 "Version Summary".bright_cyan().bold(),
2982 project_root.display().to_string().bright_white()
2983 );
2984 println!("{}", "─".repeat(72).bright_black());
2985 println!(
2986 "{:<20} {}",
2987 "Highest available".bright_white(),
2988 report.highest_available().to_string().bright_green().bold()
2989 );
2990 println!(
2991 "{:<20} {}",
2992 "Worktree".bright_white(),
2993 report
2994 .highest_worktree()
2995 .unwrap_or_else(default_version)
2996 .to_string()
2997 .bright_yellow()
2998 );
2999 println!(
3000 "{:<20} {}",
3001 "Committed HEAD".bright_white(),
3002 report
3003 .highest_head()
3004 .map(|v| v.to_string())
3005 .unwrap_or_else(|| "none".dimmed().to_string())
3006 );
3007 println!(
3008 "{:<20} {}",
3009 "GitHub tags".bright_white(),
3010 report
3011 .highest_remote_tag()
3012 .map(|v| v.to_string())
3013 .unwrap_or_else(|| "none".dimmed().to_string())
3014 );
3015 println!(
3016 "{:<20} {}",
3017 "Registry latest".bright_white(),
3018 report
3019 .highest_registry()
3020 .map(|v| v.to_string())
3021 .unwrap_or_else(|| "none".dimmed().to_string())
3022 );
3023 println!(
3024 "{:<20} {}",
3025 "Local tags".bright_white(),
3026 report
3027 .highest_local_tag()
3028 .map(|v| v.to_string())
3029 .unwrap_or_else(|| "none".dimmed().to_string())
3030 );
3031 if let Some(ledger) = &report.release_ledger {
3032 println!(
3033 "{:<20} {} {}",
3034 "Release ledger".bright_white(),
3035 ledger.version.bright_yellow(),
3036 format!("{:?}", ledger.status).bright_cyan()
3037 );
3038 }
3039 println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
3040
3041 if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
3042 println!(
3043 "{:<20} {}",
3044 "Suggested scope".bright_white(),
3045 review.scope_label.bright_cyan()
3046 );
3047 if !review.groups.is_empty() {
3048 println!("{:<20} {}", "Why".bright_white(), review.summary);
3049 for group in review.groups {
3050 println!("{:<20} {}", "", group);
3051 }
3052 }
3053 }
3054
3055 print_version_observations(
3056 "Worktree version files",
3057 &report.worktree,
3058 Some(&report.dirty_files),
3059 );
3060 print_version_observations("Committed HEAD version files", &report.head, None);
3061 print_registry_observations("Published package versions", &report.registry_versions);
3062 print_git_tag_observations("GitHub tags", &report.remote_tags);
3063 print_git_tag_observations("Local git tags", &report.local_tags);
3064
3065 let divergent = report.divergent_versions();
3066 let highest = report.highest_available();
3067 let outdated: Vec<_> = divergent
3068 .into_iter()
3069 .filter(|version| version != &highest)
3070 .collect();
3071 println!();
3072 println!("{}", "Divergence".bright_cyan().bold());
3073 println!("{}", "─".repeat(72).bright_black());
3074 println!(
3075 " {:<20} {}",
3076 "latest target".bright_white(),
3077 highest.to_string().bright_green().bold()
3078 );
3079 if !outdated.is_empty() {
3080 for version in outdated {
3081 println!(
3082 " {} {}",
3083 "•".bright_yellow(),
3084 version.to_string().bright_yellow()
3085 );
3086 }
3087 println!();
3088 println!(
3089 "{} {}",
3090 "Fix local files with".bright_white(),
3091 format!("xbp version {}", highest).black().on_bright_green()
3092 );
3093 } else {
3094 println!(" {}", "all relevant sources are aligned".green());
3095 }
3096
3097 if !report.warnings.is_empty() {
3098 println!();
3099 println!("{}", "Warnings".bright_yellow().bold());
3100 println!("{}", "─".repeat(72).bright_black());
3101 for warning in &report.warnings {
3102 println!(" {} {}", "!".bright_yellow(), warning);
3103 }
3104 }
3105}
3106
3107struct VersionDirtyFileReview {
3108 scope_label: String,
3109 summary: String,
3110 groups: Vec<String>,
3111}
3112
3113fn summarize_version_dirty_files(
3114 project_root: &Path,
3115 dirty_files: &[String],
3116) -> Option<VersionDirtyFileReview> {
3117 if dirty_files.is_empty() {
3118 return None;
3119 }
3120
3121 let mut crate_names = BTreeSet::new();
3122 let mut service_names = BTreeSet::new();
3123 let mut touches_workspace_files = false;
3124 let mut workspace_files = Vec::new();
3125 let mut crate_files = Vec::new();
3126 let mut service_files = Vec::new();
3127 let mut other_files = Vec::new();
3128
3129 for file in dirty_files {
3130 let path = Path::new(file);
3131 if path == Path::new("Cargo.toml")
3132 || path == Path::new("Cargo.lock")
3133 || path == Path::new("README.md")
3134 || path == Path::new("CHANGELOG.md")
3135 || path == Path::new("SECURITY.md")
3136 {
3137 touches_workspace_files = true;
3138 workspace_files.push(file.clone());
3139 continue;
3140 }
3141
3142 if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
3143 if let Some(crate_name) = crate_name {
3144 crate_names.insert(crate_name);
3145 crate_files.push(file.clone());
3146 }
3147 if let Some(service_name) = service_name {
3148 service_names.insert(service_name);
3149 service_files.push(file.clone());
3150 }
3151 } else {
3152 other_files.push(file.clone());
3153 }
3154 }
3155
3156 let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
3157 if crate_names.len() == 1 {
3158 format!("crate `{}`", crate_names.iter().next().unwrap())
3159 } else {
3160 format!("{} crates", crate_names.len())
3161 }
3162 } else if !service_names.is_empty() && crate_names.is_empty() {
3163 if service_names.len() == 1 {
3164 format!("service `{}`", service_names.iter().next().unwrap())
3165 } else {
3166 format!("{} services", service_names.len())
3167 }
3168 } else if touches_workspace_files {
3169 "repository / main crates".to_string()
3170 } else {
3171 "repository".to_string()
3172 };
3173
3174 let summary = if dirty_files.len() <= 8 {
3175 format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
3176 } else {
3177 format!(
3178 "{} file(s): {} ...",
3179 dirty_files.len(),
3180 dirty_files
3181 .iter()
3182 .take(8)
3183 .cloned()
3184 .collect::<Vec<_>>()
3185 .join(", ")
3186 )
3187 };
3188
3189 let mut groups = Vec::new();
3190 if !workspace_files.is_empty() {
3191 groups.push(format!(
3192 "Workspace files: {}",
3193 summarize_file_group(&workspace_files)
3194 ));
3195 }
3196 if !crate_files.is_empty() {
3197 groups.push(format!(
3198 "Crate files: {}",
3199 summarize_file_group(&crate_files)
3200 ));
3201 }
3202 if !service_files.is_empty() {
3203 groups.push(format!(
3204 "Service files: {}",
3205 summarize_file_group(&service_files)
3206 ));
3207 }
3208 if !other_files.is_empty() {
3209 groups.push(format!(
3210 "Other files: {}",
3211 summarize_file_group(&other_files)
3212 ));
3213 }
3214
3215 Some(VersionDirtyFileReview {
3216 scope_label,
3217 summary,
3218 groups,
3219 })
3220}
3221
3222fn summarize_file_group(files: &[String]) -> String {
3223 if files.is_empty() {
3224 return String::new();
3225 }
3226
3227 if files.len() <= 4 {
3228 return files.join(", ");
3229 }
3230
3231 format!(
3232 "{} (and {} more)",
3233 files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
3234 files.len() - 4
3235 )
3236}
3237
3238fn infer_version_scope_name(
3239 project_root: &Path,
3240 path: &Path,
3241) -> Option<(Option<String>, Option<String>)> {
3242 let absolute = if path.is_absolute() {
3243 path.to_path_buf()
3244 } else {
3245 project_root.join(path)
3246 };
3247
3248 let relative = absolute.strip_prefix(project_root).ok()?;
3249 let mut components = relative.components();
3250 let first = components.next()?.as_os_str().to_string_lossy().to_string();
3251 let second = components
3252 .next()
3253 .map(|value| value.as_os_str().to_string_lossy().to_string());
3254
3255 if first == "crates" {
3256 return second.map(|crate_name| (Some(crate_name), None));
3257 }
3258
3259 if first == "apps" {
3260 return second.map(|service_name| (None, Some(service_name)));
3261 }
3262
3263 None
3264}
3265
3266fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
3267 entries.iter().map(|entry| entry.version.clone()).max()
3268}
3269
3270fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
3271 let Some(highest) = highest_version_observation(entries) else {
3272 return Vec::new();
3273 };
3274
3275 entries
3276 .iter()
3277 .filter(|entry| entry.version < highest)
3278 .collect()
3279}
3280
3281fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
3282 println!();
3283 println!("{}", title.bright_cyan().bold());
3284 println!("{}", "─".repeat(72).bright_black());
3285
3286 if entries.is_empty() {
3287 println!(" {}", "none found".dimmed());
3288 return;
3289 }
3290
3291 for entry in entries {
3292 let latest_display = match (&entry.latest, &entry.raw_version) {
3293 (Some(version), _) => version.to_string().bright_green().to_string(),
3294 (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
3295 (None, None) => "unavailable".dimmed().to_string(),
3296 };
3297
3298 let note = entry
3299 .note
3300 .as_ref()
3301 .map(|value| format!(" {}", value.bright_yellow()))
3302 .unwrap_or_default();
3303 let published_at = entry
3304 .published_at
3305 .as_deref()
3306 .map(|value| format!(" {}", format!("published {value}").dimmed()))
3307 .unwrap_or_default();
3308
3309 println!(
3310 " {:<9} {:<28} {:<16} {}{}{}",
3311 entry.registry.bright_white(),
3312 entry.package_name.bright_white(),
3313 latest_display,
3314 entry.source_file.dimmed(),
3315 published_at,
3316 note
3317 );
3318 }
3319}
3320
3321#[cfg(test)]
3322fn write_version_to_configured_files(
3323 project_root: &Path,
3324 invocation_dir: &Path,
3325 registry: &[String],
3326 version_scope: &VersionScope,
3327 version: &Version,
3328) -> Result<usize, String> {
3329 write_version_to_configured_files_with_paths(
3330 project_root,
3331 invocation_dir,
3332 registry,
3333 version_scope,
3334 version,
3335 )
3336 .map(|paths| paths.len())
3337}
3338
3339fn write_version_to_configured_files_with_paths(
3340 project_root: &Path,
3341 invocation_dir: &Path,
3342 registry: &[String],
3343 version_scope: &VersionScope,
3344 version: &Version,
3345) -> Result<Vec<PathBuf>, String> {
3346 write_version_to_configured_files_with_paths_internal(
3347 project_root,
3348 invocation_dir,
3349 registry,
3350 version_scope,
3351 version,
3352 false,
3353 )
3354}
3355
3356#[cfg(test)]
3357fn sync_version_to_configured_files_with_paths(
3358 project_root: &Path,
3359 invocation_dir: &Path,
3360 registry: &[String],
3361 version_scope: &VersionScope,
3362 version: &Version,
3363) -> Result<Vec<PathBuf>, String> {
3364 write_version_to_configured_files_with_paths_internal(
3365 project_root,
3366 invocation_dir,
3367 registry,
3368 version_scope,
3369 version,
3370 true,
3371 )
3372}
3373
3374fn write_version_to_configured_files_with_paths_internal(
3375 project_root: &Path,
3376 invocation_dir: &Path,
3377 registry: &[String],
3378 version_scope: &VersionScope,
3379 version: &Version,
3380 allow_noop_when_targets_exist: bool,
3381) -> Result<Vec<PathBuf>, String> {
3382 let mut updated = 0usize;
3383 let mut matched_targets = 0usize;
3384 let mut updated_paths = Vec::new();
3385 let mut errors = Vec::new();
3386
3387 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
3388 let path = &entry.absolute;
3389 if !path.exists() {
3390 continue;
3391 }
3392 matched_targets += 1;
3393
3394 match write_version_to_resolved_path(&entry, version) {
3395 Ok(true) => {
3396 updated += 1;
3397 updated_paths.push(path.clone());
3398 }
3399 Ok(false) => {}
3400 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
3401 }
3402 }
3403
3404 if matched_targets == 0 && errors.is_empty() {
3405 return Err("No configured version files were found to update.".to_string());
3406 }
3407
3408 if !errors.is_empty() {
3409 return Err(format!(
3410 "Updated {} file(s), but some version targets failed:\n{}",
3411 updated,
3412 errors.join("\n")
3413 ));
3414 }
3415
3416 if updated == 0 && allow_noop_when_targets_exist {
3417 return Ok(updated_paths);
3418 }
3419
3420 Ok(updated_paths)
3421}
3422
3423fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
3424 let file_name = path
3425 .file_name()
3426 .and_then(|n| n.to_str())
3427 .unwrap_or_default();
3428
3429 match file_name {
3430 "README.md" => read_readme_version(path),
3431 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3432 read_openapi_version(path)
3433 }
3434 "openapi.json" | "swagger.json" => read_json_openapi_version(path),
3435 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3436 | "xbp.json" => read_json_root_version(path),
3437 "deno.json" => read_json_root_version(path),
3438 "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
3439 "Cargo.toml" => read_cargo_toml_version(path),
3440 "Cargo.lock" => read_cargo_lock_version(path),
3441 "pyproject.toml" => read_pyproject_version(path),
3442 "Chart.yaml" => read_yaml_root_version(path, "version"),
3443 "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
3444 "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
3445 "build.gradle" | "build.gradle.kts" => {
3446 read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
3447 }
3448 "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
3449 _ => match path.extension().and_then(|ext| ext.to_str()) {
3450 Some("json") => read_json_root_version(path),
3451 Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
3452 Some("toml") => read_toml_root_version(path),
3453 Some("md") => read_readme_version(path),
3454 _ => Ok(None),
3455 },
3456 }
3457}
3458
3459fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
3460 let path = &entry.absolute;
3461 let file_name = path
3462 .file_name()
3463 .and_then(|n| n.to_str())
3464 .unwrap_or_default();
3465
3466 if file_name == "Cargo.lock" {
3467 if let Some(package_name) = entry.cargo_package_override.as_deref() {
3468 return read_cargo_lock_version_for_package(path, package_name);
3469 }
3470 }
3471
3472 read_version_from_path(path)
3473}
3474
3475fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
3476 let file_name = path
3477 .file_name()
3478 .and_then(|n| n.to_str())
3479 .unwrap_or_default();
3480
3481 match file_name {
3482 "README.md" => write_readme_version(path, version).map(|_| true),
3483 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3484 write_openapi_version(path, version).map(|_| true)
3485 }
3486 "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
3487 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3488 | "xbp.json" => write_json_root_version(path, version).map(|_| true),
3489 "deno.json" => write_json_root_version(path, version).map(|_| true),
3490 "deno.jsonc" => {
3491 write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
3492 }
3493 "Cargo.toml" => write_cargo_toml_version(path, version),
3494 "Cargo.lock" => write_cargo_lock_version(path, version),
3495 "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
3496 "Chart.yaml" => write_chart_version(path, version).map(|_| true),
3497 "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
3498 "pom.xml" => {
3499 write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
3500 }
3501 "build.gradle" | "build.gradle.kts" => {
3502 write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
3503 .map(|_| true)
3504 }
3505 "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
3506 _ => match path.extension().and_then(|ext| ext.to_str()) {
3507 Some("json") => write_json_root_version(path, version).map(|_| true),
3508 Some("yaml") | Some("yml") => {
3509 write_yaml_root_version(path, "version", version).map(|_| true)
3510 }
3511 Some("toml") => write_toml_root_version(path, version).map(|_| true),
3512 Some("md") => write_readme_version(path, version).map(|_| true),
3513 _ => Err("Unsupported version file type".to_string()),
3514 },
3515 }
3516}
3517
3518fn write_version_to_resolved_path(
3519 entry: &ResolvedRegistryPath,
3520 version: &Version,
3521) -> Result<bool, String> {
3522 let path = &entry.absolute;
3523 let file_name = path
3524 .file_name()
3525 .and_then(|n| n.to_str())
3526 .unwrap_or_default();
3527
3528 if file_name == "Cargo.lock" {
3529 if let Some(package_name) = entry.cargo_package_override.as_deref() {
3530 return write_cargo_lock_version_for_package(path, Some(package_name), version);
3531 }
3532 }
3533
3534 write_version_to_path(path, version)
3535}
3536
3537fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3538 let value: JsonValue =
3539 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3540 Ok(value
3541 .get("version")
3542 .and_then(JsonValue::as_str)
3543 .map(|value| value.to_string()))
3544}
3545
3546fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
3547 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3548 read_json_root_version_from_content(&content)
3549}
3550
3551fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
3552 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3553 let mut value: JsonValue =
3554 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3555
3556 let object = value
3557 .as_object_mut()
3558 .ok_or_else(|| "Expected a JSON object".to_string())?;
3559 object.insert(
3560 "version".to_string(),
3561 JsonValue::String(version.to_string()),
3562 );
3563
3564 fs::write(
3565 path,
3566 serde_json::to_string_pretty(&value)
3567 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3568 )
3569 .map_err(|e| format!("Failed to write file: {}", e))
3570}
3571
3572fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
3573 let value: YamlValue =
3574 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3575 Ok(yaml_get_string(&value, key))
3576}
3577
3578fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
3579 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3580 read_yaml_root_version_from_content(&content, key)
3581}
3582
3583fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
3584 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3585 let mut value: YamlValue =
3586 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3587
3588 let mapping = yaml_root_mapping_mut(&mut value)?;
3589 mapping.insert(
3590 YamlValue::String(key.to_string()),
3591 YamlValue::String(version.to_string()),
3592 );
3593
3594 fs::write(
3595 path,
3596 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3597 )
3598 .map_err(|e| format!("Failed to write file: {}", e))
3599}
3600
3601fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3602 let value: YamlValue =
3603 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3604
3605 let info = yaml_get_mapping(&value, "info");
3606 Ok(info.and_then(|mapping| {
3607 mapping
3608 .get(YamlValue::String("version".to_string()))
3609 .and_then(YamlValue::as_str)
3610 .map(|value| value.to_string())
3611 }))
3612}
3613
3614fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
3615 let content: String =
3616 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3617 read_openapi_version_from_content(&content)
3618}
3619
3620fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3621 let value: JsonValue =
3622 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3623 Ok(value
3624 .get("info")
3625 .and_then(JsonValue::as_object)
3626 .and_then(|info| info.get("version"))
3627 .and_then(JsonValue::as_str)
3628 .map(|value| value.to_string()))
3629}
3630
3631fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
3632 let content: String =
3633 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3634 read_json_openapi_version_from_content(&content)
3635}
3636
3637fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3638 let content: String =
3639 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3640 let mut value: YamlValue =
3641 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3642
3643 let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
3644 let info_key: YamlValue = YamlValue::String("info".to_string());
3645 if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
3646 root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
3647 }
3648
3649 let info: &mut YamlMapping = root
3650 .get_mut(&info_key)
3651 .and_then(YamlValue::as_mapping_mut)
3652 .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
3653 info.insert(
3654 YamlValue::String("version".to_string()),
3655 YamlValue::String(version.to_string()),
3656 );
3657
3658 fs::write(
3659 path,
3660 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3661 )
3662 .map_err(|e| format!("Failed to write file: {}", e))
3663}
3664
3665fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3666 let content: String =
3667 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3668 let mut value: JsonValue =
3669 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3670
3671 let root = value
3672 .as_object_mut()
3673 .ok_or_else(|| "Expected a JSON object".to_string())?;
3674 let info = root
3675 .entry("info".to_string())
3676 .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
3677 let info_object = info
3678 .as_object_mut()
3679 .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
3680 info_object.insert(
3681 "version".to_string(),
3682 JsonValue::String(version.to_string()),
3683 );
3684
3685 fs::write(
3686 path,
3687 serde_json::to_string_pretty(&value)
3688 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3689 )
3690 .map_err(|e| format!("Failed to write file: {}", e))
3691}
3692
3693fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3694 let value: TomlValue =
3695 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3696 Ok(value
3697 .get("version")
3698 .and_then(TomlValue::as_str)
3699 .map(|value| value.to_string()))
3700}
3701
3702fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
3703 let content: String =
3704 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3705 read_toml_root_version_from_content(&content)
3706}
3707
3708fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
3709 let content: String =
3710 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3711 let mut value: TomlValue =
3712 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3713 let table = value
3714 .as_table_mut()
3715 .ok_or_else(|| "Expected a TOML table".to_string())?;
3716 table.insert(
3717 "version".to_string(),
3718 TomlValue::String(version.to_string()),
3719 );
3720 fs::write(
3721 path,
3722 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3723 )
3724 .map_err(|e| format!("Failed to write file: {}", e))
3725}
3726
3727fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
3728 crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
3729}
3730
3731fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
3732 resolve_cargo_package_version(path)
3733}
3734
3735fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
3736 write_cargo_package_version(path, version)
3737}
3738
3739fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
3740 let value: TomlValue =
3741 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3742
3743 let project_version = value
3744 .get("project")
3745 .and_then(TomlValue::as_table)
3746 .and_then(|project| project.get("version"))
3747 .and_then(TomlValue::as_str);
3748
3749 let poetry_version = value
3750 .get("tool")
3751 .and_then(TomlValue::as_table)
3752 .and_then(|tool| tool.get("poetry"))
3753 .and_then(TomlValue::as_table)
3754 .and_then(|poetry| poetry.get("version"))
3755 .and_then(TomlValue::as_str);
3756
3757 Ok(project_version
3758 .or(poetry_version)
3759 .map(|value| value.to_string()))
3760}
3761
3762fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
3763 let content: String =
3764 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3765 read_pyproject_version_from_content(&content)
3766}
3767
3768fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
3769 let content: String =
3770 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3771 let mut value: TomlValue =
3772 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3773
3774 if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
3775 project.insert(
3776 "version".to_string(),
3777 TomlValue::String(version.to_string()),
3778 );
3779 } else if let Some(poetry) = value
3780 .get_mut("tool")
3781 .and_then(TomlValue::as_table_mut)
3782 .and_then(|tool| tool.get_mut("poetry"))
3783 .and_then(TomlValue::as_table_mut)
3784 {
3785 poetry.insert(
3786 "version".to_string(),
3787 TomlValue::String(version.to_string()),
3788 );
3789 } else {
3790 let table: &mut toml::map::Map<String, TomlValue> = value
3791 .as_table_mut()
3792 .ok_or_else(|| "Expected a TOML table".to_string())?;
3793 table.insert(
3794 "version".to_string(),
3795 TomlValue::String(version.to_string()),
3796 );
3797 }
3798
3799 fs::write(
3800 path,
3801 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3802 )
3803 .map_err(|e| format!("Failed to write file: {}", e))
3804}
3805
3806fn read_cargo_lock_version_from_content(
3807 content: &str,
3808 cargo_toml_content: Option<&str>,
3809) -> Result<Option<String>, String> {
3810 read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
3811}
3812
3813fn read_cargo_lock_version_from_content_with_package(
3814 content: &str,
3815 cargo_toml_content: Option<&str>,
3816 package_name_override: Option<&str>,
3817) -> Result<Option<String>, String> {
3818 let value: TomlValue =
3819 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3820 let package_name = if let Some(package_name_override) = package_name_override {
3821 package_name_override.trim().to_string()
3822 } else {
3823 let cargo_toml_content = cargo_toml_content
3824 .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
3825 cargo_package_name_from_content(cargo_toml_content)?
3826 };
3827
3828 Ok(value
3829 .get("package")
3830 .and_then(TomlValue::as_array)
3831 .and_then(|packages| {
3832 packages.iter().find_map(|package| {
3833 let table = package.as_table()?;
3834 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3835 table
3836 .get("version")
3837 .and_then(TomlValue::as_str)
3838 .map(|value| value.to_string())
3839 } else {
3840 None
3841 }
3842 })
3843 }))
3844}
3845
3846fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
3847 let content: String =
3848 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3849 let cargo_toml: String = fs::read_to_string(
3850 path.parent()
3851 .unwrap_or_else(|| Path::new("."))
3852 .join("Cargo.toml"),
3853 )
3854 .map_err(|e| format!("Failed to read file: {}", e))?;
3855 read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
3856}
3857
3858fn read_cargo_lock_version_for_package(
3859 path: &Path,
3860 package_name: &str,
3861) -> Result<Option<String>, String> {
3862 let content: String =
3863 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3864 read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
3865}
3866
3867fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
3868 write_cargo_lock_version_for_package(path, None, version)
3869}
3870
3871fn write_cargo_lock_version_for_package(
3872 path: &Path,
3873 package_name_override: Option<&str>,
3874 version: &Version,
3875) -> Result<bool, String> {
3876 let content: String =
3877 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3878 let mut value: TomlValue =
3879 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3880 let package_name = if let Some(package_name_override) = package_name_override {
3881 package_name_override.trim().to_string()
3882 } else {
3883 let Some(package_name) = cargo_package_name(path)? else {
3884 return Ok(false);
3885 };
3886 package_name
3887 };
3888
3889 let packages: &mut Vec<TomlValue> = value
3890 .get_mut("package")
3891 .and_then(TomlValue::as_array_mut)
3892 .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
3893
3894 let mut updated = false;
3895 for package in packages {
3896 if let Some(table) = package.as_table_mut() {
3897 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3898 table.insert(
3899 "version".to_string(),
3900 TomlValue::String(version.to_string()),
3901 );
3902 updated = true;
3903 }
3904 }
3905 }
3906
3907 if !updated {
3908 return Err(format!(
3909 "Could not find package `{}` in Cargo.lock",
3910 package_name
3911 ));
3912 }
3913
3914 fs::write(
3915 path,
3916 toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3917 )
3918 .map_err(|e| format!("Failed to write file: {}", e))?;
3919
3920 Ok(true)
3921}
3922
3923fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
3924 let cargo_toml: PathBuf = path
3925 .parent()
3926 .unwrap_or_else(|| Path::new("."))
3927 .join("Cargo.toml");
3928 let content: String = fs::read_to_string(&cargo_toml)
3929 .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
3930 cargo_package_name_from_content_optional(&content)
3931}
3932
3933fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
3934 cargo_package_name_from_content_optional(content)?
3935 .ok_or_else(|| "Could not determine Cargo package name".to_string())
3936}
3937
3938fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
3939 let value: TomlValue =
3940 toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
3941 Ok(value
3942 .get("package")
3943 .and_then(TomlValue::as_table)
3944 .and_then(|package| package.get("name"))
3945 .and_then(TomlValue::as_str)
3946 .map(|value| value.to_string()))
3947}
3948
3949fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
3950 read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3951}
3952
3953fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
3954 let content: String =
3955 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3956 read_readme_version_from_content(&content)
3957}
3958
3959fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
3960 let content: String =
3961 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3962 let marker: String = format!("current version: `{}`", version);
3963 let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3964 .map_err(|e| format!("Failed to build README regex: {}", e))?;
3965
3966 let updated: String = if regex.is_match(&content) {
3967 regex.replace(&content, marker.as_str()).to_string()
3968 } else if let Some(first_break) = content.find('\n') {
3969 let mut next = String::new();
3970 next.push_str(&content[..=first_break]);
3971 next.push('\n');
3972 next.push_str(&marker);
3973 next.push('\n');
3974 next.push_str(&content[first_break + 1..]);
3975 next
3976 } else {
3977 format!("{}\n\n{}\n", content, marker)
3978 };
3979
3980 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
3981}
3982
3983fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
3984 let content: String =
3985 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3986 read_regex_version_from_content(&content, pattern)
3987}
3988
3989fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
3990 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
3991 Ok(regex
3992 .captures(content)
3993 .and_then(|captures| captures.get(1))
3994 .map(|matched| matched.as_str().trim().to_string()))
3995}
3996
3997fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
3998 let content: String =
3999 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4000 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
4001
4002 if !regex.is_match(&content) {
4003 return Err("No version pattern found".to_string());
4004 }
4005
4006 let updated: String = regex
4007 .replace(&content, |caps: ®ex::Captures<'_>| {
4008 caps[0].replace(&caps[1], &version.to_string())
4009 })
4010 .to_string();
4011 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
4012}
4013
4014#[cfg(test)]
4015fn write_package_version_to_configured_files(
4016 project_root: &Path,
4017 invocation_dir: &Path,
4018 registry: &[String],
4019 version_scope: &VersionScope,
4020 package_name: &str,
4021 version: &Version,
4022) -> Result<usize, String> {
4023 write_package_version_to_configured_files_with_paths(
4024 project_root,
4025 invocation_dir,
4026 registry,
4027 version_scope,
4028 package_name,
4029 version,
4030 )
4031 .map(|paths| paths.len())
4032}
4033
4034fn write_package_version_to_configured_files_with_paths(
4035 project_root: &Path,
4036 invocation_dir: &Path,
4037 registry: &[String],
4038 version_scope: &VersionScope,
4039 package_name: &str,
4040 version: &Version,
4041) -> Result<Vec<PathBuf>, String> {
4042 let mut updated: usize = 0usize;
4043 let mut updated_paths = Vec::new();
4044 let mut errors: Vec<String> = Vec::new();
4045
4046 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
4047 let path: &PathBuf = &entry.absolute;
4048 if !path.exists() {
4049 continue;
4050 }
4051
4052 match write_package_version_to_path(path, package_name, version) {
4053 Ok(true) => {
4054 updated += 1;
4055 updated_paths.push(path.clone());
4056 }
4057 Ok(false) => {}
4058 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
4059 }
4060 }
4061
4062 if updated == 0 && errors.is_empty() {
4063 return Err(format!(
4064 "No configured TOML files contained package assignment `{}`.",
4065 package_name
4066 ));
4067 }
4068
4069 if !errors.is_empty() {
4070 return Err(format!(
4071 "Updated {} file(s), but some package version targets failed:\n{}",
4072 updated,
4073 errors.join("\n")
4074 ));
4075 }
4076
4077 Ok(updated_paths)
4078}
4079
4080fn write_package_version_to_path(
4081 path: &Path,
4082 package_name: &str,
4083 version: &Version,
4084) -> Result<bool, String> {
4085 let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
4086 if !is_toml {
4087 return Ok(false);
4088 }
4089
4090 let content: String =
4091 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4092 let (updated, changed) =
4093 rewrite_toml_package_assignment_versions(&content, package_name, version)?;
4094 if changed {
4095 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
4096 }
4097 Ok(changed)
4098}
4099
4100fn rewrite_toml_package_assignment_versions(
4101 content: &str,
4102 package_name: &str,
4103 version: &Version,
4104) -> Result<(String, bool), String> {
4105 let escaped_name: String = regex::escape(package_name);
4106 let inline_pattern: String = format!(
4107 r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
4108 escaped_name
4109 );
4110 let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
4111
4112 let inline_regex: Regex =
4113 Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
4114 let string_regex: Regex =
4115 Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
4116
4117 let replacement: String = version.to_string();
4118
4119 let after_inline: String = inline_regex
4120 .replace_all(content, |caps: ®ex::Captures<'_>| {
4121 format!("{}{}{}", &caps[1], replacement, &caps[3])
4122 })
4123 .to_string();
4124 let after_string: String = string_regex
4125 .replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
4126 format!("{}{}{}", &caps[1], replacement, &caps[3])
4127 })
4128 .to_string();
4129
4130 Ok((after_string.clone(), after_string != content))
4131}
4132
4133fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
4134 let content: String =
4135 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4136 let mut value: YamlValue =
4137 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
4138 let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
4139 mapping.insert(
4140 YamlValue::String("version".to_string()),
4141 YamlValue::String(version.to_string()),
4142 );
4143 if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
4144 mapping.insert(
4145 YamlValue::String("appVersion".to_string()),
4146 YamlValue::String(version.to_string()),
4147 );
4148 }
4149 fs::write(
4150 path,
4151 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
4152 )
4153 .map_err(|e| format!("Failed to write file: {}", e))
4154}
4155
4156fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
4157 value
4158 .as_mapping_mut()
4159 .ok_or_else(|| "Expected a YAML mapping".to_string())
4160}
4161
4162fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
4163 value
4164 .as_mapping()
4165 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4166 .and_then(YamlValue::as_mapping)
4167}
4168
4169fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
4170 value
4171 .as_mapping()
4172 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4173 .and_then(YamlValue::as_str)
4174 .map(|value| value.to_string())
4175}
4176
4177fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
4178 let mut current: &JsonValue = value;
4179 for part in key_parts {
4180 current = current.get(*part)?;
4181 }
4182 current.as_str().map(|value| value.to_string())
4183}
4184
4185fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
4186 let mut current = value;
4187 for part in key_parts {
4188 let mapping = current.as_mapping()?;
4189 current = mapping.get(YamlValue::String((*part).to_string()))?;
4190 }
4191 current.as_str().map(|value| value.to_string())
4192}
4193
4194fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
4195 let mut current = value;
4196 for part in key_parts {
4197 current = current.get(*part)?;
4198 }
4199 current.as_str().map(|value| value.to_string())
4200}
4201
4202fn parse_version(input: &str) -> Result<Version, String> {
4203 let trimmed: &str = input.trim();
4204 let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
4205 Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
4206}
4207
4208fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
4209 let trimmed = input.trim();
4210 if trimmed.is_empty() {
4211 return Err("Release version cannot be empty.".to_string());
4212 }
4213
4214 if let Ok(version) = parse_version(trimmed) {
4215 return Ok((version.clone(), format!("v{}", version)));
4216 }
4217
4218 let prefixed = Regex::new(
4219 r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
4220 )
4221 .map_err(|e| format!("Failed to build release target parser: {}", e))?;
4222
4223 if let Some(captures) = prefixed.captures(trimmed) {
4224 let semver = captures
4225 .name("semver")
4226 .map(|m| m.as_str())
4227 .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
4228 let version = Version::parse(semver)
4229 .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
4230 return Ok((version, trimmed.to_string()));
4231 }
4232
4233 Err(format!(
4234 "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
4235 input
4236 ))
4237}
4238
4239fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
4240 let Some((raw_package, raw_version)) = input.split_once('=') else {
4241 return Ok(None);
4242 };
4243
4244 let package_name = raw_package.trim();
4245 if package_name.is_empty() {
4246 return Ok(None);
4247 }
4248
4249 let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
4250 .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
4251 if !package_name_regex.is_match(package_name) {
4252 return Err(format!(
4253 "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
4254 input
4255 ));
4256 }
4257
4258 let version = parse_version(raw_version.trim())?;
4259 Ok(Some((package_name.to_string(), version)))
4260}
4261
4262fn bump_version(current: &Version, kind: &str) -> Version {
4263 let mut next = current.clone();
4264 match kind {
4265 "major" => {
4266 next.major += 1;
4267 next.minor = 0;
4268 next.patch = 0;
4269 next.pre = semver::Prerelease::EMPTY;
4270 next.build = semver::BuildMetadata::EMPTY;
4271 }
4272 "minor" => {
4273 next.minor += 1;
4274 next.patch = 0;
4275 next.pre = semver::Prerelease::EMPTY;
4276 next.build = semver::BuildMetadata::EMPTY;
4277 }
4278 _ => {
4279 next.patch += 1;
4280 next.pre = semver::Prerelease::EMPTY;
4281 next.build = semver::BuildMetadata::EMPTY;
4282 }
4283 }
4284 next
4285}
4286
4287fn default_version() -> Version {
4288 Version::new(0, 1, 0)
4289}
4290
4291fn resolve_registry_paths(
4292 project_root: &Path,
4293 invocation_dir: &Path,
4294 registry: &[String],
4295 version_scope: &VersionScope,
4296) -> Vec<ResolvedRegistryPath> {
4297 let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
4298 let mut seen: BTreeSet<String> = BTreeSet::new();
4299 let workspace_primary_target = match version_scope {
4300 VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
4301 VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
4302 };
4303
4304 for relative in registry {
4305 match version_scope {
4306 VersionScope::Repository => {
4307 if *relative == *"Cargo.lock" {
4308 let resolved_relative = resolve_registry_relative_path(
4309 project_root,
4310 invocation_dir,
4311 version_scope,
4312 relative,
4313 );
4314 if !seen.insert(resolved_relative.clone()) {
4315 continue;
4316 }
4317
4318 resolved.push(ResolvedRegistryPath {
4319 absolute: project_root.join(&resolved_relative),
4320 relative: resolved_relative,
4321 cargo_package_override: workspace_primary_target
4322 .as_ref()
4323 .map(|target| target.package_name.clone()),
4324 });
4325 continue;
4326 }
4327
4328 if *relative == *"Cargo.toml" {
4329 if let Some(target) = workspace_primary_target.as_ref() {
4330 if !seen.insert(target.manifest_relative.clone()) {
4331 continue;
4332 }
4333
4334 resolved.push(ResolvedRegistryPath {
4335 absolute: target.manifest_absolute.clone(),
4336 relative: target.manifest_relative.clone(),
4337 cargo_package_override: None,
4338 });
4339 continue;
4340 }
4341 }
4342
4343 let resolved_relative = resolve_registry_relative_path(
4344 project_root,
4345 invocation_dir,
4346 version_scope,
4347 relative,
4348 );
4349 if !seen.insert(resolved_relative.clone()) {
4350 continue;
4351 }
4352
4353 resolved.push(ResolvedRegistryPath {
4354 absolute: project_root.join(&resolved_relative),
4355 relative: resolved_relative,
4356 cargo_package_override: None,
4357 });
4358 }
4359 VersionScope::Crate {
4360 crate_root,
4361 package_name,
4362 ..
4363 } => {
4364 if *relative == *"Cargo.lock" {
4365 let cargo_lock = project_root.join("Cargo.lock");
4366 if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4367 resolved.push(ResolvedRegistryPath {
4368 absolute: cargo_lock,
4369 relative: "Cargo.lock".to_string(),
4370 cargo_package_override: Some(package_name.clone()),
4371 });
4372 }
4373 continue;
4374 }
4375
4376 let preferred = crate_root.join(relative);
4377 if !preferred.exists() {
4378 continue;
4379 }
4380 let Ok(stripped) = preferred.strip_prefix(project_root) else {
4381 continue;
4382 };
4383 let resolved_relative = normalized_relative_path(stripped);
4384 if !seen.insert(resolved_relative.clone()) {
4385 continue;
4386 }
4387
4388 resolved.push(ResolvedRegistryPath {
4389 absolute: preferred,
4390 relative: resolved_relative,
4391 cargo_package_override: None,
4392 });
4393 }
4394 VersionScope::Service {
4395 service_root,
4396 cargo_package_name,
4397 ..
4398 } => {
4399 if *relative == *"Cargo.lock" {
4400 let local_cargo_lock = service_root.join("Cargo.lock");
4401 if local_cargo_lock.exists() {
4402 let relative_path = local_cargo_lock
4403 .strip_prefix(project_root)
4404 .ok()
4405 .map(normalized_relative_path)
4406 .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
4407 if seen.insert(relative_path.clone()) {
4408 resolved.push(ResolvedRegistryPath {
4409 absolute: local_cargo_lock,
4410 relative: relative_path,
4411 cargo_package_override: cargo_package_name.clone(),
4412 });
4413 }
4414 } else if let Some(package_name) = cargo_package_name.as_ref() {
4415 let workspace_cargo_lock = project_root.join("Cargo.lock");
4416 if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4417 resolved.push(ResolvedRegistryPath {
4418 absolute: workspace_cargo_lock,
4419 relative: "Cargo.lock".to_string(),
4420 cargo_package_override: Some(package_name.clone()),
4421 });
4422 }
4423 }
4424 continue;
4425 }
4426
4427 let preferred = service_root.join(relative);
4428 if !preferred.exists() {
4429 continue;
4430 }
4431 let Ok(stripped) = preferred.strip_prefix(project_root) else {
4432 continue;
4433 };
4434 let resolved_relative = normalized_relative_path(stripped);
4435 if !seen.insert(resolved_relative.clone()) {
4436 continue;
4437 }
4438
4439 resolved.push(ResolvedRegistryPath {
4440 absolute: preferred,
4441 relative: resolved_relative,
4442 cargo_package_override: None,
4443 });
4444 }
4445 }
4446 }
4447
4448 for configured_path in
4449 resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
4450 {
4451 if seen.insert(configured_path.relative.clone()) {
4452 resolved.push(configured_path);
4453 }
4454 }
4455
4456 resolved
4457}
4458
4459fn resolve_configured_version_target_paths(
4460 project_root: &Path,
4461 invocation_dir: &Path,
4462 version_scope: &VersionScope,
4463) -> Vec<ResolvedRegistryPath> {
4464 let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
4465 return Vec::new();
4466 };
4467
4468 let service_targets = match version_scope {
4469 VersionScope::Service {
4470 version_targets, ..
4471 } if !version_targets.is_empty() => Some(version_targets.clone()),
4472 _ => None,
4473 };
4474
4475 let manifest_paths = if let Some(service_targets) = &service_targets {
4476 service_targets
4477 .iter()
4478 .cloned()
4479 .map(PathBuf::from)
4480 .collect::<Vec<_>>()
4481 } else {
4482 let mut targets = config
4483 .version_targets
4484 .into_iter()
4485 .map(PathBuf::from)
4486 .collect::<Vec<_>>();
4487 targets.extend(
4488 config
4489 .publish
4490 .into_iter()
4491 .flat_map(|publish| [publish.npm, publish.crates])
4492 .flatten()
4493 .filter_map(|target| target.manifest_path)
4494 .map(PathBuf::from),
4495 );
4496 targets
4497 };
4498
4499 let scope_root = match version_scope {
4500 VersionScope::Repository => None,
4501 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4502 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4503 };
4504
4505 manifest_paths
4506 .into_iter()
4507 .filter_map(|manifest_path| {
4508 let absolute = if manifest_path.is_absolute() {
4509 manifest_path
4510 } else {
4511 config_root.join(manifest_path)
4512 };
4513 if !absolute.exists() {
4514 return None;
4515 }
4516 if let Some(scope_root) = scope_root {
4517 if !absolute.starts_with(scope_root) {
4518 return None;
4519 }
4520 }
4521 let relative = absolute
4522 .strip_prefix(project_root)
4523 .ok()
4524 .map(normalized_relative_path)
4525 .unwrap_or_else(|| normalized_relative_path(&absolute));
4526 Some(ResolvedRegistryPath {
4527 relative,
4528 absolute,
4529 cargo_package_override: None,
4530 })
4531 })
4532 .collect()
4533}
4534
4535fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
4536 let found = find_xbp_config_upwards(invocation_dir)?;
4537 let config_path = if found.kind == "json" {
4538 maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
4539 .ok()
4540 .flatten()
4541 .unwrap_or_else(|| found.config_path.clone())
4542 } else {
4543 found.config_path.clone()
4544 };
4545
4546 let kind = if config_path
4547 .extension()
4548 .and_then(|ext| ext.to_str())
4549 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4550 .unwrap_or(false)
4551 {
4552 "yaml"
4553 } else {
4554 "json"
4555 };
4556
4557 let content = fs::read_to_string(&config_path).ok()?;
4558 let (mut config, healed): (XbpConfig, Option<String>) =
4559 parse_config_with_auto_heal(&content, kind).ok()?;
4560 if let Some(healed_content) = healed {
4561 let _ = fs::write(&config_path, healed_content);
4562 }
4563 resolve_config_paths_for_runtime(&mut config, &found.project_root);
4564 Some((found.project_root, config))
4565}
4566
4567fn resolve_registry_relative_path(
4568 project_root: &Path,
4569 invocation_dir: &Path,
4570 version_scope: &VersionScope,
4571 relative: &str,
4572) -> String {
4573 if let Some(scope_root) = version_scope_root(version_scope) {
4574 let preferred = scope_root.join(relative);
4575 if preferred.exists() {
4576 if let Ok(stripped) = preferred.strip_prefix(project_root) {
4577 return normalized_relative_path(stripped);
4578 }
4579 }
4580 return relative.replace('\\', "/");
4581 }
4582
4583 if relative == "Cargo.toml" {
4584 if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
4585 return target.manifest_relative;
4586 }
4587 }
4588
4589 let preferred: PathBuf = invocation_dir.join(relative);
4590 if preferred.exists() {
4591 if let Ok(stripped) = preferred.strip_prefix(project_root) {
4592 return normalized_relative_path(stripped);
4593 }
4594 }
4595
4596 relative.replace('\\', "/")
4597}
4598
4599fn resolve_version_scope_with_prompt(
4600 project_root: &Path,
4601 invocation_dir: &Path,
4602) -> Result<VersionScope, String> {
4603 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4604 if let Some(scope) =
4605 select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4606 {
4607 return Ok(scope);
4608 }
4609
4610 if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
4611 {
4612 return prompt_for_version_scope_selection(&service_scopes);
4613 }
4614
4615 Ok(resolve_version_scope(project_root, invocation_dir))
4616}
4617
4618fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
4619 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4620 if let Some(service_scope) =
4621 select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4622 {
4623 return service_scope;
4624 }
4625
4626 if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
4627 return crate_scope;
4628 }
4629
4630 VersionScope::Repository
4631}
4632
4633fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
4634 let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
4635 return Vec::new();
4636 };
4637 let Some(services) = config.services else {
4638 return Vec::new();
4639 };
4640
4641 let mut scopes = services
4642 .into_iter()
4643 .filter_map(|service| build_service_scope(project_root, service))
4644 .collect::<Vec<_>>();
4645 scopes.sort_by(|left, right| {
4646 version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
4647 });
4648 scopes
4649}
4650
4651fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
4652 let version_targets = service
4653 .version_targets
4654 .clone()
4655 .unwrap_or_default()
4656 .into_iter()
4657 .map(normalized_path_string)
4658 .collect::<Vec<_>>();
4659 let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
4660 let service_relative_root = service_root
4661 .strip_prefix(project_root)
4662 .ok()
4663 .map(normalized_relative_path)
4664 .filter(|value| !value.is_empty())
4665 .unwrap_or_else(|| ".".to_string());
4666 let cargo_package_name =
4667 resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
4668 let tag_identity = cargo_package_name
4669 .clone()
4670 .unwrap_or_else(|| service.name.clone());
4671
4672 Some(VersionScope::Service {
4673 service_root,
4674 service_relative_root,
4675 service_name: service.name,
4676 tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
4677 cargo_package_name,
4678 version_targets,
4679 })
4680}
4681
4682fn resolve_service_scope_root(
4683 project_root: &Path,
4684 service: &ServiceConfig,
4685 version_targets: &[String],
4686) -> Option<PathBuf> {
4687 if !version_targets.is_empty() {
4688 return common_parent_from_targets(project_root, version_targets);
4689 }
4690
4691 service.root_directory.as_ref().map(|root| {
4692 let path = PathBuf::from(root);
4693 if path.is_absolute() {
4694 path
4695 } else {
4696 project_root.join(path)
4697 }
4698 })
4699}
4700
4701fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
4702 let mut parents = version_targets.iter().filter_map(|target| {
4703 let path = PathBuf::from(target);
4704 let absolute = if path.is_absolute() {
4705 path
4706 } else {
4707 project_root.join(path)
4708 };
4709 absolute.parent().map(Path::to_path_buf)
4710 });
4711
4712 let mut common = parents.next()?;
4713 for parent in parents {
4714 while !parent.starts_with(&common) {
4715 common = common.parent()?.to_path_buf();
4716 }
4717 }
4718 Some(common)
4719}
4720
4721fn resolve_service_scope_cargo_package_name(
4722 project_root: &Path,
4723 service_root: &Path,
4724 version_targets: &[String],
4725) -> Option<String> {
4726 let mut cargo_paths = version_targets
4727 .iter()
4728 .filter(|target| {
4729 Path::new(target)
4730 .file_name()
4731 .and_then(|value| value.to_str())
4732 == Some("Cargo.toml")
4733 })
4734 .map(|target| {
4735 let path = PathBuf::from(target);
4736 if path.is_absolute() {
4737 path
4738 } else {
4739 project_root.join(path)
4740 }
4741 })
4742 .collect::<Vec<_>>();
4743 if cargo_paths.is_empty() {
4744 cargo_paths.push(service_root.join("Cargo.toml"));
4745 }
4746
4747 cargo_paths.into_iter().find_map(|path| {
4748 let content = fs::read_to_string(path).ok()?;
4749 cargo_package_name_from_content_optional(&content)
4750 .ok()
4751 .flatten()
4752 })
4753}
4754
4755fn select_matching_service_scope(
4756 scopes: &[VersionScope],
4757 project_root: &Path,
4758 invocation_dir: &Path,
4759) -> Option<VersionScope> {
4760 scopes
4761 .iter()
4762 .filter_map(|scope| {
4763 let root = version_scope_root(scope)?;
4764 if invocation_dir == project_root && root == project_root {
4765 return None;
4766 }
4767 if invocation_dir.starts_with(root) {
4768 Some((root.components().count(), scope.clone()))
4769 } else {
4770 None
4771 }
4772 })
4773 .max_by_key(|(depth, _)| *depth)
4774 .map(|(_, scope)| scope)
4775}
4776
4777fn prompt_for_version_scope_selection(
4778 service_scopes: &[VersionScope],
4779) -> Result<VersionScope, String> {
4780 let mut items = service_scopes
4781 .iter()
4782 .map(version_scope_prompt_label)
4783 .collect::<Vec<_>>();
4784 items.push("Repository (all configured version targets)".to_string());
4785
4786 let selection = Select::with_theme(&ColorfulTheme::default())
4787 .with_prompt("Choose the project/service to version or release")
4788 .items(&items)
4789 .default(items.len().saturating_sub(1))
4790 .interact_opt()
4791 .map_err(|e| format!("Prompt failed: {}", e))?;
4792
4793 match selection {
4794 Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
4795 _ => Ok(VersionScope::Repository),
4796 }
4797}
4798
4799fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
4800 let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
4801 let cargo_toml = crate_root.join("Cargo.toml");
4802 let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
4803 let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
4804 let crate_relative_root = crate_root
4805 .strip_prefix(project_root)
4806 .ok()
4807 .map(normalized_relative_path)?;
4808
4809 Some(VersionScope::Crate {
4810 crate_root,
4811 crate_relative_root,
4812 tag_prefix: format!("{}-", package_name),
4813 package_name,
4814 })
4815}
4816
4817fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
4818 let crates_root = project_root.join("crates");
4819 let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
4820 let mut components = relative.components();
4821 let crate_name = components.next()?;
4822 Some(crates_root.join(crate_name.as_os_str()))
4823}
4824
4825fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
4826 match version_scope {
4827 VersionScope::Repository => None,
4828 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4829 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4830 }
4831}
4832
4833fn prepare_release_openapi_assets(
4834 project_root: &Path,
4835 invocation_dir: &Path,
4836 version_scope: &VersionScope,
4837 release_version: &Version,
4838 tag_name: &str,
4839) -> Result<Vec<ReleaseOpenApiAsset>, String> {
4840 let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
4841 validate_release_openapi_assets(&spec_paths, release_version)?;
4842
4843 spec_paths
4844 .into_iter()
4845 .map(|source_path| {
4846 let file_name = source_path
4847 .file_name()
4848 .and_then(|value| value.to_str())
4849 .ok_or_else(|| {
4850 format!(
4851 "Invalid OpenAPI asset path for release upload: {}",
4852 source_path.display()
4853 )
4854 })?;
4855 let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
4856 let source_label = source_path
4857 .strip_prefix(project_root)
4858 .map(normalized_relative_path)
4859 .unwrap_or_else(|_| normalized_relative_path(&source_path));
4860 Ok(ReleaseOpenApiAsset {
4861 source_path,
4862 source_label,
4863 asset_name,
4864 })
4865 })
4866 .collect()
4867}
4868
4869fn resolve_release_openapi_specs(
4870 project_root: &Path,
4871 invocation_dir: &Path,
4872 version_scope: &VersionScope,
4873) -> Result<Vec<PathBuf>, String> {
4874 const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
4875 "openapi.yaml",
4876 "openapi.yml",
4877 "openapi.json",
4878 "swagger.yaml",
4879 "swagger.yml",
4880 "swagger.json",
4881 ];
4882
4883 let mut roots: Vec<PathBuf> = Vec::new();
4884 if let Some(scope_root) = version_scope_root(version_scope) {
4885 roots.push(scope_root.to_path_buf());
4886 } else {
4887 if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
4888 roots.push(crate_root);
4889 }
4890 roots.push(project_root.to_path_buf());
4891 }
4892
4893 let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
4894 let mut seen_names: BTreeSet<String> = BTreeSet::new();
4895 let mut resolved_paths: Vec<PathBuf> = Vec::new();
4896
4897 for root in roots {
4898 if !seen_roots.insert(root.clone()) {
4899 continue;
4900 }
4901
4902 let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
4903 for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
4904 let path = root.join(file_name);
4905 if path.is_file() {
4906 root_paths.push(((*file_name).to_string(), path));
4907 }
4908 }
4909
4910 let entries = match fs::read_dir(&root) {
4911 Ok(entries) => entries,
4912 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
4913 Err(error) => {
4914 return Err(format!(
4915 "Failed to inspect OpenAPI release assets in {}: {}",
4916 root.display(),
4917 error
4918 ));
4919 }
4920 };
4921
4922 let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
4923 for entry in entries {
4924 let entry = entry.map_err(|error| {
4925 format!(
4926 "Failed to inspect OpenAPI release assets in {}: {}",
4927 root.display(),
4928 error
4929 )
4930 })?;
4931 let path = entry.path();
4932 if !path.is_file() {
4933 continue;
4934 }
4935 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4936 continue;
4937 };
4938 if !is_release_openapi_variant_file_name(file_name) {
4939 continue;
4940 }
4941 additional_specs.push((file_name.to_string(), path));
4942 }
4943 additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
4944
4945 root_paths.extend(additional_specs);
4946
4947 for (file_name, path) in root_paths {
4948 if seen_names.insert(file_name) {
4949 resolved_paths.push(path);
4950 }
4951 }
4952 }
4953
4954 Ok(resolved_paths)
4955}
4956
4957fn validate_release_openapi_assets(
4958 spec_paths: &[PathBuf],
4959 release_version: &Version,
4960) -> Result<(), String> {
4961 let Some(primary_spec_path) = spec_paths.iter().find(|path| {
4962 path.file_name()
4963 .and_then(|value| value.to_str())
4964 .map(is_primary_release_openapi_file_name)
4965 .unwrap_or(false)
4966 }) else {
4967 return Ok(());
4968 };
4969
4970 let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
4971 format!(
4972 "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
4973 primary_spec_path.display(),
4974 release_version
4975 )
4976 })?;
4977 let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
4978 format!(
4979 "Release OpenAPI spec `{}` has invalid version `{}`: {}",
4980 primary_spec_path.display(),
4981 declared_version,
4982 error
4983 )
4984 })?;
4985
4986 if &parsed_declared_version != release_version {
4987 return Err(format!(
4988 "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
4989 primary_spec_path.display(),
4990 declared_version,
4991 release_version
4992 ));
4993 }
4994
4995 Ok(())
4996}
4997
4998fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
4999 let path = Path::new(file_name);
5000 let stem = path
5001 .file_stem()
5002 .and_then(|value| value.to_str())
5003 .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
5004 let extension = path
5005 .extension()
5006 .and_then(|value| value.to_str())
5007 .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
5008 Ok(format!("{}-{}.{}", stem, tag_name, extension))
5009}
5010
5011fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
5012 matches!(
5013 file_name,
5014 "openapi.yaml"
5015 | "openapi.yml"
5016 | "openapi.json"
5017 | "swagger.yaml"
5018 | "swagger.yml"
5019 | "swagger.json"
5020 )
5021}
5022
5023fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
5024 if is_primary_release_openapi_file_name(file_name) {
5025 return false;
5026 }
5027
5028 let Some((stem, extension)) = file_name.rsplit_once('.') else {
5029 return false;
5030 };
5031 if !matches!(extension, "yaml" | "yml" | "json") {
5032 return false;
5033 }
5034
5035 stem.starts_with("openapi-") || stem.starts_with("swagger-")
5036}
5037
5038fn normalized_path_string(path: String) -> String {
5039 path.replace('\\', "/")
5040}
5041
5042fn slugify_scope_name(value: &str) -> String {
5043 let mut slug = value
5044 .chars()
5045 .map(|ch| {
5046 if ch.is_ascii_alphanumeric() {
5047 ch.to_ascii_lowercase()
5048 } else {
5049 '-'
5050 }
5051 })
5052 .collect::<String>();
5053 while slug.contains("--") {
5054 slug = slug.replace("--", "-");
5055 }
5056 slug.trim_matches('-').to_string()
5057}
5058
5059fn resolve_workspace_primary_cargo_target(
5060 project_root: &Path,
5061) -> Option<WorkspacePrimaryCargoTarget> {
5062 let workspace_manifest = project_root.join("Cargo.toml");
5063 let content = fs::read_to_string(&workspace_manifest).ok()?;
5064 let value: TomlValue = toml::from_str(&content).ok()?;
5065 if value.get("package").and_then(TomlValue::as_table).is_some() {
5066 return None;
5067 }
5068
5069 let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
5070 let mut candidate_roots = workspace
5071 .get("default-members")
5072 .and_then(TomlValue::as_array)
5073 .map(|members| {
5074 members
5075 .iter()
5076 .filter_map(TomlValue::as_str)
5077 .map(str::trim)
5078 .filter(|member| !member.is_empty() && !member.contains('*'))
5079 .map(str::to_string)
5080 .collect::<Vec<_>>()
5081 })
5082 .unwrap_or_default();
5083
5084 if candidate_roots.is_empty() {
5085 let members = workspace
5086 .get("members")
5087 .and_then(TomlValue::as_array)
5088 .map(|members| {
5089 members
5090 .iter()
5091 .filter_map(TomlValue::as_str)
5092 .map(str::trim)
5093 .filter(|member| !member.is_empty() && !member.contains('*'))
5094 .map(str::to_string)
5095 .collect::<Vec<_>>()
5096 })
5097 .unwrap_or_default();
5098 if members.len() == 1 {
5099 candidate_roots = members;
5100 }
5101 }
5102
5103 candidate_roots.sort();
5104 candidate_roots.dedup();
5105 if candidate_roots.len() != 1 {
5106 return None;
5107 }
5108
5109 let crate_relative_root = candidate_roots.into_iter().next()?;
5110 let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
5111 let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
5112 let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
5113
5114 Some(WorkspacePrimaryCargoTarget {
5115 manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
5116 manifest_absolute,
5117 package_name,
5118 })
5119}
5120
5121#[cfg(test)]
5122fn resolve_release_publish_target_filter(
5123 invocation_dir: &Path,
5124 version_scope: &VersionScope,
5125) -> Result<Option<String>, String> {
5126 let VersionScope::Service {
5127 service_name,
5128 service_root,
5129 version_targets,
5130 ..
5131 } = version_scope
5132 else {
5133 return Ok(None);
5134 };
5135
5136 let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
5137 return Ok(None);
5138 };
5139 let Some(publish) = config.publish else {
5140 return Ok(None);
5141 };
5142
5143 let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
5144 let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
5145 .into_iter()
5146 .filter_map(|(kind, target)| {
5147 let target = target?;
5148 if target.enabled.unwrap_or(true) {
5149 Some((kind, target))
5150 } else {
5151 None
5152 }
5153 })
5154 .collect::<Vec<_>>();
5155
5156 if enabled_targets.is_empty() {
5157 return Ok(None);
5158 }
5159
5160 let matched = enabled_targets
5161 .iter()
5162 .filter_map(|(kind, target)| {
5163 let manifest_path = target.manifest_path.as_ref()?;
5164 let absolute = if Path::new(manifest_path).is_absolute() {
5165 PathBuf::from(manifest_path)
5166 } else {
5167 project_root.join(manifest_path)
5168 };
5169 let relative = absolute
5170 .strip_prefix(&project_root)
5171 .ok()
5172 .map(normalized_relative_path)
5173 .unwrap_or_else(|| normalized_relative_path(&absolute));
5174
5175 if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
5176 {
5177 Some((*kind).to_string())
5178 } else {
5179 None
5180 }
5181 })
5182 .collect::<Vec<_>>();
5183
5184 if matched.is_empty() {
5185 return Err(format!(
5186 "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
5187 service_name
5188 ));
5189 }
5190
5191 if matched.len() == enabled_targets.len() {
5192 if matched.len() == 1 {
5193 return Ok(matched.into_iter().next());
5194 }
5195 return Ok(None);
5196 }
5197
5198 if matched.len() == 1 {
5199 return Ok(matched.into_iter().next());
5200 }
5201
5202 Err(format!(
5203 "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.",
5204 service_name
5205 ))
5206}
5207
5208async fn resolve_effective_linear_release_config(
5209 project_root: &Path,
5210 invocation_dir: &Path,
5211) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
5212 let global_config = resolve_global_linear_release_config();
5213 let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5214 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5215 config.linear.and_then(|linear| linear.release)
5216 } else {
5217 None
5218 };
5219
5220 Ok(resolve_linear_release_config(global_config, project_config)
5221 .map(|config| resolve_linear_release_placeholders(project_root, config)))
5222}
5223
5224fn resolve_linear_release_placeholders(
5225 project_root: &Path,
5226 mut config: ResolvedLinearReleaseConfig,
5227) -> ResolvedLinearReleaseConfig {
5228 let mut env_map = HashMap::new();
5229 for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
5230 env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
5231 }
5232 if let Some(organization_name) = config.organization_name.clone() {
5233 env_map.insert("organization_name".to_string(), organization_name);
5234 }
5235
5236 let resolved = resolve_env_placeholders(project_root, &env_map);
5237 config.initiative_ids = config
5238 .initiative_ids
5239 .iter()
5240 .enumerate()
5241 .map(|(index, initiative_id)| {
5242 resolved
5243 .get(&format!("initiative_id_{}", index))
5244 .cloned()
5245 .unwrap_or_else(|| initiative_id.clone())
5246 })
5247 .map(|value| value.trim().to_string())
5248 .filter(|value| !value.is_empty())
5249 .collect();
5250 config.organization_name = resolved
5251 .get("organization_name")
5252 .cloned()
5253 .or(config.organization_name)
5254 .map(|value| value.trim().to_string())
5255 .filter(|value| !value.is_empty());
5256 config
5257}
5258
5259async fn resolve_project_github_release_branch_config(
5260 _project_root: &Path,
5261 invocation_dir: &Path,
5262) -> Result<Option<GitHubReleaseBranchSettings>, String> {
5263 if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5264 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5265 Ok(config.github_release_branch_settings())
5266 } else {
5267 Ok(None)
5268 }
5269}
5270
5271fn normalized_relative_path(path: &Path) -> String {
5272 path.to_string_lossy().replace('\\', "/")
5273}
5274
5275fn git_repository_root(dir: &Path) -> Option<PathBuf> {
5276 if !command_exists("git") {
5277 return None;
5278 }
5279
5280 let output: std::process::Output = Command::new("git")
5281 .current_dir(dir)
5282 .args(["rev-parse", "--show-toplevel"])
5283 .output()
5284 .ok()?;
5285
5286 if !output.status.success() {
5287 return None;
5288 }
5289
5290 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5291 if root.is_empty() {
5292 None
5293 } else {
5294 Some(PathBuf::from(root))
5295 }
5296}
5297
5298fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
5299 let output: std::process::Output = Command::new("git")
5300 .current_dir(project_root)
5301 .args(args)
5302 .output()
5303 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
5304
5305 if !output.status.success() {
5306 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
5307 if stderr.is_empty() {
5308 return Err(format!(
5309 "`git {}` failed with status {}",
5310 args.join(" "),
5311 output.status
5312 ));
5313 }
5314 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
5315 }
5316
5317 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5318}
5319
5320fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
5321 let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
5322 Ok(output
5323 .lines()
5324 .map(|line| line.trim())
5325 .filter(|line| !line.is_empty())
5326 .map(|line| line.to_string())
5327 .collect())
5328}
5329
5330fn version_change_guard_state_path() -> Result<PathBuf, String> {
5331 let paths = global_xbp_paths()?;
5332 Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
5333}
5334
5335fn version_change_guard_repo_key(project_root: &Path) -> String {
5336 fs::canonicalize(project_root)
5337 .unwrap_or_else(|_| project_root.to_path_buf())
5338 .to_string_lossy()
5339 .replace('\\', "/")
5340}
5341
5342fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
5343 if !path.exists() {
5344 return Ok(VersionChangeGuardRegistry::default());
5345 }
5346
5347 let content = fs::read_to_string(path).map_err(|e| {
5348 format!(
5349 "Failed to read version-change guard state {}: {}",
5350 path.display(),
5351 e
5352 )
5353 })?;
5354
5355 Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
5356}
5357
5358fn save_version_change_guard_registry(
5359 path: &Path,
5360 registry: &VersionChangeGuardRegistry,
5361) -> Result<(), String> {
5362 if let Some(parent) = path.parent() {
5363 fs::create_dir_all(parent).map_err(|e| {
5364 format!(
5365 "Failed to create guard state directory {}: {}",
5366 parent.display(),
5367 e
5368 )
5369 })?;
5370 }
5371
5372 let content = serde_yaml::to_string(registry)
5373 .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
5374 fs::write(path, content).map_err(|e| {
5375 format!(
5376 "Failed to write version-change guard state {}: {}",
5377 path.display(),
5378 e
5379 )
5380 })
5381}
5382
5383fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
5384 if !command_exists("git") {
5385 return Ok(None);
5386 }
5387
5388 let status_output: std::process::Output = Command::new("git")
5389 .current_dir(project_root)
5390 .args(["status", "--porcelain"])
5391 .output()
5392 .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
5393 if !status_output.status.success() {
5394 return Ok(None);
5395 }
5396
5397 let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
5398 .lines()
5399 .any(|line| !line.trim().is_empty());
5400
5401 let head_output: std::process::Output = Command::new("git")
5402 .current_dir(project_root)
5403 .args(["rev-parse", "HEAD"])
5404 .output()
5405 .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
5406 let head_commit: Option<String> = if head_output.status.success() {
5407 let value: String = String::from_utf8_lossy(&head_output.stdout)
5408 .trim()
5409 .to_string();
5410 if value.is_empty() {
5411 None
5412 } else {
5413 Some(value)
5414 }
5415 } else {
5416 None
5417 };
5418
5419 Ok(Some(GitWorktreeState {
5420 is_dirty,
5421 head_commit,
5422 }))
5423}
5424
5425fn should_clear_version_change_guard(
5426 entry: &VersionChangeGuardEntry,
5427 state: &GitWorktreeState,
5428) -> bool {
5429 if entry.pending_version_change_count == 0 {
5430 return true;
5431 }
5432 if !state.is_dirty {
5433 return true;
5434 }
5435
5436 match (&entry.head_commit, &state.head_commit) {
5437 (Some(previous), Some(current)) => previous != current,
5438 (Some(_), None) => true,
5439 _ => false,
5440 }
5441}
5442
5443fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
5444 let Some(state) = git_worktree_state(project_root)? else {
5445 return Ok(());
5446 };
5447
5448 let state_path: PathBuf = version_change_guard_state_path()?;
5449 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5450 let repo_key: String = version_change_guard_repo_key(project_root);
5451 let mut changed = false;
5452
5453 if let Some(entry) = registry.entries.get(&repo_key).cloned() {
5454 if should_clear_version_change_guard(&entry, &state) {
5455 registry.entries.remove(&repo_key);
5456 changed = true;
5457 }
5458 }
5459
5460 if changed {
5461 save_version_change_guard_registry(&state_path, ®istry)?;
5462 }
5463
5464 if state.is_dirty {
5465 if let Some(entry) = registry.entries.get(&repo_key) {
5466 if entry.pending_version_change_count >= 1 {
5467 return Err(format!(
5468 "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
5469 entry.pending_version_change_count,
5470 state_path.display()
5471 ));
5472 }
5473 }
5474 }
5475
5476 Ok(())
5477}
5478
5479fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
5480 let Some(state) = git_worktree_state(project_root)? else {
5481 return Ok(());
5482 };
5483
5484 let state_path: PathBuf = version_change_guard_state_path()?;
5485 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5486 let repo_key: String = version_change_guard_repo_key(project_root);
5487
5488 if state.is_dirty {
5489 registry.entries.insert(
5490 repo_key,
5491 VersionChangeGuardEntry {
5492 pending_version_change_count: 1,
5493 head_commit: state.head_commit,
5494 },
5495 );
5496 } else {
5497 registry.entries.remove(&repo_key);
5498 }
5499
5500 save_version_change_guard_registry(&state_path, ®istry)
5501}
5502
5503fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
5504 let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
5505 Ok(!output.trim().is_empty())
5506}
5507
5508fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
5509 let remotes: String = run_git_command(project_root, &["remote"])?;
5510 let exists: bool = remotes.lines().any(|line| line.trim() == remote);
5511 if exists {
5512 Ok(())
5513 } else {
5514 Err(format!(
5515 "Git remote `{}` is not configured for this repository.",
5516 remote
5517 ))
5518 }
5519}
5520
5521fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
5522 run_git_command(project_root, &["remote", "get-url", remote])
5523}
5524
5525fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
5526 let query: String = format!("refs/tags/{}", tag);
5527 let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
5528 Ok(!output.trim().is_empty())
5529}
5530
5531fn git_head_commitish(project_root: &Path) -> Result<String, String> {
5532 let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
5533 if commitish.is_empty() {
5534 Err("Unable to resolve HEAD commit for release target.".to_string())
5535 } else {
5536 Ok(commitish)
5537 }
5538}
5539
5540fn render_release_branch_name(
5541 naming_template: &str,
5542 release_version: &Version,
5543 tag_name: &str,
5544 service_name: &str,
5545) -> Result<String, String> {
5546 let service_name = sanitize_release_branch_segment(service_name);
5547 let branch_name = naming_template
5548 .replace("{VERSION}", &release_version.to_string())
5549 .replace("{TAG}", tag_name)
5550 .replace("{SERVICE_NAME}", &service_name)
5551 .replace("${GITHUB_VERSION}", &release_version.to_string())
5552 .replace("${GITHUB_TAG}", tag_name)
5553 .replace("${SERVICE_NAME}", &service_name);
5554 let branch_name = branch_name.trim();
5555 if branch_name.is_empty() {
5556 return Err(
5557 "GitHub release branch naming template resolved to an empty branch name.".to_string(),
5558 );
5559 }
5560 Ok(branch_name.to_string())
5561}
5562
5563fn sanitize_release_branch_segment(value: &str) -> String {
5564 value
5565 .trim()
5566 .chars()
5567 .map(|character| match character {
5568 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
5569 _ => '-',
5570 })
5571 .collect::<String>()
5572 .trim_matches('-')
5573 .to_string()
5574}
5575
5576fn git_local_branch_commit(
5577 project_root: &Path,
5578 branch_name: &str,
5579) -> Result<Option<String>, String> {
5580 let output = Command::new("git")
5581 .current_dir(project_root)
5582 .args([
5583 "rev-parse",
5584 "--verify",
5585 &format!("refs/heads/{}", branch_name),
5586 ])
5587 .output()
5588 .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
5589 if !output.status.success() {
5590 return Ok(None);
5591 }
5592 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
5593 if commit.is_empty() {
5594 Ok(None)
5595 } else {
5596 Ok(Some(commit))
5597 }
5598}
5599
5600fn git_remote_branch_commit(
5601 project_root: &Path,
5602 remote: &str,
5603 branch_name: &str,
5604) -> Result<Option<String>, String> {
5605 let output = run_git_command(
5606 project_root,
5607 &[
5608 "ls-remote",
5609 "--heads",
5610 remote,
5611 &format!("refs/heads/{}", branch_name),
5612 ],
5613 )?;
5614 let commit = output
5615 .split_whitespace()
5616 .next()
5617 .map(str::trim)
5618 .filter(|value| !value.is_empty())
5619 .map(str::to_string);
5620 Ok(commit)
5621}
5622
5623fn ensure_release_branch(
5624 project_root: &Path,
5625 branch_config: &GitHubReleaseBranchSettings,
5626 release_version: &Version,
5627 tag_name: &str,
5628 service_name: &str,
5629 target_commitish: &str,
5630) -> Result<String, String> {
5631 let branch_name = render_release_branch_name(
5632 &branch_config.naming_template,
5633 release_version,
5634 tag_name,
5635 service_name,
5636 )?;
5637
5638 if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
5639 if existing_local_commit != target_commitish {
5640 return Err(format!(
5641 "Configured release branch `{}` already exists locally at {}, expected {}.",
5642 branch_name, existing_local_commit, target_commitish
5643 ));
5644 }
5645 } else {
5646 run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
5647 }
5648
5649 if let Some(existing_remote_commit) =
5650 git_remote_branch_commit(project_root, "origin", &branch_name)?
5651 {
5652 if existing_remote_commit != target_commitish {
5653 return Err(format!(
5654 "Configured release branch `{}` already exists on origin at {}, expected {}.",
5655 branch_name, existing_remote_commit, target_commitish
5656 ));
5657 }
5658 } else {
5659 run_git_command(
5660 project_root,
5661 &[
5662 "push",
5663 "origin",
5664 &format!("{}:refs/heads/{}", target_commitish, branch_name),
5665 ],
5666 )?;
5667 }
5668
5669 Ok(branch_name)
5670}
5671
5672fn release_tag_family(tag_name: &str) -> String {
5673 if tag_name.starts_with('v')
5674 && tag_name
5675 .chars()
5676 .nth(1)
5677 .map(|ch| ch.is_ascii_digit())
5678 .unwrap_or(false)
5679 {
5680 return "v".to_string();
5681 }
5682
5683 let mut family: String = String::new();
5684 for ch in tag_name.chars() {
5685 if ch.is_ascii_digit() {
5686 break;
5687 }
5688 family.push(ch);
5689 }
5690 family
5691}
5692
5693fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
5694 if family == "v" {
5695 return parse_version(tag).ok();
5696 }
5697
5698 tag.strip_prefix(family)
5699 .and_then(|rest| parse_version(rest).ok())
5700}
5701
5702fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
5703 let range: String = format!("{}..HEAD", tag);
5704 run_git_command(project_root, &["rev-list", "--count", &range])
5705 .ok()
5706 .and_then(|raw| raw.trim().parse::<usize>().ok())
5707}
5708
5709fn previous_release_tag(
5710 project_root: &Path,
5711 current_tag_name: &str,
5712) -> Result<Option<String>, String> {
5713 let family: String = release_tag_family(current_tag_name);
5714 let tag_pattern: String = format!("{}*", family);
5715 let merged_tags: String = run_git_command(
5716 project_root,
5717 &["tag", "--merged", "HEAD", "--list", &tag_pattern],
5718 )?;
5719
5720 let mut best: Option<(usize, String)> = None;
5721 for raw in merged_tags.lines() {
5722 let tag = raw.trim();
5723 if tag.is_empty() || tag == current_tag_name {
5724 continue;
5725 }
5726 if parse_release_family_version(tag, &family).is_none() {
5727 continue;
5728 }
5729
5730 let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
5731 continue;
5732 };
5733 if distance == 0 {
5734 continue;
5735 }
5736
5737 match &best {
5738 None => best = Some((distance, tag.to_string())),
5739 Some((best_distance, _)) if distance < *best_distance => {
5740 best = Some((distance, tag.to_string()))
5741 }
5742 _ => {}
5743 }
5744 }
5745
5746 Ok(best.map(|(_, tag)| tag))
5747}
5748
5749fn default_release_title(version: &Version, repo: &str) -> String {
5750 format!("{} - {}", version, repo)
5751}
5752
5753fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
5754 match version_scope {
5755 VersionScope::Repository => repo,
5756 VersionScope::Crate { package_name, .. } => package_name.as_str(),
5757 VersionScope::Service { service_name, .. } => service_name.as_str(),
5758 }
5759}
5760
5761fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
5762 match version_scope {
5763 VersionScope::Repository => "repository",
5764 VersionScope::Crate { .. } => "crate",
5765 VersionScope::Service { .. } => "service",
5766 }
5767}
5768
5769fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
5770 match version_scope {
5771 VersionScope::Repository => repo.to_string(),
5772 VersionScope::Crate { package_name, .. } => package_name.clone(),
5773 VersionScope::Service { service_name, .. } => service_name.clone(),
5774 }
5775}
5776
5777fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
5778 match version_scope {
5779 VersionScope::Repository => "Repository".to_string(),
5780 VersionScope::Crate {
5781 package_name,
5782 crate_relative_root,
5783 ..
5784 } => format!("{} ({})", package_name, crate_relative_root),
5785 VersionScope::Service {
5786 service_name,
5787 service_relative_root,
5788 ..
5789 } => format!("{} ({})", service_name, service_relative_root),
5790 }
5791}
5792
5793fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
5794 match version_scope {
5795 VersionScope::Repository => format!("v{}", version),
5796 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
5797 format!("{}{}", tag_prefix, version)
5798 }
5799 }
5800}
5801
5802fn scoped_release_tag_name(
5803 version_scope: &VersionScope,
5804 version: &Version,
5805 parsed_tag_name: &str,
5806) -> String {
5807 match version_scope {
5808 VersionScope::Repository => parsed_tag_name.to_string(),
5809 VersionScope::Crate { .. } | VersionScope::Service { .. } => {
5810 if release_tag_family(parsed_tag_name) == "v" {
5811 default_release_tag_name(version_scope, version)
5812 } else {
5813 parsed_tag_name.to_string()
5814 }
5815 }
5816 }
5817}
5818
5819fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
5820 match version_scope {
5821 VersionScope::Repository => None,
5822 VersionScope::Crate {
5823 crate_relative_root,
5824 ..
5825 } => Some(crate_relative_root.clone()),
5826 VersionScope::Service {
5827 service_relative_root,
5828 ..
5829 } if service_relative_root == "." => None,
5830 VersionScope::Service {
5831 service_relative_root,
5832 ..
5833 } => Some(service_relative_root.clone()),
5834 }
5835}
5836
5837fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
5838 let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
5839 return (None, None);
5840 };
5841 let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
5842 return (None, None);
5843 };
5844
5845 (Some(owner), Some(repo))
5846}
5847
5848fn published_initiatives_to_activity(
5849 initiatives: &[PublishedLinearInitiative],
5850) -> Vec<VersionActivityLinearInitiative> {
5851 initiatives
5852 .iter()
5853 .map(|initiative| VersionActivityLinearInitiative {
5854 id: initiative.id.clone(),
5855 name: initiative.name.clone(),
5856 url: initiative.url.clone(),
5857 })
5858 .collect()
5859}
5860
5861async fn sync_cli_version_write_activity(
5862 project_root: &Path,
5863 version_scope: &VersionScope,
5864 version: &Version,
5865 message: String,
5866) {
5867 let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
5868 let scope_label = version_scope_label(
5869 version_scope,
5870 repository_name.as_deref().unwrap_or_else(|| {
5871 project_root
5872 .file_name()
5873 .and_then(|value| value.to_str())
5874 .unwrap_or("repository")
5875 }),
5876 );
5877
5878 let payload = CliVersionActivityPayload {
5879 command_kind: "version".to_string(),
5880 repository_owner,
5881 repository_name,
5882 scope_kind: version_scope_kind(version_scope).to_string(),
5883 scope_label,
5884 version: version.to_string(),
5885 tag_name: None,
5886 title: None,
5887 release_url: None,
5888 message_markdown: Some(message),
5889 published_initiatives: Vec::new(),
5890 };
5891
5892 if let Err(error) = post_version_activity(&payload).await {
5893 eprintln!("Warning: {}", error);
5894 }
5895}
5896
5897async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
5898 let payload = CliVersionActivityPayload {
5899 command_kind: "version_release".to_string(),
5900 repository_owner: Some(summary.repository_owner.clone()),
5901 repository_name: Some(summary.repository_name.clone()),
5902 scope_kind: summary.scope_kind.clone(),
5903 scope_label: summary.scope_label.clone(),
5904 version: summary.version.to_string(),
5905 tag_name: Some(summary.tag_name.clone()),
5906 title: Some(summary.release_title.clone()),
5907 release_url: Some(summary.release_url.clone()),
5908 message_markdown: Some(summary.release_notes.clone()),
5909 published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
5910 };
5911
5912 if let Err(error) = post_version_activity(&payload).await {
5913 eprintln!("Warning: {}", error);
5914 }
5915}
5916
5917fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
5918 let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
5919 let mut rendered_notes: String = notes.trim_end().to_string();
5920 if !rendered_notes.is_empty() {
5921 rendered_notes.push('\n');
5922 }
5923 rendered_notes.push_str("Release label: ");
5924 rendered_notes.push_str(release_label);
5925 rendered_notes.push('\n');
5926 rendered_notes.push_str("Generated by XBP ");
5927 rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
5928 rendered_notes
5929}
5930
5931#[cfg(test)]
5932mod tests {
5933 use super::github_release::{
5934 github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
5935 github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
5936 github_release_update_endpoint,
5937 };
5938 use super::release_docs::{
5939 release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
5940 };
5941 use super::release_notes::{
5942 build_fallback_sections, collect_linear_issue_identifiers,
5943 deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
5944 LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
5945 };
5946 use super::{
5947 analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
5948 current_scope_ledger_path, default_release_tag_name, default_release_title,
5949 highest_version_observation, is_safe_dirty_path, normalized_relative_path,
5950 parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
5951 parse_local_git_tag_output_for_scope, parse_package_version_target,
5952 parse_release_version_target, parse_remote_git_tag_output, parse_version,
5953 prepare_release_openapi_assets, read_cargo_lock_version,
5954 read_cargo_lock_version_for_package, read_cargo_toml_version,
5955 read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
5956 read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
5957 read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
5958 read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
5959 render_release_branch_name, resolve_configured_version_target_paths,
5960 resolve_linear_release_placeholders, resolve_project_root_from, resolve_registry_paths,
5961 resolve_release_openapi_specs, resolve_release_publish_target_filter,
5962 resolve_version_scope, rewrite_toml_package_assignment_versions,
5963 select_changed_version_targets, should_clear_version_change_guard,
5964 stale_version_observations, sync_version_to_configured_files_with_paths,
5965 write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
5966 write_json_openapi_version, write_json_root_version, write_openapi_version,
5967 write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
5968 write_regex_version, write_toml_root_version, write_version_to_configured_files,
5969 write_version_to_configured_files_with_paths, write_version_to_selected_paths,
5970 write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
5971 VersionObservation, VersionScope,
5972 };
5973
5974 use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
5975 use crate::config::PackageNameLookup;
5976 use semver::Version;
5977 use std::collections::BTreeMap;
5978 use std::fs;
5979 use std::path::{Path, PathBuf};
5980 use std::time::{SystemTime, UNIX_EPOCH};
5981
5982 fn temp_dir(label: &str) -> PathBuf {
5983 let nanos: u128 = SystemTime::now()
5984 .duration_since(UNIX_EPOCH)
5985 .expect("time")
5986 .as_nanos();
5987 let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
5988 fs::create_dir_all(&dir).expect("create temp dir");
5989 dir
5990 }
5991
5992 #[test]
5993 fn parse_git_status_path_handles_renames() {
5994 assert_eq!(
5995 parse_git_status_path("R old-name -> new-name/Cargo.toml"),
5996 Some("new-name/Cargo.toml".to_string())
5997 );
5998 assert_eq!(
5999 parse_git_status_path(" M .xbp/xbp.yaml"),
6000 Some(".xbp/xbp.yaml".to_string())
6001 );
6002 }
6003
6004 #[test]
6005 fn is_safe_dirty_path_recognizes_generated_paths() {
6006 assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
6007 assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
6008 assert!(is_safe_dirty_path(
6009 "scripts/__pycache__/publish.cpython-312.pyc"
6010 ));
6011 assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
6012 assert!(!is_safe_dirty_path("Cargo.toml"));
6013 }
6014
6015 #[test]
6016 fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
6017 let analysis = analyze_dirty_worktree(&[
6018 " M .xbp/xbp.yaml".to_string(),
6019 " M crates/cli/src/main.rs".to_string(),
6020 ]);
6021 assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
6022 assert_eq!(
6023 analysis.risky_entries,
6024 vec!["crates/cli/src/main.rs".to_string()]
6025 );
6026 }
6027
6028 fn write_multi_service_config(dir: &Path) {
6029 let xbp_dir = dir.join(".xbp");
6030 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6031 fs::write(
6032 xbp_dir.join("xbp.yaml"),
6033 r#"project_name: xbp
6034version: 10.30.0
6035port: 3000
6036build_dir: ./
6037services:
6038 - name: cli
6039 target: rust
6040 branch: main
6041 port: 8080
6042 root_directory: ./
6043 version_targets:
6044 - crates/cli/Cargo.toml
6045 - name: web
6046 target: nextjs
6047 branch: main
6048 port: 3001
6049 root_directory: apps/web
6050 version_targets:
6051 - apps/web/package.json
6052publish:
6053 npm:
6054 enabled: true
6055 manifest_path: apps/web/package.json
6056 crates:
6057 enabled: true
6058 manifest_path: crates/cli/Cargo.toml
6059version_targets:
6060 - crates/cli/Cargo.toml
6061 - apps/web/package.json
6062"#,
6063 )
6064 .expect("write xbp config");
6065 }
6066
6067 #[test]
6068 fn parses_prefixed_semver() {
6069 assert_eq!(
6070 parse_version("v1.2.3").expect("version"),
6071 Version::new(1, 2, 3)
6072 );
6073 }
6074
6075 #[test]
6076 fn rejects_invalid_semver() {
6077 let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
6078 assert!(error.contains("Invalid semantic version"));
6079 }
6080
6081 #[test]
6082 fn release_target_parser_supports_plain_semver() {
6083 let (version, tag_name) =
6084 parse_release_version_target("1.2.3-alpha.1").expect("release target");
6085 assert_eq!(version.major, 1);
6086 assert_eq!(version.minor, 2);
6087 assert_eq!(version.patch, 3);
6088 assert_eq!(version.pre.as_str(), "alpha.1");
6089 assert_eq!(tag_name, "v1.2.3-alpha.1");
6090 }
6091
6092 #[test]
6093 fn release_target_parser_supports_prefixed_semver() {
6094 let (version, tag_name) =
6095 parse_release_version_target("studio-0.3.2-alpha").expect("release target");
6096 assert_eq!(version.major, 0);
6097 assert_eq!(version.minor, 3);
6098 assert_eq!(version.patch, 2);
6099 assert_eq!(version.pre.as_str(), "alpha");
6100 assert_eq!(tag_name, "studio-0.3.2-alpha");
6101 }
6102
6103 #[test]
6104 fn bumps_versions_correctly() {
6105 let base: Version = Version::new(0, 1, 0);
6106 assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
6107 assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
6108 assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
6109 }
6110
6111 #[test]
6112 fn version_change_guard_clears_when_worktree_is_clean() {
6113 let entry = VersionChangeGuardEntry {
6114 pending_version_change_count: 1,
6115 head_commit: Some("abc123".to_string()),
6116 };
6117 let state = GitWorktreeState {
6118 is_dirty: false,
6119 head_commit: Some("abc123".to_string()),
6120 };
6121 assert!(should_clear_version_change_guard(&entry, &state));
6122 }
6123
6124 #[test]
6125 fn version_change_guard_clears_when_head_changes() {
6126 let entry = VersionChangeGuardEntry {
6127 pending_version_change_count: 1,
6128 head_commit: Some("abc123".to_string()),
6129 };
6130 let state = GitWorktreeState {
6131 is_dirty: true,
6132 head_commit: Some("def456".to_string()),
6133 };
6134 assert!(should_clear_version_change_guard(&entry, &state));
6135 }
6136
6137 #[test]
6138 fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
6139 let entry = VersionChangeGuardEntry {
6140 pending_version_change_count: 1,
6141 head_commit: Some("abc123".to_string()),
6142 };
6143 let state = GitWorktreeState {
6144 is_dirty: true,
6145 head_commit: Some("abc123".to_string()),
6146 };
6147 assert!(!should_clear_version_change_guard(&entry, &state));
6148 }
6149
6150 #[test]
6151 fn render_release_branch_name_replaces_supported_tokens() {
6152 let branch = render_release_branch_name(
6153 "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
6154 &Version::new(10, 27, 0),
6155 "v10.27.0",
6156 "xbp",
6157 )
6158 .expect("branch name");
6159
6160 assert_eq!(branch, "releases/10.27.0/v10.27.0");
6161 }
6162
6163 #[test]
6164 fn render_release_branch_name_supports_service_name_template() {
6165 let branch = render_release_branch_name(
6166 "releases/{VERSION}-{SERVICE_NAME}",
6167 &Version::new(10, 27, 0),
6168 "v10.27.0",
6169 "athena/api",
6170 )
6171 .expect("branch name");
6172
6173 assert_eq!(branch, "releases/10.27.0-athena-api");
6174 }
6175
6176 #[test]
6177 fn resolve_linear_release_placeholders_reads_env_files() {
6178 let temp_dir = std::env::temp_dir().join(format!(
6179 "xbp-linear-release-placeholders-{}",
6180 std::time::SystemTime::now()
6181 .duration_since(std::time::UNIX_EPOCH)
6182 .expect("time")
6183 .as_nanos()
6184 ));
6185 fs::create_dir_all(&temp_dir).expect("temp dir");
6186 fs::write(
6187 temp_dir.join(".env.local"),
6188 "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
6189 )
6190 .expect("env file");
6191
6192 let resolved = resolve_linear_release_placeholders(
6193 &temp_dir,
6194 ResolvedLinearReleaseConfig {
6195 initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
6196 organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
6197 health: "on_track".to_string(),
6198 },
6199 );
6200
6201 assert_eq!(
6202 resolved.initiative_ids,
6203 vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
6204 );
6205 assert_eq!(
6206 resolved.organization_name.as_deref(),
6207 Some("suits-formations")
6208 );
6209
6210 let _ = fs::remove_dir_all(temp_dir);
6211 }
6212
6213 #[test]
6214 fn version_change_guard_clears_when_pending_count_is_zero() {
6215 let entry = VersionChangeGuardEntry {
6216 pending_version_change_count: 0,
6217 head_commit: Some("abc123".to_string()),
6218 };
6219 let state = GitWorktreeState {
6220 is_dirty: true,
6221 head_commit: Some("abc123".to_string()),
6222 };
6223 assert!(should_clear_version_change_guard(&entry, &state));
6224 }
6225
6226 #[test]
6227 fn parse_package_version_target_supports_assignment_syntax() {
6228 let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
6229 .expect("parse")
6230 .expect("target");
6231 assert_eq!(parsed.0, "demo_pkg".to_string());
6232 assert_eq!(parsed.1, Version::new(1, 2, 3));
6233 }
6234
6235 #[test]
6236 fn parse_package_version_target_rejects_invalid_package_names() {
6237 let error: String = parse_package_version_target("bad package=1.2.3")
6238 .expect_err("invalid package target should fail");
6239 assert!(error.contains("Invalid package target"));
6240 }
6241
6242 #[test]
6243 fn parse_package_version_target_returns_none_without_assignment() {
6244 assert!(parse_package_version_target("1.2.3")
6245 .expect("parse")
6246 .is_none());
6247 }
6248
6249 #[test]
6250 fn parse_package_version_target_returns_none_for_empty_package_name() {
6251 assert!(parse_package_version_target(" =1.2.3")
6252 .expect("parse")
6253 .is_none());
6254 }
6255
6256 #[test]
6257 fn bumping_clears_prerelease_and_build_metadata() {
6258 let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
6259 assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
6260 assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
6261 assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
6262 }
6263
6264 #[test]
6265 fn cargo_toml_adapter_reads_and_writes() {
6266 let dir: PathBuf = temp_dir("cargo");
6267 let path: PathBuf = dir.join("Cargo.toml");
6268 fs::write(
6269 &path,
6270 r#"[package]
6271 name = "xbp"
6272 version = "1.0.0"
6273 "#,
6274 )
6275 .expect("write Cargo.toml");
6276
6277 assert_eq!(
6278 read_cargo_toml_version(&path).expect("read"),
6279 Some("1.0.0".to_string())
6280 );
6281
6282 write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
6283 assert_eq!(
6284 read_version_from_path(&path).expect("read"),
6285 Some("1.1.0".to_string())
6286 );
6287
6288 let _ = fs::remove_dir_all(dir);
6289 }
6290
6291 #[test]
6292 fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
6293 let dir: PathBuf = temp_dir("workspace-root-version");
6294 let crate_dir = dir.join("crates").join("demo");
6295 fs::create_dir_all(&crate_dir).expect("create crate dir");
6296 fs::write(
6297 dir.join("Cargo.toml"),
6298 r#"[workspace]
6299members = ["crates/demo"]
6300resolver = "2"
6301
6302[workspace.package]
6303version = "0.1.0"
6304"#,
6305 )
6306 .expect("write workspace root");
6307 fs::write(
6308 crate_dir.join("Cargo.toml"),
6309 r#"[package]
6310name = "demo"
6311version = { workspace = true }
6312"#,
6313 )
6314 .expect("write crate manifest");
6315
6316 assert_eq!(
6317 read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
6318 Some("0.1.0".to_string())
6319 );
6320 assert_eq!(
6321 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6322 Some("0.1.0".to_string())
6323 );
6324
6325 write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
6326 assert_eq!(
6327 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6328 Some("0.2.0".to_string())
6329 );
6330
6331 let _ = fs::remove_dir_all(dir);
6332 }
6333
6334 #[test]
6335 fn json_root_adapter_reads_and_writes() {
6336 let dir: PathBuf = temp_dir("json");
6337 let path: PathBuf = dir.join("package.json");
6338 fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
6339
6340 assert_eq!(
6341 read_json_root_version(&path).expect("read"),
6342 Some("1.4.0".to_string())
6343 );
6344
6345 write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
6346 assert_eq!(
6347 read_version_from_path(&path).expect("read"),
6348 Some("1.5.0".to_string())
6349 );
6350
6351 let _ = fs::remove_dir_all(dir);
6352 }
6353
6354 #[test]
6355 fn yaml_root_adapter_reads_and_writes() {
6356 let dir: PathBuf = temp_dir("yaml");
6357 let path: PathBuf = dir.join("xbp.yaml");
6358 fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
6359
6360 assert_eq!(
6361 read_yaml_root_version(&path, "version").expect("read"),
6362 Some("0.2.0".to_string())
6363 );
6364
6365 write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
6366 assert_eq!(
6367 read_version_from_path(&path).expect("read"),
6368 Some("0.3.0".to_string())
6369 );
6370
6371 let _ = fs::remove_dir_all(dir);
6372 }
6373
6374 #[test]
6375 fn toml_root_adapter_reads_and_writes() {
6376 let dir: PathBuf = temp_dir("toml");
6377 let path: PathBuf = dir.join("config.toml");
6378 fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
6379
6380 assert_eq!(
6381 read_toml_root_version(&path).expect("read"),
6382 Some("3.1.4".to_string())
6383 );
6384
6385 write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
6386 assert_eq!(
6387 read_toml_root_version(&path).expect("read"),
6388 Some("3.2.0".to_string())
6389 );
6390
6391 let _ = fs::remove_dir_all(dir);
6392 }
6393
6394 #[test]
6395 fn openapi_adapter_reads_and_writes_nested_version() {
6396 let dir: PathBuf = temp_dir("openapi");
6397 let path: PathBuf = dir.join("openapi.yaml");
6398 fs::write(
6399 &path,
6400 "openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
6401 )
6402 .expect("write openapi");
6403
6404 assert_eq!(
6405 read_openapi_version(&path).expect("read"),
6406 Some("1.2.3".to_string())
6407 );
6408
6409 write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
6410 assert_eq!(
6411 read_openapi_version(&path).expect("read"),
6412 Some("2.0.0".to_string())
6413 );
6414
6415 let _ = fs::remove_dir_all(dir);
6416 }
6417
6418 #[test]
6419 fn openapi_writer_creates_missing_info_mapping() {
6420 let dir: PathBuf = temp_dir("openapi-missing-info");
6421 let path: PathBuf = dir.join("openapi.yaml");
6422 fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
6423
6424 write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6425 assert_eq!(
6426 read_openapi_version(&path).expect("read"),
6427 Some("4.0.0".to_string())
6428 );
6429
6430 let _ = fs::remove_dir_all(dir);
6431 }
6432
6433 #[test]
6434 fn json_openapi_adapter_reads_and_writes_nested_version() {
6435 let dir: PathBuf = temp_dir("openapi-json");
6436 let path: PathBuf = dir.join("openapi.json");
6437 fs::write(
6438 &path,
6439 r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
6440 )
6441 .expect("write openapi json");
6442
6443 assert_eq!(
6444 read_json_openapi_version(&path).expect("read"),
6445 Some("1.2.3".to_string())
6446 );
6447
6448 write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
6449 assert_eq!(
6450 read_json_openapi_version(&path).expect("read"),
6451 Some("2.1.0".to_string())
6452 );
6453
6454 let _ = fs::remove_dir_all(dir);
6455 }
6456
6457 #[test]
6458 fn json_openapi_writer_creates_missing_info_object() {
6459 let dir: PathBuf = temp_dir("openapi-json-missing-info");
6460 let path: PathBuf = dir.join("openapi.json");
6461 fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
6462
6463 write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6464 assert_eq!(
6465 read_json_openapi_version(&path).expect("read"),
6466 Some("4.0.0".to_string())
6467 );
6468
6469 let _ = fs::remove_dir_all(dir);
6470 }
6471
6472 #[test]
6473 fn pyproject_reader_prefers_project_version() {
6474 let dir: PathBuf = temp_dir("pyproject-project");
6475 let path: PathBuf = dir.join("pyproject.toml");
6476 fs::write(
6477 &path,
6478 "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
6479 )
6480 .expect("write pyproject");
6481
6482 assert_eq!(
6483 read_pyproject_version(&path).expect("read"),
6484 Some("0.8.0".to_string())
6485 );
6486
6487 let _ = fs::remove_dir_all(dir);
6488 }
6489
6490 #[test]
6491 fn pyproject_reader_falls_back_to_poetry_version() {
6492 let dir: PathBuf = temp_dir("pyproject-poetry");
6493 let path: PathBuf = dir.join("pyproject.toml");
6494 fs::write(
6495 &path,
6496 "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
6497 )
6498 .expect("write pyproject");
6499
6500 assert_eq!(
6501 read_pyproject_version(&path).expect("read"),
6502 Some("1.9.0".to_string())
6503 );
6504
6505 let _ = fs::remove_dir_all(dir);
6506 }
6507
6508 #[test]
6509 fn pyproject_writer_updates_project_table() {
6510 let dir: PathBuf = temp_dir("pyproject-write-project");
6511 let path: PathBuf = dir.join("pyproject.toml");
6512 fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
6513 .expect("write pyproject");
6514
6515 write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
6516 assert_eq!(
6517 read_pyproject_version(&path).expect("read"),
6518 Some("1.1.0".to_string())
6519 );
6520
6521 let _ = fs::remove_dir_all(dir);
6522 }
6523
6524 #[test]
6525 fn pyproject_writer_updates_poetry_table() {
6526 let dir: PathBuf = temp_dir("pyproject-write-poetry");
6527 let path: PathBuf = dir.join("pyproject.toml");
6528 fs::write(
6529 &path,
6530 "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
6531 )
6532 .expect("write pyproject");
6533
6534 write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
6535 assert_eq!(
6536 read_pyproject_version(&path).expect("read"),
6537 Some("2.1.0".to_string())
6538 );
6539
6540 let _ = fs::remove_dir_all(dir);
6541 }
6542
6543 #[test]
6544 fn cargo_lock_reader_and_writer_follow_package_name() {
6545 let dir: PathBuf = temp_dir("cargo-lock");
6546 let cargo_toml: PathBuf = dir.join("Cargo.toml");
6547 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6548 fs::write(
6549 &cargo_toml,
6550 r#"[package]
6551 name = "xbp"
6552 version = "1.0.0"
6553 "#,
6554 )
6555 .expect("write Cargo.toml");
6556 fs::write(
6557 &cargo_lock,
6558 r#"version = 4
6559
6560 [[package]]
6561 name = "xbp"
6562 version = "1.0.0"
6563
6564 [[package]]
6565 name = "other"
6566 version = "9.9.9"
6567 "#,
6568 )
6569 .expect("write Cargo.lock");
6570
6571 assert_eq!(
6572 read_cargo_lock_version(&cargo_lock).expect("read"),
6573 Some("1.0.0".to_string())
6574 );
6575
6576 write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
6577 assert_eq!(
6578 read_cargo_lock_version(&cargo_lock).expect("read"),
6579 Some("1.0.1".to_string())
6580 );
6581
6582 let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
6583 assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
6584
6585 let _ = fs::remove_dir_all(dir);
6586 }
6587
6588 #[test]
6589 fn cargo_lock_writer_errors_when_package_missing() {
6590 let dir: PathBuf = temp_dir("cargo-lock-missing");
6591 fs::write(
6592 dir.join("Cargo.toml"),
6593 "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
6594 )
6595 .expect("write Cargo.toml");
6596 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6597 fs::write(
6598 &cargo_lock,
6599 "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
6600 )
6601 .expect("write Cargo.lock");
6602
6603 let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
6604 .expect_err("missing package should fail");
6605 assert!(error.contains("Could not find package `xbp`"));
6606
6607 let _ = fs::remove_dir_all(dir);
6608 }
6609
6610 #[test]
6611 fn cargo_package_name_reads_package_section() {
6612 let dir: PathBuf = temp_dir("cargo-package-name");
6613 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6614 fs::write(
6615 dir.join("Cargo.toml"),
6616 "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
6617 )
6618 .expect("write Cargo.toml");
6619 fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
6620
6621 assert_eq!(
6622 cargo_package_name(&cargo_lock).expect("name"),
6623 Some("xbp-cli".to_string())
6624 );
6625
6626 let _ = fs::remove_dir_all(dir);
6627 }
6628
6629 #[test]
6630 fn cargo_toml_writer_skips_workspace_manifest_without_package() {
6631 let dir: PathBuf = temp_dir("cargo-workspace-manifest");
6632 let path: PathBuf = dir.join("Cargo.toml");
6633 fs::write(
6634 &path,
6635 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6636 )
6637 .expect("write Cargo.toml");
6638
6639 let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
6640 assert!(changed);
6641 let content = fs::read_to_string(&path).expect("read Cargo.toml");
6642 assert!(content.contains("[workspace.package]"));
6643 assert!(content.contains("version = \"2.0.0\""));
6644
6645 let _ = fs::remove_dir_all(dir);
6646 }
6647
6648 #[test]
6649 fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
6650 let dir: PathBuf = temp_dir("workspace-cargo-skip");
6651 fs::write(
6652 dir.join("Cargo.toml"),
6653 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6654 )
6655 .expect("write Cargo.toml");
6656 fs::write(
6657 dir.join("Cargo.lock"),
6658 "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
6659 )
6660 .expect("write Cargo.lock");
6661 fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
6662 .expect("write README");
6663
6664 let updated = write_version_to_configured_files(
6665 &dir,
6666 &dir,
6667 &[
6668 "Cargo.toml".to_string(),
6669 "Cargo.lock".to_string(),
6670 "README.md".to_string(),
6671 ],
6672 &VersionScope::Repository,
6673 &Version::new(1, 1, 0),
6674 )
6675 .expect("write versions");
6676
6677 assert_eq!(updated, 2);
6678 assert_eq!(
6679 read_readme_version(&dir.join("README.md")).expect("read"),
6680 Some("1.1.0".to_string())
6681 );
6682 let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
6683 assert!(cargo_content.contains("version = \"1.1.0\""));
6684
6685 let _ = fs::remove_dir_all(dir);
6686 }
6687
6688 #[test]
6689 fn repository_scope_prefers_workspace_default_member_manifest() {
6690 let dir: PathBuf = temp_dir("workspace-default-member-path");
6691 let crate_dir: PathBuf = dir.join("crates").join("cli");
6692 fs::create_dir_all(&crate_dir).expect("create crate dir");
6693 fs::write(
6694 dir.join("Cargo.toml"),
6695 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6696 )
6697 .expect("write workspace cargo");
6698 fs::write(
6699 crate_dir.join("Cargo.toml"),
6700 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6701 )
6702 .expect("write crate cargo");
6703
6704 let resolved = super::resolve_registry_relative_path(
6705 &dir,
6706 &dir,
6707 &VersionScope::Repository,
6708 "Cargo.toml",
6709 );
6710
6711 assert_eq!(resolved, "crates/cli/Cargo.toml");
6712
6713 let _ = fs::remove_dir_all(dir);
6714 }
6715
6716 #[test]
6717 fn root_discovered_service_does_not_hijack_repository_scope() {
6718 let dir: PathBuf = temp_dir("root-discovered-service-scope");
6719 let crate_dir: PathBuf = dir.join("crates").join("cli");
6720 let xbp_dir: PathBuf = dir.join(".xbp");
6721 fs::create_dir_all(&crate_dir).expect("create crate dir");
6722 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6723 fs::write(
6724 dir.join("Cargo.toml"),
6725 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6726 )
6727 .expect("write workspace cargo");
6728 fs::write(
6729 crate_dir.join("Cargo.toml"),
6730 "[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6731 )
6732 .expect("write crate cargo");
6733 fs::write(
6734 dir.join("Cargo.lock"),
6735 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6736 )
6737 .expect("write cargo lock");
6738 fs::write(
6739 xbp_dir.join("xbp.yaml"),
6740 r#"project_name: xbp
6741version: 10.35.0
6742port: 3000
6743build_dir: ./
6744services:
6745 - name: xbp-2
6746 target: python
6747 branch: main
6748 port: 8001
6749 root_directory: ./
6750 version_targets:
6751 - Cargo.toml
6752 - name: crates-cli
6753 target: rust
6754 branch: main
6755 port: 8002
6756 root_directory: crates/cli
6757 version_targets:
6758 - crates/cli/Cargo.toml
6759publish:
6760 crates:
6761 enabled: true
6762 package_name: xbp
6763 working_directory: crates/cli
6764 manifest_path: crates/cli/Cargo.toml
6765version_targets:
6766 - Cargo.toml
6767 - crates/cli/Cargo.toml
6768"#,
6769 )
6770 .expect("write xbp config");
6771
6772 assert!(matches!(
6773 resolve_version_scope(&dir, &dir),
6774 VersionScope::Repository
6775 ));
6776
6777 let resolved = resolve_registry_paths(
6778 &dir,
6779 &dir,
6780 &["Cargo.toml".to_string(), "Cargo.lock".to_string()],
6781 &VersionScope::Repository,
6782 );
6783 assert!(resolved
6784 .iter()
6785 .any(|path| path.relative == "crates/cli/Cargo.toml"));
6786 assert_eq!(
6787 read_highest_version_from_targets(&resolved).expect("read selected versions"),
6788 Some(Version::new(10, 35, 0))
6789 );
6790
6791 let _ = fs::remove_dir_all(dir);
6792 }
6793
6794 #[test]
6795 fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
6796 let dir: PathBuf = temp_dir("workspace-default-member-writer");
6797 let crate_dir: PathBuf = dir.join("crates").join("cli");
6798 fs::create_dir_all(&crate_dir).expect("create crate dir");
6799 fs::write(
6800 dir.join("Cargo.toml"),
6801 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6802 )
6803 .expect("write workspace cargo");
6804 fs::write(
6805 crate_dir.join("Cargo.toml"),
6806 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6807 )
6808 .expect("write crate cargo");
6809 fs::write(
6810 dir.join("Cargo.lock"),
6811 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
6812 )
6813 .expect("write cargo lock");
6814 fs::write(
6815 dir.join("README.md"),
6816 "# XBP\n\ncurrent version: `10.21.0`\n",
6817 )
6818 .expect("write readme");
6819
6820 let updated = write_version_to_configured_files(
6821 &dir,
6822 &dir,
6823 &[
6824 "Cargo.toml".to_string(),
6825 "Cargo.lock".to_string(),
6826 "README.md".to_string(),
6827 ],
6828 &VersionScope::Repository,
6829 &Version::new(10, 22, 0),
6830 )
6831 .expect("write versions");
6832
6833 assert_eq!(updated, 3);
6834 assert_eq!(
6835 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
6836 Some("10.22.0".to_string())
6837 );
6838 assert_eq!(
6839 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
6840 Some("10.22.0".to_string())
6841 );
6842 assert_eq!(
6843 read_readme_version(&dir.join("README.md")).expect("read readme"),
6844 Some("10.22.0".to_string())
6845 );
6846
6847 let _ = fs::remove_dir_all(dir);
6848 }
6849
6850 #[test]
6851 fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
6852 let dir: PathBuf = temp_dir("publish-manifest-version-target");
6853 let package_dir = dir.join("packages").join("heroui");
6854 let xbp_dir = dir.join(".xbp");
6855 fs::create_dir_all(&package_dir).expect("create package dir");
6856 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6857
6858 fs::write(
6859 xbp_dir.join("xbp.yaml"),
6860 r#"project_name: athena-auth-ui
6861version: 0.3.1
6862port: 4000
6863build_dir: ./
6864publish:
6865 npm:
6866 enabled: true
6867 working_directory: packages/heroui
6868 manifest_path: packages/heroui/package.json
6869"#,
6870 )
6871 .expect("write xbp config");
6872 fs::write(
6873 dir.join("package.json"),
6874 r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
6875 )
6876 .expect("write root package");
6877 fs::write(
6878 package_dir.join("package.json"),
6879 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
6880 )
6881 .expect("write package manifest");
6882
6883 let updated = write_version_to_configured_files(
6884 &dir,
6885 &dir,
6886 &["package.json".to_string()],
6887 &VersionScope::Repository,
6888 &Version::new(0, 3, 1),
6889 )
6890 .expect("write versions");
6891
6892 assert_eq!(updated, 2);
6893 assert_eq!(
6894 read_json_root_version(&dir.join("package.json")).expect("read root package"),
6895 Some("0.3.1".to_string())
6896 );
6897 assert_eq!(
6898 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6899 Some("0.3.1".to_string())
6900 );
6901
6902 let _ = fs::remove_dir_all(dir);
6903 }
6904
6905 #[test]
6906 fn configured_writer_updates_version_targets_from_xbp_config() {
6907 let dir: PathBuf = temp_dir("explicit-version-targets");
6908 let app_dir = dir.join("apps").join("web");
6909 let cli_dir = dir.join("crates").join("cli");
6910 let xbp_dir = dir.join(".xbp");
6911 fs::create_dir_all(&app_dir).expect("create app dir");
6912 fs::create_dir_all(&cli_dir).expect("create cli dir");
6913 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6914
6915 fs::write(
6916 xbp_dir.join("xbp.yaml"),
6917 r#"project_name: xbp
6918version: 10.30.0
6919port: 3000
6920build_dir: ./
6921version_targets:
6922 - crates/cli/Cargo.toml
6923 - apps/web/package.json
6924"#,
6925 )
6926 .expect("write xbp config");
6927 fs::write(
6928 cli_dir.join("Cargo.toml"),
6929 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6930 )
6931 .expect("write cli Cargo.toml");
6932 fs::write(
6933 app_dir.join("package.json"),
6934 r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
6935 )
6936 .expect("write app package");
6937
6938 let updated = write_version_to_configured_files(
6939 &dir,
6940 &dir,
6941 &[".xbp/xbp.yaml".to_string()],
6942 &VersionScope::Repository,
6943 &Version::new(10, 30, 0),
6944 )
6945 .expect("write versions");
6946
6947 assert_eq!(updated, 3);
6948 assert_eq!(
6949 read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
6950 Some("10.30.0".to_string())
6951 );
6952 assert_eq!(
6953 read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
6954 Some("10.30.0".to_string())
6955 );
6956 assert_eq!(
6957 read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
6958 Some("10.30.0".to_string())
6959 );
6960
6961 let _ = fs::remove_dir_all(dir);
6962 }
6963
6964 #[test]
6965 fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
6966 let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
6967 let package_dir = dir.join("packages").join("heroui");
6968 let xbp_dir = dir.join(".xbp");
6969 fs::create_dir_all(&package_dir).expect("create package dir");
6970 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6971
6972 fs::write(
6973 xbp_dir.join("xbp.yaml"),
6974 r#"project_name: athena-auth-ui
6975version: 0.3.0
6976port: 4000
6977build_dir: ./
6978publish:
6979 npm:
6980 enabled: true
6981 working_directory: packages/heroui
6982 manifest_path: packages/heroui/package.json
6983"#,
6984 )
6985 .expect("write xbp config");
6986 fs::write(
6987 dir.join("package.json"),
6988 r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
6989 )
6990 .expect("write root package");
6991 fs::write(
6992 package_dir.join("package.json"),
6993 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
6994 )
6995 .expect("write package manifest");
6996
6997 let _updated_paths = sync_version_to_configured_files_with_paths(
6998 &dir,
6999 &dir,
7000 &["package.json".to_string()],
7001 &VersionScope::Repository,
7002 &Version::new(0, 3, 0),
7003 )
7004 .expect("sync versions");
7005
7006 assert_eq!(
7007 read_json_root_version(&dir.join("package.json")).expect("read root package"),
7008 Some("0.3.0".to_string())
7009 );
7010 assert_eq!(
7011 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
7012 Some("0.3.0".to_string())
7013 );
7014
7015 let _ = fs::remove_dir_all(dir);
7016 }
7017
7018 #[test]
7019 fn readme_adapter_updates_current_version_marker() {
7020 let dir: PathBuf = temp_dir("readme");
7021 let path: PathBuf = dir.join("README.md");
7022 fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7023
7024 write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
7025 assert_eq!(
7026 read_readme_version(&path).expect("read"),
7027 Some("1.2.0".to_string())
7028 );
7029
7030 let _ = fs::remove_dir_all(dir);
7031 }
7032
7033 #[test]
7034 fn readme_writer_inserts_marker_when_missing() {
7035 let dir: PathBuf = temp_dir("readme-insert");
7036 let path: PathBuf = dir.join("README.md");
7037 fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
7038
7039 write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
7040 let content: String = fs::read_to_string(&path).expect("read readme");
7041 assert!(content.contains("current version: `3.0.0`"));
7042
7043 let _ = fs::remove_dir_all(dir);
7044 }
7045
7046 #[test]
7047 fn regex_adapter_reads_and_writes_versions() {
7048 let dir: PathBuf = temp_dir("regex");
7049 let path: PathBuf = dir.join("build.gradle");
7050 fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
7051
7052 assert_eq!(
7053 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7054 Some("5.4.3".to_string())
7055 );
7056
7057 write_regex_version(
7058 &path,
7059 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7060 &Version::new(5, 5, 0),
7061 )
7062 .expect("write");
7063
7064 assert_eq!(
7065 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7066 Some("5.5.0".to_string())
7067 );
7068
7069 let _ = fs::remove_dir_all(dir);
7070 }
7071
7072 #[test]
7073 fn regex_writer_errors_without_matching_pattern() {
7074 let dir: PathBuf = temp_dir("regex-miss");
7075 let path: PathBuf = dir.join("build.gradle");
7076 fs::write(&path, "group = 'demo'\n").expect("write gradle");
7077
7078 let error: String = write_regex_version(
7079 &path,
7080 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7081 &Version::new(1, 0, 0),
7082 )
7083 .expect_err("missing version should fail");
7084 assert!(error.contains("No version pattern found"));
7085
7086 let _ = fs::remove_dir_all(dir);
7087 }
7088
7089 #[test]
7090 fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
7091 let original: &str = r#"[dependencies]
7092 serde = "1.0.219"
7093 tokio = { version = "1.44.1", features = ["full"] }
7094 "#;
7095
7096 let (updated, changed) =
7097 rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
7098 .expect("rewrite");
7099 assert!(changed);
7100 assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
7101
7102 let (updated, changed) =
7103 rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
7104 .expect("rewrite");
7105 assert!(changed);
7106 assert!(updated.contains(r#"serde = "1.1.0""#));
7107 }
7108
7109 #[test]
7110 fn package_version_writer_updates_registry_toml_targets() {
7111 let dir: PathBuf = temp_dir("package-version-registry");
7112 let cargo_toml: PathBuf = dir.join("Cargo.toml");
7113 fs::write(
7114 &cargo_toml,
7115 r#"[package]
7116 name = "demo"
7117 version = "0.1.0"
7118
7119 [dependencies]
7120 serde = "1.0.219"
7121 tokio = { version = "1.44.1", features = ["full"] }
7122 "#,
7123 )
7124 .expect("write Cargo.toml");
7125
7126 let updated: usize = write_package_version_to_configured_files(
7127 &dir,
7128 &dir,
7129 &["Cargo.toml".to_string()],
7130 &VersionScope::Repository,
7131 "tokio",
7132 &Version::new(1, 45, 1),
7133 )
7134 .expect("update package assignment");
7135 assert_eq!(updated, 1);
7136
7137 let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
7138 assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
7139
7140 let _ = fs::remove_dir_all(dir);
7141 }
7142
7143 #[test]
7144 fn package_version_writer_errors_when_package_assignment_not_found() {
7145 let dir: PathBuf = temp_dir("package-version-missing");
7146 let cargo_toml: PathBuf = dir.join("Cargo.toml");
7147 fs::write(
7148 &cargo_toml,
7149 r#"[package]
7150 name = "demo"
7151 version = "0.1.0"
7152
7153 [dependencies]
7154 serde = "1.0.219"
7155 "#,
7156 )
7157 .expect("write Cargo.toml");
7158
7159 let error: String = write_package_version_to_configured_files(
7160 &dir,
7161 &dir,
7162 &["Cargo.toml".to_string()],
7163 &VersionScope::Repository,
7164 "tokio",
7165 &Version::new(1, 45, 1),
7166 )
7167 .expect_err("missing package assignment should fail");
7168 assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
7169
7170 let _ = fs::remove_dir_all(dir);
7171 }
7172
7173 #[test]
7174 fn chart_writer_updates_app_version_when_present() {
7175 let dir: PathBuf = temp_dir("chart");
7176 let path: PathBuf = dir.join("Chart.yaml");
7177 fs::write(
7178 &path,
7179 "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
7180 )
7181 .expect("write chart");
7182
7183 write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
7184 let content: String = fs::read_to_string(&path).expect("read chart");
7185 assert!(content.contains("version: 0.2.0"));
7186 assert!(content.contains("appVersion: 0.2.0"));
7187
7188 let _ = fs::remove_dir_all(dir);
7189 }
7190
7191 #[test]
7192 fn configured_file_writer_deduplicates_registry_entries() {
7193 let dir: PathBuf = temp_dir("dedupe");
7194 let readme: PathBuf = dir.join("README.md");
7195 fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7196
7197 let updated: usize = write_version_to_configured_files(
7198 &dir,
7199 &dir,
7200 &[
7201 "README.md".to_string(),
7202 "README.md".to_string(),
7203 "missing.md".to_string(),
7204 ],
7205 &VersionScope::Repository,
7206 &Version::new(1, 1, 0),
7207 )
7208 .expect("write versions");
7209
7210 assert_eq!(updated, 1);
7211 assert_eq!(
7212 read_readme_version(&readme).expect("read"),
7213 Some("1.1.0".to_string())
7214 );
7215
7216 let _ = fs::remove_dir_all(dir);
7217 }
7218
7219 #[test]
7220 fn configured_file_writer_prefers_invocation_directory_targets() {
7221 let dir: PathBuf = temp_dir("invocation-precedence");
7222 let app_dir: PathBuf = dir.join("apps").join("web");
7223 fs::create_dir_all(&app_dir).expect("create app dir");
7224
7225 let root_package: PathBuf = dir.join("package.json");
7226 let app_package: PathBuf = app_dir.join("package.json");
7227 fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
7228 .expect("write root package");
7229 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7230 .expect("write app package");
7231
7232 let updated: usize = write_version_to_configured_files(
7233 &dir,
7234 &app_dir,
7235 &["package.json".to_string()],
7236 &VersionScope::Repository,
7237 &Version::new(2, 14, 0),
7238 )
7239 .expect("write versions");
7240 assert_eq!(updated, 1);
7241
7242 assert_eq!(
7243 read_json_root_version(&root_package).expect("read root"),
7244 Some("9.9.9".to_string())
7245 );
7246 assert_eq!(
7247 read_json_root_version(&app_package).expect("read app"),
7248 Some("2.14.0".to_string())
7249 );
7250
7251 let _ = fs::remove_dir_all(dir);
7252 }
7253
7254 #[test]
7255 fn resolve_project_root_prefers_nearest_xbp_config() {
7256 let dir: PathBuf = temp_dir("nested-xbp-project-root");
7257 let service_root: PathBuf = dir.join("services").join("athena-auth");
7258 let nested_dir: PathBuf = service_root.join("src");
7259 let service_xbp_dir: PathBuf = service_root.join(".xbp");
7260 fs::create_dir_all(&nested_dir).expect("create nested dir");
7261 fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7262 fs::write(
7263 service_xbp_dir.join("xbp.yaml"),
7264 "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7265 )
7266 .expect("write service xbp config");
7267
7268 assert_eq!(resolve_project_root_from(&nested_dir), service_root);
7269
7270 let _ = fs::remove_dir_all(dir);
7271 }
7272
7273 #[test]
7274 fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
7275 let dir: PathBuf = temp_dir("nested-xbp-version-scope");
7276 let outer_xbp_dir = dir.join(".xbp");
7277 let service_root: PathBuf = dir.join("services").join("athena-auth");
7278 let nested_dir: PathBuf = service_root.join("src");
7279 let service_xbp_dir: PathBuf = service_root.join(".xbp");
7280 fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
7281 fs::create_dir_all(&nested_dir).expect("create nested dir");
7282 fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7283
7284 fs::write(
7285 outer_xbp_dir.join("xbp.yaml"),
7286 "project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
7287 )
7288 .expect("write outer xbp config");
7289 fs::write(
7290 dir.join("Cargo.toml"),
7291 "[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
7292 )
7293 .expect("write outer cargo");
7294 fs::write(
7295 dir.join("Cargo.lock"),
7296 "version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
7297 )
7298 .expect("write outer cargo lock");
7299 fs::write(
7300 dir.join("README.md"),
7301 "# root\n\ncurrent version: `9.9.9`\n",
7302 )
7303 .expect("write outer readme");
7304 fs::write(
7305 dir.join("openapi.yaml"),
7306 "openapi: 3.0.3\ninfo:\n title: Root\n version: 9.9.9\n",
7307 )
7308 .expect("write outer openapi");
7309 fs::write(
7310 dir.join("package.json"),
7311 r#"{ "name": "root", "version": "9.9.9" }"#,
7312 )
7313 .expect("write outer package");
7314
7315 fs::write(
7316 service_xbp_dir.join("xbp.yaml"),
7317 "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7318 )
7319 .expect("write service xbp config");
7320 fs::write(
7321 service_root.join("Cargo.toml"),
7322 "[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7323 )
7324 .expect("write service cargo");
7325 fs::write(
7326 service_root.join("Cargo.lock"),
7327 "version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7328 )
7329 .expect("write service cargo lock");
7330 fs::write(
7331 service_root.join("README.md"),
7332 "# athena-auth\n\ncurrent version: `3.22.0`\n",
7333 )
7334 .expect("write service readme");
7335 fs::write(
7336 service_root.join("openapi.yaml"),
7337 "openapi: 3.0.3\ninfo:\n title: Athena Auth\n version: 3.22.0\n",
7338 )
7339 .expect("write service openapi");
7340 fs::write(
7341 service_root.join("package.json"),
7342 r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
7343 )
7344 .expect("write service package");
7345
7346 let project_root = resolve_project_root_from(&nested_dir);
7347 let updated_paths = write_version_to_configured_files_with_paths(
7348 &project_root,
7349 &nested_dir,
7350 &[
7351 ".xbp/xbp.yaml".to_string(),
7352 "Cargo.toml".to_string(),
7353 "Cargo.lock".to_string(),
7354 "README.md".to_string(),
7355 "openapi.yaml".to_string(),
7356 "package.json".to_string(),
7357 ],
7358 &VersionScope::Repository,
7359 &Version::new(3, 22, 1),
7360 )
7361 .expect("write nested project versions");
7362
7363 let updated_relative = updated_paths
7364 .iter()
7365 .map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
7366 .map(normalized_relative_path)
7367 .collect::<Vec<_>>();
7368
7369 assert_eq!(
7370 updated_relative,
7371 vec![
7372 ".xbp/xbp.yaml".to_string(),
7373 "Cargo.toml".to_string(),
7374 "Cargo.lock".to_string(),
7375 "README.md".to_string(),
7376 "openapi.yaml".to_string(),
7377 "package.json".to_string(),
7378 ]
7379 );
7380
7381 assert_eq!(
7382 read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
7383 .expect("read outer xbp"),
7384 Some("9.9.9".to_string())
7385 );
7386 assert_eq!(
7387 read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
7388 Some("9.9.9".to_string())
7389 );
7390 assert_eq!(
7391 read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
7392 Some("9.9.9".to_string())
7393 );
7394 assert_eq!(
7395 read_readme_version(&dir.join("README.md")).expect("read outer readme"),
7396 Some("9.9.9".to_string())
7397 );
7398 assert_eq!(
7399 read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
7400 Some("9.9.9".to_string())
7401 );
7402 assert_eq!(
7403 read_json_root_version(&dir.join("package.json")).expect("read outer package"),
7404 Some("9.9.9".to_string())
7405 );
7406
7407 assert_eq!(
7408 read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
7409 .expect("read service xbp"),
7410 Some("3.22.1".to_string())
7411 );
7412 assert_eq!(
7413 read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
7414 Some("3.22.1".to_string())
7415 );
7416 assert_eq!(
7417 read_cargo_lock_version(&service_root.join("Cargo.lock"))
7418 .expect("read service cargo lock"),
7419 Some("3.22.1".to_string())
7420 );
7421 assert_eq!(
7422 read_readme_version(&service_root.join("README.md")).expect("read service readme"),
7423 Some("3.22.1".to_string())
7424 );
7425 assert_eq!(
7426 read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
7427 Some("3.22.1".to_string())
7428 );
7429 assert_eq!(
7430 read_json_root_version(&service_root.join("package.json"))
7431 .expect("read service package"),
7432 Some("3.22.1".to_string())
7433 );
7434
7435 let _ = fs::remove_dir_all(dir);
7436 }
7437
7438 #[test]
7439 fn resolve_version_scope_detects_crate_scoped_invocation() {
7440 let dir: PathBuf = temp_dir("crate-scope");
7441 let crate_dir: PathBuf = dir.join("crates").join("alpha");
7442 let nested_dir: PathBuf = crate_dir.join("src");
7443 fs::create_dir_all(&nested_dir).expect("create nested dir");
7444 fs::write(
7445 crate_dir.join("Cargo.toml"),
7446 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7447 )
7448 .expect("write Cargo.toml");
7449
7450 let scope = resolve_version_scope(&dir, &nested_dir);
7451 match scope {
7452 VersionScope::Crate {
7453 package_name,
7454 crate_relative_root,
7455 tag_prefix,
7456 ..
7457 } => {
7458 assert_eq!(package_name, "alpha-crate");
7459 assert_eq!(crate_relative_root, "crates/alpha");
7460 assert_eq!(tag_prefix, "alpha-crate-");
7461 }
7462 _ => panic!("expected crate scope"),
7463 }
7464
7465 let _ = fs::remove_dir_all(dir);
7466 }
7467
7468 #[test]
7469 fn resolve_version_scope_detects_service_scoped_invocation() {
7470 let dir: PathBuf = temp_dir("service-scope");
7471 let service_dir: PathBuf = dir.join("apps").join("web");
7472 let nested_dir: PathBuf = service_dir.join("src");
7473 let cli_dir: PathBuf = dir.join("crates").join("cli");
7474 fs::create_dir_all(&nested_dir).expect("create nested dir");
7475 fs::create_dir_all(&cli_dir).expect("create cli dir");
7476 write_multi_service_config(&dir);
7477 fs::write(
7478 service_dir.join("package.json"),
7479 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7480 )
7481 .expect("write package");
7482 fs::write(
7483 cli_dir.join("Cargo.toml"),
7484 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7485 )
7486 .expect("write cargo");
7487
7488 let scope = resolve_version_scope(&dir, &nested_dir);
7489 match scope {
7490 VersionScope::Service {
7491 service_name,
7492 service_relative_root,
7493 tag_prefix,
7494 ..
7495 } => {
7496 assert_eq!(service_name, "web");
7497 assert_eq!(service_relative_root, "apps/web");
7498 assert_eq!(tag_prefix, "web-");
7499 }
7500 _ => panic!("expected service scope"),
7501 }
7502
7503 let _ = fs::remove_dir_all(dir);
7504 }
7505
7506 #[test]
7507 fn service_scoped_configured_targets_only_include_selected_service() {
7508 let dir: PathBuf = temp_dir("service-targets");
7509 let service_dir: PathBuf = dir.join("apps").join("web");
7510 let nested_dir: PathBuf = service_dir.join("src");
7511 let cli_dir: PathBuf = dir.join("crates").join("cli");
7512 fs::create_dir_all(&nested_dir).expect("create nested dir");
7513 fs::create_dir_all(&cli_dir).expect("create cli dir");
7514 write_multi_service_config(&dir);
7515 fs::write(
7516 service_dir.join("package.json"),
7517 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7518 )
7519 .expect("write package");
7520 fs::write(
7521 cli_dir.join("Cargo.toml"),
7522 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7523 )
7524 .expect("write cargo");
7525
7526 let scope = resolve_version_scope(&dir, &nested_dir);
7527 let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
7528 assert_eq!(targets.len(), 1);
7529 assert_eq!(targets[0].relative, "apps/web/package.json");
7530
7531 let _ = fs::remove_dir_all(dir);
7532 }
7533
7534 #[test]
7535 fn service_scoped_release_publish_filter_selects_matching_target() {
7536 let dir: PathBuf = temp_dir("service-publish-filter");
7537 let service_dir: PathBuf = dir.join("apps").join("web");
7538 let nested_dir: PathBuf = service_dir.join("src");
7539 let cli_dir: PathBuf = dir.join("crates").join("cli");
7540 fs::create_dir_all(&nested_dir).expect("create nested dir");
7541 fs::create_dir_all(&cli_dir).expect("create cli dir");
7542 write_multi_service_config(&dir);
7543 fs::write(
7544 service_dir.join("package.json"),
7545 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7546 )
7547 .expect("write package");
7548 fs::write(
7549 cli_dir.join("Cargo.toml"),
7550 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7551 )
7552 .expect("write cargo");
7553
7554 let scope = resolve_version_scope(&dir, &nested_dir);
7555 let publish_target =
7556 resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
7557 assert_eq!(publish_target.as_deref(), Some("npm"));
7558
7559 let _ = fs::remove_dir_all(dir);
7560 }
7561
7562 #[test]
7563 fn current_scope_ledger_path_uses_repo_local_scope_directory() {
7564 let dir: PathBuf = temp_dir("ledger-path");
7565 let scope = resolve_version_scope(&dir, &dir);
7566 let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
7567
7568 assert_eq!(
7569 path,
7570 dir.join(".xbp")
7571 .join("releases")
7572 .join("repository-xbp")
7573 .join("10.30.0.yaml")
7574 );
7575
7576 let _ = fs::remove_dir_all(dir);
7577 }
7578
7579 #[test]
7580 fn repository_change_selection_matches_only_changed_target_manifest_root() {
7581 let dir: PathBuf = temp_dir("selection-direct");
7582 let service_dir: PathBuf = dir.join("apps").join("web");
7583 let cli_dir: PathBuf = dir.join("crates").join("cli");
7584 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7585 fs::create_dir_all(&cli_dir).expect("create cli dir");
7586 write_multi_service_config(&dir);
7587 fs::write(
7588 service_dir.join("package.json"),
7589 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7590 )
7591 .expect("write package");
7592 fs::write(
7593 cli_dir.join("Cargo.toml"),
7594 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7595 )
7596 .expect("write cargo");
7597
7598 let scope = resolve_version_scope(&dir, &dir);
7599 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7600 let selected = select_changed_version_targets(
7601 &dir,
7602 &scope,
7603 &targets,
7604 &["apps/web/src/app.tsx".into()],
7605 );
7606
7607 assert_eq!(selected.len(), 1);
7608 assert_eq!(selected[0].relative, "apps/web/package.json");
7609
7610 let _ = fs::remove_dir_all(dir);
7611 }
7612
7613 #[test]
7614 fn repository_change_selection_fans_out_on_shared_root_change() {
7615 let dir: PathBuf = temp_dir("selection-shared");
7616 let service_dir: PathBuf = dir.join("apps").join("web");
7617 let cli_dir: PathBuf = dir.join("crates").join("cli");
7618 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7619 fs::create_dir_all(&cli_dir).expect("create cli dir");
7620 write_multi_service_config(&dir);
7621 fs::write(
7622 service_dir.join("package.json"),
7623 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7624 )
7625 .expect("write package");
7626 fs::write(
7627 cli_dir.join("Cargo.toml"),
7628 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7629 )
7630 .expect("write cargo");
7631
7632 let scope = resolve_version_scope(&dir, &dir);
7633 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7634 let selected =
7635 select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
7636
7637 assert_eq!(selected.len(), 2);
7638 assert_eq!(
7639 selected
7640 .iter()
7641 .map(|target| target.relative.as_str())
7642 .collect::<Vec<_>>(),
7643 vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
7644 );
7645
7646 let _ = fs::remove_dir_all(dir);
7647 }
7648
7649 #[test]
7650 fn selected_version_writer_updates_only_selected_targets() {
7651 let dir: PathBuf = temp_dir("selection-write");
7652 let service_dir: PathBuf = dir.join("apps").join("web");
7653 let cli_dir: PathBuf = dir.join("crates").join("cli");
7654 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7655 fs::create_dir_all(&cli_dir).expect("create cli dir");
7656 write_multi_service_config(&dir);
7657 fs::write(
7658 service_dir.join("package.json"),
7659 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7660 )
7661 .expect("write package");
7662 fs::write(
7663 cli_dir.join("Cargo.toml"),
7664 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7665 )
7666 .expect("write cargo");
7667
7668 let scope = resolve_version_scope(&dir, &dir);
7669 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7670 let selected = select_changed_version_targets(
7671 &dir,
7672 &scope,
7673 &targets,
7674 &["apps/web/src/app.tsx".into()],
7675 );
7676
7677 let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
7678 .expect("write selected targets");
7679 assert_eq!(updated.len(), 1);
7680 assert_eq!(
7681 read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
7682 Some("10.30.0".to_string())
7683 );
7684 assert_eq!(
7685 read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
7686 Some("10.29.0".to_string())
7687 );
7688
7689 let _ = fs::remove_dir_all(dir);
7690 }
7691
7692 #[test]
7693 fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
7694 let dir: PathBuf = temp_dir("crate-writer");
7695 let crate_dir: PathBuf = dir.join("crates").join("alpha");
7696 fs::create_dir_all(&crate_dir).expect("create crate dir");
7697 fs::write(
7698 crate_dir.join("Cargo.toml"),
7699 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7700 )
7701 .expect("write crate Cargo.toml");
7702 fs::write(
7703 dir.join("Cargo.lock"),
7704 "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
7705 )
7706 .expect("write Cargo.lock");
7707 fs::write(
7708 dir.join("README.md"),
7709 "# root\n\ncurrent version: `9.9.9`\n",
7710 )
7711 .expect("write root readme");
7712
7713 let scope = resolve_version_scope(&dir, &crate_dir);
7714 let updated = write_version_to_configured_files(
7715 &dir,
7716 &crate_dir,
7717 &[
7718 "Cargo.toml".to_string(),
7719 "Cargo.lock".to_string(),
7720 "README.md".to_string(),
7721 ],
7722 &scope,
7723 &Version::new(1, 3, 0),
7724 )
7725 .expect("write versions");
7726
7727 assert_eq!(updated, 2);
7728 assert_eq!(
7729 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
7730 Some("1.3.0".to_string())
7731 );
7732 assert_eq!(
7733 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
7734 .expect("read cargo lock"),
7735 Some("1.3.0".to_string())
7736 );
7737 assert_eq!(
7738 read_readme_version(&dir.join("README.md")).expect("read readme"),
7739 Some("9.9.9".to_string())
7740 );
7741
7742 let _ = fs::remove_dir_all(dir);
7743 }
7744
7745 #[test]
7746 fn release_openapi_resolution_prefers_crate_scope() {
7747 let dir: PathBuf = temp_dir("release-openapi-crate");
7748 let crate_dir: PathBuf = dir.join("crates").join("monitor");
7749 let nested_dir: PathBuf = crate_dir.join("src");
7750 fs::create_dir_all(&nested_dir).expect("create nested dir");
7751 fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
7752 let crate_openapi: PathBuf = crate_dir.join("openapi.json");
7753 fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
7754
7755 let scope = resolve_version_scope(&dir, &nested_dir);
7756 let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
7757 .expect("crate-scoped openapi")
7758 .into_iter()
7759 .next()
7760 .expect("crate-scoped openapi");
7761 assert_eq!(resolved, crate_openapi);
7762
7763 let _ = fs::remove_dir_all(dir);
7764 }
7765
7766 #[test]
7767 fn release_openapi_resolution_falls_back_to_repo_root() {
7768 let dir: PathBuf = temp_dir("release-openapi-root");
7769 let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
7770 fs::create_dir_all(&crate_dir).expect("create crate dir");
7771 let root_openapi: PathBuf = dir.join("openapi.json");
7772 fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
7773
7774 let scope = resolve_version_scope(&dir, &dir);
7775 let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
7776 .expect("repo root openapi")
7777 .into_iter()
7778 .next()
7779 .expect("repo root openapi");
7780 assert_eq!(resolved, root_openapi);
7781
7782 let _ = fs::remove_dir_all(dir);
7783 }
7784
7785 #[test]
7786 fn release_openapi_assets_use_versioned_release_tag_names() {
7787 let dir: PathBuf = temp_dir("release-openapi-assets");
7788 fs::write(
7789 dir.join("openapi.yaml"),
7790 "openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.2\n",
7791 )
7792 .expect("write http openapi");
7793 fs::write(
7794 dir.join("openapi-wss.yaml"),
7795 "openapi: 3.0.3\ninfo:\n title: Athena WSS\n version: 1.3.0\n",
7796 )
7797 .expect("write wss openapi");
7798
7799 let assets = prepare_release_openapi_assets(
7800 &dir,
7801 &dir,
7802 &VersionScope::Repository,
7803 &Version::new(3, 15, 2),
7804 "v3.15.2",
7805 )
7806 .expect("prepare release assets");
7807
7808 assert_eq!(
7809 assets
7810 .iter()
7811 .map(|asset| asset.asset_name.as_str())
7812 .collect::<Vec<_>>(),
7813 vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
7814 );
7815 assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
7816 assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
7817
7818 let _ = fs::remove_dir_all(dir);
7819 }
7820
7821 #[test]
7822 fn release_openapi_assets_reject_http_version_mismatch() {
7823 let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
7824 fs::write(
7825 dir.join("openapi.yaml"),
7826 "openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.1\n",
7827 )
7828 .expect("write http openapi");
7829
7830 let error = prepare_release_openapi_assets(
7831 &dir,
7832 &dir,
7833 &VersionScope::Repository,
7834 &Version::new(3, 15, 2),
7835 "v3.15.2",
7836 )
7837 .expect_err("mismatched OpenAPI version should fail");
7838
7839 assert!(error.contains("does not match release version"));
7840
7841 let _ = fs::remove_dir_all(dir);
7842 }
7843
7844 #[test]
7845 fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
7846 let dir: PathBuf = temp_dir("invocation-dedupe");
7847 let app_dir: PathBuf = dir.join("apps").join("web");
7848 fs::create_dir_all(&app_dir).expect("create app dir");
7849
7850 let app_package: PathBuf = app_dir.join("package.json");
7851 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7852 .expect("write app package");
7853
7854 let updated: usize = write_version_to_configured_files(
7855 &dir,
7856 &app_dir,
7857 &[
7858 "package.json".to_string(),
7859 "apps/web/package.json".to_string(),
7860 ],
7861 &VersionScope::Repository,
7862 &Version::new(2, 14, 0),
7863 )
7864 .expect("write versions");
7865 assert_eq!(updated, 1);
7866
7867 assert_eq!(
7868 read_json_root_version(&app_package).expect("read app"),
7869 Some("2.14.0".to_string())
7870 );
7871
7872 let _ = fs::remove_dir_all(dir);
7873 }
7874
7875 #[test]
7876 fn configured_file_writer_errors_when_no_targets_exist() {
7877 let dir: PathBuf = temp_dir("no-targets");
7878 let error: String = write_version_to_configured_files(
7879 &dir,
7880 &dir,
7881 &["missing.toml".to_string()],
7882 &VersionScope::Repository,
7883 &Version::new(1, 0, 0),
7884 )
7885 .expect_err("missing targets should fail");
7886
7887 assert!(error.contains("No configured version files were found"));
7888
7889 let _ = fs::remove_dir_all(dir);
7890 }
7891
7892 #[test]
7893 fn remote_git_tag_parser_deduplicates_peeled_refs() {
7894 let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
7895 "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
7896 );
7897
7898 assert_eq!(parsed.len(), 2);
7899 assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
7900 assert_eq!(
7901 parsed[1].version,
7902 Version::parse("0.1.7-exp").expect("version")
7903 );
7904 assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
7905 }
7906
7907 #[test]
7908 fn local_git_tag_parser_normalizes_prefixed_versions() {
7909 let parsed: Vec<crate::commands::version::GitTagObservation> =
7910 parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
7911
7912 assert_eq!(parsed.len(), 2);
7913 assert_eq!(parsed[0].version, Version::new(1, 0, 0));
7914 assert_eq!(
7915 parsed[0].raw_tags,
7916 vec!["1.0.0".to_string(), "v1.0.0".to_string()]
7917 );
7918 }
7919
7920 #[test]
7921 fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
7922 let scope = VersionScope::Crate {
7923 crate_root: PathBuf::from("/tmp/crates/alpha"),
7924 crate_relative_root: "crates/alpha".to_string(),
7925 package_name: "alpha-crate".to_string(),
7926 tag_prefix: "alpha-crate-".to_string(),
7927 };
7928
7929 let parsed = parse_local_git_tag_output_for_scope(
7930 "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
7931 &scope,
7932 );
7933
7934 assert_eq!(parsed.len(), 2);
7935 assert_eq!(parsed[0].version, Version::new(1, 2, 0));
7936 assert_eq!(parsed[1].version, Version::new(1, 0, 0));
7937 }
7938
7939 #[test]
7940 fn crate_scoped_release_tags_default_to_package_prefix() {
7941 let scope = VersionScope::Crate {
7942 crate_root: PathBuf::from("/tmp/crates/alpha"),
7943 crate_relative_root: "crates/alpha".to_string(),
7944 package_name: "alpha-crate".to_string(),
7945 tag_prefix: "alpha-crate-".to_string(),
7946 };
7947
7948 assert_eq!(
7949 default_release_tag_name(&scope, &Version::new(1, 2, 3)),
7950 "alpha-crate-1.2.3"
7951 );
7952 }
7953
7954 #[test]
7955 fn blob_reader_handles_head_readme_versions() {
7956 assert_eq!(
7957 read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
7958 .expect("read"),
7959 Some("0.4.0".to_string())
7960 );
7961 }
7962
7963 #[test]
7964 fn blob_reader_handles_head_cargo_lock_versions() {
7965 let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
7966 let cargo_lock: &str =
7967 "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
7968
7969 assert_eq!(
7970 read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
7971 Some("0.2.0".to_string())
7972 );
7973 }
7974
7975 #[test]
7976 fn package_name_lookup_reads_json_name_for_npm() {
7977 let lookup: PackageNameLookup = PackageNameLookup {
7978 file: "package.json".to_string(),
7979 format: "json".to_string(),
7980 key: "name".to_string(),
7981 registry: "npm".to_string(),
7982 };
7983
7984 assert_eq!(
7985 read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
7986 .expect("read"),
7987 Some("@xylex/athena-mcp".to_string())
7988 );
7989 }
7990
7991 #[test]
7992 fn package_name_lookup_reads_toml_nested_package_name() {
7993 let lookup: PackageNameLookup = PackageNameLookup {
7994 file: "Cargo.toml".to_string(),
7995 format: "toml".to_string(),
7996 key: "package.name".to_string(),
7997 registry: "crates.io".to_string(),
7998 };
7999
8000 assert_eq!(
8001 read_package_name_from_lookup(
8002 &lookup,
8003 "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
8004 )
8005 .expect("read"),
8006 Some("athena-mcp".to_string())
8007 );
8008 }
8009
8010 #[test]
8011 fn package_name_lookup_errors_on_unknown_format() {
8012 let lookup: PackageNameLookup = PackageNameLookup {
8013 file: "meta.txt".to_string(),
8014 format: "ini".to_string(),
8015 key: "name".to_string(),
8016 registry: "npm".to_string(),
8017 };
8018
8019 let error = read_package_name_from_lookup(&lookup, "name=demo")
8020 .expect_err("unsupported format should fail");
8021 assert!(error.contains("Unsupported lookup format"));
8022 }
8023
8024 #[test]
8025 fn highest_version_observation_returns_max_version() {
8026 let entries: Vec<VersionObservation> = vec![
8027 VersionObservation {
8028 location: "README.md".to_string(),
8029 version: Version::new(1, 0, 0),
8030 },
8031 VersionObservation {
8032 location: "Cargo.toml".to_string(),
8033 version: Version::new(1, 2, 0),
8034 },
8035 ];
8036
8037 assert_eq!(
8038 highest_version_observation(&entries).expect("max version"),
8039 Version::new(1, 2, 0)
8040 );
8041 }
8042
8043 #[test]
8044 fn stale_version_observations_only_returns_outdated_entries() {
8045 let entries: Vec<VersionObservation> = vec![
8046 VersionObservation {
8047 location: "README.md".to_string(),
8048 version: Version::new(1, 1, 0),
8049 },
8050 VersionObservation {
8051 location: "Cargo.toml".to_string(),
8052 version: Version::new(1, 2, 0),
8053 },
8054 VersionObservation {
8055 location: "openapi.yaml".to_string(),
8056 version: Version::new(1, 0, 5),
8057 },
8058 ];
8059
8060 let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
8061 assert_eq!(stale.len(), 2);
8062 assert!(stale.iter().any(|entry| entry.location == "README.md"));
8063 assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
8064 assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
8065 }
8066
8067 #[test]
8068 fn parses_github_remote_urls() {
8069 assert_eq!(
8070 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
8071 Some(("xylex-group".to_string(), "xbp".to_string()))
8072 );
8073 assert_eq!(
8074 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
8075 Some(("xylex-group".to_string(), "xbp".to_string()))
8076 );
8077 assert_eq!(
8078 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
8079 Some(("xylex-group".to_string(), "xbp".to_string()))
8080 );
8081 assert_eq!(
8082 parse_github_repo_from_remote_url(
8083 "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
8084 ),
8085 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8086 );
8087 assert_eq!(
8088 parse_github_repo_from_remote_url(
8089 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
8090 ),
8091 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8092 );
8093 assert_eq!(
8094 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
8095 None
8096 );
8097 }
8098
8099 #[test]
8100 fn redacts_credentials_in_remote_urls() {
8101 let redacted = redact_remote_url_credentials(
8102 "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
8103 );
8104 assert!(redacted.contains("REDACTED"));
8105 assert!(!redacted.contains("ghp_secretvalue"));
8106
8107 let username_only = redact_remote_url_credentials(
8108 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
8109 );
8110 assert!(username_only.contains("REDACTED@github.com"));
8111 assert!(!username_only.contains("floris-xlx@github.com"));
8112
8113 let ssh_remote =
8114 redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
8115 assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
8116 }
8117
8118 #[test]
8119 fn builds_github_release_urls_with_encoded_tag_segments() {
8120 let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
8121 assert_eq!(
8122 create_url.as_str(),
8123 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
8124 );
8125
8126 let lookup_url =
8127 github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
8128 .expect("url");
8129 assert_eq!(
8130 lookup_url.as_str(),
8131 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
8132 );
8133
8134 let update_url =
8135 github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8136 assert_eq!(
8137 update_url.as_str(),
8138 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
8139 );
8140
8141 let lookup_with_special_tag = github_release_by_tag_endpoint(
8142 "SuitsBooks",
8143 "suits-invoicing",
8144 "release candidate/v0.0.1+build",
8145 )
8146 .expect("url");
8147 assert_eq!(
8148 lookup_with_special_tag.as_str(),
8149 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
8150 );
8151 }
8152
8153 #[test]
8154 fn builds_github_release_asset_urls() {
8155 let list_url =
8156 github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8157 assert_eq!(
8158 list_url.as_str(),
8159 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
8160 );
8161
8162 let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
8163 .expect("url");
8164 assert_eq!(
8165 delete_url.as_str(),
8166 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
8167 );
8168
8169 let upload_url = github_release_asset_upload_endpoint(
8170 "SuitsBooks",
8171 "suits-invoicing",
8172 42,
8173 "openapi spec.json",
8174 )
8175 .expect("url");
8176 assert_eq!(
8177 upload_url.as_str(),
8178 "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
8179 );
8180 }
8181
8182 #[test]
8183 fn maps_release_latest_policy_to_github_api_values() {
8184 assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
8185 assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
8186 assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
8187 }
8188
8189 #[test]
8190 fn release_channel_from_semver_prerelease_labels() {
8191 let stable = Version::parse("3.6.2").expect("version");
8192 let nightly = Version::parse("3.6.2-nightly.1").expect("version");
8193 let experimental = Version::parse("0.1.1-alpha.1").expect("version");
8194 assert_eq!(release_channel(&stable), "stable");
8195 assert_eq!(release_channel(&nightly), "nightly");
8196 assert_eq!(release_channel(&experimental), "experimental");
8197 }
8198
8199 #[test]
8200 fn renders_release_docs_from_entries() {
8201 let entries = vec![
8202 ReleaseDocEntry {
8203 tag: "v3.6.2".to_string(),
8204 version: Version::parse("3.6.2").expect("version"),
8205 date: "2026-04-27".to_string(),
8206 },
8207 ReleaseDocEntry {
8208 tag: "docs-0.1.1-alpha.1".to_string(),
8209 version: Version::parse("0.1.1-alpha.1").expect("version"),
8210 date: "2026-04-20".to_string(),
8211 },
8212 ];
8213 let changelog = render_changelog("xylex-group", "athena", &entries);
8214 assert!(changelog.contains("## [3.6.2]"));
8215 assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
8216 assert!(changelog.contains("Release channel: stable"));
8217 assert!(changelog.contains("Release channel: experimental"));
8218
8219 let security = render_security_policy(&entries);
8220 assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
8221 assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
8222 }
8223
8224 #[test]
8225 fn formats_release_commit_lines_with_sha_and_pr_links() {
8226 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
8227 let formatted =
8228 format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
8229 .expect("formatted line");
8230
8231 assert_eq!(
8232 formatted,
8233 "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
8234 );
8235 }
8236
8237 #[test]
8238 fn formats_release_commit_lines_with_linear_links_when_available() {
8239 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
8240 let issue_infos = BTreeMap::from([(
8241 "SUI-1336".to_string(),
8242 LinearIssueInfo {
8243 title: "Release flow".to_string(),
8244 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8245 },
8246 )]);
8247 let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
8248 .expect("formatted line");
8249
8250 assert_eq!(
8251 formatted,
8252 "[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)"
8253 );
8254 }
8255
8256 #[test]
8257 fn renders_release_notes_in_requested_layout() {
8258 let commits = vec![
8259 ReleaseCommitEntry {
8260 full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
8261 short_sha: "abcdef1".to_string(),
8262 subject: "Improve release docs (#42)".to_string(),
8263 date: "2026-05-24".to_string(),
8264 },
8265 ReleaseCommitEntry {
8266 full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
8267 short_sha: "fedcba9".to_string(),
8268 subject: "Fix release flow for SUI-1336".to_string(),
8269 date: "2026-05-25".to_string(),
8270 },
8271 ];
8272 let pull_request_infos = BTreeMap::from([(
8273 "42".to_string(),
8274 super::release_notes::GithubPullRequestInfo {
8275 title: "Improve release docs".to_string(),
8276 url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
8277 },
8278 )]);
8279 let issue_infos = BTreeMap::from([(
8280 "SUI-1336".to_string(),
8281 LinearIssueInfo {
8282 title: "Release flow".to_string(),
8283 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8284 },
8285 )]);
8286 let sections = build_fallback_sections(&commits);
8287 let rendered = render_release_notes(&ReleaseNotesRenderInput {
8288 release_title: "1.7.0 - athena-auth",
8289 current_tag_name: "v1.7.0",
8290 owner: "xylex-group",
8291 repo: "athena-auth",
8292 previous_tag: Some("v1.6.0"),
8293 sections: §ions,
8294 commit_entries: &commits,
8295 pull_request_infos: &pull_request_infos,
8296 linear_issue_infos: &issue_infos,
8297 });
8298
8299 assert_eq!(
8300 rendered,
8301 "# [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)"
8302 );
8303 }
8304
8305 #[test]
8306 fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
8307 let commits = vec![
8308 ReleaseCommitEntry {
8309 full_sha: "a".repeat(40),
8310 short_sha: "aaaaaaa".to_string(),
8311 subject: "Fix SUI-1336 and SUI-1440".to_string(),
8312 date: "2026-05-24".to_string(),
8313 },
8314 ReleaseCommitEntry {
8315 full_sha: "b".repeat(40),
8316 short_sha: "bbbbbbb".to_string(),
8317 subject: "Touch SUI-1336 again".to_string(),
8318 date: "2026-05-25".to_string(),
8319 },
8320 ];
8321
8322 assert_eq!(
8323 collect_linear_issue_identifiers(&commits),
8324 vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
8325 );
8326 }
8327
8328 #[test]
8329 fn release_title_defaults_to_version_and_repo() {
8330 assert_eq!(
8331 default_release_title(&Version::new(1, 7, 0), "athena-auth"),
8332 "1.7.0 - athena-auth"
8333 );
8334 }
8335
8336 #[test]
8337 fn deduplicates_release_commit_entries_by_exact_subject() {
8338 let commits = vec![
8339 ReleaseCommitEntry {
8340 full_sha: "a".repeat(40),
8341 short_sha: "aaaaaaa".to_string(),
8342 subject: "Improve release docs".to_string(),
8343 date: "2026-05-24".to_string(),
8344 },
8345 ReleaseCommitEntry {
8346 full_sha: "b".repeat(40),
8347 short_sha: "bbbbbbb".to_string(),
8348 subject: "Improve release docs".to_string(),
8349 date: "2026-05-25".to_string(),
8350 },
8351 ];
8352
8353 let deduplicated = deduplicate_release_commit_entries(&commits);
8354 assert_eq!(deduplicated.len(), 1);
8355 assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
8356 }
8357
8358 #[test]
8359 fn fallback_sections_collapse_related_commit_themes() {
8360 let commits = vec![
8361 ReleaseCommitEntry {
8362 full_sha: "a".repeat(40),
8363 short_sha: "chat001".to_string(),
8364 subject: "Add optimistic chat retries".to_string(),
8365 date: "2026-06-01".to_string(),
8366 },
8367 ReleaseCommitEntry {
8368 full_sha: "b".repeat(40),
8369 short_sha: "chat002".to_string(),
8370 subject: "Persist deleted-message state in chat".to_string(),
8371 date: "2026-06-01".to_string(),
8372 },
8373 ReleaseCommitEntry {
8374 full_sha: "c".repeat(40),
8375 short_sha: "file001".to_string(),
8376 subject: "Fix upload UTF-8 audit retry handling".to_string(),
8377 date: "2026-06-01".to_string(),
8378 },
8379 ReleaseCommitEntry {
8380 full_sha: "d".repeat(40),
8381 short_sha: "ath001".to_string(),
8382 subject: "Migrate form progress routes to Athena".to_string(),
8383 date: "2026-06-01".to_string(),
8384 },
8385 ReleaseCommitEntry {
8386 full_sha: "e".repeat(40),
8387 short_sha: "ath002".to_string(),
8388 subject: "Update Athena models and package wiring".to_string(),
8389 date: "2026-06-01".to_string(),
8390 },
8391 ];
8392
8393 let sections = build_fallback_sections(&commits);
8394 assert_eq!(sections.len(), 3);
8395 assert_eq!(sections[0].title, "Cases & Communication");
8396 assert!(!sections[0].summary.is_empty());
8397 assert_eq!(sections[0].bullets.len(), 2);
8398 assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
8399 assert!(sections[0].bullets[0].summary.contains("chat"));
8400 assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
8401 assert!(sections[0].bullets[1].summary.contains("deleted-message"));
8402 assert_eq!(sections[1].title, "Reliability");
8403 assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
8404 assert_eq!(sections[2].title, "Athena Migration");
8405 assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
8406 }
8407
8408 #[test]
8409 fn appends_release_label_footer_for_pre_release() {
8410 let with_label = append_release_label_footer("# Release", true);
8411 assert_eq!(
8412 with_label,
8413 format!(
8414 "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
8415 env!("CARGO_PKG_VERSION")
8416 )
8417 );
8418 }
8419}