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 !target.absolute.exists() {
1868 continue;
1869 }
1870
1871 match read_version_from_resolved_path(target) {
1872 Ok(Some(version)) => {
1873 let parsed = parse_version(&version)?;
1874 if highest
1875 .as_ref()
1876 .is_none_or(|current: &Version| parsed > *current)
1877 {
1878 highest = Some(parsed);
1879 }
1880 }
1881 Ok(None) => {}
1882 Err(err) => return Err(err),
1883 }
1884 }
1885 Ok(highest)
1886}
1887
1888fn write_version_to_selected_paths(
1889 targets: &[ResolvedRegistryPath],
1890 version: &Version,
1891 allow_noop_when_targets_exist: bool,
1892) -> Result<Vec<PathBuf>, String> {
1893 let mut updated = 0usize;
1894 let mut matched_targets = 0usize;
1895 let mut updated_paths = Vec::new();
1896 let mut errors = Vec::new();
1897
1898 for entry in targets {
1899 let path = &entry.absolute;
1900 if !path.exists() {
1901 continue;
1902 }
1903 matched_targets += 1;
1904 match write_version_to_resolved_path(entry, version) {
1905 Ok(true) => {
1906 updated += 1;
1907 updated_paths.push(path.clone());
1908 }
1909 Ok(false) => {}
1910 Err(error) => errors.push(format!("{}: {}", path.display(), error)),
1911 }
1912 }
1913
1914 if matched_targets == 0 && errors.is_empty() {
1915 return Err("No selected version files were found to update.".to_string());
1916 }
1917 if !errors.is_empty() {
1918 return Err(format!(
1919 "Updated {} file(s), but some selected version targets failed:\n{}",
1920 updated,
1921 errors.join("\n")
1922 ));
1923 }
1924 if updated == 0 && allow_noop_when_targets_exist {
1925 return Ok(updated_paths);
1926 }
1927 Ok(updated_paths)
1928}
1929
1930fn initialize_release_ledger(
1931 version_scope: &VersionScope,
1932 repo: &str,
1933 release_version: &Version,
1934 tag_name: &str,
1935 branch_name: Option<String>,
1936 selection: &ChangedTargetSelection,
1937) -> ReleaseLedger {
1938 ReleaseLedger {
1939 scope_kind: version_scope_kind(version_scope).to_string(),
1940 scope_label: version_scope_label(version_scope, repo),
1941 scope_slug: current_scope_slug(version_scope, repo),
1942 version: release_version.to_string(),
1943 tag_name: tag_name.to_string(),
1944 branch_name,
1945 baseline: ReleaseBaselineRecord {
1946 source: selection.baseline_source.clone(),
1947 reference: selection.baseline_reference.clone(),
1948 tag: selection.baseline_tag.clone(),
1949 },
1950 selection: ReleaseSelectionRecord {
1951 changed_files: selection.changed_files.clone(),
1952 version_targets: selection
1953 .version_targets
1954 .iter()
1955 .map(|target| ReleaseVersionTarget {
1956 path: target.relative.clone(),
1957 })
1958 .collect(),
1959 publish_targets: selection.publish_targets.clone(),
1960 },
1961 status: ReleaseLedgerStatus::InProgress,
1962 steps: BTreeMap::new(),
1963 created_at: Some(chrono::Utc::now()),
1964 updated_at: Some(chrono::Utc::now()),
1965 completed_at: None,
1966 }
1967}
1968
1969fn update_ledger_step(
1970 ledger: &mut ReleaseLedger,
1971 step_name: &str,
1972 status: ReleaseLedgerStepStatus,
1973 details: Option<serde_json::Value>,
1974 last_error: Option<String>,
1975) {
1976 let now = chrono::Utc::now();
1977 let step = ledger.steps.entry(step_name.to_string()).or_default();
1978 if step.started_at.is_none() {
1979 step.started_at = Some(now);
1980 }
1981 if matches!(
1982 status,
1983 ReleaseLedgerStepStatus::Completed
1984 | ReleaseLedgerStepStatus::Failed
1985 | ReleaseLedgerStepStatus::Skipped
1986 | ReleaseLedgerStepStatus::VerifiedRemote
1987 ) {
1988 step.completed_at = Some(now);
1989 }
1990 step.status = status;
1991 if let Some(details) = details {
1992 step.details = details;
1993 }
1994 step.last_error = last_error;
1995 ledger.updated_at = Some(now);
1996 ledger.status = match step.status {
1997 ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
1998 _ => ReleaseLedgerStatus::InProgress,
1999 };
2000}
2001
2002fn complete_release_ledger(ledger: &mut ReleaseLedger) {
2003 let now = chrono::Utc::now();
2004 ledger.status = ReleaseLedgerStatus::Completed;
2005 ledger.updated_at = Some(now);
2006 ledger.completed_at = Some(now);
2007}
2008
2009fn ledger_version_targets_to_resolved(
2010 project_root: &Path,
2011 ledger: &ReleaseLedger,
2012) -> Vec<ResolvedRegistryPath> {
2013 ledger
2014 .selection
2015 .version_targets
2016 .iter()
2017 .map(|target| ResolvedRegistryPath {
2018 relative: target.path.clone(),
2019 absolute: project_root.join(&target.path),
2020 cargo_package_override: None,
2021 })
2022 .collect()
2023}
2024
2025fn print_selected_release_targets(selection: &ChangedTargetSelection) {
2026 println!("{}", "Selected release targets".bright_cyan().bold());
2027 if selection.version_targets.is_empty() {
2028 println!(" {}", "No version targets selected".dimmed());
2029 } else {
2030 println!(" {}", "Version targets".bright_white());
2031 for target in &selection.version_targets {
2032 println!(" {} {}", "•".bright_cyan(), target.relative);
2033 }
2034 }
2035 if selection.publish_targets.is_empty() {
2036 println!(" {}", "Publish targets: none".dimmed());
2037 } else {
2038 println!(" {}", "Publish targets".bright_white());
2039 for target in &selection.publish_targets {
2040 println!(
2041 " {} {} {}",
2042 "•".bright_cyan(),
2043 target.kind,
2044 target.manifest_path
2045 );
2046 }
2047 }
2048}
2049
2050fn begin_release_step(
2051 ledger: &mut ReleaseLedger,
2052 ledger_path: &Path,
2053 step_name: &str,
2054 details: Option<serde_json::Value>,
2055) -> Result<(), String> {
2056 update_ledger_step(
2057 ledger,
2058 step_name,
2059 ReleaseLedgerStepStatus::Running,
2060 details,
2061 None,
2062 );
2063 save_release_ledger(ledger_path, ledger)
2064}
2065
2066fn complete_release_step(
2067 ledger: &mut ReleaseLedger,
2068 ledger_path: &Path,
2069 step_name: &str,
2070 status: ReleaseLedgerStepStatus,
2071 details: Option<serde_json::Value>,
2072) -> Result<(), String> {
2073 update_ledger_step(ledger, step_name, status, details, None);
2074 save_release_ledger(ledger_path, ledger)
2075}
2076
2077fn fail_release_step(
2078 ledger: &mut ReleaseLedger,
2079 ledger_path: &Path,
2080 step_name: &str,
2081 error: &str,
2082) -> Result<(), String> {
2083 update_ledger_step(
2084 ledger,
2085 step_name,
2086 ReleaseLedgerStepStatus::Failed,
2087 None,
2088 Some(error.to_string()),
2089 );
2090 save_release_ledger(ledger_path, ledger)
2091}
2092
2093async fn verify_selected_publish_targets(
2094 project_root: &Path,
2095 selected_publish_targets: &[ReleasePublishTarget],
2096 expected_version: &Version,
2097) -> Result<bool, String> {
2098 if selected_publish_targets.is_empty() {
2099 return Ok(false);
2100 }
2101 let client = reqwest::Client::new();
2102 for target in selected_publish_targets {
2103 let manifest_path = project_root.join(&target.manifest_path);
2104 let package_name = target
2105 .package_name
2106 .clone()
2107 .or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
2108 .ok_or_else(|| {
2109 format!(
2110 "Could not resolve package name for selected publish target `{}`.",
2111 target.manifest_path
2112 )
2113 })?;
2114 let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
2115 if details.version.trim() != expected_version.to_string() {
2116 return Ok(false);
2117 }
2118 }
2119 Ok(true)
2120}
2121
2122fn detect_package_name_from_manifest(kind: &str, manifest_path: &Path) -> Option<String> {
2123 match kind {
2124 "npm" => {
2125 let content = fs::read_to_string(manifest_path).ok()?;
2126 let json: JsonValue = serde_json::from_str(&content).ok()?;
2127 json.get("name")
2128 .and_then(JsonValue::as_str)
2129 .map(str::trim)
2130 .filter(|value| !value.is_empty())
2131 .map(str::to_string)
2132 }
2133 "crates" => cargo_package_name(manifest_path).ok().flatten(),
2134 _ => None,
2135 }
2136}
2137
2138fn publish_target_filter_from_selection(
2139 selected_publish_targets: &[ReleasePublishTarget],
2140) -> Result<Option<String>, String> {
2141 let kinds = selected_publish_targets
2142 .iter()
2143 .map(|target| target.kind.as_str())
2144 .collect::<BTreeSet<_>>();
2145 if kinds.is_empty() || kinds.len() > 1 {
2146 return Ok(None);
2147 }
2148 let kind = kinds.iter().next().copied().unwrap_or_default();
2149 if kind.is_empty() {
2150 return Ok(None);
2151 }
2152 match kind {
2153 "npm" | "crates" => Ok(Some(kind.to_string())),
2154 other => Err(format!(
2155 "Unsupported selected publish target kind `{}` in release ledger.",
2156 other
2157 )),
2158 }
2159}
2160
2161fn collect_version_report(
2162 project_root: &Path,
2163 invocation_dir: &Path,
2164 registry: &[String],
2165 version_scope: &VersionScope,
2166) -> VersionReport {
2167 let mut report: VersionReport = VersionReport::default();
2168 let repo_label = project_root
2169 .file_name()
2170 .and_then(|value| value.to_str())
2171 .unwrap_or("repository");
2172 let scope_slug = current_scope_slug(version_scope, repo_label);
2173 report.worktree = collect_local_versions(
2174 project_root,
2175 invocation_dir,
2176 registry,
2177 version_scope,
2178 &mut report.warnings,
2179 );
2180 match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
2181 Ok(entries) => report.head = entries,
2182 Err(err) => report.warnings.push(err),
2183 }
2184 match collect_git_versions(project_root, version_scope) {
2185 Ok(tags) => report.local_tags = tags,
2186 Err(err) => report.warnings.push(err),
2187 }
2188 match collect_remote_git_versions(project_root, "origin", version_scope) {
2189 Ok(tags) => report.remote_tags = tags,
2190 Err(err) => report.warnings.push(err),
2191 }
2192 match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
2193 Ok(files) => report.dirty_files = files,
2194 Err(err) => report.warnings.push(err),
2195 }
2196 match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
2197 Ok(ledger) => report.release_ledger = ledger,
2198 Err(err) => report.warnings.push(err),
2199 }
2200 report
2201}
2202
2203fn resolve_current_version_for_bump(
2204 project_root: &Path,
2205 invocation_dir: &Path,
2206 registry: &[String],
2207 version_scope: &VersionScope,
2208) -> Version {
2209 let mut _warnings = Vec::new();
2210 let local_versions = collect_local_versions(
2211 project_root,
2212 invocation_dir,
2213 registry,
2214 version_scope,
2215 &mut _warnings,
2216 );
2217 let head_versions =
2218 collect_head_versions(project_root, invocation_dir, registry, version_scope)
2219 .unwrap_or_default();
2220 let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
2221
2222 local_versions
2223 .iter()
2224 .map(|entry| entry.version.clone())
2225 .chain(head_versions.iter().map(|entry| entry.version.clone()))
2226 .chain(local_tags.iter().map(|entry| entry.version.clone()))
2227 .max()
2228 .unwrap_or_else(default_version)
2229}
2230
2231async fn auto_commit_command_paths(
2232 project_root: &Path,
2233 paths: Vec<PathBuf>,
2234 message: String,
2235 action_label: &'static str,
2236) {
2237 match commit_paths(AutoCommitRequest {
2238 project_root,
2239 paths,
2240 message,
2241 action_label,
2242 })
2243 .await
2244 {
2245 Ok(AutoCommitResult::Committed(_)) => {}
2246 Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
2247 Err(e) => print_skip(action_label, &e),
2248 }
2249}
2250
2251async fn collect_registry_versions(
2252 project_root: &Path,
2253 invocation_dir: &Path,
2254 lookups: &[PackageNameLookup],
2255 version_scope: &VersionScope,
2256 warnings: &mut Vec<String>,
2257) -> Vec<RegistryVersionObservation> {
2258 let mut entries: Vec<RegistryVersionObservation> = Vec::new();
2259 let mut seen: BTreeSet<String> = BTreeSet::new();
2260 let client: reqwest::Client = reqwest::Client::new();
2261
2262 for lookup in lookups {
2263 let dedupe_key: String = format!(
2264 "{}|{}|{}|{}",
2265 lookup.file, lookup.format, lookup.key, lookup.registry
2266 );
2267 if !seen.insert(dedupe_key) {
2268 continue;
2269 }
2270
2271 let source_file = resolve_registry_relative_path(
2272 project_root,
2273 invocation_dir,
2274 version_scope,
2275 &lookup.file,
2276 );
2277 let path = project_root.join(&source_file);
2278 if !path.exists() {
2279 continue;
2280 }
2281
2282 let content: String = match fs::read_to_string(&path) {
2283 Ok(content) => content,
2284 Err(err) => {
2285 warnings.push(format!("Failed to read {}: {}", path.display(), err));
2286 continue;
2287 }
2288 };
2289
2290 let package_name: String = match read_package_name_from_lookup(lookup, &content) {
2291 Ok(Some(value)) => value,
2292 Ok(None) => continue,
2293 Err(err) => {
2294 warnings.push(format!("{}: {}", source_file, err));
2295 continue;
2296 }
2297 };
2298
2299 let (latest, raw_version, published_at, note) =
2300 match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
2301 Ok(details) => {
2302 let parsed: Option<Version> = parse_version(&details.version).ok();
2303 let note: Option<String> = if parsed.is_none() {
2304 Some(format!("Non-semver registry version: {}", details.version))
2305 } else {
2306 None
2307 };
2308 (parsed, Some(details.version), details.published_at, note)
2309 }
2310 Err(err) => (None, None, None, Some(err)),
2311 };
2312
2313 entries.push(RegistryVersionObservation {
2314 registry: lookup.registry.clone(),
2315 package_name,
2316 source_file,
2317 latest,
2318 raw_version,
2319 published_at,
2320 note,
2321 });
2322 }
2323
2324 entries.sort_by(|a, b| {
2325 a.registry
2326 .cmp(&b.registry)
2327 .then_with(|| a.package_name.cmp(&b.package_name))
2328 });
2329 entries
2330}
2331
2332fn read_package_name_from_lookup(
2333 lookup: &PackageNameLookup,
2334 content: &str,
2335) -> Result<Option<String>, String> {
2336 let key_parts: Vec<&str> = lookup
2337 .key
2338 .split('.')
2339 .map(|part| part.trim())
2340 .filter(|part| !part.is_empty())
2341 .collect();
2342 if key_parts.is_empty() {
2343 return Err("Lookup key cannot be empty".to_string());
2344 }
2345
2346 let format: String = lookup.format.trim().to_ascii_lowercase();
2347 match format.as_str() {
2348 "json" => {
2349 let value: JsonValue = serde_json::from_str(content)
2350 .map_err(|e| format!("Failed to parse JSON: {}", e))?;
2351 Ok(json_lookup_string(&value, &key_parts))
2352 }
2353 "yaml" | "yml" => {
2354 let value: YamlValue = serde_yaml::from_str(content)
2355 .map_err(|e| format!("Failed to parse YAML: {}", e))?;
2356 Ok(yaml_lookup_string(&value, &key_parts))
2357 }
2358 "toml" => {
2359 let value: TomlValue =
2360 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2361 Ok(toml_lookup_string(&value, &key_parts))
2362 }
2363 other => Err(format!("Unsupported lookup format `{}`", other)),
2364 }
2365}
2366
2367async fn fetch_registry_latest_version(
2368 client: &reqwest::Client,
2369 registry: &str,
2370 package_name: &str,
2371) -> Result<RegistryVersionDetails, String> {
2372 let normalized_registry: String = registry.trim().to_ascii_lowercase();
2373 match normalized_registry.as_str() {
2374 "npm" => fetch_npm_latest_version(client, package_name).await,
2375 "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
2376 _ => Err(format!("Unsupported registry `{}`", registry)),
2377 }
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct NpmLatestResponse {
2382 #[serde(rename = "dist-tags")]
2383 dist_tags: NpmDistTags,
2384 #[serde(default)]
2385 time: BTreeMap<String, String>,
2386}
2387
2388#[derive(Debug, Deserialize)]
2389struct NpmDistTags {
2390 latest: String,
2391}
2392
2393async fn fetch_npm_latest_version(
2394 client: &reqwest::Client,
2395 package_name: &str,
2396) -> Result<RegistryVersionDetails, String> {
2397 let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
2398 .map_err(|e| format!("Failed to build npm URL: {}", e))?;
2399 {
2400 let mut segments = url
2401 .path_segments_mut()
2402 .map_err(|_| "Failed to compose npm URL segments".to_string())?;
2403 segments.push(package_name);
2404 }
2405
2406 let response: reqwest::Response = client
2407 .get(url)
2408 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2409 .send()
2410 .await
2411 .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
2412
2413 if !response.status().is_success() {
2414 return Err(format!(
2415 "npm lookup for {} returned status {}",
2416 package_name,
2417 response.status()
2418 ));
2419 }
2420
2421 let payload: NpmLatestResponse = response
2422 .json()
2423 .await
2424 .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
2425 Ok(RegistryVersionDetails {
2426 published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
2427 version: payload.dist_tags.latest,
2428 })
2429}
2430
2431#[derive(Debug, Deserialize)]
2432struct CratesIoMeta {
2433 newest_version: String,
2434}
2435
2436#[derive(Debug, Deserialize)]
2437struct CratesIoVersionMeta {
2438 num: String,
2439 created_at: Option<String>,
2440}
2441
2442#[derive(Debug, Deserialize)]
2443struct CratesIoLatestResponse {
2444 #[serde(rename = "crate")]
2445 crate_meta: CratesIoMeta,
2446 #[serde(default)]
2447 versions: Vec<CratesIoVersionMeta>,
2448}
2449
2450async fn fetch_crates_latest_version(
2451 client: &reqwest::Client,
2452 package_name: &str,
2453) -> Result<RegistryVersionDetails, String> {
2454 let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
2455 .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
2456 {
2457 let mut segments = url
2458 .path_segments_mut()
2459 .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
2460 segments.push(package_name);
2461 }
2462
2463 let response: reqwest::Response = client
2464 .get(url)
2465 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2466 .send()
2467 .await
2468 .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
2469
2470 if !response.status().is_success() {
2471 return Err(format!(
2472 "crates.io lookup for {} returned status {}",
2473 package_name,
2474 response.status()
2475 ));
2476 }
2477
2478 let payload: CratesIoLatestResponse = response.json().await.map_err(|e| {
2479 format!(
2480 "Failed to parse crates.io response for {}: {}",
2481 package_name, e
2482 )
2483 })?;
2484 let published_at = payload
2485 .versions
2486 .iter()
2487 .find(|version| version.num == payload.crate_meta.newest_version)
2488 .and_then(|version| version.created_at.clone());
2489 Ok(RegistryVersionDetails {
2490 version: payload.crate_meta.newest_version,
2491 published_at,
2492 })
2493}
2494
2495fn collect_local_versions(
2496 project_root: &Path,
2497 invocation_dir: &Path,
2498 registry: &[String],
2499 version_scope: &VersionScope,
2500 warnings: &mut Vec<String>,
2501) -> Vec<VersionObservation> {
2502 let mut observed = Vec::new();
2503
2504 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2505 let path: &PathBuf = &entry.absolute;
2506 if !path.exists() {
2507 continue;
2508 }
2509
2510 match read_version_from_resolved_path(&entry) {
2511 Ok(Some(version)) => {
2512 if let Ok(parsed) = parse_version(&version) {
2513 observed.push(VersionObservation {
2514 location: entry.relative.clone(),
2515 version: parsed,
2516 });
2517 } else {
2518 warnings.push(format!("Ignoring non-semver version in {}", path.display()));
2519 }
2520 }
2521 Ok(None) => {}
2522 Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
2523 }
2524 }
2525
2526 observed.sort_by(|a, b| a.location.cmp(&b.location));
2527 observed
2528}
2529
2530fn collect_git_versions(
2531 project_root: &Path,
2532 version_scope: &VersionScope,
2533) -> Result<Vec<GitTagObservation>, String> {
2534 if !command_exists("git") {
2535 return Err("Git is not installed; skipping git tag inspection.".to_string());
2536 }
2537
2538 let output: std::process::Output = Command::new("git")
2539 .current_dir(project_root)
2540 .args(["tag", "--list"])
2541 .output()
2542 .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
2543
2544 if !output.status.success() {
2545 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2546 if stderr.is_empty() {
2547 return Err("`git tag --list` failed in the current directory.".to_string());
2548 }
2549 return Err(format!("`git tag --list` failed: {}", stderr));
2550 }
2551
2552 Ok(parse_local_git_tag_output_for_scope(
2553 &String::from_utf8_lossy(&output.stdout),
2554 version_scope,
2555 ))
2556}
2557
2558fn collect_remote_git_versions(
2559 project_root: &Path,
2560 remote: &str,
2561 version_scope: &VersionScope,
2562) -> Result<Vec<GitTagObservation>, String> {
2563 if !command_exists("git") {
2564 return Err("Git is not installed; skipping remote tag inspection.".to_string());
2565 }
2566
2567 let output: std::process::Output = Command::new("git")
2568 .current_dir(project_root)
2569 .args(["ls-remote", "--tags", remote])
2570 .output()
2571 .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
2572
2573 if !output.status.success() {
2574 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2575 if stderr.is_empty() {
2576 return Err(format!("`git ls-remote --tags {}` failed.", remote));
2577 }
2578 return Err(format!(
2579 "`git ls-remote --tags {}` failed: {}",
2580 remote, stderr
2581 ));
2582 }
2583
2584 Ok(parse_remote_git_tag_output_for_scope(
2585 &String::from_utf8_lossy(&output.stdout),
2586 version_scope,
2587 ))
2588}
2589
2590fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
2591 let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
2592
2593 if tags.is_empty() {
2594 println!("No semantic git tags found in {}.", project_root.display());
2595 return Ok(());
2596 }
2597
2598 println!("Git versions from `git tag --list`:");
2599 for tag in tags {
2600 if tag.raw_tags.len() > 1 {
2601 println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
2602 } else {
2603 println!(" {}", tag.version);
2604 }
2605 }
2606
2607 Ok(())
2608}
2609
2610fn print_version_observations(
2611 title: &str,
2612 entries: &[VersionObservation],
2613 dirty_files: Option<&[String]>,
2614) {
2615 println!();
2616 println!("{}", title.bright_cyan().bold());
2617 println!("{}", "─".repeat(72).bright_black());
2618
2619 if entries.is_empty() {
2620 println!(" {}", "none found".dimmed());
2621 return;
2622 }
2623
2624 let Some(highest) = highest_version_observation(entries) else {
2625 println!(" {}", "none found".dimmed());
2626 return;
2627 };
2628
2629 let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
2630 let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
2631 println!(
2632 " {:<28} {} ({}/{})",
2633 "latest".bright_white(),
2634 highest.to_string().bright_green().bold(),
2635 latest_count,
2636 entries.len()
2637 );
2638
2639 if stale_entries.is_empty() {
2640 return;
2641 }
2642
2643 println!(" {}", "stale entries".bright_yellow().bold());
2644 for entry in stale_entries {
2645 let dirty: bool = dirty_files
2646 .map(|files| files.iter().any(|file| file == &entry.location))
2647 .unwrap_or(false);
2648 let dirty_suffix: String = if dirty {
2649 format!(" {}", "modified".bright_magenta())
2650 } else {
2651 String::new()
2652 };
2653
2654 println!(
2655 " {:<28} {}{}",
2656 entry.location.bright_white(),
2657 entry.version.to_string().bright_green(),
2658 dirty_suffix
2659 );
2660 }
2661}
2662
2663fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
2664 println!();
2665 println!("{}", title.bright_cyan().bold());
2666 println!("{}", "─".repeat(72).bright_black());
2667
2668 if tags.is_empty() {
2669 println!(" {}", "none found".dimmed());
2670 return;
2671 }
2672
2673 let latest = &tags[0];
2674 if latest.raw_tags.len() > 1 {
2675 println!(
2676 " {:<20} {}",
2677 latest.version.to_string().bright_green().bold(),
2678 latest.raw_tags.join(", ").dimmed()
2679 );
2680 } else {
2681 println!(" {}", latest.version.to_string().bright_green().bold());
2682 }
2683
2684 if tags.len() > 1 {
2685 let older = tags
2686 .iter()
2687 .skip(1)
2688 .take(5)
2689 .map(|tag| tag.version.to_string())
2690 .collect::<Vec<_>>();
2691 println!(
2692 " {:<20} {}",
2693 "older tags".bright_white(),
2694 older.join(", ").dimmed()
2695 );
2696 if tags.len() > 6 {
2697 println!(
2698 " {:<20} {}",
2699 "older hidden".bright_white(),
2700 format!("{} more", tags.len() - 6).dimmed()
2701 );
2702 }
2703 }
2704}
2705
2706fn collect_head_versions(
2707 project_root: &Path,
2708 invocation_dir: &Path,
2709 registry: &[String],
2710 version_scope: &VersionScope,
2711) -> Result<Vec<VersionObservation>, String> {
2712 if !command_exists("git") {
2713 return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
2714 }
2715
2716 let head_check = Command::new("git")
2717 .current_dir(project_root)
2718 .args(["rev-parse", "--verify", "HEAD"])
2719 .output()
2720 .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
2721
2722 if !head_check.status.success() {
2723 return Ok(Vec::new());
2724 }
2725
2726 let mut observed = Vec::new();
2727 let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
2728
2729 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2730 let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
2731 continue;
2732 };
2733
2734 match read_version_from_blob_with_override(
2735 &entry.relative,
2736 &content,
2737 cargo_toml_content.as_deref(),
2738 entry.cargo_package_override.as_deref(),
2739 ) {
2740 Ok(Some(version)) => {
2741 if let Ok(parsed) = parse_version(&version) {
2742 observed.push(VersionObservation {
2743 location: entry.relative.clone(),
2744 version: parsed,
2745 });
2746 }
2747 }
2748 Ok(None) => {}
2749 Err(_) => {}
2750 }
2751 }
2752
2753 observed.sort_by(|a, b| a.location.cmp(&b.location));
2754 Ok(observed)
2755}
2756
2757fn collect_dirty_version_files(
2758 project_root: &Path,
2759 invocation_dir: &Path,
2760 registry: &[String],
2761 version_scope: &VersionScope,
2762) -> Result<Vec<String>, String> {
2763 if !command_exists("git") {
2764 return Err("Git is not installed; skipping worktree status inspection.".to_string());
2765 }
2766
2767 let mut args = vec!["status", "--porcelain", "--"];
2768 let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
2769 for entry in &resolved {
2770 args.push(entry.relative.as_str());
2771 }
2772
2773 let output = Command::new("git")
2774 .current_dir(project_root)
2775 .args(&args)
2776 .output()
2777 .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
2778
2779 if !output.status.success() {
2780 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2781 if stderr.is_empty() {
2782 return Err("`git status --porcelain` failed.".to_string());
2783 }
2784 return Err(format!("`git status --porcelain` failed: {}", stderr));
2785 }
2786
2787 let mut dirty = Vec::new();
2788 for line in String::from_utf8_lossy(&output.stdout).lines() {
2789 if line.len() < 4 {
2790 continue;
2791 }
2792 let path = line[3..].trim();
2793 if !path.is_empty() {
2794 dirty.push(path.replace('\\', "/"));
2795 }
2796 }
2797
2798 dirty.sort();
2799 dirty.dedup();
2800 Ok(dirty)
2801}
2802
2803fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
2804 let output = Command::new("git")
2805 .current_dir(project_root)
2806 .args(["show", &format!("HEAD:{}", relative)])
2807 .output()
2808 .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
2809
2810 if !output.status.success() {
2811 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2812 if stderr.is_empty() {
2813 return Err(format!("{} is not present in HEAD", relative));
2814 }
2815 return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
2816 }
2817
2818 String::from_utf8(output.stdout)
2819 .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
2820}
2821
2822fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2823 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2824 for line in output.lines() {
2825 let tag = line.trim();
2826 if tag.is_empty() {
2827 continue;
2828 }
2829 if let Ok(version) = parse_version(tag) {
2830 by_version.entry(version).or_default().push(tag.to_string());
2831 }
2832 }
2833 git_tag_map_to_vec(by_version)
2834}
2835
2836fn parse_local_git_tag_output_for_scope(
2837 output: &str,
2838 version_scope: &VersionScope,
2839) -> Vec<GitTagObservation> {
2840 match version_scope {
2841 VersionScope::Repository => parse_local_git_tag_output(output),
2842 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2843 parse_scoped_git_tag_output(output, tag_prefix)
2844 }
2845 }
2846}
2847
2848fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2849 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2850 for line in output.lines() {
2851 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2852 let tag = reference
2853 .strip_prefix("refs/tags/")
2854 .unwrap_or(reference)
2855 .trim_end_matches("^{}")
2856 .trim();
2857
2858 if tag.is_empty() {
2859 continue;
2860 }
2861 if let Ok(version) = parse_version(tag) {
2862 by_version.entry(version).or_default().push(tag.to_string());
2863 }
2864 }
2865 git_tag_map_to_vec(by_version)
2866}
2867
2868fn parse_remote_git_tag_output_for_scope(
2869 output: &str,
2870 version_scope: &VersionScope,
2871) -> Vec<GitTagObservation> {
2872 match version_scope {
2873 VersionScope::Repository => parse_remote_git_tag_output(output),
2874 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2875 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2876 for line in output.lines() {
2877 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2878 let tag = reference
2879 .strip_prefix("refs/tags/")
2880 .unwrap_or(reference)
2881 .trim_end_matches("^{}")
2882 .trim();
2883
2884 if tag.is_empty() {
2885 continue;
2886 }
2887 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2888 by_version.entry(version).or_default().push(tag.to_string());
2889 }
2890 }
2891 git_tag_map_to_vec(by_version)
2892 }
2893 }
2894}
2895
2896fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
2897 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2898 for line in output.lines() {
2899 let tag = line.trim();
2900 if tag.is_empty() {
2901 continue;
2902 }
2903 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2904 by_version.entry(version).or_default().push(tag.to_string());
2905 }
2906 }
2907 git_tag_map_to_vec(by_version)
2908}
2909
2910fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
2911 let mut versions: Vec<GitTagObservation> = by_version
2912 .into_iter()
2913 .map(|(version, mut raw_tags)| {
2914 raw_tags.sort();
2915 raw_tags.dedup();
2916 GitTagObservation { version, raw_tags }
2917 })
2918 .collect();
2919 versions.sort_by(|a, b| b.version.cmp(&a.version));
2920 versions
2921}
2922
2923#[cfg(test)]
2924fn read_version_from_blob(
2925 relative: &str,
2926 content: &str,
2927 cargo_toml_content: Option<&str>,
2928) -> Result<Option<String>, String> {
2929 read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
2930}
2931
2932fn read_version_from_blob_with_override(
2933 relative: &str,
2934 content: &str,
2935 cargo_toml_content: Option<&str>,
2936 cargo_package_override: Option<&str>,
2937) -> Result<Option<String>, String> {
2938 let file_name = Path::new(relative)
2939 .file_name()
2940 .and_then(|n| n.to_str())
2941 .unwrap_or_default();
2942
2943 match file_name {
2944 "README.md" => read_readme_version_from_content(content),
2945 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2946 read_openapi_version_from_content(content)
2947 }
2948 "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
2949 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2950 | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
2951 "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
2952 "Cargo.toml" => read_cargo_toml_version_from_content(content),
2953 "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
2954 content,
2955 cargo_toml_content,
2956 cargo_package_override,
2957 ),
2958 "pyproject.toml" => read_pyproject_version_from_content(content),
2959 "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
2960 "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
2961 "pom.xml" => {
2962 read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
2963 }
2964 "build.gradle" | "build.gradle.kts" => {
2965 read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
2966 }
2967 "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
2968 _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
2969 Some("json") => read_json_root_version_from_content(content),
2970 Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
2971 Some("toml") => read_toml_root_version_from_content(content),
2972 Some("md") => read_readme_version_from_content(content),
2973 _ => Ok(None),
2974 },
2975 }
2976}
2977
2978fn print_version_report(project_root: &Path, report: &VersionReport) {
2979 let dirty_suffix = if report.dirty_files.is_empty() {
2980 "clean".green().to_string()
2981 } else {
2982 format!("dirty ({})", report.dirty_files.len())
2983 .bright_magenta()
2984 .to_string()
2985 };
2986
2987 println!(
2988 "\n{} {}",
2989 "Version Summary".bright_cyan().bold(),
2990 project_root.display().to_string().bright_white()
2991 );
2992 println!("{}", "─".repeat(72).bright_black());
2993 println!(
2994 "{:<20} {}",
2995 "Highest available".bright_white(),
2996 report.highest_available().to_string().bright_green().bold()
2997 );
2998 println!(
2999 "{:<20} {}",
3000 "Worktree".bright_white(),
3001 report
3002 .highest_worktree()
3003 .unwrap_or_else(default_version)
3004 .to_string()
3005 .bright_yellow()
3006 );
3007 println!(
3008 "{:<20} {}",
3009 "Committed HEAD".bright_white(),
3010 report
3011 .highest_head()
3012 .map(|v| v.to_string())
3013 .unwrap_or_else(|| "none".dimmed().to_string())
3014 );
3015 println!(
3016 "{:<20} {}",
3017 "GitHub tags".bright_white(),
3018 report
3019 .highest_remote_tag()
3020 .map(|v| v.to_string())
3021 .unwrap_or_else(|| "none".dimmed().to_string())
3022 );
3023 println!(
3024 "{:<20} {}",
3025 "Registry latest".bright_white(),
3026 report
3027 .highest_registry()
3028 .map(|v| v.to_string())
3029 .unwrap_or_else(|| "none".dimmed().to_string())
3030 );
3031 println!(
3032 "{:<20} {}",
3033 "Local tags".bright_white(),
3034 report
3035 .highest_local_tag()
3036 .map(|v| v.to_string())
3037 .unwrap_or_else(|| "none".dimmed().to_string())
3038 );
3039 if let Some(ledger) = &report.release_ledger {
3040 println!(
3041 "{:<20} {} {}",
3042 "Release ledger".bright_white(),
3043 ledger.version.bright_yellow(),
3044 format!("{:?}", ledger.status).bright_cyan()
3045 );
3046 }
3047 println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
3048
3049 if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
3050 println!(
3051 "{:<20} {}",
3052 "Suggested scope".bright_white(),
3053 review.scope_label.bright_cyan()
3054 );
3055 if !review.groups.is_empty() {
3056 println!("{:<20} {}", "Why".bright_white(), review.summary);
3057 for group in review.groups {
3058 println!("{:<20} {}", "", group);
3059 }
3060 }
3061 }
3062
3063 print_version_observations(
3064 "Worktree version files",
3065 &report.worktree,
3066 Some(&report.dirty_files),
3067 );
3068 print_version_observations("Committed HEAD version files", &report.head, None);
3069 print_registry_observations("Published package versions", &report.registry_versions);
3070 print_git_tag_observations("GitHub tags", &report.remote_tags);
3071 print_git_tag_observations("Local git tags", &report.local_tags);
3072
3073 let divergent = report.divergent_versions();
3074 let highest = report.highest_available();
3075 let outdated: Vec<_> = divergent
3076 .into_iter()
3077 .filter(|version| version != &highest)
3078 .collect();
3079 println!();
3080 println!("{}", "Divergence".bright_cyan().bold());
3081 println!("{}", "─".repeat(72).bright_black());
3082 println!(
3083 " {:<20} {}",
3084 "latest target".bright_white(),
3085 highest.to_string().bright_green().bold()
3086 );
3087 if !outdated.is_empty() {
3088 for version in outdated {
3089 println!(
3090 " {} {}",
3091 "•".bright_yellow(),
3092 version.to_string().bright_yellow()
3093 );
3094 }
3095 println!();
3096 println!(
3097 "{} {}",
3098 "Fix local files with".bright_white(),
3099 format!("xbp version {}", highest).black().on_bright_green()
3100 );
3101 } else {
3102 println!(" {}", "all relevant sources are aligned".green());
3103 }
3104
3105 if !report.warnings.is_empty() {
3106 println!();
3107 println!("{}", "Warnings".bright_yellow().bold());
3108 println!("{}", "─".repeat(72).bright_black());
3109 for warning in &report.warnings {
3110 println!(" {} {}", "!".bright_yellow(), warning);
3111 }
3112 }
3113}
3114
3115struct VersionDirtyFileReview {
3116 scope_label: String,
3117 summary: String,
3118 groups: Vec<String>,
3119}
3120
3121fn summarize_version_dirty_files(
3122 project_root: &Path,
3123 dirty_files: &[String],
3124) -> Option<VersionDirtyFileReview> {
3125 if dirty_files.is_empty() {
3126 return None;
3127 }
3128
3129 let mut crate_names = BTreeSet::new();
3130 let mut service_names = BTreeSet::new();
3131 let mut touches_workspace_files = false;
3132 let mut workspace_files = Vec::new();
3133 let mut crate_files = Vec::new();
3134 let mut service_files = Vec::new();
3135 let mut other_files = Vec::new();
3136
3137 for file in dirty_files {
3138 let path = Path::new(file);
3139 if path == Path::new("Cargo.toml")
3140 || path == Path::new("Cargo.lock")
3141 || path == Path::new("README.md")
3142 || path == Path::new("CHANGELOG.md")
3143 || path == Path::new("SECURITY.md")
3144 {
3145 touches_workspace_files = true;
3146 workspace_files.push(file.clone());
3147 continue;
3148 }
3149
3150 if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
3151 if let Some(crate_name) = crate_name {
3152 crate_names.insert(crate_name);
3153 crate_files.push(file.clone());
3154 }
3155 if let Some(service_name) = service_name {
3156 service_names.insert(service_name);
3157 service_files.push(file.clone());
3158 }
3159 } else {
3160 other_files.push(file.clone());
3161 }
3162 }
3163
3164 let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
3165 if crate_names.len() == 1 {
3166 format!("crate `{}`", crate_names.iter().next().unwrap())
3167 } else {
3168 format!("{} crates", crate_names.len())
3169 }
3170 } else if !service_names.is_empty() && crate_names.is_empty() {
3171 if service_names.len() == 1 {
3172 format!("service `{}`", service_names.iter().next().unwrap())
3173 } else {
3174 format!("{} services", service_names.len())
3175 }
3176 } else if touches_workspace_files {
3177 "repository / main crates".to_string()
3178 } else {
3179 "repository".to_string()
3180 };
3181
3182 let summary = if dirty_files.len() <= 8 {
3183 format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
3184 } else {
3185 format!(
3186 "{} file(s): {} ...",
3187 dirty_files.len(),
3188 dirty_files
3189 .iter()
3190 .take(8)
3191 .cloned()
3192 .collect::<Vec<_>>()
3193 .join(", ")
3194 )
3195 };
3196
3197 let mut groups = Vec::new();
3198 if !workspace_files.is_empty() {
3199 groups.push(format!(
3200 "Workspace files: {}",
3201 summarize_file_group(&workspace_files)
3202 ));
3203 }
3204 if !crate_files.is_empty() {
3205 groups.push(format!(
3206 "Crate files: {}",
3207 summarize_file_group(&crate_files)
3208 ));
3209 }
3210 if !service_files.is_empty() {
3211 groups.push(format!(
3212 "Service files: {}",
3213 summarize_file_group(&service_files)
3214 ));
3215 }
3216 if !other_files.is_empty() {
3217 groups.push(format!(
3218 "Other files: {}",
3219 summarize_file_group(&other_files)
3220 ));
3221 }
3222
3223 Some(VersionDirtyFileReview {
3224 scope_label,
3225 summary,
3226 groups,
3227 })
3228}
3229
3230fn summarize_file_group(files: &[String]) -> String {
3231 if files.is_empty() {
3232 return String::new();
3233 }
3234
3235 if files.len() <= 4 {
3236 return files.join(", ");
3237 }
3238
3239 format!(
3240 "{} (and {} more)",
3241 files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
3242 files.len() - 4
3243 )
3244}
3245
3246fn infer_version_scope_name(
3247 project_root: &Path,
3248 path: &Path,
3249) -> Option<(Option<String>, Option<String>)> {
3250 let absolute = if path.is_absolute() {
3251 path.to_path_buf()
3252 } else {
3253 project_root.join(path)
3254 };
3255
3256 let relative = absolute.strip_prefix(project_root).ok()?;
3257 let mut components = relative.components();
3258 let first = components.next()?.as_os_str().to_string_lossy().to_string();
3259 let second = components
3260 .next()
3261 .map(|value| value.as_os_str().to_string_lossy().to_string());
3262
3263 if first == "crates" {
3264 return second.map(|crate_name| (Some(crate_name), None));
3265 }
3266
3267 if first == "apps" {
3268 return second.map(|service_name| (None, Some(service_name)));
3269 }
3270
3271 None
3272}
3273
3274fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
3275 entries.iter().map(|entry| entry.version.clone()).max()
3276}
3277
3278fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
3279 let Some(highest) = highest_version_observation(entries) else {
3280 return Vec::new();
3281 };
3282
3283 entries
3284 .iter()
3285 .filter(|entry| entry.version < highest)
3286 .collect()
3287}
3288
3289fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
3290 println!();
3291 println!("{}", title.bright_cyan().bold());
3292 println!("{}", "─".repeat(72).bright_black());
3293
3294 if entries.is_empty() {
3295 println!(" {}", "none found".dimmed());
3296 return;
3297 }
3298
3299 for entry in entries {
3300 let latest_display = match (&entry.latest, &entry.raw_version) {
3301 (Some(version), _) => version.to_string().bright_green().to_string(),
3302 (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
3303 (None, None) => "unavailable".dimmed().to_string(),
3304 };
3305
3306 let note = entry
3307 .note
3308 .as_ref()
3309 .map(|value| format!(" {}", value.bright_yellow()))
3310 .unwrap_or_default();
3311 let published_at = entry
3312 .published_at
3313 .as_deref()
3314 .map(|value| format!(" {}", format!("published {value}").dimmed()))
3315 .unwrap_or_default();
3316
3317 println!(
3318 " {:<9} {:<28} {:<16} {}{}{}",
3319 entry.registry.bright_white(),
3320 entry.package_name.bright_white(),
3321 latest_display,
3322 entry.source_file.dimmed(),
3323 published_at,
3324 note
3325 );
3326 }
3327}
3328
3329#[cfg(test)]
3330fn write_version_to_configured_files(
3331 project_root: &Path,
3332 invocation_dir: &Path,
3333 registry: &[String],
3334 version_scope: &VersionScope,
3335 version: &Version,
3336) -> Result<usize, String> {
3337 write_version_to_configured_files_with_paths(
3338 project_root,
3339 invocation_dir,
3340 registry,
3341 version_scope,
3342 version,
3343 )
3344 .map(|paths| paths.len())
3345}
3346
3347fn write_version_to_configured_files_with_paths(
3348 project_root: &Path,
3349 invocation_dir: &Path,
3350 registry: &[String],
3351 version_scope: &VersionScope,
3352 version: &Version,
3353) -> Result<Vec<PathBuf>, String> {
3354 write_version_to_configured_files_with_paths_internal(
3355 project_root,
3356 invocation_dir,
3357 registry,
3358 version_scope,
3359 version,
3360 false,
3361 )
3362}
3363
3364#[cfg(test)]
3365fn sync_version_to_configured_files_with_paths(
3366 project_root: &Path,
3367 invocation_dir: &Path,
3368 registry: &[String],
3369 version_scope: &VersionScope,
3370 version: &Version,
3371) -> Result<Vec<PathBuf>, String> {
3372 write_version_to_configured_files_with_paths_internal(
3373 project_root,
3374 invocation_dir,
3375 registry,
3376 version_scope,
3377 version,
3378 true,
3379 )
3380}
3381
3382fn write_version_to_configured_files_with_paths_internal(
3383 project_root: &Path,
3384 invocation_dir: &Path,
3385 registry: &[String],
3386 version_scope: &VersionScope,
3387 version: &Version,
3388 allow_noop_when_targets_exist: bool,
3389) -> Result<Vec<PathBuf>, String> {
3390 let mut updated = 0usize;
3391 let mut matched_targets = 0usize;
3392 let mut updated_paths = Vec::new();
3393 let mut errors = Vec::new();
3394
3395 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
3396 let path = &entry.absolute;
3397 if !path.exists() {
3398 continue;
3399 }
3400 matched_targets += 1;
3401
3402 match write_version_to_resolved_path(&entry, version) {
3403 Ok(true) => {
3404 updated += 1;
3405 updated_paths.push(path.clone());
3406 }
3407 Ok(false) => {}
3408 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
3409 }
3410 }
3411
3412 if matched_targets == 0 && errors.is_empty() {
3413 return Err("No configured version files were found to update.".to_string());
3414 }
3415
3416 if !errors.is_empty() {
3417 return Err(format!(
3418 "Updated {} file(s), but some version targets failed:\n{}",
3419 updated,
3420 errors.join("\n")
3421 ));
3422 }
3423
3424 if updated == 0 && allow_noop_when_targets_exist {
3425 return Ok(updated_paths);
3426 }
3427
3428 Ok(updated_paths)
3429}
3430
3431fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
3432 let file_name = path
3433 .file_name()
3434 .and_then(|n| n.to_str())
3435 .unwrap_or_default();
3436
3437 match file_name {
3438 "README.md" => read_readme_version(path),
3439 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3440 read_openapi_version(path)
3441 }
3442 "openapi.json" | "swagger.json" => read_json_openapi_version(path),
3443 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3444 | "xbp.json" => read_json_root_version(path),
3445 "deno.json" => read_json_root_version(path),
3446 "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
3447 "Cargo.toml" => read_cargo_toml_version(path),
3448 "Cargo.lock" => read_cargo_lock_version(path),
3449 "pyproject.toml" => read_pyproject_version(path),
3450 "Chart.yaml" => read_yaml_root_version(path, "version"),
3451 "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
3452 "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
3453 "build.gradle" | "build.gradle.kts" => {
3454 read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
3455 }
3456 "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
3457 _ => match path.extension().and_then(|ext| ext.to_str()) {
3458 Some("json") => read_json_root_version(path),
3459 Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
3460 Some("toml") => read_toml_root_version(path),
3461 Some("md") => read_readme_version(path),
3462 _ => Ok(None),
3463 },
3464 }
3465}
3466
3467fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
3468 let path = &entry.absolute;
3469 let file_name = path
3470 .file_name()
3471 .and_then(|n| n.to_str())
3472 .unwrap_or_default();
3473
3474 if file_name == "Cargo.lock" {
3475 if let Some(package_name) = entry.cargo_package_override.as_deref() {
3476 return read_cargo_lock_version_for_package(path, package_name);
3477 }
3478 }
3479
3480 read_version_from_path(path)
3481}
3482
3483fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
3484 let file_name = path
3485 .file_name()
3486 .and_then(|n| n.to_str())
3487 .unwrap_or_default();
3488
3489 match file_name {
3490 "README.md" => write_readme_version(path, version).map(|_| true),
3491 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3492 write_openapi_version(path, version).map(|_| true)
3493 }
3494 "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
3495 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3496 | "xbp.json" => write_json_root_version(path, version).map(|_| true),
3497 "deno.json" => write_json_root_version(path, version).map(|_| true),
3498 "deno.jsonc" => {
3499 write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
3500 }
3501 "Cargo.toml" => write_cargo_toml_version(path, version),
3502 "Cargo.lock" => write_cargo_lock_version(path, version),
3503 "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
3504 "Chart.yaml" => write_chart_version(path, version).map(|_| true),
3505 "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
3506 "pom.xml" => {
3507 write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
3508 }
3509 "build.gradle" | "build.gradle.kts" => {
3510 write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
3511 .map(|_| true)
3512 }
3513 "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
3514 _ => match path.extension().and_then(|ext| ext.to_str()) {
3515 Some("json") => write_json_root_version(path, version).map(|_| true),
3516 Some("yaml") | Some("yml") => {
3517 write_yaml_root_version(path, "version", version).map(|_| true)
3518 }
3519 Some("toml") => write_toml_root_version(path, version).map(|_| true),
3520 Some("md") => write_readme_version(path, version).map(|_| true),
3521 _ => Err("Unsupported version file type".to_string()),
3522 },
3523 }
3524}
3525
3526fn write_version_to_resolved_path(
3527 entry: &ResolvedRegistryPath,
3528 version: &Version,
3529) -> Result<bool, String> {
3530 let path = &entry.absolute;
3531 let file_name = path
3532 .file_name()
3533 .and_then(|n| n.to_str())
3534 .unwrap_or_default();
3535
3536 if file_name == "Cargo.lock" {
3537 if let Some(package_name) = entry.cargo_package_override.as_deref() {
3538 return write_cargo_lock_version_for_package(path, Some(package_name), version);
3539 }
3540 }
3541
3542 write_version_to_path(path, version)
3543}
3544
3545fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3546 let value: JsonValue =
3547 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3548 Ok(value
3549 .get("version")
3550 .and_then(JsonValue::as_str)
3551 .map(|value| value.to_string()))
3552}
3553
3554fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
3555 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3556 read_json_root_version_from_content(&content)
3557}
3558
3559fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
3560 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3561 let mut value: JsonValue =
3562 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3563
3564 let object = value
3565 .as_object_mut()
3566 .ok_or_else(|| "Expected a JSON object".to_string())?;
3567 object.insert(
3568 "version".to_string(),
3569 JsonValue::String(version.to_string()),
3570 );
3571
3572 fs::write(
3573 path,
3574 serde_json::to_string_pretty(&value)
3575 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3576 )
3577 .map_err(|e| format!("Failed to write file: {}", e))
3578}
3579
3580fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
3581 let value: YamlValue =
3582 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3583 Ok(yaml_get_string(&value, key))
3584}
3585
3586fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
3587 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3588 read_yaml_root_version_from_content(&content, key)
3589}
3590
3591fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
3592 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3593 let mut value: YamlValue =
3594 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3595
3596 let mapping = yaml_root_mapping_mut(&mut value)?;
3597 mapping.insert(
3598 YamlValue::String(key.to_string()),
3599 YamlValue::String(version.to_string()),
3600 );
3601
3602 fs::write(
3603 path,
3604 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3605 )
3606 .map_err(|e| format!("Failed to write file: {}", e))
3607}
3608
3609fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3610 let value: YamlValue =
3611 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3612
3613 let info = yaml_get_mapping(&value, "info");
3614 Ok(info.and_then(|mapping| {
3615 mapping
3616 .get(YamlValue::String("version".to_string()))
3617 .and_then(YamlValue::as_str)
3618 .map(|value| value.to_string())
3619 }))
3620}
3621
3622fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
3623 let content: String =
3624 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3625 read_openapi_version_from_content(&content)
3626}
3627
3628fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3629 let value: JsonValue =
3630 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3631 Ok(value
3632 .get("info")
3633 .and_then(JsonValue::as_object)
3634 .and_then(|info| info.get("version"))
3635 .and_then(JsonValue::as_str)
3636 .map(|value| value.to_string()))
3637}
3638
3639fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
3640 let content: String =
3641 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3642 read_json_openapi_version_from_content(&content)
3643}
3644
3645fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3646 let content: String =
3647 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3648 let mut value: YamlValue =
3649 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3650
3651 let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
3652 let info_key: YamlValue = YamlValue::String("info".to_string());
3653 if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
3654 root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
3655 }
3656
3657 let info: &mut YamlMapping = root
3658 .get_mut(&info_key)
3659 .and_then(YamlValue::as_mapping_mut)
3660 .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
3661 info.insert(
3662 YamlValue::String("version".to_string()),
3663 YamlValue::String(version.to_string()),
3664 );
3665
3666 fs::write(
3667 path,
3668 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3669 )
3670 .map_err(|e| format!("Failed to write file: {}", e))
3671}
3672
3673fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3674 let content: String =
3675 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3676 let mut value: JsonValue =
3677 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3678
3679 let root = value
3680 .as_object_mut()
3681 .ok_or_else(|| "Expected a JSON object".to_string())?;
3682 let info = root
3683 .entry("info".to_string())
3684 .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
3685 let info_object = info
3686 .as_object_mut()
3687 .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
3688 info_object.insert(
3689 "version".to_string(),
3690 JsonValue::String(version.to_string()),
3691 );
3692
3693 fs::write(
3694 path,
3695 serde_json::to_string_pretty(&value)
3696 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3697 )
3698 .map_err(|e| format!("Failed to write file: {}", e))
3699}
3700
3701fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3702 let value: TomlValue =
3703 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3704 Ok(value
3705 .get("version")
3706 .and_then(TomlValue::as_str)
3707 .map(|value| value.to_string()))
3708}
3709
3710fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
3711 let content: String =
3712 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3713 read_toml_root_version_from_content(&content)
3714}
3715
3716fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
3717 let content: String =
3718 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3719 let mut value: TomlValue =
3720 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3721 let table = value
3722 .as_table_mut()
3723 .ok_or_else(|| "Expected a TOML table".to_string())?;
3724 table.insert(
3725 "version".to_string(),
3726 TomlValue::String(version.to_string()),
3727 );
3728 fs::write(
3729 path,
3730 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3731 )
3732 .map_err(|e| format!("Failed to write file: {}", e))
3733}
3734
3735fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
3736 crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
3737}
3738
3739fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
3740 resolve_cargo_package_version(path)
3741}
3742
3743fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
3744 write_cargo_package_version(path, version)
3745}
3746
3747fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
3748 let value: TomlValue =
3749 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3750
3751 let project_version = value
3752 .get("project")
3753 .and_then(TomlValue::as_table)
3754 .and_then(|project| project.get("version"))
3755 .and_then(TomlValue::as_str);
3756
3757 let poetry_version = value
3758 .get("tool")
3759 .and_then(TomlValue::as_table)
3760 .and_then(|tool| tool.get("poetry"))
3761 .and_then(TomlValue::as_table)
3762 .and_then(|poetry| poetry.get("version"))
3763 .and_then(TomlValue::as_str);
3764
3765 Ok(project_version
3766 .or(poetry_version)
3767 .map(|value| value.to_string()))
3768}
3769
3770fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
3771 let content: String =
3772 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3773 read_pyproject_version_from_content(&content)
3774}
3775
3776fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
3777 let content: String =
3778 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3779 let mut value: TomlValue =
3780 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3781
3782 if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
3783 project.insert(
3784 "version".to_string(),
3785 TomlValue::String(version.to_string()),
3786 );
3787 } else if let Some(poetry) = value
3788 .get_mut("tool")
3789 .and_then(TomlValue::as_table_mut)
3790 .and_then(|tool| tool.get_mut("poetry"))
3791 .and_then(TomlValue::as_table_mut)
3792 {
3793 poetry.insert(
3794 "version".to_string(),
3795 TomlValue::String(version.to_string()),
3796 );
3797 } else {
3798 let table: &mut toml::map::Map<String, TomlValue> = value
3799 .as_table_mut()
3800 .ok_or_else(|| "Expected a TOML table".to_string())?;
3801 table.insert(
3802 "version".to_string(),
3803 TomlValue::String(version.to_string()),
3804 );
3805 }
3806
3807 fs::write(
3808 path,
3809 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3810 )
3811 .map_err(|e| format!("Failed to write file: {}", e))
3812}
3813
3814fn read_cargo_lock_version_from_content(
3815 content: &str,
3816 cargo_toml_content: Option<&str>,
3817) -> Result<Option<String>, String> {
3818 read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
3819}
3820
3821fn read_cargo_lock_version_from_content_with_package(
3822 content: &str,
3823 cargo_toml_content: Option<&str>,
3824 package_name_override: Option<&str>,
3825) -> Result<Option<String>, String> {
3826 let value: TomlValue =
3827 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3828 let package_name = if let Some(package_name_override) = package_name_override {
3829 package_name_override.trim().to_string()
3830 } else {
3831 let cargo_toml_content = cargo_toml_content
3832 .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
3833 cargo_package_name_from_content(cargo_toml_content)?
3834 };
3835
3836 Ok(value
3837 .get("package")
3838 .and_then(TomlValue::as_array)
3839 .and_then(|packages| {
3840 packages.iter().find_map(|package| {
3841 let table = package.as_table()?;
3842 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3843 table
3844 .get("version")
3845 .and_then(TomlValue::as_str)
3846 .map(|value| value.to_string())
3847 } else {
3848 None
3849 }
3850 })
3851 }))
3852}
3853
3854fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
3855 let content: String =
3856 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3857 let cargo_toml: String = fs::read_to_string(
3858 path.parent()
3859 .unwrap_or_else(|| Path::new("."))
3860 .join("Cargo.toml"),
3861 )
3862 .map_err(|e| format!("Failed to read file: {}", e))?;
3863 read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
3864}
3865
3866fn read_cargo_lock_version_for_package(
3867 path: &Path,
3868 package_name: &str,
3869) -> Result<Option<String>, String> {
3870 let content: String =
3871 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3872 read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
3873}
3874
3875fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
3876 write_cargo_lock_version_for_package(path, None, version)
3877}
3878
3879fn write_cargo_lock_version_for_package(
3880 path: &Path,
3881 package_name_override: Option<&str>,
3882 version: &Version,
3883) -> Result<bool, String> {
3884 let content: String =
3885 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3886 let mut value: TomlValue =
3887 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3888 let package_name = if let Some(package_name_override) = package_name_override {
3889 package_name_override.trim().to_string()
3890 } else {
3891 let Some(package_name) = cargo_package_name(path)? else {
3892 return Ok(false);
3893 };
3894 package_name
3895 };
3896
3897 let packages: &mut Vec<TomlValue> = value
3898 .get_mut("package")
3899 .and_then(TomlValue::as_array_mut)
3900 .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
3901
3902 let mut updated = false;
3903 for package in packages {
3904 if let Some(table) = package.as_table_mut() {
3905 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3906 table.insert(
3907 "version".to_string(),
3908 TomlValue::String(version.to_string()),
3909 );
3910 updated = true;
3911 }
3912 }
3913 }
3914
3915 if !updated {
3916 return Err(format!(
3917 "Could not find package `{}` in Cargo.lock",
3918 package_name
3919 ));
3920 }
3921
3922 fs::write(
3923 path,
3924 toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3925 )
3926 .map_err(|e| format!("Failed to write file: {}", e))?;
3927
3928 Ok(true)
3929}
3930
3931fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
3932 let cargo_toml: PathBuf = path
3933 .parent()
3934 .unwrap_or_else(|| Path::new("."))
3935 .join("Cargo.toml");
3936 let content: String = fs::read_to_string(&cargo_toml)
3937 .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
3938 cargo_package_name_from_content_optional(&content)
3939}
3940
3941fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
3942 cargo_package_name_from_content_optional(content)?
3943 .ok_or_else(|| "Could not determine Cargo package name".to_string())
3944}
3945
3946fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
3947 let value: TomlValue =
3948 toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
3949 Ok(value
3950 .get("package")
3951 .and_then(TomlValue::as_table)
3952 .and_then(|package| package.get("name"))
3953 .and_then(TomlValue::as_str)
3954 .map(|value| value.to_string()))
3955}
3956
3957fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
3958 read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3959}
3960
3961fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
3962 let content: String =
3963 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3964 read_readme_version_from_content(&content)
3965}
3966
3967fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
3968 let content: String =
3969 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3970 let marker: String = format!("current version: `{}`", version);
3971 let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3972 .map_err(|e| format!("Failed to build README regex: {}", e))?;
3973
3974 let updated: String = if regex.is_match(&content) {
3975 regex.replace(&content, marker.as_str()).to_string()
3976 } else if let Some(first_break) = content.find('\n') {
3977 let mut next = String::new();
3978 next.push_str(&content[..=first_break]);
3979 next.push('\n');
3980 next.push_str(&marker);
3981 next.push('\n');
3982 next.push_str(&content[first_break + 1..]);
3983 next
3984 } else {
3985 format!("{}\n\n{}\n", content, marker)
3986 };
3987
3988 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
3989}
3990
3991fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
3992 let content: String =
3993 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3994 read_regex_version_from_content(&content, pattern)
3995}
3996
3997fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
3998 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
3999 Ok(regex
4000 .captures(content)
4001 .and_then(|captures| captures.get(1))
4002 .map(|matched| matched.as_str().trim().to_string()))
4003}
4004
4005fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
4006 let content: String =
4007 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4008 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
4009
4010 if !regex.is_match(&content) {
4011 return Err("No version pattern found".to_string());
4012 }
4013
4014 let updated: String = regex
4015 .replace(&content, |caps: ®ex::Captures<'_>| {
4016 caps[0].replace(&caps[1], &version.to_string())
4017 })
4018 .to_string();
4019 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
4020}
4021
4022#[cfg(test)]
4023fn write_package_version_to_configured_files(
4024 project_root: &Path,
4025 invocation_dir: &Path,
4026 registry: &[String],
4027 version_scope: &VersionScope,
4028 package_name: &str,
4029 version: &Version,
4030) -> Result<usize, String> {
4031 write_package_version_to_configured_files_with_paths(
4032 project_root,
4033 invocation_dir,
4034 registry,
4035 version_scope,
4036 package_name,
4037 version,
4038 )
4039 .map(|paths| paths.len())
4040}
4041
4042fn write_package_version_to_configured_files_with_paths(
4043 project_root: &Path,
4044 invocation_dir: &Path,
4045 registry: &[String],
4046 version_scope: &VersionScope,
4047 package_name: &str,
4048 version: &Version,
4049) -> Result<Vec<PathBuf>, String> {
4050 let mut updated: usize = 0usize;
4051 let mut updated_paths = Vec::new();
4052 let mut errors: Vec<String> = Vec::new();
4053
4054 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
4055 let path: &PathBuf = &entry.absolute;
4056 if !path.exists() {
4057 continue;
4058 }
4059
4060 match write_package_version_to_path(path, package_name, version) {
4061 Ok(true) => {
4062 updated += 1;
4063 updated_paths.push(path.clone());
4064 }
4065 Ok(false) => {}
4066 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
4067 }
4068 }
4069
4070 if updated == 0 && errors.is_empty() {
4071 return Err(format!(
4072 "No configured TOML files contained package assignment `{}`.",
4073 package_name
4074 ));
4075 }
4076
4077 if !errors.is_empty() {
4078 return Err(format!(
4079 "Updated {} file(s), but some package version targets failed:\n{}",
4080 updated,
4081 errors.join("\n")
4082 ));
4083 }
4084
4085 Ok(updated_paths)
4086}
4087
4088fn write_package_version_to_path(
4089 path: &Path,
4090 package_name: &str,
4091 version: &Version,
4092) -> Result<bool, String> {
4093 let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
4094 if !is_toml {
4095 return Ok(false);
4096 }
4097
4098 let content: String =
4099 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4100 let (updated, changed) =
4101 rewrite_toml_package_assignment_versions(&content, package_name, version)?;
4102 if changed {
4103 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
4104 }
4105 Ok(changed)
4106}
4107
4108fn rewrite_toml_package_assignment_versions(
4109 content: &str,
4110 package_name: &str,
4111 version: &Version,
4112) -> Result<(String, bool), String> {
4113 let escaped_name: String = regex::escape(package_name);
4114 let inline_pattern: String = format!(
4115 r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
4116 escaped_name
4117 );
4118 let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
4119
4120 let inline_regex: Regex =
4121 Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
4122 let string_regex: Regex =
4123 Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
4124
4125 let replacement: String = version.to_string();
4126
4127 let after_inline: String = inline_regex
4128 .replace_all(content, |caps: ®ex::Captures<'_>| {
4129 format!("{}{}{}", &caps[1], replacement, &caps[3])
4130 })
4131 .to_string();
4132 let after_string: String = string_regex
4133 .replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
4134 format!("{}{}{}", &caps[1], replacement, &caps[3])
4135 })
4136 .to_string();
4137
4138 Ok((after_string.clone(), after_string != content))
4139}
4140
4141fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
4142 let content: String =
4143 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4144 let mut value: YamlValue =
4145 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
4146 let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
4147 mapping.insert(
4148 YamlValue::String("version".to_string()),
4149 YamlValue::String(version.to_string()),
4150 );
4151 if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
4152 mapping.insert(
4153 YamlValue::String("appVersion".to_string()),
4154 YamlValue::String(version.to_string()),
4155 );
4156 }
4157 fs::write(
4158 path,
4159 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
4160 )
4161 .map_err(|e| format!("Failed to write file: {}", e))
4162}
4163
4164fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
4165 value
4166 .as_mapping_mut()
4167 .ok_or_else(|| "Expected a YAML mapping".to_string())
4168}
4169
4170fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
4171 value
4172 .as_mapping()
4173 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4174 .and_then(YamlValue::as_mapping)
4175}
4176
4177fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
4178 value
4179 .as_mapping()
4180 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4181 .and_then(YamlValue::as_str)
4182 .map(|value| value.to_string())
4183}
4184
4185fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
4186 let mut current: &JsonValue = value;
4187 for part in key_parts {
4188 current = current.get(*part)?;
4189 }
4190 current.as_str().map(|value| value.to_string())
4191}
4192
4193fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
4194 let mut current = value;
4195 for part in key_parts {
4196 let mapping = current.as_mapping()?;
4197 current = mapping.get(YamlValue::String((*part).to_string()))?;
4198 }
4199 current.as_str().map(|value| value.to_string())
4200}
4201
4202fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
4203 let mut current = value;
4204 for part in key_parts {
4205 current = current.get(*part)?;
4206 }
4207 current.as_str().map(|value| value.to_string())
4208}
4209
4210fn parse_version(input: &str) -> Result<Version, String> {
4211 let trimmed: &str = input.trim();
4212 let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
4213 Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
4214}
4215
4216fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
4217 let trimmed = input.trim();
4218 if trimmed.is_empty() {
4219 return Err("Release version cannot be empty.".to_string());
4220 }
4221
4222 if let Ok(version) = parse_version(trimmed) {
4223 return Ok((version.clone(), format!("v{}", version)));
4224 }
4225
4226 let prefixed = Regex::new(
4227 r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
4228 )
4229 .map_err(|e| format!("Failed to build release target parser: {}", e))?;
4230
4231 if let Some(captures) = prefixed.captures(trimmed) {
4232 let semver = captures
4233 .name("semver")
4234 .map(|m| m.as_str())
4235 .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
4236 let version = Version::parse(semver)
4237 .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
4238 return Ok((version, trimmed.to_string()));
4239 }
4240
4241 Err(format!(
4242 "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
4243 input
4244 ))
4245}
4246
4247fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
4248 let Some((raw_package, raw_version)) = input.split_once('=') else {
4249 return Ok(None);
4250 };
4251
4252 let package_name = raw_package.trim();
4253 if package_name.is_empty() {
4254 return Ok(None);
4255 }
4256
4257 let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
4258 .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
4259 if !package_name_regex.is_match(package_name) {
4260 return Err(format!(
4261 "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
4262 input
4263 ));
4264 }
4265
4266 let version = parse_version(raw_version.trim())?;
4267 Ok(Some((package_name.to_string(), version)))
4268}
4269
4270fn bump_version(current: &Version, kind: &str) -> Version {
4271 let mut next = current.clone();
4272 match kind {
4273 "major" => {
4274 next.major += 1;
4275 next.minor = 0;
4276 next.patch = 0;
4277 next.pre = semver::Prerelease::EMPTY;
4278 next.build = semver::BuildMetadata::EMPTY;
4279 }
4280 "minor" => {
4281 next.minor += 1;
4282 next.patch = 0;
4283 next.pre = semver::Prerelease::EMPTY;
4284 next.build = semver::BuildMetadata::EMPTY;
4285 }
4286 _ => {
4287 next.patch += 1;
4288 next.pre = semver::Prerelease::EMPTY;
4289 next.build = semver::BuildMetadata::EMPTY;
4290 }
4291 }
4292 next
4293}
4294
4295fn default_version() -> Version {
4296 Version::new(0, 1, 0)
4297}
4298
4299fn resolve_registry_paths(
4300 project_root: &Path,
4301 invocation_dir: &Path,
4302 registry: &[String],
4303 version_scope: &VersionScope,
4304) -> Vec<ResolvedRegistryPath> {
4305 let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
4306 let mut seen: BTreeSet<String> = BTreeSet::new();
4307 let workspace_primary_target = match version_scope {
4308 VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
4309 VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
4310 };
4311
4312 for relative in registry {
4313 match version_scope {
4314 VersionScope::Repository => {
4315 if *relative == *"Cargo.lock" {
4316 let resolved_relative = resolve_registry_relative_path(
4317 project_root,
4318 invocation_dir,
4319 version_scope,
4320 relative,
4321 );
4322 if !seen.insert(resolved_relative.clone()) {
4323 continue;
4324 }
4325
4326 resolved.push(ResolvedRegistryPath {
4327 absolute: project_root.join(&resolved_relative),
4328 relative: resolved_relative,
4329 cargo_package_override: workspace_primary_target
4330 .as_ref()
4331 .map(|target| target.package_name.clone()),
4332 });
4333 continue;
4334 }
4335
4336 if *relative == *"Cargo.toml" {
4337 if let Some(target) = workspace_primary_target.as_ref() {
4338 if !seen.insert(target.manifest_relative.clone()) {
4339 continue;
4340 }
4341
4342 resolved.push(ResolvedRegistryPath {
4343 absolute: target.manifest_absolute.clone(),
4344 relative: target.manifest_relative.clone(),
4345 cargo_package_override: None,
4346 });
4347 continue;
4348 }
4349 }
4350
4351 let resolved_relative = resolve_registry_relative_path(
4352 project_root,
4353 invocation_dir,
4354 version_scope,
4355 relative,
4356 );
4357 if !seen.insert(resolved_relative.clone()) {
4358 continue;
4359 }
4360
4361 resolved.push(ResolvedRegistryPath {
4362 absolute: project_root.join(&resolved_relative),
4363 relative: resolved_relative,
4364 cargo_package_override: None,
4365 });
4366 }
4367 VersionScope::Crate {
4368 crate_root,
4369 package_name,
4370 ..
4371 } => {
4372 if *relative == *"Cargo.lock" {
4373 let cargo_lock = project_root.join("Cargo.lock");
4374 if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4375 resolved.push(ResolvedRegistryPath {
4376 absolute: cargo_lock,
4377 relative: "Cargo.lock".to_string(),
4378 cargo_package_override: Some(package_name.clone()),
4379 });
4380 }
4381 continue;
4382 }
4383
4384 let preferred = crate_root.join(relative);
4385 if !preferred.exists() {
4386 continue;
4387 }
4388 let Ok(stripped) = preferred.strip_prefix(project_root) else {
4389 continue;
4390 };
4391 let resolved_relative = normalized_relative_path(stripped);
4392 if !seen.insert(resolved_relative.clone()) {
4393 continue;
4394 }
4395
4396 resolved.push(ResolvedRegistryPath {
4397 absolute: preferred,
4398 relative: resolved_relative,
4399 cargo_package_override: None,
4400 });
4401 }
4402 VersionScope::Service {
4403 service_root,
4404 cargo_package_name,
4405 ..
4406 } => {
4407 if *relative == *"Cargo.lock" {
4408 let local_cargo_lock = service_root.join("Cargo.lock");
4409 if local_cargo_lock.exists() {
4410 let relative_path = local_cargo_lock
4411 .strip_prefix(project_root)
4412 .ok()
4413 .map(normalized_relative_path)
4414 .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
4415 if seen.insert(relative_path.clone()) {
4416 resolved.push(ResolvedRegistryPath {
4417 absolute: local_cargo_lock,
4418 relative: relative_path,
4419 cargo_package_override: cargo_package_name.clone(),
4420 });
4421 }
4422 } else if let Some(package_name) = cargo_package_name.as_ref() {
4423 let workspace_cargo_lock = project_root.join("Cargo.lock");
4424 if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4425 resolved.push(ResolvedRegistryPath {
4426 absolute: workspace_cargo_lock,
4427 relative: "Cargo.lock".to_string(),
4428 cargo_package_override: Some(package_name.clone()),
4429 });
4430 }
4431 }
4432 continue;
4433 }
4434
4435 let preferred = service_root.join(relative);
4436 if !preferred.exists() {
4437 continue;
4438 }
4439 let Ok(stripped) = preferred.strip_prefix(project_root) else {
4440 continue;
4441 };
4442 let resolved_relative = normalized_relative_path(stripped);
4443 if !seen.insert(resolved_relative.clone()) {
4444 continue;
4445 }
4446
4447 resolved.push(ResolvedRegistryPath {
4448 absolute: preferred,
4449 relative: resolved_relative,
4450 cargo_package_override: None,
4451 });
4452 }
4453 }
4454 }
4455
4456 for configured_path in
4457 resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
4458 {
4459 if seen.insert(configured_path.relative.clone()) {
4460 resolved.push(configured_path);
4461 }
4462 }
4463
4464 resolved
4465}
4466
4467fn resolve_configured_version_target_paths(
4468 project_root: &Path,
4469 invocation_dir: &Path,
4470 version_scope: &VersionScope,
4471) -> Vec<ResolvedRegistryPath> {
4472 let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
4473 return Vec::new();
4474 };
4475
4476 let service_targets = match version_scope {
4477 VersionScope::Service {
4478 version_targets, ..
4479 } if !version_targets.is_empty() => Some(version_targets.clone()),
4480 _ => None,
4481 };
4482
4483 let manifest_paths = if let Some(service_targets) = &service_targets {
4484 service_targets
4485 .iter()
4486 .cloned()
4487 .map(PathBuf::from)
4488 .collect::<Vec<_>>()
4489 } else {
4490 let mut targets = config
4491 .version_targets
4492 .into_iter()
4493 .map(PathBuf::from)
4494 .collect::<Vec<_>>();
4495 targets.extend(
4496 config
4497 .publish
4498 .into_iter()
4499 .flat_map(|publish| [publish.npm, publish.crates])
4500 .flatten()
4501 .filter_map(|target| target.manifest_path)
4502 .map(PathBuf::from),
4503 );
4504 targets
4505 };
4506
4507 let scope_root = match version_scope {
4508 VersionScope::Repository => None,
4509 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4510 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4511 };
4512
4513 manifest_paths
4514 .into_iter()
4515 .filter_map(|manifest_path| {
4516 let absolute = if manifest_path.is_absolute() {
4517 manifest_path
4518 } else {
4519 config_root.join(manifest_path)
4520 };
4521 if !absolute.exists() {
4522 return None;
4523 }
4524 if let Some(scope_root) = scope_root {
4525 if !absolute.starts_with(scope_root) {
4526 return None;
4527 }
4528 }
4529 let relative = absolute
4530 .strip_prefix(project_root)
4531 .ok()
4532 .map(normalized_relative_path)
4533 .unwrap_or_else(|| normalized_relative_path(&absolute));
4534 Some(ResolvedRegistryPath {
4535 relative,
4536 absolute,
4537 cargo_package_override: None,
4538 })
4539 })
4540 .collect()
4541}
4542
4543fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
4544 let found = find_xbp_config_upwards(invocation_dir)?;
4545 let config_path = if found.kind == "json" {
4546 maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
4547 .ok()
4548 .flatten()
4549 .unwrap_or_else(|| found.config_path.clone())
4550 } else {
4551 found.config_path.clone()
4552 };
4553
4554 let kind = if config_path
4555 .extension()
4556 .and_then(|ext| ext.to_str())
4557 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4558 .unwrap_or(false)
4559 {
4560 "yaml"
4561 } else {
4562 "json"
4563 };
4564
4565 let content = fs::read_to_string(&config_path).ok()?;
4566 let (mut config, healed): (XbpConfig, Option<String>) =
4567 parse_config_with_auto_heal(&content, kind).ok()?;
4568 if let Some(healed_content) = healed {
4569 let _ = fs::write(&config_path, healed_content);
4570 }
4571 resolve_config_paths_for_runtime(&mut config, &found.project_root);
4572 Some((found.project_root, config))
4573}
4574
4575fn resolve_registry_relative_path(
4576 project_root: &Path,
4577 invocation_dir: &Path,
4578 version_scope: &VersionScope,
4579 relative: &str,
4580) -> String {
4581 if let Some(scope_root) = version_scope_root(version_scope) {
4582 let preferred = scope_root.join(relative);
4583 if preferred.exists() {
4584 if let Ok(stripped) = preferred.strip_prefix(project_root) {
4585 return normalized_relative_path(stripped);
4586 }
4587 }
4588 return relative.replace('\\', "/");
4589 }
4590
4591 if relative == "Cargo.toml" {
4592 if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
4593 return target.manifest_relative;
4594 }
4595 }
4596
4597 let preferred: PathBuf = invocation_dir.join(relative);
4598 if preferred.exists() {
4599 if let Ok(stripped) = preferred.strip_prefix(project_root) {
4600 return normalized_relative_path(stripped);
4601 }
4602 }
4603
4604 relative.replace('\\', "/")
4605}
4606
4607fn resolve_version_scope_with_prompt(
4608 project_root: &Path,
4609 invocation_dir: &Path,
4610) -> Result<VersionScope, String> {
4611 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4612 if let Some(scope) =
4613 select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4614 {
4615 return Ok(scope);
4616 }
4617
4618 if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
4619 {
4620 return prompt_for_version_scope_selection(&service_scopes);
4621 }
4622
4623 Ok(resolve_version_scope(project_root, invocation_dir))
4624}
4625
4626fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
4627 let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4628 if let Some(service_scope) =
4629 select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4630 {
4631 return service_scope;
4632 }
4633
4634 if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
4635 return crate_scope;
4636 }
4637
4638 VersionScope::Repository
4639}
4640
4641fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
4642 let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
4643 return Vec::new();
4644 };
4645 let Some(services) = config.services else {
4646 return Vec::new();
4647 };
4648
4649 let mut scopes = services
4650 .into_iter()
4651 .filter_map(|service| build_service_scope(project_root, service))
4652 .collect::<Vec<_>>();
4653 scopes.sort_by(|left, right| {
4654 version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
4655 });
4656 scopes
4657}
4658
4659fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
4660 let version_targets = service
4661 .version_targets
4662 .clone()
4663 .unwrap_or_default()
4664 .into_iter()
4665 .map(normalized_path_string)
4666 .collect::<Vec<_>>();
4667 let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
4668 let service_relative_root = service_root
4669 .strip_prefix(project_root)
4670 .ok()
4671 .map(normalized_relative_path)
4672 .filter(|value| !value.is_empty())
4673 .unwrap_or_else(|| ".".to_string());
4674 let cargo_package_name =
4675 resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
4676 let tag_identity = cargo_package_name
4677 .clone()
4678 .unwrap_or_else(|| service.name.clone());
4679
4680 Some(VersionScope::Service {
4681 service_root,
4682 service_relative_root,
4683 service_name: service.name,
4684 tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
4685 cargo_package_name,
4686 version_targets,
4687 })
4688}
4689
4690fn resolve_service_scope_root(
4691 project_root: &Path,
4692 service: &ServiceConfig,
4693 version_targets: &[String],
4694) -> Option<PathBuf> {
4695 if !version_targets.is_empty() {
4696 return common_parent_from_targets(project_root, version_targets);
4697 }
4698
4699 service.root_directory.as_ref().map(|root| {
4700 let path = PathBuf::from(root);
4701 if path.is_absolute() {
4702 path
4703 } else {
4704 project_root.join(path)
4705 }
4706 })
4707}
4708
4709fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
4710 let mut parents = version_targets.iter().filter_map(|target| {
4711 let path = PathBuf::from(target);
4712 let absolute = if path.is_absolute() {
4713 path
4714 } else {
4715 project_root.join(path)
4716 };
4717 absolute.parent().map(Path::to_path_buf)
4718 });
4719
4720 let mut common = parents.next()?;
4721 for parent in parents {
4722 while !parent.starts_with(&common) {
4723 common = common.parent()?.to_path_buf();
4724 }
4725 }
4726 Some(common)
4727}
4728
4729fn resolve_service_scope_cargo_package_name(
4730 project_root: &Path,
4731 service_root: &Path,
4732 version_targets: &[String],
4733) -> Option<String> {
4734 let mut cargo_paths = version_targets
4735 .iter()
4736 .filter(|target| {
4737 Path::new(target)
4738 .file_name()
4739 .and_then(|value| value.to_str())
4740 == Some("Cargo.toml")
4741 })
4742 .map(|target| {
4743 let path = PathBuf::from(target);
4744 if path.is_absolute() {
4745 path
4746 } else {
4747 project_root.join(path)
4748 }
4749 })
4750 .collect::<Vec<_>>();
4751 if cargo_paths.is_empty() {
4752 cargo_paths.push(service_root.join("Cargo.toml"));
4753 }
4754
4755 cargo_paths.into_iter().find_map(|path| {
4756 let content = fs::read_to_string(path).ok()?;
4757 cargo_package_name_from_content_optional(&content)
4758 .ok()
4759 .flatten()
4760 })
4761}
4762
4763fn select_matching_service_scope(
4764 scopes: &[VersionScope],
4765 project_root: &Path,
4766 invocation_dir: &Path,
4767) -> Option<VersionScope> {
4768 scopes
4769 .iter()
4770 .filter_map(|scope| {
4771 let root = version_scope_root(scope)?;
4772 if invocation_dir == project_root && root == project_root {
4773 return None;
4774 }
4775 if invocation_dir.starts_with(root) {
4776 Some((root.components().count(), scope.clone()))
4777 } else {
4778 None
4779 }
4780 })
4781 .max_by_key(|(depth, _)| *depth)
4782 .map(|(_, scope)| scope)
4783}
4784
4785fn prompt_for_version_scope_selection(
4786 service_scopes: &[VersionScope],
4787) -> Result<VersionScope, String> {
4788 let mut items = service_scopes
4789 .iter()
4790 .map(version_scope_prompt_label)
4791 .collect::<Vec<_>>();
4792 items.push("Repository (all configured version targets)".to_string());
4793
4794 let selection = Select::with_theme(&ColorfulTheme::default())
4795 .with_prompt("Choose the project/service to version or release")
4796 .items(&items)
4797 .default(items.len().saturating_sub(1))
4798 .interact_opt()
4799 .map_err(|e| format!("Prompt failed: {}", e))?;
4800
4801 match selection {
4802 Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
4803 _ => Ok(VersionScope::Repository),
4804 }
4805}
4806
4807fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
4808 let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
4809 let cargo_toml = crate_root.join("Cargo.toml");
4810 let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
4811 let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
4812 let crate_relative_root = crate_root
4813 .strip_prefix(project_root)
4814 .ok()
4815 .map(normalized_relative_path)?;
4816
4817 Some(VersionScope::Crate {
4818 crate_root,
4819 crate_relative_root,
4820 tag_prefix: format!("{}-", package_name),
4821 package_name,
4822 })
4823}
4824
4825fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
4826 let crates_root = project_root.join("crates");
4827 let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
4828 let mut components = relative.components();
4829 let crate_name = components.next()?;
4830 Some(crates_root.join(crate_name.as_os_str()))
4831}
4832
4833fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
4834 match version_scope {
4835 VersionScope::Repository => None,
4836 VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4837 VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4838 }
4839}
4840
4841fn prepare_release_openapi_assets(
4842 project_root: &Path,
4843 invocation_dir: &Path,
4844 version_scope: &VersionScope,
4845 release_version: &Version,
4846 tag_name: &str,
4847) -> Result<Vec<ReleaseOpenApiAsset>, String> {
4848 let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
4849 validate_release_openapi_assets(&spec_paths, release_version)?;
4850
4851 spec_paths
4852 .into_iter()
4853 .map(|source_path| {
4854 let file_name = source_path
4855 .file_name()
4856 .and_then(|value| value.to_str())
4857 .ok_or_else(|| {
4858 format!(
4859 "Invalid OpenAPI asset path for release upload: {}",
4860 source_path.display()
4861 )
4862 })?;
4863 let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
4864 let source_label = source_path
4865 .strip_prefix(project_root)
4866 .map(normalized_relative_path)
4867 .unwrap_or_else(|_| normalized_relative_path(&source_path));
4868 Ok(ReleaseOpenApiAsset {
4869 source_path,
4870 source_label,
4871 asset_name,
4872 })
4873 })
4874 .collect()
4875}
4876
4877fn resolve_release_openapi_specs(
4878 project_root: &Path,
4879 invocation_dir: &Path,
4880 version_scope: &VersionScope,
4881) -> Result<Vec<PathBuf>, String> {
4882 const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
4883 "openapi.yaml",
4884 "openapi.yml",
4885 "openapi.json",
4886 "swagger.yaml",
4887 "swagger.yml",
4888 "swagger.json",
4889 ];
4890
4891 let mut roots: Vec<PathBuf> = Vec::new();
4892 if let Some(scope_root) = version_scope_root(version_scope) {
4893 roots.push(scope_root.to_path_buf());
4894 } else {
4895 if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
4896 roots.push(crate_root);
4897 }
4898 roots.push(project_root.to_path_buf());
4899 }
4900
4901 let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
4902 let mut seen_names: BTreeSet<String> = BTreeSet::new();
4903 let mut resolved_paths: Vec<PathBuf> = Vec::new();
4904
4905 for root in roots {
4906 if !seen_roots.insert(root.clone()) {
4907 continue;
4908 }
4909
4910 let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
4911 for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
4912 let path = root.join(file_name);
4913 if path.is_file() {
4914 root_paths.push(((*file_name).to_string(), path));
4915 }
4916 }
4917
4918 let entries = match fs::read_dir(&root) {
4919 Ok(entries) => entries,
4920 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
4921 Err(error) => {
4922 return Err(format!(
4923 "Failed to inspect OpenAPI release assets in {}: {}",
4924 root.display(),
4925 error
4926 ));
4927 }
4928 };
4929
4930 let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
4931 for entry in entries {
4932 let entry = entry.map_err(|error| {
4933 format!(
4934 "Failed to inspect OpenAPI release assets in {}: {}",
4935 root.display(),
4936 error
4937 )
4938 })?;
4939 let path = entry.path();
4940 if !path.is_file() {
4941 continue;
4942 }
4943 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4944 continue;
4945 };
4946 if !is_release_openapi_variant_file_name(file_name) {
4947 continue;
4948 }
4949 additional_specs.push((file_name.to_string(), path));
4950 }
4951 additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
4952
4953 root_paths.extend(additional_specs);
4954
4955 for (file_name, path) in root_paths {
4956 if seen_names.insert(file_name) {
4957 resolved_paths.push(path);
4958 }
4959 }
4960 }
4961
4962 Ok(resolved_paths)
4963}
4964
4965fn validate_release_openapi_assets(
4966 spec_paths: &[PathBuf],
4967 release_version: &Version,
4968) -> Result<(), String> {
4969 let Some(primary_spec_path) = spec_paths.iter().find(|path| {
4970 path.file_name()
4971 .and_then(|value| value.to_str())
4972 .map(is_primary_release_openapi_file_name)
4973 .unwrap_or(false)
4974 }) else {
4975 return Ok(());
4976 };
4977
4978 let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
4979 format!(
4980 "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
4981 primary_spec_path.display(),
4982 release_version
4983 )
4984 })?;
4985 let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
4986 format!(
4987 "Release OpenAPI spec `{}` has invalid version `{}`: {}",
4988 primary_spec_path.display(),
4989 declared_version,
4990 error
4991 )
4992 })?;
4993
4994 if &parsed_declared_version != release_version {
4995 return Err(format!(
4996 "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
4997 primary_spec_path.display(),
4998 declared_version,
4999 release_version
5000 ));
5001 }
5002
5003 Ok(())
5004}
5005
5006fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
5007 let path = Path::new(file_name);
5008 let stem = path
5009 .file_stem()
5010 .and_then(|value| value.to_str())
5011 .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
5012 let extension = path
5013 .extension()
5014 .and_then(|value| value.to_str())
5015 .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
5016 Ok(format!("{}-{}.{}", stem, tag_name, extension))
5017}
5018
5019fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
5020 matches!(
5021 file_name,
5022 "openapi.yaml"
5023 | "openapi.yml"
5024 | "openapi.json"
5025 | "swagger.yaml"
5026 | "swagger.yml"
5027 | "swagger.json"
5028 )
5029}
5030
5031fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
5032 if is_primary_release_openapi_file_name(file_name) {
5033 return false;
5034 }
5035
5036 let Some((stem, extension)) = file_name.rsplit_once('.') else {
5037 return false;
5038 };
5039 if !matches!(extension, "yaml" | "yml" | "json") {
5040 return false;
5041 }
5042
5043 stem.starts_with("openapi-") || stem.starts_with("swagger-")
5044}
5045
5046fn normalized_path_string(path: String) -> String {
5047 path.replace('\\', "/")
5048}
5049
5050fn slugify_scope_name(value: &str) -> String {
5051 let mut slug = value
5052 .chars()
5053 .map(|ch| {
5054 if ch.is_ascii_alphanumeric() {
5055 ch.to_ascii_lowercase()
5056 } else {
5057 '-'
5058 }
5059 })
5060 .collect::<String>();
5061 while slug.contains("--") {
5062 slug = slug.replace("--", "-");
5063 }
5064 slug.trim_matches('-').to_string()
5065}
5066
5067fn resolve_workspace_primary_cargo_target(
5068 project_root: &Path,
5069) -> Option<WorkspacePrimaryCargoTarget> {
5070 let workspace_manifest = project_root.join("Cargo.toml");
5071 let content = fs::read_to_string(&workspace_manifest).ok()?;
5072 let value: TomlValue = toml::from_str(&content).ok()?;
5073 if value.get("package").and_then(TomlValue::as_table).is_some() {
5074 return None;
5075 }
5076
5077 let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
5078 let mut candidate_roots = workspace
5079 .get("default-members")
5080 .and_then(TomlValue::as_array)
5081 .map(|members| {
5082 members
5083 .iter()
5084 .filter_map(TomlValue::as_str)
5085 .map(str::trim)
5086 .filter(|member| !member.is_empty() && !member.contains('*'))
5087 .map(str::to_string)
5088 .collect::<Vec<_>>()
5089 })
5090 .unwrap_or_default();
5091
5092 if candidate_roots.is_empty() {
5093 let members = workspace
5094 .get("members")
5095 .and_then(TomlValue::as_array)
5096 .map(|members| {
5097 members
5098 .iter()
5099 .filter_map(TomlValue::as_str)
5100 .map(str::trim)
5101 .filter(|member| !member.is_empty() && !member.contains('*'))
5102 .map(str::to_string)
5103 .collect::<Vec<_>>()
5104 })
5105 .unwrap_or_default();
5106 if members.len() == 1 {
5107 candidate_roots = members;
5108 }
5109 }
5110
5111 candidate_roots.sort();
5112 candidate_roots.dedup();
5113 if candidate_roots.len() != 1 {
5114 return None;
5115 }
5116
5117 let crate_relative_root = candidate_roots.into_iter().next()?;
5118 let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
5119 let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
5120 let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
5121
5122 Some(WorkspacePrimaryCargoTarget {
5123 manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
5124 manifest_absolute,
5125 package_name,
5126 })
5127}
5128
5129#[cfg(test)]
5130fn resolve_release_publish_target_filter(
5131 invocation_dir: &Path,
5132 version_scope: &VersionScope,
5133) -> Result<Option<String>, String> {
5134 let VersionScope::Service {
5135 service_name,
5136 service_root,
5137 version_targets,
5138 ..
5139 } = version_scope
5140 else {
5141 return Ok(None);
5142 };
5143
5144 let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
5145 return Ok(None);
5146 };
5147 let Some(publish) = config.publish else {
5148 return Ok(None);
5149 };
5150
5151 let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
5152 let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
5153 .into_iter()
5154 .filter_map(|(kind, target)| {
5155 let target = target?;
5156 if target.enabled.unwrap_or(true) {
5157 Some((kind, target))
5158 } else {
5159 None
5160 }
5161 })
5162 .collect::<Vec<_>>();
5163
5164 if enabled_targets.is_empty() {
5165 return Ok(None);
5166 }
5167
5168 let matched = enabled_targets
5169 .iter()
5170 .filter_map(|(kind, target)| {
5171 let manifest_path = target.manifest_path.as_ref()?;
5172 let absolute = if Path::new(manifest_path).is_absolute() {
5173 PathBuf::from(manifest_path)
5174 } else {
5175 project_root.join(manifest_path)
5176 };
5177 let relative = absolute
5178 .strip_prefix(&project_root)
5179 .ok()
5180 .map(normalized_relative_path)
5181 .unwrap_or_else(|| normalized_relative_path(&absolute));
5182
5183 if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
5184 {
5185 Some((*kind).to_string())
5186 } else {
5187 None
5188 }
5189 })
5190 .collect::<Vec<_>>();
5191
5192 if matched.is_empty() {
5193 return Err(format!(
5194 "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
5195 service_name
5196 ));
5197 }
5198
5199 if matched.len() == enabled_targets.len() {
5200 if matched.len() == 1 {
5201 return Ok(matched.into_iter().next());
5202 }
5203 return Ok(None);
5204 }
5205
5206 if matched.len() == 1 {
5207 return Ok(matched.into_iter().next());
5208 }
5209
5210 Err(format!(
5211 "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.",
5212 service_name
5213 ))
5214}
5215
5216async fn resolve_effective_linear_release_config(
5217 project_root: &Path,
5218 invocation_dir: &Path,
5219) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
5220 let global_config = resolve_global_linear_release_config();
5221 let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5222 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5223 config.linear.and_then(|linear| linear.release)
5224 } else {
5225 None
5226 };
5227
5228 Ok(resolve_linear_release_config(global_config, project_config)
5229 .map(|config| resolve_linear_release_placeholders(project_root, config)))
5230}
5231
5232fn resolve_linear_release_placeholders(
5233 project_root: &Path,
5234 mut config: ResolvedLinearReleaseConfig,
5235) -> ResolvedLinearReleaseConfig {
5236 let mut env_map = HashMap::new();
5237 for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
5238 env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
5239 }
5240 if let Some(organization_name) = config.organization_name.clone() {
5241 env_map.insert("organization_name".to_string(), organization_name);
5242 }
5243
5244 let resolved = resolve_env_placeholders(project_root, &env_map);
5245 config.initiative_ids = config
5246 .initiative_ids
5247 .iter()
5248 .enumerate()
5249 .map(|(index, initiative_id)| {
5250 resolved
5251 .get(&format!("initiative_id_{}", index))
5252 .cloned()
5253 .unwrap_or_else(|| initiative_id.clone())
5254 })
5255 .map(|value| value.trim().to_string())
5256 .filter(|value| !value.is_empty())
5257 .collect();
5258 config.organization_name = resolved
5259 .get("organization_name")
5260 .cloned()
5261 .or(config.organization_name)
5262 .map(|value| value.trim().to_string())
5263 .filter(|value| !value.is_empty());
5264 config
5265}
5266
5267async fn resolve_project_github_release_branch_config(
5268 _project_root: &Path,
5269 invocation_dir: &Path,
5270) -> Result<Option<GitHubReleaseBranchSettings>, String> {
5271 if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5272 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5273 Ok(config.github_release_branch_settings())
5274 } else {
5275 Ok(None)
5276 }
5277}
5278
5279fn normalized_relative_path(path: &Path) -> String {
5280 path.to_string_lossy().replace('\\', "/")
5281}
5282
5283fn git_repository_root(dir: &Path) -> Option<PathBuf> {
5284 if !command_exists("git") {
5285 return None;
5286 }
5287
5288 let output: std::process::Output = Command::new("git")
5289 .current_dir(dir)
5290 .args(["rev-parse", "--show-toplevel"])
5291 .output()
5292 .ok()?;
5293
5294 if !output.status.success() {
5295 return None;
5296 }
5297
5298 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5299 if root.is_empty() {
5300 None
5301 } else {
5302 Some(PathBuf::from(root))
5303 }
5304}
5305
5306fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
5307 let output: std::process::Output = Command::new("git")
5308 .current_dir(project_root)
5309 .args(args)
5310 .output()
5311 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
5312
5313 if !output.status.success() {
5314 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
5315 if stderr.is_empty() {
5316 return Err(format!(
5317 "`git {}` failed with status {}",
5318 args.join(" "),
5319 output.status
5320 ));
5321 }
5322 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
5323 }
5324
5325 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5326}
5327
5328fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
5329 let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
5330 Ok(output
5331 .lines()
5332 .map(|line| line.trim())
5333 .filter(|line| !line.is_empty())
5334 .map(|line| line.to_string())
5335 .collect())
5336}
5337
5338fn version_change_guard_state_path() -> Result<PathBuf, String> {
5339 let paths = global_xbp_paths()?;
5340 Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
5341}
5342
5343fn version_change_guard_repo_key(project_root: &Path) -> String {
5344 fs::canonicalize(project_root)
5345 .unwrap_or_else(|_| project_root.to_path_buf())
5346 .to_string_lossy()
5347 .replace('\\', "/")
5348}
5349
5350fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
5351 if !path.exists() {
5352 return Ok(VersionChangeGuardRegistry::default());
5353 }
5354
5355 let content = fs::read_to_string(path).map_err(|e| {
5356 format!(
5357 "Failed to read version-change guard state {}: {}",
5358 path.display(),
5359 e
5360 )
5361 })?;
5362
5363 Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
5364}
5365
5366fn save_version_change_guard_registry(
5367 path: &Path,
5368 registry: &VersionChangeGuardRegistry,
5369) -> Result<(), String> {
5370 if let Some(parent) = path.parent() {
5371 fs::create_dir_all(parent).map_err(|e| {
5372 format!(
5373 "Failed to create guard state directory {}: {}",
5374 parent.display(),
5375 e
5376 )
5377 })?;
5378 }
5379
5380 let content = serde_yaml::to_string(registry)
5381 .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
5382 fs::write(path, content).map_err(|e| {
5383 format!(
5384 "Failed to write version-change guard state {}: {}",
5385 path.display(),
5386 e
5387 )
5388 })
5389}
5390
5391fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
5392 if !command_exists("git") {
5393 return Ok(None);
5394 }
5395
5396 let status_output: std::process::Output = Command::new("git")
5397 .current_dir(project_root)
5398 .args(["status", "--porcelain"])
5399 .output()
5400 .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
5401 if !status_output.status.success() {
5402 return Ok(None);
5403 }
5404
5405 let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
5406 .lines()
5407 .any(|line| !line.trim().is_empty());
5408
5409 let head_output: std::process::Output = Command::new("git")
5410 .current_dir(project_root)
5411 .args(["rev-parse", "HEAD"])
5412 .output()
5413 .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
5414 let head_commit: Option<String> = if head_output.status.success() {
5415 let value: String = String::from_utf8_lossy(&head_output.stdout)
5416 .trim()
5417 .to_string();
5418 if value.is_empty() {
5419 None
5420 } else {
5421 Some(value)
5422 }
5423 } else {
5424 None
5425 };
5426
5427 Ok(Some(GitWorktreeState {
5428 is_dirty,
5429 head_commit,
5430 }))
5431}
5432
5433fn should_clear_version_change_guard(
5434 entry: &VersionChangeGuardEntry,
5435 state: &GitWorktreeState,
5436) -> bool {
5437 if entry.pending_version_change_count == 0 {
5438 return true;
5439 }
5440 if !state.is_dirty {
5441 return true;
5442 }
5443
5444 match (&entry.head_commit, &state.head_commit) {
5445 (Some(previous), Some(current)) => previous != current,
5446 (Some(_), None) => true,
5447 _ => false,
5448 }
5449}
5450
5451fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
5452 let Some(state) = git_worktree_state(project_root)? else {
5453 return Ok(());
5454 };
5455
5456 let state_path: PathBuf = version_change_guard_state_path()?;
5457 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5458 let repo_key: String = version_change_guard_repo_key(project_root);
5459 let mut changed = false;
5460
5461 if let Some(entry) = registry.entries.get(&repo_key).cloned() {
5462 if should_clear_version_change_guard(&entry, &state) {
5463 registry.entries.remove(&repo_key);
5464 changed = true;
5465 }
5466 }
5467
5468 if changed {
5469 save_version_change_guard_registry(&state_path, ®istry)?;
5470 }
5471
5472 if state.is_dirty {
5473 if let Some(entry) = registry.entries.get(&repo_key) {
5474 if entry.pending_version_change_count >= 1 {
5475 return Err(format!(
5476 "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
5477 entry.pending_version_change_count,
5478 state_path.display()
5479 ));
5480 }
5481 }
5482 }
5483
5484 Ok(())
5485}
5486
5487fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
5488 let Some(state) = git_worktree_state(project_root)? else {
5489 return Ok(());
5490 };
5491
5492 let state_path: PathBuf = version_change_guard_state_path()?;
5493 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5494 let repo_key: String = version_change_guard_repo_key(project_root);
5495
5496 if state.is_dirty {
5497 registry.entries.insert(
5498 repo_key,
5499 VersionChangeGuardEntry {
5500 pending_version_change_count: 1,
5501 head_commit: state.head_commit,
5502 },
5503 );
5504 } else {
5505 registry.entries.remove(&repo_key);
5506 }
5507
5508 save_version_change_guard_registry(&state_path, ®istry)
5509}
5510
5511fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
5512 let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
5513 Ok(!output.trim().is_empty())
5514}
5515
5516fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
5517 let remotes: String = run_git_command(project_root, &["remote"])?;
5518 let exists: bool = remotes.lines().any(|line| line.trim() == remote);
5519 if exists {
5520 Ok(())
5521 } else {
5522 Err(format!(
5523 "Git remote `{}` is not configured for this repository.",
5524 remote
5525 ))
5526 }
5527}
5528
5529fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
5530 run_git_command(project_root, &["remote", "get-url", remote])
5531}
5532
5533fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
5534 let query: String = format!("refs/tags/{}", tag);
5535 let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
5536 Ok(!output.trim().is_empty())
5537}
5538
5539fn git_head_commitish(project_root: &Path) -> Result<String, String> {
5540 let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
5541 if commitish.is_empty() {
5542 Err("Unable to resolve HEAD commit for release target.".to_string())
5543 } else {
5544 Ok(commitish)
5545 }
5546}
5547
5548fn render_release_branch_name(
5549 naming_template: &str,
5550 release_version: &Version,
5551 tag_name: &str,
5552 service_name: &str,
5553) -> Result<String, String> {
5554 let service_name = sanitize_release_branch_segment(service_name);
5555 let branch_name = naming_template
5556 .replace("{VERSION}", &release_version.to_string())
5557 .replace("{TAG}", tag_name)
5558 .replace("{SERVICE_NAME}", &service_name)
5559 .replace("${GITHUB_VERSION}", &release_version.to_string())
5560 .replace("${GITHUB_TAG}", tag_name)
5561 .replace("${SERVICE_NAME}", &service_name);
5562 let branch_name = branch_name.trim();
5563 if branch_name.is_empty() {
5564 return Err(
5565 "GitHub release branch naming template resolved to an empty branch name.".to_string(),
5566 );
5567 }
5568 Ok(branch_name.to_string())
5569}
5570
5571fn sanitize_release_branch_segment(value: &str) -> String {
5572 value
5573 .trim()
5574 .chars()
5575 .map(|character| match character {
5576 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
5577 _ => '-',
5578 })
5579 .collect::<String>()
5580 .trim_matches('-')
5581 .to_string()
5582}
5583
5584fn git_local_branch_commit(
5585 project_root: &Path,
5586 branch_name: &str,
5587) -> Result<Option<String>, String> {
5588 let output = Command::new("git")
5589 .current_dir(project_root)
5590 .args([
5591 "rev-parse",
5592 "--verify",
5593 &format!("refs/heads/{}", branch_name),
5594 ])
5595 .output()
5596 .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
5597 if !output.status.success() {
5598 return Ok(None);
5599 }
5600 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
5601 if commit.is_empty() {
5602 Ok(None)
5603 } else {
5604 Ok(Some(commit))
5605 }
5606}
5607
5608fn git_remote_branch_commit(
5609 project_root: &Path,
5610 remote: &str,
5611 branch_name: &str,
5612) -> Result<Option<String>, String> {
5613 let output = run_git_command(
5614 project_root,
5615 &[
5616 "ls-remote",
5617 "--heads",
5618 remote,
5619 &format!("refs/heads/{}", branch_name),
5620 ],
5621 )?;
5622 let commit = output
5623 .split_whitespace()
5624 .next()
5625 .map(str::trim)
5626 .filter(|value| !value.is_empty())
5627 .map(str::to_string);
5628 Ok(commit)
5629}
5630
5631fn ensure_release_branch(
5632 project_root: &Path,
5633 branch_config: &GitHubReleaseBranchSettings,
5634 release_version: &Version,
5635 tag_name: &str,
5636 service_name: &str,
5637 target_commitish: &str,
5638) -> Result<String, String> {
5639 let branch_name = render_release_branch_name(
5640 &branch_config.naming_template,
5641 release_version,
5642 tag_name,
5643 service_name,
5644 )?;
5645
5646 if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
5647 if existing_local_commit != target_commitish {
5648 return Err(format!(
5649 "Configured release branch `{}` already exists locally at {}, expected {}.",
5650 branch_name, existing_local_commit, target_commitish
5651 ));
5652 }
5653 } else {
5654 run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
5655 }
5656
5657 if let Some(existing_remote_commit) =
5658 git_remote_branch_commit(project_root, "origin", &branch_name)?
5659 {
5660 if existing_remote_commit != target_commitish {
5661 return Err(format!(
5662 "Configured release branch `{}` already exists on origin at {}, expected {}.",
5663 branch_name, existing_remote_commit, target_commitish
5664 ));
5665 }
5666 } else {
5667 run_git_command(
5668 project_root,
5669 &[
5670 "push",
5671 "origin",
5672 &format!("{}:refs/heads/{}", target_commitish, branch_name),
5673 ],
5674 )?;
5675 }
5676
5677 Ok(branch_name)
5678}
5679
5680fn release_tag_family(tag_name: &str) -> String {
5681 if tag_name.starts_with('v')
5682 && tag_name
5683 .chars()
5684 .nth(1)
5685 .map(|ch| ch.is_ascii_digit())
5686 .unwrap_or(false)
5687 {
5688 return "v".to_string();
5689 }
5690
5691 let mut family: String = String::new();
5692 for ch in tag_name.chars() {
5693 if ch.is_ascii_digit() {
5694 break;
5695 }
5696 family.push(ch);
5697 }
5698 family
5699}
5700
5701fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
5702 if family == "v" {
5703 return parse_version(tag).ok();
5704 }
5705
5706 tag.strip_prefix(family)
5707 .and_then(|rest| parse_version(rest).ok())
5708}
5709
5710fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
5711 let range: String = format!("{}..HEAD", tag);
5712 run_git_command(project_root, &["rev-list", "--count", &range])
5713 .ok()
5714 .and_then(|raw| raw.trim().parse::<usize>().ok())
5715}
5716
5717fn previous_release_tag(
5718 project_root: &Path,
5719 current_tag_name: &str,
5720) -> Result<Option<String>, String> {
5721 let family: String = release_tag_family(current_tag_name);
5722 let tag_pattern: String = format!("{}*", family);
5723 let merged_tags: String = run_git_command(
5724 project_root,
5725 &["tag", "--merged", "HEAD", "--list", &tag_pattern],
5726 )?;
5727
5728 let mut best: Option<(usize, String)> = None;
5729 for raw in merged_tags.lines() {
5730 let tag = raw.trim();
5731 if tag.is_empty() || tag == current_tag_name {
5732 continue;
5733 }
5734 if parse_release_family_version(tag, &family).is_none() {
5735 continue;
5736 }
5737
5738 let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
5739 continue;
5740 };
5741 if distance == 0 {
5742 continue;
5743 }
5744
5745 match &best {
5746 None => best = Some((distance, tag.to_string())),
5747 Some((best_distance, _)) if distance < *best_distance => {
5748 best = Some((distance, tag.to_string()))
5749 }
5750 _ => {}
5751 }
5752 }
5753
5754 Ok(best.map(|(_, tag)| tag))
5755}
5756
5757fn default_release_title(version: &Version, repo: &str) -> String {
5758 format!("{} - {}", version, repo)
5759}
5760
5761fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
5762 match version_scope {
5763 VersionScope::Repository => repo,
5764 VersionScope::Crate { package_name, .. } => package_name.as_str(),
5765 VersionScope::Service { service_name, .. } => service_name.as_str(),
5766 }
5767}
5768
5769fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
5770 match version_scope {
5771 VersionScope::Repository => "repository",
5772 VersionScope::Crate { .. } => "crate",
5773 VersionScope::Service { .. } => "service",
5774 }
5775}
5776
5777fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
5778 match version_scope {
5779 VersionScope::Repository => repo.to_string(),
5780 VersionScope::Crate { package_name, .. } => package_name.clone(),
5781 VersionScope::Service { service_name, .. } => service_name.clone(),
5782 }
5783}
5784
5785fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
5786 match version_scope {
5787 VersionScope::Repository => "Repository".to_string(),
5788 VersionScope::Crate {
5789 package_name,
5790 crate_relative_root,
5791 ..
5792 } => format!("{} ({})", package_name, crate_relative_root),
5793 VersionScope::Service {
5794 service_name,
5795 service_relative_root,
5796 ..
5797 } => format!("{} ({})", service_name, service_relative_root),
5798 }
5799}
5800
5801fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
5802 match version_scope {
5803 VersionScope::Repository => format!("v{}", version),
5804 VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
5805 format!("{}{}", tag_prefix, version)
5806 }
5807 }
5808}
5809
5810fn scoped_release_tag_name(
5811 version_scope: &VersionScope,
5812 version: &Version,
5813 parsed_tag_name: &str,
5814) -> String {
5815 match version_scope {
5816 VersionScope::Repository => parsed_tag_name.to_string(),
5817 VersionScope::Crate { .. } | VersionScope::Service { .. } => {
5818 if release_tag_family(parsed_tag_name) == "v" {
5819 default_release_tag_name(version_scope, version)
5820 } else {
5821 parsed_tag_name.to_string()
5822 }
5823 }
5824 }
5825}
5826
5827fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
5828 match version_scope {
5829 VersionScope::Repository => None,
5830 VersionScope::Crate {
5831 crate_relative_root,
5832 ..
5833 } => Some(crate_relative_root.clone()),
5834 VersionScope::Service {
5835 service_relative_root,
5836 ..
5837 } if service_relative_root == "." => None,
5838 VersionScope::Service {
5839 service_relative_root,
5840 ..
5841 } => Some(service_relative_root.clone()),
5842 }
5843}
5844
5845fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
5846 let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
5847 return (None, None);
5848 };
5849 let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
5850 return (None, None);
5851 };
5852
5853 (Some(owner), Some(repo))
5854}
5855
5856fn published_initiatives_to_activity(
5857 initiatives: &[PublishedLinearInitiative],
5858) -> Vec<VersionActivityLinearInitiative> {
5859 initiatives
5860 .iter()
5861 .map(|initiative| VersionActivityLinearInitiative {
5862 id: initiative.id.clone(),
5863 name: initiative.name.clone(),
5864 url: initiative.url.clone(),
5865 })
5866 .collect()
5867}
5868
5869async fn sync_cli_version_write_activity(
5870 project_root: &Path,
5871 version_scope: &VersionScope,
5872 version: &Version,
5873 message: String,
5874) {
5875 let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
5876 let scope_label = version_scope_label(
5877 version_scope,
5878 repository_name.as_deref().unwrap_or_else(|| {
5879 project_root
5880 .file_name()
5881 .and_then(|value| value.to_str())
5882 .unwrap_or("repository")
5883 }),
5884 );
5885
5886 let payload = CliVersionActivityPayload {
5887 command_kind: "version".to_string(),
5888 repository_owner,
5889 repository_name,
5890 scope_kind: version_scope_kind(version_scope).to_string(),
5891 scope_label,
5892 version: version.to_string(),
5893 tag_name: None,
5894 title: None,
5895 release_url: None,
5896 message_markdown: Some(message),
5897 published_initiatives: Vec::new(),
5898 };
5899
5900 if let Err(error) = post_version_activity(&payload).await {
5901 eprintln!("Warning: {}", error);
5902 }
5903}
5904
5905async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
5906 let payload = CliVersionActivityPayload {
5907 command_kind: "version_release".to_string(),
5908 repository_owner: Some(summary.repository_owner.clone()),
5909 repository_name: Some(summary.repository_name.clone()),
5910 scope_kind: summary.scope_kind.clone(),
5911 scope_label: summary.scope_label.clone(),
5912 version: summary.version.to_string(),
5913 tag_name: Some(summary.tag_name.clone()),
5914 title: Some(summary.release_title.clone()),
5915 release_url: Some(summary.release_url.clone()),
5916 message_markdown: Some(summary.release_notes.clone()),
5917 published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
5918 };
5919
5920 if let Err(error) = post_version_activity(&payload).await {
5921 eprintln!("Warning: {}", error);
5922 }
5923}
5924
5925fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
5926 let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
5927 let mut rendered_notes: String = notes.trim_end().to_string();
5928 if !rendered_notes.is_empty() {
5929 rendered_notes.push('\n');
5930 }
5931 rendered_notes.push_str("Release label: ");
5932 rendered_notes.push_str(release_label);
5933 rendered_notes.push('\n');
5934 rendered_notes.push_str("Generated by XBP ");
5935 rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
5936 rendered_notes
5937}
5938
5939#[cfg(test)]
5940mod tests {
5941 use super::github_release::{
5942 github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
5943 github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
5944 github_release_update_endpoint,
5945 };
5946 use super::release_docs::{
5947 release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
5948 };
5949 use super::release_notes::{
5950 build_fallback_sections, collect_linear_issue_identifiers,
5951 deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
5952 LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
5953 };
5954 use super::{
5955 analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
5956 current_scope_ledger_path, default_release_tag_name, default_release_title,
5957 highest_version_observation, is_safe_dirty_path, normalized_relative_path,
5958 parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
5959 parse_local_git_tag_output_for_scope, parse_package_version_target,
5960 parse_release_version_target, parse_remote_git_tag_output, parse_version,
5961 prepare_release_openapi_assets, read_cargo_lock_version,
5962 read_cargo_lock_version_for_package, read_cargo_toml_version,
5963 read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
5964 read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
5965 read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
5966 read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
5967 render_release_branch_name, resolve_configured_version_target_paths,
5968 resolve_linear_release_placeholders, resolve_project_root_from, resolve_registry_paths,
5969 resolve_release_openapi_specs, resolve_release_publish_target_filter,
5970 resolve_version_scope, rewrite_toml_package_assignment_versions,
5971 select_changed_version_targets, should_clear_version_change_guard,
5972 stale_version_observations, sync_version_to_configured_files_with_paths,
5973 write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
5974 write_json_openapi_version, write_json_root_version, write_openapi_version,
5975 write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
5976 write_regex_version, write_toml_root_version, write_version_to_configured_files,
5977 write_version_to_configured_files_with_paths, write_version_to_selected_paths,
5978 write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
5979 VersionObservation, VersionScope,
5980 };
5981
5982 use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
5983 use crate::config::PackageNameLookup;
5984 use semver::Version;
5985 use std::collections::BTreeMap;
5986 use std::fs;
5987 use std::path::{Path, PathBuf};
5988 use std::time::{SystemTime, UNIX_EPOCH};
5989
5990 fn temp_dir(label: &str) -> PathBuf {
5991 let nanos: u128 = SystemTime::now()
5992 .duration_since(UNIX_EPOCH)
5993 .expect("time")
5994 .as_nanos();
5995 let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
5996 fs::create_dir_all(&dir).expect("create temp dir");
5997 dir
5998 }
5999
6000 #[test]
6001 fn parse_git_status_path_handles_renames() {
6002 assert_eq!(
6003 parse_git_status_path("R old-name -> new-name/Cargo.toml"),
6004 Some("new-name/Cargo.toml".to_string())
6005 );
6006 assert_eq!(
6007 parse_git_status_path(" M .xbp/xbp.yaml"),
6008 Some(".xbp/xbp.yaml".to_string())
6009 );
6010 }
6011
6012 #[test]
6013 fn is_safe_dirty_path_recognizes_generated_paths() {
6014 assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
6015 assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
6016 assert!(is_safe_dirty_path(
6017 "scripts/__pycache__/publish.cpython-312.pyc"
6018 ));
6019 assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
6020 assert!(!is_safe_dirty_path("Cargo.toml"));
6021 }
6022
6023 #[test]
6024 fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
6025 let analysis = analyze_dirty_worktree(&[
6026 " M .xbp/xbp.yaml".to_string(),
6027 " M crates/cli/src/main.rs".to_string(),
6028 ]);
6029 assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
6030 assert_eq!(
6031 analysis.risky_entries,
6032 vec!["crates/cli/src/main.rs".to_string()]
6033 );
6034 }
6035
6036 fn write_multi_service_config(dir: &Path) {
6037 let xbp_dir = dir.join(".xbp");
6038 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6039 fs::write(
6040 xbp_dir.join("xbp.yaml"),
6041 r#"project_name: xbp
6042version: 10.30.0
6043port: 3000
6044build_dir: ./
6045services:
6046 - name: cli
6047 target: rust
6048 branch: main
6049 port: 8080
6050 root_directory: ./
6051 version_targets:
6052 - crates/cli/Cargo.toml
6053 - name: web
6054 target: nextjs
6055 branch: main
6056 port: 3001
6057 root_directory: apps/web
6058 version_targets:
6059 - apps/web/package.json
6060publish:
6061 npm:
6062 enabled: true
6063 manifest_path: apps/web/package.json
6064 crates:
6065 enabled: true
6066 manifest_path: crates/cli/Cargo.toml
6067version_targets:
6068 - crates/cli/Cargo.toml
6069 - apps/web/package.json
6070"#,
6071 )
6072 .expect("write xbp config");
6073 }
6074
6075 #[test]
6076 fn parses_prefixed_semver() {
6077 assert_eq!(
6078 parse_version("v1.2.3").expect("version"),
6079 Version::new(1, 2, 3)
6080 );
6081 }
6082
6083 #[test]
6084 fn rejects_invalid_semver() {
6085 let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
6086 assert!(error.contains("Invalid semantic version"));
6087 }
6088
6089 #[test]
6090 fn release_target_parser_supports_plain_semver() {
6091 let (version, tag_name) =
6092 parse_release_version_target("1.2.3-alpha.1").expect("release target");
6093 assert_eq!(version.major, 1);
6094 assert_eq!(version.minor, 2);
6095 assert_eq!(version.patch, 3);
6096 assert_eq!(version.pre.as_str(), "alpha.1");
6097 assert_eq!(tag_name, "v1.2.3-alpha.1");
6098 }
6099
6100 #[test]
6101 fn release_target_parser_supports_prefixed_semver() {
6102 let (version, tag_name) =
6103 parse_release_version_target("studio-0.3.2-alpha").expect("release target");
6104 assert_eq!(version.major, 0);
6105 assert_eq!(version.minor, 3);
6106 assert_eq!(version.patch, 2);
6107 assert_eq!(version.pre.as_str(), "alpha");
6108 assert_eq!(tag_name, "studio-0.3.2-alpha");
6109 }
6110
6111 #[test]
6112 fn bumps_versions_correctly() {
6113 let base: Version = Version::new(0, 1, 0);
6114 assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
6115 assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
6116 assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
6117 }
6118
6119 #[test]
6120 fn version_change_guard_clears_when_worktree_is_clean() {
6121 let entry = VersionChangeGuardEntry {
6122 pending_version_change_count: 1,
6123 head_commit: Some("abc123".to_string()),
6124 };
6125 let state = GitWorktreeState {
6126 is_dirty: false,
6127 head_commit: Some("abc123".to_string()),
6128 };
6129 assert!(should_clear_version_change_guard(&entry, &state));
6130 }
6131
6132 #[test]
6133 fn version_change_guard_clears_when_head_changes() {
6134 let entry = VersionChangeGuardEntry {
6135 pending_version_change_count: 1,
6136 head_commit: Some("abc123".to_string()),
6137 };
6138 let state = GitWorktreeState {
6139 is_dirty: true,
6140 head_commit: Some("def456".to_string()),
6141 };
6142 assert!(should_clear_version_change_guard(&entry, &state));
6143 }
6144
6145 #[test]
6146 fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
6147 let entry = VersionChangeGuardEntry {
6148 pending_version_change_count: 1,
6149 head_commit: Some("abc123".to_string()),
6150 };
6151 let state = GitWorktreeState {
6152 is_dirty: true,
6153 head_commit: Some("abc123".to_string()),
6154 };
6155 assert!(!should_clear_version_change_guard(&entry, &state));
6156 }
6157
6158 #[test]
6159 fn render_release_branch_name_replaces_supported_tokens() {
6160 let branch = render_release_branch_name(
6161 "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
6162 &Version::new(10, 27, 0),
6163 "v10.27.0",
6164 "xbp",
6165 )
6166 .expect("branch name");
6167
6168 assert_eq!(branch, "releases/10.27.0/v10.27.0");
6169 }
6170
6171 #[test]
6172 fn render_release_branch_name_supports_service_name_template() {
6173 let branch = render_release_branch_name(
6174 "releases/{VERSION}-{SERVICE_NAME}",
6175 &Version::new(10, 27, 0),
6176 "v10.27.0",
6177 "athena/api",
6178 )
6179 .expect("branch name");
6180
6181 assert_eq!(branch, "releases/10.27.0-athena-api");
6182 }
6183
6184 #[test]
6185 fn resolve_linear_release_placeholders_reads_env_files() {
6186 let temp_dir = std::env::temp_dir().join(format!(
6187 "xbp-linear-release-placeholders-{}",
6188 std::time::SystemTime::now()
6189 .duration_since(std::time::UNIX_EPOCH)
6190 .expect("time")
6191 .as_nanos()
6192 ));
6193 fs::create_dir_all(&temp_dir).expect("temp dir");
6194 fs::write(
6195 temp_dir.join(".env.local"),
6196 "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
6197 )
6198 .expect("env file");
6199
6200 let resolved = resolve_linear_release_placeholders(
6201 &temp_dir,
6202 ResolvedLinearReleaseConfig {
6203 initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
6204 organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
6205 health: "on_track".to_string(),
6206 },
6207 );
6208
6209 assert_eq!(
6210 resolved.initiative_ids,
6211 vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
6212 );
6213 assert_eq!(
6214 resolved.organization_name.as_deref(),
6215 Some("suits-formations")
6216 );
6217
6218 let _ = fs::remove_dir_all(temp_dir);
6219 }
6220
6221 #[test]
6222 fn version_change_guard_clears_when_pending_count_is_zero() {
6223 let entry = VersionChangeGuardEntry {
6224 pending_version_change_count: 0,
6225 head_commit: Some("abc123".to_string()),
6226 };
6227 let state = GitWorktreeState {
6228 is_dirty: true,
6229 head_commit: Some("abc123".to_string()),
6230 };
6231 assert!(should_clear_version_change_guard(&entry, &state));
6232 }
6233
6234 #[test]
6235 fn parse_package_version_target_supports_assignment_syntax() {
6236 let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
6237 .expect("parse")
6238 .expect("target");
6239 assert_eq!(parsed.0, "demo_pkg".to_string());
6240 assert_eq!(parsed.1, Version::new(1, 2, 3));
6241 }
6242
6243 #[test]
6244 fn parse_package_version_target_rejects_invalid_package_names() {
6245 let error: String = parse_package_version_target("bad package=1.2.3")
6246 .expect_err("invalid package target should fail");
6247 assert!(error.contains("Invalid package target"));
6248 }
6249
6250 #[test]
6251 fn parse_package_version_target_returns_none_without_assignment() {
6252 assert!(parse_package_version_target("1.2.3")
6253 .expect("parse")
6254 .is_none());
6255 }
6256
6257 #[test]
6258 fn parse_package_version_target_returns_none_for_empty_package_name() {
6259 assert!(parse_package_version_target(" =1.2.3")
6260 .expect("parse")
6261 .is_none());
6262 }
6263
6264 #[test]
6265 fn bumping_clears_prerelease_and_build_metadata() {
6266 let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
6267 assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
6268 assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
6269 assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
6270 }
6271
6272 #[test]
6273 fn cargo_toml_adapter_reads_and_writes() {
6274 let dir: PathBuf = temp_dir("cargo");
6275 let path: PathBuf = dir.join("Cargo.toml");
6276 fs::write(
6277 &path,
6278 r#"[package]
6279 name = "xbp"
6280 version = "1.0.0"
6281 "#,
6282 )
6283 .expect("write Cargo.toml");
6284
6285 assert_eq!(
6286 read_cargo_toml_version(&path).expect("read"),
6287 Some("1.0.0".to_string())
6288 );
6289
6290 write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
6291 assert_eq!(
6292 read_version_from_path(&path).expect("read"),
6293 Some("1.1.0".to_string())
6294 );
6295
6296 let _ = fs::remove_dir_all(dir);
6297 }
6298
6299 #[test]
6300 fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
6301 let dir: PathBuf = temp_dir("workspace-root-version");
6302 let crate_dir = dir.join("crates").join("demo");
6303 fs::create_dir_all(&crate_dir).expect("create crate dir");
6304 fs::write(
6305 dir.join("Cargo.toml"),
6306 r#"[workspace]
6307members = ["crates/demo"]
6308resolver = "2"
6309
6310[workspace.package]
6311version = "0.1.0"
6312"#,
6313 )
6314 .expect("write workspace root");
6315 fs::write(
6316 crate_dir.join("Cargo.toml"),
6317 r#"[package]
6318name = "demo"
6319version = { workspace = true }
6320"#,
6321 )
6322 .expect("write crate manifest");
6323
6324 assert_eq!(
6325 read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
6326 Some("0.1.0".to_string())
6327 );
6328 assert_eq!(
6329 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6330 Some("0.1.0".to_string())
6331 );
6332
6333 write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
6334 assert_eq!(
6335 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6336 Some("0.2.0".to_string())
6337 );
6338
6339 let _ = fs::remove_dir_all(dir);
6340 }
6341
6342 #[test]
6343 fn json_root_adapter_reads_and_writes() {
6344 let dir: PathBuf = temp_dir("json");
6345 let path: PathBuf = dir.join("package.json");
6346 fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
6347
6348 assert_eq!(
6349 read_json_root_version(&path).expect("read"),
6350 Some("1.4.0".to_string())
6351 );
6352
6353 write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
6354 assert_eq!(
6355 read_version_from_path(&path).expect("read"),
6356 Some("1.5.0".to_string())
6357 );
6358
6359 let _ = fs::remove_dir_all(dir);
6360 }
6361
6362 #[test]
6363 fn yaml_root_adapter_reads_and_writes() {
6364 let dir: PathBuf = temp_dir("yaml");
6365 let path: PathBuf = dir.join("xbp.yaml");
6366 fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
6367
6368 assert_eq!(
6369 read_yaml_root_version(&path, "version").expect("read"),
6370 Some("0.2.0".to_string())
6371 );
6372
6373 write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
6374 assert_eq!(
6375 read_version_from_path(&path).expect("read"),
6376 Some("0.3.0".to_string())
6377 );
6378
6379 let _ = fs::remove_dir_all(dir);
6380 }
6381
6382 #[test]
6383 fn toml_root_adapter_reads_and_writes() {
6384 let dir: PathBuf = temp_dir("toml");
6385 let path: PathBuf = dir.join("config.toml");
6386 fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
6387
6388 assert_eq!(
6389 read_toml_root_version(&path).expect("read"),
6390 Some("3.1.4".to_string())
6391 );
6392
6393 write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
6394 assert_eq!(
6395 read_toml_root_version(&path).expect("read"),
6396 Some("3.2.0".to_string())
6397 );
6398
6399 let _ = fs::remove_dir_all(dir);
6400 }
6401
6402 #[test]
6403 fn openapi_adapter_reads_and_writes_nested_version() {
6404 let dir: PathBuf = temp_dir("openapi");
6405 let path: PathBuf = dir.join("openapi.yaml");
6406 fs::write(
6407 &path,
6408 "openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
6409 )
6410 .expect("write openapi");
6411
6412 assert_eq!(
6413 read_openapi_version(&path).expect("read"),
6414 Some("1.2.3".to_string())
6415 );
6416
6417 write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
6418 assert_eq!(
6419 read_openapi_version(&path).expect("read"),
6420 Some("2.0.0".to_string())
6421 );
6422
6423 let _ = fs::remove_dir_all(dir);
6424 }
6425
6426 #[test]
6427 fn openapi_writer_creates_missing_info_mapping() {
6428 let dir: PathBuf = temp_dir("openapi-missing-info");
6429 let path: PathBuf = dir.join("openapi.yaml");
6430 fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
6431
6432 write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6433 assert_eq!(
6434 read_openapi_version(&path).expect("read"),
6435 Some("4.0.0".to_string())
6436 );
6437
6438 let _ = fs::remove_dir_all(dir);
6439 }
6440
6441 #[test]
6442 fn json_openapi_adapter_reads_and_writes_nested_version() {
6443 let dir: PathBuf = temp_dir("openapi-json");
6444 let path: PathBuf = dir.join("openapi.json");
6445 fs::write(
6446 &path,
6447 r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
6448 )
6449 .expect("write openapi json");
6450
6451 assert_eq!(
6452 read_json_openapi_version(&path).expect("read"),
6453 Some("1.2.3".to_string())
6454 );
6455
6456 write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
6457 assert_eq!(
6458 read_json_openapi_version(&path).expect("read"),
6459 Some("2.1.0".to_string())
6460 );
6461
6462 let _ = fs::remove_dir_all(dir);
6463 }
6464
6465 #[test]
6466 fn json_openapi_writer_creates_missing_info_object() {
6467 let dir: PathBuf = temp_dir("openapi-json-missing-info");
6468 let path: PathBuf = dir.join("openapi.json");
6469 fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
6470
6471 write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6472 assert_eq!(
6473 read_json_openapi_version(&path).expect("read"),
6474 Some("4.0.0".to_string())
6475 );
6476
6477 let _ = fs::remove_dir_all(dir);
6478 }
6479
6480 #[test]
6481 fn pyproject_reader_prefers_project_version() {
6482 let dir: PathBuf = temp_dir("pyproject-project");
6483 let path: PathBuf = dir.join("pyproject.toml");
6484 fs::write(
6485 &path,
6486 "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
6487 )
6488 .expect("write pyproject");
6489
6490 assert_eq!(
6491 read_pyproject_version(&path).expect("read"),
6492 Some("0.8.0".to_string())
6493 );
6494
6495 let _ = fs::remove_dir_all(dir);
6496 }
6497
6498 #[test]
6499 fn pyproject_reader_falls_back_to_poetry_version() {
6500 let dir: PathBuf = temp_dir("pyproject-poetry");
6501 let path: PathBuf = dir.join("pyproject.toml");
6502 fs::write(
6503 &path,
6504 "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
6505 )
6506 .expect("write pyproject");
6507
6508 assert_eq!(
6509 read_pyproject_version(&path).expect("read"),
6510 Some("1.9.0".to_string())
6511 );
6512
6513 let _ = fs::remove_dir_all(dir);
6514 }
6515
6516 #[test]
6517 fn pyproject_writer_updates_project_table() {
6518 let dir: PathBuf = temp_dir("pyproject-write-project");
6519 let path: PathBuf = dir.join("pyproject.toml");
6520 fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
6521 .expect("write pyproject");
6522
6523 write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
6524 assert_eq!(
6525 read_pyproject_version(&path).expect("read"),
6526 Some("1.1.0".to_string())
6527 );
6528
6529 let _ = fs::remove_dir_all(dir);
6530 }
6531
6532 #[test]
6533 fn pyproject_writer_updates_poetry_table() {
6534 let dir: PathBuf = temp_dir("pyproject-write-poetry");
6535 let path: PathBuf = dir.join("pyproject.toml");
6536 fs::write(
6537 &path,
6538 "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
6539 )
6540 .expect("write pyproject");
6541
6542 write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
6543 assert_eq!(
6544 read_pyproject_version(&path).expect("read"),
6545 Some("2.1.0".to_string())
6546 );
6547
6548 let _ = fs::remove_dir_all(dir);
6549 }
6550
6551 #[test]
6552 fn cargo_lock_reader_and_writer_follow_package_name() {
6553 let dir: PathBuf = temp_dir("cargo-lock");
6554 let cargo_toml: PathBuf = dir.join("Cargo.toml");
6555 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6556 fs::write(
6557 &cargo_toml,
6558 r#"[package]
6559 name = "xbp"
6560 version = "1.0.0"
6561 "#,
6562 )
6563 .expect("write Cargo.toml");
6564 fs::write(
6565 &cargo_lock,
6566 r#"version = 4
6567
6568 [[package]]
6569 name = "xbp"
6570 version = "1.0.0"
6571
6572 [[package]]
6573 name = "other"
6574 version = "9.9.9"
6575 "#,
6576 )
6577 .expect("write Cargo.lock");
6578
6579 assert_eq!(
6580 read_cargo_lock_version(&cargo_lock).expect("read"),
6581 Some("1.0.0".to_string())
6582 );
6583
6584 write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
6585 assert_eq!(
6586 read_cargo_lock_version(&cargo_lock).expect("read"),
6587 Some("1.0.1".to_string())
6588 );
6589
6590 let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
6591 assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
6592
6593 let _ = fs::remove_dir_all(dir);
6594 }
6595
6596 #[test]
6597 fn cargo_lock_writer_errors_when_package_missing() {
6598 let dir: PathBuf = temp_dir("cargo-lock-missing");
6599 fs::write(
6600 dir.join("Cargo.toml"),
6601 "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
6602 )
6603 .expect("write Cargo.toml");
6604 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6605 fs::write(
6606 &cargo_lock,
6607 "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
6608 )
6609 .expect("write Cargo.lock");
6610
6611 let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
6612 .expect_err("missing package should fail");
6613 assert!(error.contains("Could not find package `xbp`"));
6614
6615 let _ = fs::remove_dir_all(dir);
6616 }
6617
6618 #[test]
6619 fn cargo_package_name_reads_package_section() {
6620 let dir: PathBuf = temp_dir("cargo-package-name");
6621 let cargo_lock: PathBuf = dir.join("Cargo.lock");
6622 fs::write(
6623 dir.join("Cargo.toml"),
6624 "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
6625 )
6626 .expect("write Cargo.toml");
6627 fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
6628
6629 assert_eq!(
6630 cargo_package_name(&cargo_lock).expect("name"),
6631 Some("xbp-cli".to_string())
6632 );
6633
6634 let _ = fs::remove_dir_all(dir);
6635 }
6636
6637 #[test]
6638 fn cargo_toml_writer_skips_workspace_manifest_without_package() {
6639 let dir: PathBuf = temp_dir("cargo-workspace-manifest");
6640 let path: PathBuf = dir.join("Cargo.toml");
6641 fs::write(
6642 &path,
6643 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6644 )
6645 .expect("write Cargo.toml");
6646
6647 let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
6648 assert!(changed);
6649 let content = fs::read_to_string(&path).expect("read Cargo.toml");
6650 assert!(content.contains("[workspace.package]"));
6651 assert!(content.contains("version = \"2.0.0\""));
6652
6653 let _ = fs::remove_dir_all(dir);
6654 }
6655
6656 #[test]
6657 fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
6658 let dir: PathBuf = temp_dir("workspace-cargo-skip");
6659 fs::write(
6660 dir.join("Cargo.toml"),
6661 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6662 )
6663 .expect("write Cargo.toml");
6664 fs::write(
6665 dir.join("Cargo.lock"),
6666 "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
6667 )
6668 .expect("write Cargo.lock");
6669 fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
6670 .expect("write README");
6671
6672 let updated = write_version_to_configured_files(
6673 &dir,
6674 &dir,
6675 &[
6676 "Cargo.toml".to_string(),
6677 "Cargo.lock".to_string(),
6678 "README.md".to_string(),
6679 ],
6680 &VersionScope::Repository,
6681 &Version::new(1, 1, 0),
6682 )
6683 .expect("write versions");
6684
6685 assert_eq!(updated, 2);
6686 assert_eq!(
6687 read_readme_version(&dir.join("README.md")).expect("read"),
6688 Some("1.1.0".to_string())
6689 );
6690 let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
6691 assert!(cargo_content.contains("version = \"1.1.0\""));
6692
6693 let _ = fs::remove_dir_all(dir);
6694 }
6695
6696 #[test]
6697 fn repository_scope_prefers_workspace_default_member_manifest() {
6698 let dir: PathBuf = temp_dir("workspace-default-member-path");
6699 let crate_dir: PathBuf = dir.join("crates").join("cli");
6700 fs::create_dir_all(&crate_dir).expect("create crate dir");
6701 fs::write(
6702 dir.join("Cargo.toml"),
6703 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6704 )
6705 .expect("write workspace cargo");
6706 fs::write(
6707 crate_dir.join("Cargo.toml"),
6708 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6709 )
6710 .expect("write crate cargo");
6711
6712 let resolved = super::resolve_registry_relative_path(
6713 &dir,
6714 &dir,
6715 &VersionScope::Repository,
6716 "Cargo.toml",
6717 );
6718
6719 assert_eq!(resolved, "crates/cli/Cargo.toml");
6720
6721 let _ = fs::remove_dir_all(dir);
6722 }
6723
6724 #[test]
6725 fn root_discovered_service_does_not_hijack_repository_scope() {
6726 let dir: PathBuf = temp_dir("root-discovered-service-scope");
6727 let crate_dir: PathBuf = dir.join("crates").join("cli");
6728 let xbp_dir: PathBuf = dir.join(".xbp");
6729 fs::create_dir_all(&crate_dir).expect("create crate dir");
6730 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6731 fs::write(
6732 dir.join("Cargo.toml"),
6733 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6734 )
6735 .expect("write workspace cargo");
6736 fs::write(
6737 crate_dir.join("Cargo.toml"),
6738 "[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6739 )
6740 .expect("write crate cargo");
6741 fs::write(
6742 dir.join("Cargo.lock"),
6743 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6744 )
6745 .expect("write cargo lock");
6746 fs::write(
6747 xbp_dir.join("xbp.yaml"),
6748 r#"project_name: xbp
6749version: 10.35.0
6750port: 3000
6751build_dir: ./
6752services:
6753 - name: xbp-2
6754 target: python
6755 branch: main
6756 port: 8001
6757 root_directory: ./
6758 version_targets:
6759 - Cargo.toml
6760 - name: crates-cli
6761 target: rust
6762 branch: main
6763 port: 8002
6764 root_directory: crates/cli
6765 version_targets:
6766 - crates/cli/Cargo.toml
6767publish:
6768 crates:
6769 enabled: true
6770 package_name: xbp
6771 working_directory: crates/cli
6772 manifest_path: crates/cli/Cargo.toml
6773version_targets:
6774 - Cargo.toml
6775 - crates/cli/Cargo.toml
6776"#,
6777 )
6778 .expect("write xbp config");
6779
6780 assert!(matches!(
6781 resolve_version_scope(&dir, &dir),
6782 VersionScope::Repository
6783 ));
6784
6785 let resolved = resolve_registry_paths(
6786 &dir,
6787 &dir,
6788 &["Cargo.toml".to_string(), "Cargo.lock".to_string()],
6789 &VersionScope::Repository,
6790 );
6791 assert!(resolved
6792 .iter()
6793 .any(|path| path.relative == "crates/cli/Cargo.toml"));
6794 assert_eq!(
6795 read_highest_version_from_targets(&resolved).expect("read selected versions"),
6796 Some(Version::new(10, 35, 0))
6797 );
6798
6799 let _ = fs::remove_dir_all(dir);
6800 }
6801
6802 #[test]
6803 fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
6804 let dir: PathBuf = temp_dir("workspace-default-member-writer");
6805 let crate_dir: PathBuf = dir.join("crates").join("cli");
6806 fs::create_dir_all(&crate_dir).expect("create crate dir");
6807 fs::write(
6808 dir.join("Cargo.toml"),
6809 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6810 )
6811 .expect("write workspace cargo");
6812 fs::write(
6813 crate_dir.join("Cargo.toml"),
6814 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6815 )
6816 .expect("write crate cargo");
6817 fs::write(
6818 dir.join("Cargo.lock"),
6819 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
6820 )
6821 .expect("write cargo lock");
6822 fs::write(
6823 dir.join("README.md"),
6824 "# XBP\n\ncurrent version: `10.21.0`\n",
6825 )
6826 .expect("write readme");
6827
6828 let updated = write_version_to_configured_files(
6829 &dir,
6830 &dir,
6831 &[
6832 "Cargo.toml".to_string(),
6833 "Cargo.lock".to_string(),
6834 "README.md".to_string(),
6835 ],
6836 &VersionScope::Repository,
6837 &Version::new(10, 22, 0),
6838 )
6839 .expect("write versions");
6840
6841 assert_eq!(updated, 3);
6842 assert_eq!(
6843 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
6844 Some("10.22.0".to_string())
6845 );
6846 assert_eq!(
6847 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
6848 Some("10.22.0".to_string())
6849 );
6850 assert_eq!(
6851 read_readme_version(&dir.join("README.md")).expect("read readme"),
6852 Some("10.22.0".to_string())
6853 );
6854
6855 let _ = fs::remove_dir_all(dir);
6856 }
6857
6858 #[test]
6859 fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
6860 let dir: PathBuf = temp_dir("publish-manifest-version-target");
6861 let package_dir = dir.join("packages").join("heroui");
6862 let xbp_dir = dir.join(".xbp");
6863 fs::create_dir_all(&package_dir).expect("create package dir");
6864 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6865
6866 fs::write(
6867 xbp_dir.join("xbp.yaml"),
6868 r#"project_name: athena-auth-ui
6869version: 0.3.1
6870port: 4000
6871build_dir: ./
6872publish:
6873 npm:
6874 enabled: true
6875 working_directory: packages/heroui
6876 manifest_path: packages/heroui/package.json
6877"#,
6878 )
6879 .expect("write xbp config");
6880 fs::write(
6881 dir.join("package.json"),
6882 r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
6883 )
6884 .expect("write root package");
6885 fs::write(
6886 package_dir.join("package.json"),
6887 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
6888 )
6889 .expect("write package manifest");
6890
6891 let updated = write_version_to_configured_files(
6892 &dir,
6893 &dir,
6894 &["package.json".to_string()],
6895 &VersionScope::Repository,
6896 &Version::new(0, 3, 1),
6897 )
6898 .expect("write versions");
6899
6900 assert_eq!(updated, 2);
6901 assert_eq!(
6902 read_json_root_version(&dir.join("package.json")).expect("read root package"),
6903 Some("0.3.1".to_string())
6904 );
6905 assert_eq!(
6906 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6907 Some("0.3.1".to_string())
6908 );
6909
6910 let _ = fs::remove_dir_all(dir);
6911 }
6912
6913 #[test]
6914 fn configured_writer_updates_version_targets_from_xbp_config() {
6915 let dir: PathBuf = temp_dir("explicit-version-targets");
6916 let app_dir = dir.join("apps").join("web");
6917 let cli_dir = dir.join("crates").join("cli");
6918 let xbp_dir = dir.join(".xbp");
6919 fs::create_dir_all(&app_dir).expect("create app dir");
6920 fs::create_dir_all(&cli_dir).expect("create cli dir");
6921 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6922
6923 fs::write(
6924 xbp_dir.join("xbp.yaml"),
6925 r#"project_name: xbp
6926version: 10.30.0
6927port: 3000
6928build_dir: ./
6929version_targets:
6930 - crates/cli/Cargo.toml
6931 - apps/web/package.json
6932"#,
6933 )
6934 .expect("write xbp config");
6935 fs::write(
6936 cli_dir.join("Cargo.toml"),
6937 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6938 )
6939 .expect("write cli Cargo.toml");
6940 fs::write(
6941 app_dir.join("package.json"),
6942 r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
6943 )
6944 .expect("write app package");
6945
6946 let updated = write_version_to_configured_files(
6947 &dir,
6948 &dir,
6949 &[".xbp/xbp.yaml".to_string()],
6950 &VersionScope::Repository,
6951 &Version::new(10, 30, 0),
6952 )
6953 .expect("write versions");
6954
6955 assert_eq!(updated, 3);
6956 assert_eq!(
6957 read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
6958 Some("10.30.0".to_string())
6959 );
6960 assert_eq!(
6961 read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
6962 Some("10.30.0".to_string())
6963 );
6964 assert_eq!(
6965 read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
6966 Some("10.30.0".to_string())
6967 );
6968
6969 let _ = fs::remove_dir_all(dir);
6970 }
6971
6972 #[test]
6973 fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
6974 let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
6975 let package_dir = dir.join("packages").join("heroui");
6976 let xbp_dir = dir.join(".xbp");
6977 fs::create_dir_all(&package_dir).expect("create package dir");
6978 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6979
6980 fs::write(
6981 xbp_dir.join("xbp.yaml"),
6982 r#"project_name: athena-auth-ui
6983version: 0.3.0
6984port: 4000
6985build_dir: ./
6986publish:
6987 npm:
6988 enabled: true
6989 working_directory: packages/heroui
6990 manifest_path: packages/heroui/package.json
6991"#,
6992 )
6993 .expect("write xbp config");
6994 fs::write(
6995 dir.join("package.json"),
6996 r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
6997 )
6998 .expect("write root package");
6999 fs::write(
7000 package_dir.join("package.json"),
7001 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
7002 )
7003 .expect("write package manifest");
7004
7005 let _updated_paths = sync_version_to_configured_files_with_paths(
7006 &dir,
7007 &dir,
7008 &["package.json".to_string()],
7009 &VersionScope::Repository,
7010 &Version::new(0, 3, 0),
7011 )
7012 .expect("sync versions");
7013
7014 assert_eq!(
7015 read_json_root_version(&dir.join("package.json")).expect("read root package"),
7016 Some("0.3.0".to_string())
7017 );
7018 assert_eq!(
7019 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
7020 Some("0.3.0".to_string())
7021 );
7022
7023 let _ = fs::remove_dir_all(dir);
7024 }
7025
7026 #[test]
7027 fn readme_adapter_updates_current_version_marker() {
7028 let dir: PathBuf = temp_dir("readme");
7029 let path: PathBuf = dir.join("README.md");
7030 fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7031
7032 write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
7033 assert_eq!(
7034 read_readme_version(&path).expect("read"),
7035 Some("1.2.0".to_string())
7036 );
7037
7038 let _ = fs::remove_dir_all(dir);
7039 }
7040
7041 #[test]
7042 fn readme_writer_inserts_marker_when_missing() {
7043 let dir: PathBuf = temp_dir("readme-insert");
7044 let path: PathBuf = dir.join("README.md");
7045 fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
7046
7047 write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
7048 let content: String = fs::read_to_string(&path).expect("read readme");
7049 assert!(content.contains("current version: `3.0.0`"));
7050
7051 let _ = fs::remove_dir_all(dir);
7052 }
7053
7054 #[test]
7055 fn regex_adapter_reads_and_writes_versions() {
7056 let dir: PathBuf = temp_dir("regex");
7057 let path: PathBuf = dir.join("build.gradle");
7058 fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
7059
7060 assert_eq!(
7061 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7062 Some("5.4.3".to_string())
7063 );
7064
7065 write_regex_version(
7066 &path,
7067 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7068 &Version::new(5, 5, 0),
7069 )
7070 .expect("write");
7071
7072 assert_eq!(
7073 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7074 Some("5.5.0".to_string())
7075 );
7076
7077 let _ = fs::remove_dir_all(dir);
7078 }
7079
7080 #[test]
7081 fn regex_writer_errors_without_matching_pattern() {
7082 let dir: PathBuf = temp_dir("regex-miss");
7083 let path: PathBuf = dir.join("build.gradle");
7084 fs::write(&path, "group = 'demo'\n").expect("write gradle");
7085
7086 let error: String = write_regex_version(
7087 &path,
7088 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7089 &Version::new(1, 0, 0),
7090 )
7091 .expect_err("missing version should fail");
7092 assert!(error.contains("No version pattern found"));
7093
7094 let _ = fs::remove_dir_all(dir);
7095 }
7096
7097 #[test]
7098 fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
7099 let original: &str = r#"[dependencies]
7100 serde = "1.0.219"
7101 tokio = { version = "1.44.1", features = ["full"] }
7102 "#;
7103
7104 let (updated, changed) =
7105 rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
7106 .expect("rewrite");
7107 assert!(changed);
7108 assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
7109
7110 let (updated, changed) =
7111 rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
7112 .expect("rewrite");
7113 assert!(changed);
7114 assert!(updated.contains(r#"serde = "1.1.0""#));
7115 }
7116
7117 #[test]
7118 fn package_version_writer_updates_registry_toml_targets() {
7119 let dir: PathBuf = temp_dir("package-version-registry");
7120 let cargo_toml: PathBuf = dir.join("Cargo.toml");
7121 fs::write(
7122 &cargo_toml,
7123 r#"[package]
7124 name = "demo"
7125 version = "0.1.0"
7126
7127 [dependencies]
7128 serde = "1.0.219"
7129 tokio = { version = "1.44.1", features = ["full"] }
7130 "#,
7131 )
7132 .expect("write Cargo.toml");
7133
7134 let updated: usize = write_package_version_to_configured_files(
7135 &dir,
7136 &dir,
7137 &["Cargo.toml".to_string()],
7138 &VersionScope::Repository,
7139 "tokio",
7140 &Version::new(1, 45, 1),
7141 )
7142 .expect("update package assignment");
7143 assert_eq!(updated, 1);
7144
7145 let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
7146 assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
7147
7148 let _ = fs::remove_dir_all(dir);
7149 }
7150
7151 #[test]
7152 fn package_version_writer_errors_when_package_assignment_not_found() {
7153 let dir: PathBuf = temp_dir("package-version-missing");
7154 let cargo_toml: PathBuf = dir.join("Cargo.toml");
7155 fs::write(
7156 &cargo_toml,
7157 r#"[package]
7158 name = "demo"
7159 version = "0.1.0"
7160
7161 [dependencies]
7162 serde = "1.0.219"
7163 "#,
7164 )
7165 .expect("write Cargo.toml");
7166
7167 let error: String = write_package_version_to_configured_files(
7168 &dir,
7169 &dir,
7170 &["Cargo.toml".to_string()],
7171 &VersionScope::Repository,
7172 "tokio",
7173 &Version::new(1, 45, 1),
7174 )
7175 .expect_err("missing package assignment should fail");
7176 assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
7177
7178 let _ = fs::remove_dir_all(dir);
7179 }
7180
7181 #[test]
7182 fn chart_writer_updates_app_version_when_present() {
7183 let dir: PathBuf = temp_dir("chart");
7184 let path: PathBuf = dir.join("Chart.yaml");
7185 fs::write(
7186 &path,
7187 "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
7188 )
7189 .expect("write chart");
7190
7191 write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
7192 let content: String = fs::read_to_string(&path).expect("read chart");
7193 assert!(content.contains("version: 0.2.0"));
7194 assert!(content.contains("appVersion: 0.2.0"));
7195
7196 let _ = fs::remove_dir_all(dir);
7197 }
7198
7199 #[test]
7200 fn configured_file_writer_deduplicates_registry_entries() {
7201 let dir: PathBuf = temp_dir("dedupe");
7202 let readme: PathBuf = dir.join("README.md");
7203 fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7204
7205 let updated: usize = write_version_to_configured_files(
7206 &dir,
7207 &dir,
7208 &[
7209 "README.md".to_string(),
7210 "README.md".to_string(),
7211 "missing.md".to_string(),
7212 ],
7213 &VersionScope::Repository,
7214 &Version::new(1, 1, 0),
7215 )
7216 .expect("write versions");
7217
7218 assert_eq!(updated, 1);
7219 assert_eq!(
7220 read_readme_version(&readme).expect("read"),
7221 Some("1.1.0".to_string())
7222 );
7223
7224 let _ = fs::remove_dir_all(dir);
7225 }
7226
7227 #[test]
7228 fn configured_file_writer_prefers_invocation_directory_targets() {
7229 let dir: PathBuf = temp_dir("invocation-precedence");
7230 let app_dir: PathBuf = dir.join("apps").join("web");
7231 fs::create_dir_all(&app_dir).expect("create app dir");
7232
7233 let root_package: PathBuf = dir.join("package.json");
7234 let app_package: PathBuf = app_dir.join("package.json");
7235 fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
7236 .expect("write root package");
7237 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7238 .expect("write app package");
7239
7240 let updated: usize = write_version_to_configured_files(
7241 &dir,
7242 &app_dir,
7243 &["package.json".to_string()],
7244 &VersionScope::Repository,
7245 &Version::new(2, 14, 0),
7246 )
7247 .expect("write versions");
7248 assert_eq!(updated, 1);
7249
7250 assert_eq!(
7251 read_json_root_version(&root_package).expect("read root"),
7252 Some("9.9.9".to_string())
7253 );
7254 assert_eq!(
7255 read_json_root_version(&app_package).expect("read app"),
7256 Some("2.14.0".to_string())
7257 );
7258
7259 let _ = fs::remove_dir_all(dir);
7260 }
7261
7262 #[test]
7263 fn resolve_project_root_prefers_nearest_xbp_config() {
7264 let dir: PathBuf = temp_dir("nested-xbp-project-root");
7265 let service_root: PathBuf = dir.join("services").join("athena-auth");
7266 let nested_dir: PathBuf = service_root.join("src");
7267 let service_xbp_dir: PathBuf = service_root.join(".xbp");
7268 fs::create_dir_all(&nested_dir).expect("create nested dir");
7269 fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7270 fs::write(
7271 service_xbp_dir.join("xbp.yaml"),
7272 "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7273 )
7274 .expect("write service xbp config");
7275
7276 assert_eq!(resolve_project_root_from(&nested_dir), service_root);
7277
7278 let _ = fs::remove_dir_all(dir);
7279 }
7280
7281 #[test]
7282 fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
7283 let dir: PathBuf = temp_dir("nested-xbp-version-scope");
7284 let outer_xbp_dir = dir.join(".xbp");
7285 let service_root: PathBuf = dir.join("services").join("athena-auth");
7286 let nested_dir: PathBuf = service_root.join("src");
7287 let service_xbp_dir: PathBuf = service_root.join(".xbp");
7288 fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
7289 fs::create_dir_all(&nested_dir).expect("create nested dir");
7290 fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7291
7292 fs::write(
7293 outer_xbp_dir.join("xbp.yaml"),
7294 "project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
7295 )
7296 .expect("write outer xbp config");
7297 fs::write(
7298 dir.join("Cargo.toml"),
7299 "[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
7300 )
7301 .expect("write outer cargo");
7302 fs::write(
7303 dir.join("Cargo.lock"),
7304 "version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
7305 )
7306 .expect("write outer cargo lock");
7307 fs::write(
7308 dir.join("README.md"),
7309 "# root\n\ncurrent version: `9.9.9`\n",
7310 )
7311 .expect("write outer readme");
7312 fs::write(
7313 dir.join("openapi.yaml"),
7314 "openapi: 3.0.3\ninfo:\n title: Root\n version: 9.9.9\n",
7315 )
7316 .expect("write outer openapi");
7317 fs::write(
7318 dir.join("package.json"),
7319 r#"{ "name": "root", "version": "9.9.9" }"#,
7320 )
7321 .expect("write outer package");
7322
7323 fs::write(
7324 service_xbp_dir.join("xbp.yaml"),
7325 "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7326 )
7327 .expect("write service xbp config");
7328 fs::write(
7329 service_root.join("Cargo.toml"),
7330 "[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7331 )
7332 .expect("write service cargo");
7333 fs::write(
7334 service_root.join("Cargo.lock"),
7335 "version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7336 )
7337 .expect("write service cargo lock");
7338 fs::write(
7339 service_root.join("README.md"),
7340 "# athena-auth\n\ncurrent version: `3.22.0`\n",
7341 )
7342 .expect("write service readme");
7343 fs::write(
7344 service_root.join("openapi.yaml"),
7345 "openapi: 3.0.3\ninfo:\n title: Athena Auth\n version: 3.22.0\n",
7346 )
7347 .expect("write service openapi");
7348 fs::write(
7349 service_root.join("package.json"),
7350 r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
7351 )
7352 .expect("write service package");
7353
7354 let project_root = resolve_project_root_from(&nested_dir);
7355 let updated_paths = write_version_to_configured_files_with_paths(
7356 &project_root,
7357 &nested_dir,
7358 &[
7359 ".xbp/xbp.yaml".to_string(),
7360 "Cargo.toml".to_string(),
7361 "Cargo.lock".to_string(),
7362 "README.md".to_string(),
7363 "openapi.yaml".to_string(),
7364 "package.json".to_string(),
7365 ],
7366 &VersionScope::Repository,
7367 &Version::new(3, 22, 1),
7368 )
7369 .expect("write nested project versions");
7370
7371 let updated_relative = updated_paths
7372 .iter()
7373 .map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
7374 .map(normalized_relative_path)
7375 .collect::<Vec<_>>();
7376
7377 assert_eq!(
7378 updated_relative,
7379 vec![
7380 ".xbp/xbp.yaml".to_string(),
7381 "Cargo.toml".to_string(),
7382 "Cargo.lock".to_string(),
7383 "README.md".to_string(),
7384 "openapi.yaml".to_string(),
7385 "package.json".to_string(),
7386 ]
7387 );
7388
7389 assert_eq!(
7390 read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
7391 .expect("read outer xbp"),
7392 Some("9.9.9".to_string())
7393 );
7394 assert_eq!(
7395 read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
7396 Some("9.9.9".to_string())
7397 );
7398 assert_eq!(
7399 read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
7400 Some("9.9.9".to_string())
7401 );
7402 assert_eq!(
7403 read_readme_version(&dir.join("README.md")).expect("read outer readme"),
7404 Some("9.9.9".to_string())
7405 );
7406 assert_eq!(
7407 read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
7408 Some("9.9.9".to_string())
7409 );
7410 assert_eq!(
7411 read_json_root_version(&dir.join("package.json")).expect("read outer package"),
7412 Some("9.9.9".to_string())
7413 );
7414
7415 assert_eq!(
7416 read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
7417 .expect("read service xbp"),
7418 Some("3.22.1".to_string())
7419 );
7420 assert_eq!(
7421 read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
7422 Some("3.22.1".to_string())
7423 );
7424 assert_eq!(
7425 read_cargo_lock_version(&service_root.join("Cargo.lock"))
7426 .expect("read service cargo lock"),
7427 Some("3.22.1".to_string())
7428 );
7429 assert_eq!(
7430 read_readme_version(&service_root.join("README.md")).expect("read service readme"),
7431 Some("3.22.1".to_string())
7432 );
7433 assert_eq!(
7434 read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
7435 Some("3.22.1".to_string())
7436 );
7437 assert_eq!(
7438 read_json_root_version(&service_root.join("package.json"))
7439 .expect("read service package"),
7440 Some("3.22.1".to_string())
7441 );
7442
7443 let _ = fs::remove_dir_all(dir);
7444 }
7445
7446 #[test]
7447 fn resolve_version_scope_detects_crate_scoped_invocation() {
7448 let dir: PathBuf = temp_dir("crate-scope");
7449 let crate_dir: PathBuf = dir.join("crates").join("alpha");
7450 let nested_dir: PathBuf = crate_dir.join("src");
7451 fs::create_dir_all(&nested_dir).expect("create nested dir");
7452 fs::write(
7453 crate_dir.join("Cargo.toml"),
7454 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7455 )
7456 .expect("write Cargo.toml");
7457
7458 let scope = resolve_version_scope(&dir, &nested_dir);
7459 match scope {
7460 VersionScope::Crate {
7461 package_name,
7462 crate_relative_root,
7463 tag_prefix,
7464 ..
7465 } => {
7466 assert_eq!(package_name, "alpha-crate");
7467 assert_eq!(crate_relative_root, "crates/alpha");
7468 assert_eq!(tag_prefix, "alpha-crate-");
7469 }
7470 _ => panic!("expected crate scope"),
7471 }
7472
7473 let _ = fs::remove_dir_all(dir);
7474 }
7475
7476 #[test]
7477 fn resolve_version_scope_detects_service_scoped_invocation() {
7478 let dir: PathBuf = temp_dir("service-scope");
7479 let service_dir: PathBuf = dir.join("apps").join("web");
7480 let nested_dir: PathBuf = service_dir.join("src");
7481 let cli_dir: PathBuf = dir.join("crates").join("cli");
7482 fs::create_dir_all(&nested_dir).expect("create nested dir");
7483 fs::create_dir_all(&cli_dir).expect("create cli dir");
7484 write_multi_service_config(&dir);
7485 fs::write(
7486 service_dir.join("package.json"),
7487 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7488 )
7489 .expect("write package");
7490 fs::write(
7491 cli_dir.join("Cargo.toml"),
7492 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7493 )
7494 .expect("write cargo");
7495
7496 let scope = resolve_version_scope(&dir, &nested_dir);
7497 match scope {
7498 VersionScope::Service {
7499 service_name,
7500 service_relative_root,
7501 tag_prefix,
7502 ..
7503 } => {
7504 assert_eq!(service_name, "web");
7505 assert_eq!(service_relative_root, "apps/web");
7506 assert_eq!(tag_prefix, "web-");
7507 }
7508 _ => panic!("expected service scope"),
7509 }
7510
7511 let _ = fs::remove_dir_all(dir);
7512 }
7513
7514 #[test]
7515 fn service_scoped_configured_targets_only_include_selected_service() {
7516 let dir: PathBuf = temp_dir("service-targets");
7517 let service_dir: PathBuf = dir.join("apps").join("web");
7518 let nested_dir: PathBuf = service_dir.join("src");
7519 let cli_dir: PathBuf = dir.join("crates").join("cli");
7520 fs::create_dir_all(&nested_dir).expect("create nested dir");
7521 fs::create_dir_all(&cli_dir).expect("create cli dir");
7522 write_multi_service_config(&dir);
7523 fs::write(
7524 service_dir.join("package.json"),
7525 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7526 )
7527 .expect("write package");
7528 fs::write(
7529 cli_dir.join("Cargo.toml"),
7530 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7531 )
7532 .expect("write cargo");
7533
7534 let scope = resolve_version_scope(&dir, &nested_dir);
7535 let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
7536 assert_eq!(targets.len(), 1);
7537 assert_eq!(targets[0].relative, "apps/web/package.json");
7538
7539 let _ = fs::remove_dir_all(dir);
7540 }
7541
7542 #[test]
7543 fn service_scoped_release_publish_filter_selects_matching_target() {
7544 let dir: PathBuf = temp_dir("service-publish-filter");
7545 let service_dir: PathBuf = dir.join("apps").join("web");
7546 let nested_dir: PathBuf = service_dir.join("src");
7547 let cli_dir: PathBuf = dir.join("crates").join("cli");
7548 fs::create_dir_all(&nested_dir).expect("create nested dir");
7549 fs::create_dir_all(&cli_dir).expect("create cli dir");
7550 write_multi_service_config(&dir);
7551 fs::write(
7552 service_dir.join("package.json"),
7553 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7554 )
7555 .expect("write package");
7556 fs::write(
7557 cli_dir.join("Cargo.toml"),
7558 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7559 )
7560 .expect("write cargo");
7561
7562 let scope = resolve_version_scope(&dir, &nested_dir);
7563 let publish_target =
7564 resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
7565 assert_eq!(publish_target.as_deref(), Some("npm"));
7566
7567 let _ = fs::remove_dir_all(dir);
7568 }
7569
7570 #[test]
7571 fn current_scope_ledger_path_uses_repo_local_scope_directory() {
7572 let dir: PathBuf = temp_dir("ledger-path");
7573 let scope = resolve_version_scope(&dir, &dir);
7574 let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
7575
7576 assert_eq!(
7577 path,
7578 dir.join(".xbp")
7579 .join("releases")
7580 .join("repository-xbp")
7581 .join("10.30.0.yaml")
7582 );
7583
7584 let _ = fs::remove_dir_all(dir);
7585 }
7586
7587 #[test]
7588 fn repository_change_selection_matches_only_changed_target_manifest_root() {
7589 let dir: PathBuf = temp_dir("selection-direct");
7590 let service_dir: PathBuf = dir.join("apps").join("web");
7591 let cli_dir: PathBuf = dir.join("crates").join("cli");
7592 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7593 fs::create_dir_all(&cli_dir).expect("create cli dir");
7594 write_multi_service_config(&dir);
7595 fs::write(
7596 service_dir.join("package.json"),
7597 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7598 )
7599 .expect("write package");
7600 fs::write(
7601 cli_dir.join("Cargo.toml"),
7602 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7603 )
7604 .expect("write cargo");
7605
7606 let scope = resolve_version_scope(&dir, &dir);
7607 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7608 let selected = select_changed_version_targets(
7609 &dir,
7610 &scope,
7611 &targets,
7612 &["apps/web/src/app.tsx".into()],
7613 );
7614
7615 assert_eq!(selected.len(), 1);
7616 assert_eq!(selected[0].relative, "apps/web/package.json");
7617
7618 let _ = fs::remove_dir_all(dir);
7619 }
7620
7621 #[test]
7622 fn repository_change_selection_fans_out_on_shared_root_change() {
7623 let dir: PathBuf = temp_dir("selection-shared");
7624 let service_dir: PathBuf = dir.join("apps").join("web");
7625 let cli_dir: PathBuf = dir.join("crates").join("cli");
7626 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7627 fs::create_dir_all(&cli_dir).expect("create cli dir");
7628 write_multi_service_config(&dir);
7629 fs::write(
7630 service_dir.join("package.json"),
7631 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7632 )
7633 .expect("write package");
7634 fs::write(
7635 cli_dir.join("Cargo.toml"),
7636 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7637 )
7638 .expect("write cargo");
7639
7640 let scope = resolve_version_scope(&dir, &dir);
7641 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7642 let selected =
7643 select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
7644
7645 assert_eq!(selected.len(), 2);
7646 assert_eq!(
7647 selected
7648 .iter()
7649 .map(|target| target.relative.as_str())
7650 .collect::<Vec<_>>(),
7651 vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
7652 );
7653
7654 let _ = fs::remove_dir_all(dir);
7655 }
7656
7657 #[test]
7658 fn selected_version_writer_updates_only_selected_targets() {
7659 let dir: PathBuf = temp_dir("selection-write");
7660 let service_dir: PathBuf = dir.join("apps").join("web");
7661 let cli_dir: PathBuf = dir.join("crates").join("cli");
7662 fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7663 fs::create_dir_all(&cli_dir).expect("create cli dir");
7664 write_multi_service_config(&dir);
7665 fs::write(
7666 service_dir.join("package.json"),
7667 r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7668 )
7669 .expect("write package");
7670 fs::write(
7671 cli_dir.join("Cargo.toml"),
7672 "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7673 )
7674 .expect("write cargo");
7675
7676 let scope = resolve_version_scope(&dir, &dir);
7677 let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7678 let selected = select_changed_version_targets(
7679 &dir,
7680 &scope,
7681 &targets,
7682 &["apps/web/src/app.tsx".into()],
7683 );
7684
7685 let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
7686 .expect("write selected targets");
7687 assert_eq!(updated.len(), 1);
7688 assert_eq!(
7689 read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
7690 Some("10.30.0".to_string())
7691 );
7692 assert_eq!(
7693 read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
7694 Some("10.29.0".to_string())
7695 );
7696
7697 let _ = fs::remove_dir_all(dir);
7698 }
7699
7700 #[test]
7701 fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
7702 let dir: PathBuf = temp_dir("crate-writer");
7703 let crate_dir: PathBuf = dir.join("crates").join("alpha");
7704 fs::create_dir_all(&crate_dir).expect("create crate dir");
7705 fs::write(
7706 crate_dir.join("Cargo.toml"),
7707 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7708 )
7709 .expect("write crate Cargo.toml");
7710 fs::write(
7711 dir.join("Cargo.lock"),
7712 "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
7713 )
7714 .expect("write Cargo.lock");
7715 fs::write(
7716 dir.join("README.md"),
7717 "# root\n\ncurrent version: `9.9.9`\n",
7718 )
7719 .expect("write root readme");
7720
7721 let scope = resolve_version_scope(&dir, &crate_dir);
7722 let updated = write_version_to_configured_files(
7723 &dir,
7724 &crate_dir,
7725 &[
7726 "Cargo.toml".to_string(),
7727 "Cargo.lock".to_string(),
7728 "README.md".to_string(),
7729 ],
7730 &scope,
7731 &Version::new(1, 3, 0),
7732 )
7733 .expect("write versions");
7734
7735 assert_eq!(updated, 2);
7736 assert_eq!(
7737 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
7738 Some("1.3.0".to_string())
7739 );
7740 assert_eq!(
7741 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
7742 .expect("read cargo lock"),
7743 Some("1.3.0".to_string())
7744 );
7745 assert_eq!(
7746 read_readme_version(&dir.join("README.md")).expect("read readme"),
7747 Some("9.9.9".to_string())
7748 );
7749
7750 let _ = fs::remove_dir_all(dir);
7751 }
7752
7753 #[test]
7754 fn release_openapi_resolution_prefers_crate_scope() {
7755 let dir: PathBuf = temp_dir("release-openapi-crate");
7756 let crate_dir: PathBuf = dir.join("crates").join("monitor");
7757 let nested_dir: PathBuf = crate_dir.join("src");
7758 fs::create_dir_all(&nested_dir).expect("create nested dir");
7759 fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
7760 let crate_openapi: PathBuf = crate_dir.join("openapi.json");
7761 fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
7762
7763 let scope = resolve_version_scope(&dir, &nested_dir);
7764 let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
7765 .expect("crate-scoped openapi")
7766 .into_iter()
7767 .next()
7768 .expect("crate-scoped openapi");
7769 assert_eq!(resolved, crate_openapi);
7770
7771 let _ = fs::remove_dir_all(dir);
7772 }
7773
7774 #[test]
7775 fn release_openapi_resolution_falls_back_to_repo_root() {
7776 let dir: PathBuf = temp_dir("release-openapi-root");
7777 let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
7778 fs::create_dir_all(&crate_dir).expect("create crate dir");
7779 let root_openapi: PathBuf = dir.join("openapi.json");
7780 fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
7781
7782 let scope = resolve_version_scope(&dir, &dir);
7783 let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
7784 .expect("repo root openapi")
7785 .into_iter()
7786 .next()
7787 .expect("repo root openapi");
7788 assert_eq!(resolved, root_openapi);
7789
7790 let _ = fs::remove_dir_all(dir);
7791 }
7792
7793 #[test]
7794 fn release_openapi_assets_use_versioned_release_tag_names() {
7795 let dir: PathBuf = temp_dir("release-openapi-assets");
7796 fs::write(
7797 dir.join("openapi.yaml"),
7798 "openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.2\n",
7799 )
7800 .expect("write http openapi");
7801 fs::write(
7802 dir.join("openapi-wss.yaml"),
7803 "openapi: 3.0.3\ninfo:\n title: Athena WSS\n version: 1.3.0\n",
7804 )
7805 .expect("write wss openapi");
7806
7807 let assets = prepare_release_openapi_assets(
7808 &dir,
7809 &dir,
7810 &VersionScope::Repository,
7811 &Version::new(3, 15, 2),
7812 "v3.15.2",
7813 )
7814 .expect("prepare release assets");
7815
7816 assert_eq!(
7817 assets
7818 .iter()
7819 .map(|asset| asset.asset_name.as_str())
7820 .collect::<Vec<_>>(),
7821 vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
7822 );
7823 assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
7824 assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
7825
7826 let _ = fs::remove_dir_all(dir);
7827 }
7828
7829 #[test]
7830 fn release_openapi_assets_reject_http_version_mismatch() {
7831 let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
7832 fs::write(
7833 dir.join("openapi.yaml"),
7834 "openapi: 3.0.3\ninfo:\n title: Athena RS\n version: 3.15.1\n",
7835 )
7836 .expect("write http openapi");
7837
7838 let error = prepare_release_openapi_assets(
7839 &dir,
7840 &dir,
7841 &VersionScope::Repository,
7842 &Version::new(3, 15, 2),
7843 "v3.15.2",
7844 )
7845 .expect_err("mismatched OpenAPI version should fail");
7846
7847 assert!(error.contains("does not match release version"));
7848
7849 let _ = fs::remove_dir_all(dir);
7850 }
7851
7852 #[test]
7853 fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
7854 let dir: PathBuf = temp_dir("invocation-dedupe");
7855 let app_dir: PathBuf = dir.join("apps").join("web");
7856 fs::create_dir_all(&app_dir).expect("create app dir");
7857
7858 let app_package: PathBuf = app_dir.join("package.json");
7859 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7860 .expect("write app package");
7861
7862 let updated: usize = write_version_to_configured_files(
7863 &dir,
7864 &app_dir,
7865 &[
7866 "package.json".to_string(),
7867 "apps/web/package.json".to_string(),
7868 ],
7869 &VersionScope::Repository,
7870 &Version::new(2, 14, 0),
7871 )
7872 .expect("write versions");
7873 assert_eq!(updated, 1);
7874
7875 assert_eq!(
7876 read_json_root_version(&app_package).expect("read app"),
7877 Some("2.14.0".to_string())
7878 );
7879
7880 let _ = fs::remove_dir_all(dir);
7881 }
7882
7883 #[test]
7884 fn configured_file_writer_errors_when_no_targets_exist() {
7885 let dir: PathBuf = temp_dir("no-targets");
7886 let error: String = write_version_to_configured_files(
7887 &dir,
7888 &dir,
7889 &["missing.toml".to_string()],
7890 &VersionScope::Repository,
7891 &Version::new(1, 0, 0),
7892 )
7893 .expect_err("missing targets should fail");
7894
7895 assert!(error.contains("No configured version files were found"));
7896
7897 let _ = fs::remove_dir_all(dir);
7898 }
7899
7900 #[test]
7901 fn remote_git_tag_parser_deduplicates_peeled_refs() {
7902 let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
7903 "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
7904 );
7905
7906 assert_eq!(parsed.len(), 2);
7907 assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
7908 assert_eq!(
7909 parsed[1].version,
7910 Version::parse("0.1.7-exp").expect("version")
7911 );
7912 assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
7913 }
7914
7915 #[test]
7916 fn local_git_tag_parser_normalizes_prefixed_versions() {
7917 let parsed: Vec<crate::commands::version::GitTagObservation> =
7918 parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
7919
7920 assert_eq!(parsed.len(), 2);
7921 assert_eq!(parsed[0].version, Version::new(1, 0, 0));
7922 assert_eq!(
7923 parsed[0].raw_tags,
7924 vec!["1.0.0".to_string(), "v1.0.0".to_string()]
7925 );
7926 }
7927
7928 #[test]
7929 fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
7930 let scope = VersionScope::Crate {
7931 crate_root: PathBuf::from("/tmp/crates/alpha"),
7932 crate_relative_root: "crates/alpha".to_string(),
7933 package_name: "alpha-crate".to_string(),
7934 tag_prefix: "alpha-crate-".to_string(),
7935 };
7936
7937 let parsed = parse_local_git_tag_output_for_scope(
7938 "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
7939 &scope,
7940 );
7941
7942 assert_eq!(parsed.len(), 2);
7943 assert_eq!(parsed[0].version, Version::new(1, 2, 0));
7944 assert_eq!(parsed[1].version, Version::new(1, 0, 0));
7945 }
7946
7947 #[test]
7948 fn crate_scoped_release_tags_default_to_package_prefix() {
7949 let scope = VersionScope::Crate {
7950 crate_root: PathBuf::from("/tmp/crates/alpha"),
7951 crate_relative_root: "crates/alpha".to_string(),
7952 package_name: "alpha-crate".to_string(),
7953 tag_prefix: "alpha-crate-".to_string(),
7954 };
7955
7956 assert_eq!(
7957 default_release_tag_name(&scope, &Version::new(1, 2, 3)),
7958 "alpha-crate-1.2.3"
7959 );
7960 }
7961
7962 #[test]
7963 fn blob_reader_handles_head_readme_versions() {
7964 assert_eq!(
7965 read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
7966 .expect("read"),
7967 Some("0.4.0".to_string())
7968 );
7969 }
7970
7971 #[test]
7972 fn blob_reader_handles_head_cargo_lock_versions() {
7973 let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
7974 let cargo_lock: &str =
7975 "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
7976
7977 assert_eq!(
7978 read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
7979 Some("0.2.0".to_string())
7980 );
7981 }
7982
7983 #[test]
7984 fn package_name_lookup_reads_json_name_for_npm() {
7985 let lookup: PackageNameLookup = PackageNameLookup {
7986 file: "package.json".to_string(),
7987 format: "json".to_string(),
7988 key: "name".to_string(),
7989 registry: "npm".to_string(),
7990 };
7991
7992 assert_eq!(
7993 read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
7994 .expect("read"),
7995 Some("@xylex/athena-mcp".to_string())
7996 );
7997 }
7998
7999 #[test]
8000 fn package_name_lookup_reads_toml_nested_package_name() {
8001 let lookup: PackageNameLookup = PackageNameLookup {
8002 file: "Cargo.toml".to_string(),
8003 format: "toml".to_string(),
8004 key: "package.name".to_string(),
8005 registry: "crates.io".to_string(),
8006 };
8007
8008 assert_eq!(
8009 read_package_name_from_lookup(
8010 &lookup,
8011 "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
8012 )
8013 .expect("read"),
8014 Some("athena-mcp".to_string())
8015 );
8016 }
8017
8018 #[test]
8019 fn package_name_lookup_errors_on_unknown_format() {
8020 let lookup: PackageNameLookup = PackageNameLookup {
8021 file: "meta.txt".to_string(),
8022 format: "ini".to_string(),
8023 key: "name".to_string(),
8024 registry: "npm".to_string(),
8025 };
8026
8027 let error = read_package_name_from_lookup(&lookup, "name=demo")
8028 .expect_err("unsupported format should fail");
8029 assert!(error.contains("Unsupported lookup format"));
8030 }
8031
8032 #[test]
8033 fn highest_version_observation_returns_max_version() {
8034 let entries: Vec<VersionObservation> = vec![
8035 VersionObservation {
8036 location: "README.md".to_string(),
8037 version: Version::new(1, 0, 0),
8038 },
8039 VersionObservation {
8040 location: "Cargo.toml".to_string(),
8041 version: Version::new(1, 2, 0),
8042 },
8043 ];
8044
8045 assert_eq!(
8046 highest_version_observation(&entries).expect("max version"),
8047 Version::new(1, 2, 0)
8048 );
8049 }
8050
8051 #[test]
8052 fn stale_version_observations_only_returns_outdated_entries() {
8053 let entries: Vec<VersionObservation> = vec![
8054 VersionObservation {
8055 location: "README.md".to_string(),
8056 version: Version::new(1, 1, 0),
8057 },
8058 VersionObservation {
8059 location: "Cargo.toml".to_string(),
8060 version: Version::new(1, 2, 0),
8061 },
8062 VersionObservation {
8063 location: "openapi.yaml".to_string(),
8064 version: Version::new(1, 0, 5),
8065 },
8066 ];
8067
8068 let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
8069 assert_eq!(stale.len(), 2);
8070 assert!(stale.iter().any(|entry| entry.location == "README.md"));
8071 assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
8072 assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
8073 }
8074
8075 #[test]
8076 fn parses_github_remote_urls() {
8077 assert_eq!(
8078 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
8079 Some(("xylex-group".to_string(), "xbp".to_string()))
8080 );
8081 assert_eq!(
8082 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
8083 Some(("xylex-group".to_string(), "xbp".to_string()))
8084 );
8085 assert_eq!(
8086 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
8087 Some(("xylex-group".to_string(), "xbp".to_string()))
8088 );
8089 assert_eq!(
8090 parse_github_repo_from_remote_url(
8091 "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
8092 ),
8093 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8094 );
8095 assert_eq!(
8096 parse_github_repo_from_remote_url(
8097 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
8098 ),
8099 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8100 );
8101 assert_eq!(
8102 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
8103 None
8104 );
8105 }
8106
8107 #[test]
8108 fn redacts_credentials_in_remote_urls() {
8109 let redacted = redact_remote_url_credentials(
8110 "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
8111 );
8112 assert!(redacted.contains("REDACTED"));
8113 assert!(!redacted.contains("ghp_secretvalue"));
8114
8115 let username_only = redact_remote_url_credentials(
8116 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
8117 );
8118 assert!(username_only.contains("REDACTED@github.com"));
8119 assert!(!username_only.contains("floris-xlx@github.com"));
8120
8121 let ssh_remote =
8122 redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
8123 assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
8124 }
8125
8126 #[test]
8127 fn builds_github_release_urls_with_encoded_tag_segments() {
8128 let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
8129 assert_eq!(
8130 create_url.as_str(),
8131 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
8132 );
8133
8134 let lookup_url =
8135 github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
8136 .expect("url");
8137 assert_eq!(
8138 lookup_url.as_str(),
8139 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
8140 );
8141
8142 let update_url =
8143 github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8144 assert_eq!(
8145 update_url.as_str(),
8146 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
8147 );
8148
8149 let lookup_with_special_tag = github_release_by_tag_endpoint(
8150 "SuitsBooks",
8151 "suits-invoicing",
8152 "release candidate/v0.0.1+build",
8153 )
8154 .expect("url");
8155 assert_eq!(
8156 lookup_with_special_tag.as_str(),
8157 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
8158 );
8159 }
8160
8161 #[test]
8162 fn builds_github_release_asset_urls() {
8163 let list_url =
8164 github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8165 assert_eq!(
8166 list_url.as_str(),
8167 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
8168 );
8169
8170 let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
8171 .expect("url");
8172 assert_eq!(
8173 delete_url.as_str(),
8174 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
8175 );
8176
8177 let upload_url = github_release_asset_upload_endpoint(
8178 "SuitsBooks",
8179 "suits-invoicing",
8180 42,
8181 "openapi spec.json",
8182 )
8183 .expect("url");
8184 assert_eq!(
8185 upload_url.as_str(),
8186 "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
8187 );
8188 }
8189
8190 #[test]
8191 fn maps_release_latest_policy_to_github_api_values() {
8192 assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
8193 assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
8194 assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
8195 }
8196
8197 #[test]
8198 fn release_channel_from_semver_prerelease_labels() {
8199 let stable = Version::parse("3.6.2").expect("version");
8200 let nightly = Version::parse("3.6.2-nightly.1").expect("version");
8201 let experimental = Version::parse("0.1.1-alpha.1").expect("version");
8202 assert_eq!(release_channel(&stable), "stable");
8203 assert_eq!(release_channel(&nightly), "nightly");
8204 assert_eq!(release_channel(&experimental), "experimental");
8205 }
8206
8207 #[test]
8208 fn renders_release_docs_from_entries() {
8209 let entries = vec![
8210 ReleaseDocEntry {
8211 tag: "v3.6.2".to_string(),
8212 version: Version::parse("3.6.2").expect("version"),
8213 date: "2026-04-27".to_string(),
8214 },
8215 ReleaseDocEntry {
8216 tag: "docs-0.1.1-alpha.1".to_string(),
8217 version: Version::parse("0.1.1-alpha.1").expect("version"),
8218 date: "2026-04-20".to_string(),
8219 },
8220 ];
8221 let changelog = render_changelog("xylex-group", "athena", &entries);
8222 assert!(changelog.contains("## [3.6.2]"));
8223 assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
8224 assert!(changelog.contains("Release channel: stable"));
8225 assert!(changelog.contains("Release channel: experimental"));
8226
8227 let security = render_security_policy(&entries);
8228 assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
8229 assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
8230 }
8231
8232 #[test]
8233 fn formats_release_commit_lines_with_sha_and_pr_links() {
8234 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
8235 let formatted =
8236 format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
8237 .expect("formatted line");
8238
8239 assert_eq!(
8240 formatted,
8241 "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
8242 );
8243 }
8244
8245 #[test]
8246 fn formats_release_commit_lines_with_linear_links_when_available() {
8247 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
8248 let issue_infos = BTreeMap::from([(
8249 "SUI-1336".to_string(),
8250 LinearIssueInfo {
8251 title: "Release flow".to_string(),
8252 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8253 },
8254 )]);
8255 let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
8256 .expect("formatted line");
8257
8258 assert_eq!(
8259 formatted,
8260 "[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)"
8261 );
8262 }
8263
8264 #[test]
8265 fn renders_release_notes_in_requested_layout() {
8266 let commits = vec![
8267 ReleaseCommitEntry {
8268 full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
8269 short_sha: "abcdef1".to_string(),
8270 subject: "Improve release docs (#42)".to_string(),
8271 date: "2026-05-24".to_string(),
8272 },
8273 ReleaseCommitEntry {
8274 full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
8275 short_sha: "fedcba9".to_string(),
8276 subject: "Fix release flow for SUI-1336".to_string(),
8277 date: "2026-05-25".to_string(),
8278 },
8279 ];
8280 let pull_request_infos = BTreeMap::from([(
8281 "42".to_string(),
8282 super::release_notes::GithubPullRequestInfo {
8283 title: "Improve release docs".to_string(),
8284 url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
8285 },
8286 )]);
8287 let issue_infos = BTreeMap::from([(
8288 "SUI-1336".to_string(),
8289 LinearIssueInfo {
8290 title: "Release flow".to_string(),
8291 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8292 },
8293 )]);
8294 let sections = build_fallback_sections(&commits);
8295 let rendered = render_release_notes(&ReleaseNotesRenderInput {
8296 release_title: "1.7.0 - athena-auth",
8297 current_tag_name: "v1.7.0",
8298 owner: "xylex-group",
8299 repo: "athena-auth",
8300 previous_tag: Some("v1.6.0"),
8301 sections: §ions,
8302 commit_entries: &commits,
8303 pull_request_infos: &pull_request_infos,
8304 linear_issue_infos: &issue_infos,
8305 });
8306
8307 assert_eq!(
8308 rendered,
8309 "# [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)"
8310 );
8311 }
8312
8313 #[test]
8314 fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
8315 let commits = vec![
8316 ReleaseCommitEntry {
8317 full_sha: "a".repeat(40),
8318 short_sha: "aaaaaaa".to_string(),
8319 subject: "Fix SUI-1336 and SUI-1440".to_string(),
8320 date: "2026-05-24".to_string(),
8321 },
8322 ReleaseCommitEntry {
8323 full_sha: "b".repeat(40),
8324 short_sha: "bbbbbbb".to_string(),
8325 subject: "Touch SUI-1336 again".to_string(),
8326 date: "2026-05-25".to_string(),
8327 },
8328 ];
8329
8330 assert_eq!(
8331 collect_linear_issue_identifiers(&commits),
8332 vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
8333 );
8334 }
8335
8336 #[test]
8337 fn release_title_defaults_to_version_and_repo() {
8338 assert_eq!(
8339 default_release_title(&Version::new(1, 7, 0), "athena-auth"),
8340 "1.7.0 - athena-auth"
8341 );
8342 }
8343
8344 #[test]
8345 fn deduplicates_release_commit_entries_by_exact_subject() {
8346 let commits = vec![
8347 ReleaseCommitEntry {
8348 full_sha: "a".repeat(40),
8349 short_sha: "aaaaaaa".to_string(),
8350 subject: "Improve release docs".to_string(),
8351 date: "2026-05-24".to_string(),
8352 },
8353 ReleaseCommitEntry {
8354 full_sha: "b".repeat(40),
8355 short_sha: "bbbbbbb".to_string(),
8356 subject: "Improve release docs".to_string(),
8357 date: "2026-05-25".to_string(),
8358 },
8359 ];
8360
8361 let deduplicated = deduplicate_release_commit_entries(&commits);
8362 assert_eq!(deduplicated.len(), 1);
8363 assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
8364 }
8365
8366 #[test]
8367 fn fallback_sections_collapse_related_commit_themes() {
8368 let commits = vec![
8369 ReleaseCommitEntry {
8370 full_sha: "a".repeat(40),
8371 short_sha: "chat001".to_string(),
8372 subject: "Add optimistic chat retries".to_string(),
8373 date: "2026-06-01".to_string(),
8374 },
8375 ReleaseCommitEntry {
8376 full_sha: "b".repeat(40),
8377 short_sha: "chat002".to_string(),
8378 subject: "Persist deleted-message state in chat".to_string(),
8379 date: "2026-06-01".to_string(),
8380 },
8381 ReleaseCommitEntry {
8382 full_sha: "c".repeat(40),
8383 short_sha: "file001".to_string(),
8384 subject: "Fix upload UTF-8 audit retry handling".to_string(),
8385 date: "2026-06-01".to_string(),
8386 },
8387 ReleaseCommitEntry {
8388 full_sha: "d".repeat(40),
8389 short_sha: "ath001".to_string(),
8390 subject: "Migrate form progress routes to Athena".to_string(),
8391 date: "2026-06-01".to_string(),
8392 },
8393 ReleaseCommitEntry {
8394 full_sha: "e".repeat(40),
8395 short_sha: "ath002".to_string(),
8396 subject: "Update Athena models and package wiring".to_string(),
8397 date: "2026-06-01".to_string(),
8398 },
8399 ];
8400
8401 let sections = build_fallback_sections(&commits);
8402 assert_eq!(sections.len(), 3);
8403 assert_eq!(sections[0].title, "Cases & Communication");
8404 assert!(!sections[0].summary.is_empty());
8405 assert_eq!(sections[0].bullets.len(), 2);
8406 assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
8407 assert!(sections[0].bullets[0].summary.contains("chat"));
8408 assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
8409 assert!(sections[0].bullets[1].summary.contains("deleted-message"));
8410 assert_eq!(sections[1].title, "Reliability");
8411 assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
8412 assert_eq!(sections[2].title, "Athena Migration");
8413 assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
8414 }
8415
8416 #[test]
8417 fn appends_release_label_footer_for_pre_release() {
8418 let with_label = append_release_label_footer("# Release", true);
8419 assert_eq!(
8420 with_label,
8421 format!(
8422 "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
8423 env!("CARGO_PKG_VERSION")
8424 )
8425 );
8426 }
8427}