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