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