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