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