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