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