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