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