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