1use crate::cli::auto_commit::{commit_paths, print_skip, AutoCommitRequest, AutoCommitResult};
4use crate::cli::ui::Loader;
5use crate::commands::publish::run_publish_command_with_progress_prefix;
6use crate::commands::PublishCommandOptions;
7use crate::config::{
8 global_xbp_paths, load_package_name_files_registry, load_versioning_files_registry,
9 resolve_github_oauth2_key, resolve_global_linear_release_config, resolve_linear_api_key,
10 resolve_openrouter_api_key, PackageNameLookup,
11};
12use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
13use crate::strategies::{resolve_config_paths_for_runtime, DeploymentConfig, XbpConfig};
14use crate::utils::{
15 command_exists, find_xbp_config_upwards, maybe_auto_convert_legacy_xbp_json_to_yaml,
16 parse_config_with_auto_heal, parse_github_repo_from_remote_url, redact_remote_url_credentials,
17 resolve_env_placeholders,
18};
19use colored::Colorize;
20use regex::Regex;
21use semver::Version;
22use serde::{Deserialize, Serialize};
23use serde_json::Value as JsonValue;
24use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
25use std::collections::HashMap;
26use std::collections::{BTreeMap, BTreeSet};
27use std::env;
28use std::fs;
29use std::path::{Path, PathBuf};
30use std::process::Command;
31use toml::Value as TomlValue;
32
33#[path = "version/github_release.rs"]
34mod github_release;
35#[path = "version/release_docs.rs"]
36mod release_docs;
37#[path = "version/release_linear.rs"]
38mod release_linear;
39#[path = "version/release_notes.rs"]
40mod release_notes;
41#[path = "version/workspace_release.rs"]
42mod workspace_release;
43
44use github_release::{
45 create_github_release, get_github_release_by_tag, update_github_release,
46 upload_github_release_asset, GithubReleaseInput, GithubReleaseResult, GithubReleaseTagResponse,
47};
48use release_docs::sync_release_docs;
49use release_linear::{
50 publish_release_to_linear_initiatives, resolve_linear_release_config,
51 LinearReleasePublishInput, ResolvedLinearReleaseConfig,
52};
53use release_notes::{generate_release_notes, ReleaseNotesRequest};
54pub use workspace_release::{
55 run_version_workspace_command, WorkspacePublishRunOptions, WorkspaceVersionCheckOptions,
56 WorkspaceVersionCommand, WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions,
57 WorkspaceVersionValidateOptions,
58};
59
60#[derive(Clone, Debug)]
61struct VersionObservation {
62 location: String,
63 version: Version,
64}
65
66#[derive(Clone, Debug)]
67struct GitTagObservation {
68 version: Version,
69 raw_tags: Vec<String>,
70}
71
72#[derive(Clone, Debug)]
73struct RegistryVersionObservation {
74 registry: String,
75 package_name: String,
76 source_file: String,
77 latest: Option<Version>,
78 raw_version: Option<String>,
79 note: Option<String>,
80}
81
82#[derive(Clone, Debug)]
83struct ResolvedRegistryPath {
84 relative: String,
85 absolute: PathBuf,
86 cargo_package_override: Option<String>,
87}
88
89#[derive(Clone, Debug)]
90struct WorkspacePrimaryCargoTarget {
91 manifest_relative: String,
92 manifest_absolute: PathBuf,
93 package_name: String,
94}
95
96#[derive(Clone, Debug)]
97enum VersionScope {
98 Repository,
99 Crate {
100 crate_root: PathBuf,
101 crate_relative_root: String,
102 package_name: String,
103 tag_prefix: String,
104 },
105}
106
107#[derive(Default, Debug)]
108struct VersionReport {
109 worktree: Vec<VersionObservation>,
110 head: Vec<VersionObservation>,
111 local_tags: Vec<GitTagObservation>,
112 remote_tags: Vec<GitTagObservation>,
113 registry_versions: Vec<RegistryVersionObservation>,
114 dirty_files: Vec<String>,
115 warnings: Vec<String>,
116}
117
118const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
119
120#[derive(Clone, Debug, Default, Deserialize, Serialize)]
121struct VersionChangeGuardRegistry {
122 #[serde(default)]
123 entries: BTreeMap<String, VersionChangeGuardEntry>,
124}
125
126#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
127struct VersionChangeGuardEntry {
128 #[serde(default)]
129 pending_version_change_count: usize,
130 #[serde(default)]
131 head_commit: Option<String>,
132}
133
134#[derive(Clone, Debug, Default, PartialEq, Eq)]
135struct GitWorktreeState {
136 is_dirty: bool,
137 head_commit: Option<String>,
138}
139
140impl VersionReport {
141 fn highest_worktree(&self) -> Option<Version> {
142 self.worktree
143 .iter()
144 .map(|entry| entry.version.clone())
145 .max()
146 }
147
148 fn highest_head(&self) -> Option<Version> {
149 self.head.iter().map(|entry| entry.version.clone()).max()
150 }
151
152 fn highest_local_tag(&self) -> Option<Version> {
153 self.local_tags
154 .iter()
155 .map(|entry| entry.version.clone())
156 .max()
157 }
158
159 fn highest_remote_tag(&self) -> Option<Version> {
160 self.remote_tags
161 .iter()
162 .map(|entry| entry.version.clone())
163 .max()
164 }
165
166 fn highest_git(&self) -> Option<Version> {
167 self.highest_remote_tag()
168 .or_else(|| self.highest_local_tag())
169 }
170
171 fn highest_registry(&self) -> Option<Version> {
172 self.registry_versions
173 .iter()
174 .filter_map(|entry| entry.latest.clone())
175 .max()
176 }
177
178 fn highest_available(&self) -> Version {
179 self.highest_worktree()
180 .into_iter()
181 .chain(self.highest_head())
182 .chain(self.highest_git())
183 .chain(self.highest_registry())
184 .max()
185 .unwrap_or_else(default_version)
186 }
187
188 fn divergent_versions(&self) -> Vec<Version> {
189 let mut versions = BTreeSet::new();
190 for entry in &self.worktree {
191 versions.insert(entry.version.clone());
192 }
193 for entry in &self.head {
194 versions.insert(entry.version.clone());
195 }
196 for entry in &self.local_tags {
197 versions.insert(entry.version.clone());
198 }
199 for entry in &self.remote_tags {
200 versions.insert(entry.version.clone());
201 }
202 for entry in &self.registry_versions {
203 if let Some(version) = &entry.latest {
204 versions.insert(version.clone());
205 }
206 }
207 versions.into_iter().collect()
208 }
209}
210
211pub async fn run_version_command(
212 target: Option<String>,
213 git_only: bool,
214 _debug: bool,
215) -> Result<(), String> {
216 if git_only && target.is_some() {
217 return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
218 }
219
220 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
221 let project_root: PathBuf = resolve_project_root();
222 let version_scope: VersionScope = resolve_version_scope(&project_root, &invocation_dir);
223 let registry: Vec<String> = load_versioning_files_registry()?;
224
225 if git_only {
226 print_git_versions(&project_root, &version_scope)?;
227 return Ok(());
228 }
229
230 match target.as_deref() {
231 None => {
232 let mut report: VersionReport =
233 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
234 match load_package_name_files_registry() {
235 Ok(lookups) => {
236 report.registry_versions = collect_registry_versions(
237 &project_root,
238 &invocation_dir,
239 &lookups,
240 &version_scope,
241 &mut report.warnings,
242 )
243 .await;
244 }
245 Err(err) => report.warnings.push(err),
246 }
247 print_version_report(&project_root, &report);
248 Ok(())
249 }
250 Some(bump_target @ ("major" | "minor" | "patch")) => {
251 enforce_version_change_guard(&project_root)?;
252 let current: Version = resolve_current_version_for_bump(
253 &project_root,
254 &invocation_dir,
255 ®istry,
256 &version_scope,
257 );
258 let next: Version = bump_version(¤t, bump_target);
259 let updated_paths = write_version_to_configured_files_with_paths(
260 &project_root,
261 &invocation_dir,
262 ®istry,
263 &version_scope,
264 &next,
265 )?;
266 let updated = updated_paths.len();
267 println!(
268 "Updated {} version file(s) from {} to {}.",
269 updated, current, next
270 );
271 auto_commit_command_paths(
272 &project_root,
273 updated_paths,
274 format!("chore(version): update version to {}", next),
275 "xbp version",
276 )
277 .await;
278 record_version_change_guard(&project_root)?;
279 Ok(())
280 }
281 Some(explicit) => {
282 enforce_version_change_guard(&project_root)?;
283 if let Some((package_name, version)) = parse_package_version_target(explicit)? {
284 let updated_paths = write_package_version_to_configured_files_with_paths(
285 &project_root,
286 &invocation_dir,
287 ®istry,
288 &version_scope,
289 &package_name,
290 &version,
291 )?;
292 let updated = updated_paths.len();
293 println!(
294 "Updated {} file(s) for package `{}` to {}.",
295 updated, package_name, version
296 );
297 auto_commit_command_paths(
298 &project_root,
299 updated_paths,
300 format!("chore(version): set {} to {}", package_name, version),
301 "xbp version",
302 )
303 .await;
304 record_version_change_guard(&project_root)?;
305 } else {
306 let version: Version = parse_version(explicit)?;
307 let updated_paths = write_version_to_configured_files_with_paths(
308 &project_root,
309 &invocation_dir,
310 ®istry,
311 &version_scope,
312 &version,
313 )?;
314 let updated = updated_paths.len();
315 println!("Updated {} version file(s) to {}.", updated, version);
316 auto_commit_command_paths(
317 &project_root,
318 updated_paths,
319 format!("chore(version): update version to {}", version),
320 "xbp version",
321 )
322 .await;
323 record_version_change_guard(&project_root)?;
324 }
325 Ok(())
326 }
327 }
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub enum ReleaseLatestPolicy {
332 True,
333 False,
334 Legacy,
335}
336
337impl ReleaseLatestPolicy {
338 pub(crate) fn as_github_api_value(self) -> &'static str {
339 match self {
340 Self::True => "true",
341 Self::False => "false",
342 Self::Legacy => "legacy",
343 }
344 }
345}
346
347#[derive(Debug, Clone)]
348pub struct VersionReleaseOptions {
349 pub explicit_version: Option<String>,
350 pub allow_dirty: bool,
351 pub title: Option<String>,
352 pub notes: Option<String>,
353 pub notes_file: Option<PathBuf>,
354 pub draft: bool,
355 pub prerelease: bool,
356 pub publish: bool,
357 pub latest_policy: ReleaseLatestPolicy,
358}
359
360struct ReleaseWorkflowSummary {
361 tag_name: String,
362 release_url: String,
363 uploaded_openapi_asset: Option<String>,
364 published_initiatives: Vec<String>,
365 release_branch: Option<String>,
366}
367
368pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
369 let loader = Loader::start("Publishing release");
370 let result: Result<ReleaseWorkflowSummary, String> = async {
371 let VersionReleaseOptions {
372 explicit_version,
373 allow_dirty,
374 title,
375 notes,
376 notes_file,
377 draft,
378 prerelease,
379 publish,
380 latest_policy,
381 } = options;
382
383 if notes.is_some() && notes_file.is_some() {
384 return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
385 }
386
387 if !command_exists("git") {
388 return Err(
389 "Git is required for `xbp version release`, but it is not installed.".to_string(),
390 );
391 }
392
393 let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
394 let project_root: PathBuf = resolve_project_root();
395 let version_scope: VersionScope = resolve_version_scope(&project_root, &invocation_dir);
396 let sync_explicit_version = explicit_version.is_some();
397 let total_steps = 8usize + usize::from(sync_explicit_version) + usize::from(publish);
398 let mut step = 1usize;
399
400 loader.update(&format!(
401 "[{}/{}] Validating git state and resolving release target",
402 step, total_steps
403 ));
404 if !allow_dirty {
405 let dirty: Vec<String> = git_dirty_entries(&project_root)?;
406 if !dirty.is_empty() {
407 let preview = dirty.into_iter().take(8).collect::<Vec<_>>().join(", ");
408 return Err(format!(
409 "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
410 preview
411 ));
412 }
413 }
414
415 let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
416 let (version, parsed_tag_name) = parse_release_version_target(&raw)?;
417 (
418 version.clone(),
419 scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
420 )
421 } else {
422 let registry: Vec<String> = load_versioning_files_registry()?;
423 let report: VersionReport =
424 collect_version_report(&project_root, &invocation_dir, ®istry, &version_scope);
425 let release_version: Version = report.highest_available();
426 let tag_name: String = default_release_tag_name(&version_scope, &release_version);
427 (release_version, tag_name)
428 };
429
430 if sync_explicit_version {
431 step += 1;
432 loader.update(&format!(
433 "[{}/{}] Syncing configured version files",
434 step, total_steps
435 ));
436 let registry: Vec<String> = load_versioning_files_registry()?;
437 let updated_paths = sync_version_to_configured_files_with_paths(
438 &project_root,
439 &invocation_dir,
440 ®istry,
441 &version_scope,
442 &release_version,
443 )?;
444 if !updated_paths.is_empty() {
445 auto_commit_command_paths(
446 &project_root,
447 updated_paths,
448 format!("chore(version): update version to {}", release_version),
449 "xbp version release",
450 )
451 .await;
452 }
453 }
454
455 ensure_remote_exists(&project_root, "origin")?;
456 let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
457 let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
458
459 if publish {
460 step += 1;
461 loader.update(&format!(
462 "[{}/{}] Publishing configured packages",
463 step, total_steps
464 ));
465 run_publish_command_with_progress_prefix(
466 PublishCommandOptions {
467 dry_run: false,
468 allow_dirty,
469 target: None,
470 expected_version: Some(release_version.to_string()),
471 },
472 &loader,
473 format!("[{}/{}]", step, total_steps),
474 )
475 .await?;
476 }
477
478 step += 1;
479 loader.update(&format!(
480 "[{}/{}] Resolving GitHub repository and auth",
481 step, total_steps
482 ));
483 let origin_url: String = git_remote_url(&project_root, "origin")?;
484 let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
485 format!(
486 "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.",
487 redact_remote_url_credentials(&origin_url)
488 )
489 })?;
490
491 let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
492 "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
493 .to_string()
494 })?;
495 let release_title: String = title.unwrap_or_else(|| {
496 default_release_title(
497 &release_version,
498 release_title_subject(&version_scope, &repo),
499 )
500 });
501 let release_branch_config =
502 resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
503
504 step += 1;
505 loader.update(&format!(
506 "[{}/{}] Generating release notes",
507 step, total_steps
508 ));
509 let release_notes_body: String = if let Some(path) = notes_file {
510 fs::read_to_string(&path).map_err(|e| {
511 format!(
512 "Failed to read release notes file {}: {}",
513 path.display(),
514 e
515 )
516 })?
517 } else if let Some(body) = notes {
518 body
519 } else {
520 generate_release_notes(&ReleaseNotesRequest {
521 project_root: &project_root,
522 release_title: &release_title,
523 current_tag_name: &tag_name,
524 owner: &owner,
525 repo: &repo,
526 github_token: &github_token,
527 linear_api_key: resolve_linear_api_key().as_deref(),
528 openrouter_api_key: resolve_openrouter_api_key().as_deref(),
529 path_filter: release_notes_scope_path(&version_scope).as_deref(),
530 })
531 .await?
532 };
533 let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
534
535 step += 1;
536 loader.update(&format!(
537 "[{}/{}] Creating and pushing release tag",
538 step, total_steps
539 ));
540 let tag_message: String = format!("Release {}", tag_name);
541 let target_commitish: String = git_head_commitish(&project_root)?;
542 let created_release_branch = if let Some(branch_config) = &release_branch_config {
543 Some(ensure_release_branch(
544 &project_root,
545 branch_config,
546 &release_version,
547 &tag_name,
548 &target_commitish,
549 )?)
550 } else {
551 None
552 };
553 if !tag_exists_local {
554 run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])?;
555 }
556 if !tag_exists_remote {
557 run_git_command(&project_root, &["push", "origin", &tag_name])?;
558 }
559
560 let release_input: GithubReleaseInput = GithubReleaseInput {
561 owner: owner.clone(),
562 repo: repo.clone(),
563 token: github_token,
564 tag_name: tag_name.clone(),
565 target_commitish,
566 title: release_title,
567 notes: release_notes,
568 draft,
569 prerelease,
570 latest_policy,
571 };
572
573 step += 1;
574 loader.update(&format!(
575 "[{}/{}] Publishing GitHub release",
576 step, total_steps
577 ));
578 let release_result: GithubReleaseResult = match create_github_release(&release_input).await {
579 Ok(result) => result,
580 Err(create_error) => {
581 let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
582 format!(
583 "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
584 create_error, tag_name, e
585 )
586 })?;
587
588 let Some(existing_release) = existing_release else {
589 return Err(format!(
590 "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
591 create_error, tag_name
592 ));
593 };
594
595 let needs_update: bool = existing_release.prerelease.unwrap_or(false)
596 != release_input.prerelease
597 || existing_release.draft.unwrap_or(false) != release_input.draft
598 || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
599
600 if needs_update {
601 update_github_release(&release_input, existing_release.id)
602 .await
603 .map_err(|e| {
604 format!(
605 "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
606 create_error, tag_name, e
607 )
608 })?
609 } else {
610 GithubReleaseResult {
611 id: existing_release.id,
612 html_url: existing_release.html_url.unwrap_or_else(|| {
613 format!(
614 "https://github.com/{}/{}/releases/tag/{}",
615 release_input.owner, release_input.repo, release_input.tag_name
616 )
617 }),
618 }
619 }
620 }
621 };
622 let release_url = release_result.html_url.clone();
623
624 step += 1;
625 loader.update(&format!(
626 "[{}/{}] Publishing release integrations",
627 step, total_steps
628 ));
629 let openapi_path = resolve_release_openapi_spec(&project_root, &invocation_dir);
630 let linear_release_config =
631 resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
632 let openapi_release_input = release_input.clone();
633 let linear_release_input = release_input.clone();
634 let openapi_project_root = project_root.clone();
635 let openapi_tag_name = tag_name.clone();
636 let linear_tag_name = tag_name.clone();
637 let linear_release_url = release_url.clone();
638
639 let openapi_future = async move {
640 if let Some(openapi_path) = openapi_path {
641 upload_github_release_asset(
642 &openapi_release_input,
643 release_result.id,
644 &openapi_path,
645 )
646 .await
647 .map_err(|e| {
648 format!(
649 "Release `{}` was published, but uploading OpenAPI asset `{}` failed: {}",
650 openapi_tag_name,
651 openapi_path.display(),
652 e
653 )
654 })?;
655 Ok(Some(
656 openapi_path
657 .strip_prefix(&openapi_project_root)
658 .map(normalized_relative_path)
659 .unwrap_or_else(|_| normalized_relative_path(&openapi_path)),
660 ))
661 } else {
662 Ok(None)
663 }
664 };
665
666 let linear_future = async move {
667 if let Some(linear_release_config) = linear_release_config {
668 let linear_api_key: String = resolve_linear_api_key().ok_or_else(|| {
669 "A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key`."
670 .to_string()
671 })?;
672 publish_release_to_linear_initiatives(&LinearReleasePublishInput {
673 api_key: linear_api_key,
674 initiative_ids: linear_release_config.initiative_ids,
675 organization_name: linear_release_config.organization_name,
676 health: linear_release_config.health,
677 release_title: linear_release_input.title.clone(),
678 release_tag: linear_release_input.tag_name.clone(),
679 release_url: linear_release_url,
680 release_notes: linear_release_input.notes.clone(),
681 })
682 .await
683 .map_err(|e| {
684 format!(
685 "Release `{}` was published, but publishing to configured Linear initiatives failed: {}",
686 linear_tag_name, e
687 )
688 })
689 } else {
690 Ok(Vec::new())
691 }
692 };
693
694 let (uploaded_openapi_asset, published_initiatives) =
695 tokio::try_join!(openapi_future, linear_future)?;
696
697 step += 1;
698 loader.update(&format!(
699 "[{}/{}] Syncing release docs",
700 step, total_steps
701 ));
702 let release_doc_paths = sync_release_docs(&project_root, &owner, &repo)?;
703 step += 1;
704 loader.update(&format!(
705 "[{}/{}] Auto-committing release docs",
706 step, total_steps
707 ));
708 auto_commit_command_paths(
709 &project_root,
710 release_doc_paths,
711 format!("docs(release): sync release docs for {}", tag_name),
712 "xbp version release",
713 )
714 .await;
715
716 Ok(ReleaseWorkflowSummary {
717 tag_name,
718 release_url,
719 uploaded_openapi_asset,
720 published_initiatives,
721 release_branch: created_release_branch,
722 })
723 }
724 .await;
725
726 match result {
727 Ok(summary) => {
728 loader.success_with(&format!("Published {}", summary.tag_name));
729 println!("Released {} successfully.", summary.tag_name);
730 println!("GitHub release: {}", summary.release_url);
731 if let Some(openapi_asset) = summary.uploaded_openapi_asset {
732 println!("Uploaded OpenAPI asset: {}", openapi_asset);
733 }
734 if !summary.published_initiatives.is_empty() {
735 println!(
736 "Published release update to Linear initiative(s): {}",
737 summary.published_initiatives.join(", ")
738 );
739 }
740 if let Some(release_branch) = summary.release_branch {
741 println!("Release branch: {}", release_branch);
742 }
743 println!("Updated release docs: CHANGELOG.md and SECURITY.md");
744 Ok(())
745 }
746 Err(error) => {
747 loader.fail(&error);
748 Err(error)
749 }
750 }
751}
752
753pub async fn print_version() {
755 println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
756}
757
758fn resolve_project_root() -> PathBuf {
759 let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
760
761 if let Some(root) = git_repository_root(&cwd) {
762 return root;
763 }
764
765 if let Some(found) = find_xbp_config_upwards(&cwd) {
766 return found.project_root;
767 }
768
769 cwd
770}
771
772fn collect_version_report(
773 project_root: &Path,
774 invocation_dir: &Path,
775 registry: &[String],
776 version_scope: &VersionScope,
777) -> VersionReport {
778 let mut report: VersionReport = VersionReport::default();
779 report.worktree = collect_local_versions(
780 project_root,
781 invocation_dir,
782 registry,
783 version_scope,
784 &mut report.warnings,
785 );
786 match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
787 Ok(entries) => report.head = entries,
788 Err(err) => report.warnings.push(err),
789 }
790 match collect_git_versions(project_root, version_scope) {
791 Ok(tags) => report.local_tags = tags,
792 Err(err) => report.warnings.push(err),
793 }
794 match collect_remote_git_versions(project_root, "origin", version_scope) {
795 Ok(tags) => report.remote_tags = tags,
796 Err(err) => report.warnings.push(err),
797 }
798 match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
799 Ok(files) => report.dirty_files = files,
800 Err(err) => report.warnings.push(err),
801 }
802 report
803}
804
805fn resolve_current_version_for_bump(
806 project_root: &Path,
807 invocation_dir: &Path,
808 registry: &[String],
809 version_scope: &VersionScope,
810) -> Version {
811 let mut _warnings = Vec::new();
812 let local_versions = collect_local_versions(
813 project_root,
814 invocation_dir,
815 registry,
816 version_scope,
817 &mut _warnings,
818 );
819 let head_versions =
820 collect_head_versions(project_root, invocation_dir, registry, version_scope)
821 .unwrap_or_default();
822 let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
823
824 local_versions
825 .iter()
826 .map(|entry| entry.version.clone())
827 .chain(head_versions.iter().map(|entry| entry.version.clone()))
828 .chain(local_tags.iter().map(|entry| entry.version.clone()))
829 .max()
830 .unwrap_or_else(default_version)
831}
832
833async fn auto_commit_command_paths(
834 project_root: &Path,
835 paths: Vec<PathBuf>,
836 message: String,
837 action_label: &'static str,
838) {
839 match commit_paths(AutoCommitRequest {
840 project_root,
841 paths,
842 message,
843 action_label,
844 })
845 .await
846 {
847 Ok(AutoCommitResult::Committed(_)) => {}
848 Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
849 Err(e) => print_skip(action_label, &e),
850 }
851}
852
853async fn collect_registry_versions(
854 project_root: &Path,
855 invocation_dir: &Path,
856 lookups: &[PackageNameLookup],
857 version_scope: &VersionScope,
858 warnings: &mut Vec<String>,
859) -> Vec<RegistryVersionObservation> {
860 let mut entries: Vec<RegistryVersionObservation> = Vec::new();
861 let mut seen: BTreeSet<String> = BTreeSet::new();
862 let client: reqwest::Client = reqwest::Client::new();
863
864 for lookup in lookups {
865 let dedupe_key: String = format!(
866 "{}|{}|{}|{}",
867 lookup.file, lookup.format, lookup.key, lookup.registry
868 );
869 if !seen.insert(dedupe_key) {
870 continue;
871 }
872
873 let source_file = resolve_registry_relative_path(
874 project_root,
875 invocation_dir,
876 version_scope,
877 &lookup.file,
878 );
879 let path = project_root.join(&source_file);
880 if !path.exists() {
881 continue;
882 }
883
884 let content: String = match fs::read_to_string(&path) {
885 Ok(content) => content,
886 Err(err) => {
887 warnings.push(format!("Failed to read {}: {}", path.display(), err));
888 continue;
889 }
890 };
891
892 let package_name: String = match read_package_name_from_lookup(lookup, &content) {
893 Ok(Some(value)) => value,
894 Ok(None) => continue,
895 Err(err) => {
896 warnings.push(format!("{}: {}", source_file, err));
897 continue;
898 }
899 };
900
901 let (latest, raw_version, note) =
902 match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
903 Ok(version) => {
904 let parsed: Option<Version> = parse_version(&version).ok();
905 let note: Option<String> = if parsed.is_none() {
906 Some(format!("Non-semver registry version: {}", version))
907 } else {
908 None
909 };
910 (parsed, Some(version), note)
911 }
912 Err(err) => (None, None, Some(err)),
913 };
914
915 entries.push(RegistryVersionObservation {
916 registry: lookup.registry.clone(),
917 package_name,
918 source_file,
919 latest,
920 raw_version,
921 note,
922 });
923 }
924
925 entries.sort_by(|a, b| {
926 a.registry
927 .cmp(&b.registry)
928 .then_with(|| a.package_name.cmp(&b.package_name))
929 });
930 entries
931}
932
933fn read_package_name_from_lookup(
934 lookup: &PackageNameLookup,
935 content: &str,
936) -> Result<Option<String>, String> {
937 let key_parts: Vec<&str> = lookup
938 .key
939 .split('.')
940 .map(|part| part.trim())
941 .filter(|part| !part.is_empty())
942 .collect();
943 if key_parts.is_empty() {
944 return Err("Lookup key cannot be empty".to_string());
945 }
946
947 let format: String = lookup.format.trim().to_ascii_lowercase();
948 match format.as_str() {
949 "json" => {
950 let value: JsonValue = serde_json::from_str(content)
951 .map_err(|e| format!("Failed to parse JSON: {}", e))?;
952 Ok(json_lookup_string(&value, &key_parts))
953 }
954 "yaml" | "yml" => {
955 let value: YamlValue = serde_yaml::from_str(content)
956 .map_err(|e| format!("Failed to parse YAML: {}", e))?;
957 Ok(yaml_lookup_string(&value, &key_parts))
958 }
959 "toml" => {
960 let value: TomlValue =
961 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
962 Ok(toml_lookup_string(&value, &key_parts))
963 }
964 other => Err(format!("Unsupported lookup format `{}`", other)),
965 }
966}
967
968async fn fetch_registry_latest_version(
969 client: &reqwest::Client,
970 registry: &str,
971 package_name: &str,
972) -> Result<String, String> {
973 let normalized_registry: String = registry.trim().to_ascii_lowercase();
974 match normalized_registry.as_str() {
975 "npm" => fetch_npm_latest_version(client, package_name).await,
976 "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
977 _ => Err(format!("Unsupported registry `{}`", registry)),
978 }
979}
980
981#[derive(Debug, Deserialize)]
982struct NpmLatestResponse {
983 version: String,
984}
985
986async fn fetch_npm_latest_version(
987 client: &reqwest::Client,
988 package_name: &str,
989) -> Result<String, String> {
990 let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
991 .map_err(|e| format!("Failed to build npm URL: {}", e))?;
992 {
993 let mut segments = url
994 .path_segments_mut()
995 .map_err(|_| "Failed to compose npm URL segments".to_string())?;
996 segments.push(package_name);
997 segments.push("latest");
998 }
999
1000 let response: reqwest::Response = client
1001 .get(url)
1002 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1003 .send()
1004 .await
1005 .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
1006
1007 if !response.status().is_success() {
1008 return Err(format!(
1009 "npm lookup for {} returned status {}",
1010 package_name,
1011 response.status()
1012 ));
1013 }
1014
1015 let payload: NpmLatestResponse = response
1016 .json()
1017 .await
1018 .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
1019 Ok(payload.version)
1020}
1021
1022#[derive(Debug, Deserialize)]
1023struct CratesIoResponse {
1024 #[serde(rename = "crate")]
1025 crate_meta: CratesIoMeta,
1026}
1027
1028#[derive(Debug, Deserialize)]
1029struct CratesIoMeta {
1030 newest_version: String,
1031}
1032
1033async fn fetch_crates_latest_version(
1034 client: &reqwest::Client,
1035 package_name: &str,
1036) -> Result<String, String> {
1037 let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
1038 .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
1039 {
1040 let mut segments = url
1041 .path_segments_mut()
1042 .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
1043 segments.push(package_name);
1044 }
1045
1046 let response: reqwest::Response = client
1047 .get(url)
1048 .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1049 .send()
1050 .await
1051 .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
1052
1053 if !response.status().is_success() {
1054 return Err(format!(
1055 "crates.io lookup for {} returned status {}",
1056 package_name,
1057 response.status()
1058 ));
1059 }
1060
1061 let payload: CratesIoResponse = response.json().await.map_err(|e| {
1062 format!(
1063 "Failed to parse crates.io response for {}: {}",
1064 package_name, e
1065 )
1066 })?;
1067 Ok(payload.crate_meta.newest_version)
1068}
1069
1070fn collect_local_versions(
1071 project_root: &Path,
1072 invocation_dir: &Path,
1073 registry: &[String],
1074 version_scope: &VersionScope,
1075 warnings: &mut Vec<String>,
1076) -> Vec<VersionObservation> {
1077 let mut observed = Vec::new();
1078
1079 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1080 let path: &PathBuf = &entry.absolute;
1081 if !path.exists() {
1082 continue;
1083 }
1084
1085 match read_version_from_resolved_path(&entry) {
1086 Ok(Some(version)) => {
1087 if let Ok(parsed) = parse_version(&version) {
1088 observed.push(VersionObservation {
1089 location: entry.relative.clone(),
1090 version: parsed,
1091 });
1092 } else {
1093 warnings.push(format!("Ignoring non-semver version in {}", path.display()));
1094 }
1095 }
1096 Ok(None) => {}
1097 Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
1098 }
1099 }
1100
1101 observed.sort_by(|a, b| a.location.cmp(&b.location));
1102 observed
1103}
1104
1105fn collect_git_versions(
1106 project_root: &Path,
1107 version_scope: &VersionScope,
1108) -> Result<Vec<GitTagObservation>, String> {
1109 if !command_exists("git") {
1110 return Err("Git is not installed; skipping git tag inspection.".to_string());
1111 }
1112
1113 let output: std::process::Output = Command::new("git")
1114 .current_dir(project_root)
1115 .args(["tag", "--list"])
1116 .output()
1117 .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
1118
1119 if !output.status.success() {
1120 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1121 if stderr.is_empty() {
1122 return Err("`git tag --list` failed in the current directory.".to_string());
1123 }
1124 return Err(format!("`git tag --list` failed: {}", stderr));
1125 }
1126
1127 Ok(parse_local_git_tag_output_for_scope(
1128 &String::from_utf8_lossy(&output.stdout),
1129 version_scope,
1130 ))
1131}
1132
1133fn collect_remote_git_versions(
1134 project_root: &Path,
1135 remote: &str,
1136 version_scope: &VersionScope,
1137) -> Result<Vec<GitTagObservation>, String> {
1138 if !command_exists("git") {
1139 return Err("Git is not installed; skipping remote tag inspection.".to_string());
1140 }
1141
1142 let output: std::process::Output = Command::new("git")
1143 .current_dir(project_root)
1144 .args(["ls-remote", "--tags", remote])
1145 .output()
1146 .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
1147
1148 if !output.status.success() {
1149 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1150 if stderr.is_empty() {
1151 return Err(format!("`git ls-remote --tags {}` failed.", remote));
1152 }
1153 return Err(format!(
1154 "`git ls-remote --tags {}` failed: {}",
1155 remote, stderr
1156 ));
1157 }
1158
1159 Ok(parse_remote_git_tag_output_for_scope(
1160 &String::from_utf8_lossy(&output.stdout),
1161 version_scope,
1162 ))
1163}
1164
1165fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
1166 let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
1167
1168 if tags.is_empty() {
1169 println!("No semantic git tags found in {}.", project_root.display());
1170 return Ok(());
1171 }
1172
1173 println!("Git versions from `git tag --list`:");
1174 for tag in tags {
1175 if tag.raw_tags.len() > 1 {
1176 println!(" {} ({})", tag.version, tag.raw_tags.join(", "));
1177 } else {
1178 println!(" {}", tag.version);
1179 }
1180 }
1181
1182 Ok(())
1183}
1184
1185fn print_version_observations(
1186 title: &str,
1187 entries: &[VersionObservation],
1188 dirty_files: Option<&[String]>,
1189) {
1190 println!();
1191 println!("{}", title.bright_cyan().bold());
1192 println!("{}", "─".repeat(72).bright_black());
1193
1194 if entries.is_empty() {
1195 println!(" {}", "none found".dimmed());
1196 return;
1197 }
1198
1199 let Some(highest) = highest_version_observation(entries) else {
1200 println!(" {}", "none found".dimmed());
1201 return;
1202 };
1203
1204 let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
1205 let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
1206 println!(
1207 " {:<28} {} ({}/{})",
1208 "latest".bright_white(),
1209 highest.to_string().bright_green().bold(),
1210 latest_count,
1211 entries.len()
1212 );
1213
1214 if stale_entries.is_empty() {
1215 return;
1216 }
1217
1218 println!(" {}", "stale entries".bright_yellow().bold());
1219 for entry in stale_entries {
1220 let dirty: bool = dirty_files
1221 .map(|files| files.iter().any(|file| file == &entry.location))
1222 .unwrap_or(false);
1223 let dirty_suffix: String = if dirty {
1224 format!(" {}", "modified".bright_magenta())
1225 } else {
1226 String::new()
1227 };
1228
1229 println!(
1230 " {:<28} {}{}",
1231 entry.location.bright_white(),
1232 entry.version.to_string().bright_green(),
1233 dirty_suffix
1234 );
1235 }
1236}
1237
1238fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
1239 println!();
1240 println!("{}", title.bright_cyan().bold());
1241 println!("{}", "─".repeat(72).bright_black());
1242
1243 if tags.is_empty() {
1244 println!(" {}", "none found".dimmed());
1245 return;
1246 }
1247
1248 let latest = &tags[0];
1249 if latest.raw_tags.len() > 1 {
1250 println!(
1251 " {:<20} {}",
1252 latest.version.to_string().bright_green().bold(),
1253 latest.raw_tags.join(", ").dimmed()
1254 );
1255 } else {
1256 println!(" {}", latest.version.to_string().bright_green().bold());
1257 }
1258
1259 if tags.len() > 1 {
1260 println!(
1261 " {:<20} {}",
1262 "older tags".bright_white(),
1263 format!("{} hidden", tags.len() - 1).dimmed()
1264 );
1265 }
1266}
1267
1268fn collect_head_versions(
1269 project_root: &Path,
1270 invocation_dir: &Path,
1271 registry: &[String],
1272 version_scope: &VersionScope,
1273) -> Result<Vec<VersionObservation>, String> {
1274 if !command_exists("git") {
1275 return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
1276 }
1277
1278 let head_check = Command::new("git")
1279 .current_dir(project_root)
1280 .args(["rev-parse", "--verify", "HEAD"])
1281 .output()
1282 .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
1283
1284 if !head_check.status.success() {
1285 return Ok(Vec::new());
1286 }
1287
1288 let mut observed = Vec::new();
1289 let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
1290
1291 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1292 let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
1293 continue;
1294 };
1295
1296 match read_version_from_blob_with_override(
1297 &entry.relative,
1298 &content,
1299 cargo_toml_content.as_deref(),
1300 entry.cargo_package_override.as_deref(),
1301 ) {
1302 Ok(Some(version)) => {
1303 if let Ok(parsed) = parse_version(&version) {
1304 observed.push(VersionObservation {
1305 location: entry.relative.clone(),
1306 version: parsed,
1307 });
1308 }
1309 }
1310 Ok(None) => {}
1311 Err(_) => {}
1312 }
1313 }
1314
1315 observed.sort_by(|a, b| a.location.cmp(&b.location));
1316 Ok(observed)
1317}
1318
1319fn collect_dirty_version_files(
1320 project_root: &Path,
1321 invocation_dir: &Path,
1322 registry: &[String],
1323 version_scope: &VersionScope,
1324) -> Result<Vec<String>, String> {
1325 if !command_exists("git") {
1326 return Err("Git is not installed; skipping worktree status inspection.".to_string());
1327 }
1328
1329 let mut args = vec!["status", "--porcelain", "--"];
1330 let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1331 for entry in &resolved {
1332 args.push(entry.relative.as_str());
1333 }
1334
1335 let output = Command::new("git")
1336 .current_dir(project_root)
1337 .args(&args)
1338 .output()
1339 .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
1340
1341 if !output.status.success() {
1342 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1343 if stderr.is_empty() {
1344 return Err("`git status --porcelain` failed.".to_string());
1345 }
1346 return Err(format!("`git status --porcelain` failed: {}", stderr));
1347 }
1348
1349 let mut dirty = Vec::new();
1350 for line in String::from_utf8_lossy(&output.stdout).lines() {
1351 if line.len() < 4 {
1352 continue;
1353 }
1354 let path = line[3..].trim();
1355 if !path.is_empty() {
1356 dirty.push(path.replace('\\', "/"));
1357 }
1358 }
1359
1360 dirty.sort();
1361 dirty.dedup();
1362 Ok(dirty)
1363}
1364
1365fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
1366 let output = Command::new("git")
1367 .current_dir(project_root)
1368 .args(["show", &format!("HEAD:{}", relative)])
1369 .output()
1370 .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
1371
1372 if !output.status.success() {
1373 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1374 if stderr.is_empty() {
1375 return Err(format!("{} is not present in HEAD", relative));
1376 }
1377 return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
1378 }
1379
1380 String::from_utf8(output.stdout)
1381 .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
1382}
1383
1384fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1385 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1386 for line in output.lines() {
1387 let tag = line.trim();
1388 if tag.is_empty() {
1389 continue;
1390 }
1391 if let Ok(version) = parse_version(tag) {
1392 by_version.entry(version).or_default().push(tag.to_string());
1393 }
1394 }
1395 git_tag_map_to_vec(by_version)
1396}
1397
1398fn parse_local_git_tag_output_for_scope(
1399 output: &str,
1400 version_scope: &VersionScope,
1401) -> Vec<GitTagObservation> {
1402 match version_scope {
1403 VersionScope::Repository => parse_local_git_tag_output(output),
1404 VersionScope::Crate { tag_prefix, .. } => parse_scoped_git_tag_output(output, tag_prefix),
1405 }
1406}
1407
1408fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1409 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1410 for line in output.lines() {
1411 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1412 let tag = reference
1413 .strip_prefix("refs/tags/")
1414 .unwrap_or(reference)
1415 .trim_end_matches("^{}")
1416 .trim();
1417
1418 if tag.is_empty() {
1419 continue;
1420 }
1421 if let Ok(version) = parse_version(tag) {
1422 by_version.entry(version).or_default().push(tag.to_string());
1423 }
1424 }
1425 git_tag_map_to_vec(by_version)
1426}
1427
1428fn parse_remote_git_tag_output_for_scope(
1429 output: &str,
1430 version_scope: &VersionScope,
1431) -> Vec<GitTagObservation> {
1432 match version_scope {
1433 VersionScope::Repository => parse_remote_git_tag_output(output),
1434 VersionScope::Crate { tag_prefix, .. } => {
1435 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1436 for line in output.lines() {
1437 let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1438 let tag = reference
1439 .strip_prefix("refs/tags/")
1440 .unwrap_or(reference)
1441 .trim_end_matches("^{}")
1442 .trim();
1443
1444 if tag.is_empty() {
1445 continue;
1446 }
1447 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1448 by_version.entry(version).or_default().push(tag.to_string());
1449 }
1450 }
1451 git_tag_map_to_vec(by_version)
1452 }
1453 }
1454}
1455
1456fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
1457 let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1458 for line in output.lines() {
1459 let tag = line.trim();
1460 if tag.is_empty() {
1461 continue;
1462 }
1463 if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1464 by_version.entry(version).or_default().push(tag.to_string());
1465 }
1466 }
1467 git_tag_map_to_vec(by_version)
1468}
1469
1470fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
1471 let mut versions: Vec<GitTagObservation> = by_version
1472 .into_iter()
1473 .map(|(version, mut raw_tags)| {
1474 raw_tags.sort();
1475 raw_tags.dedup();
1476 GitTagObservation { version, raw_tags }
1477 })
1478 .collect();
1479 versions.sort_by(|a, b| b.version.cmp(&a.version));
1480 versions
1481}
1482
1483#[cfg(test)]
1484fn read_version_from_blob(
1485 relative: &str,
1486 content: &str,
1487 cargo_toml_content: Option<&str>,
1488) -> Result<Option<String>, String> {
1489 read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
1490}
1491
1492fn read_version_from_blob_with_override(
1493 relative: &str,
1494 content: &str,
1495 cargo_toml_content: Option<&str>,
1496 cargo_package_override: Option<&str>,
1497) -> Result<Option<String>, String> {
1498 let file_name = Path::new(relative)
1499 .file_name()
1500 .and_then(|n| n.to_str())
1501 .unwrap_or_default();
1502
1503 match file_name {
1504 "README.md" => read_readme_version_from_content(content),
1505 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1506 read_openapi_version_from_content(content)
1507 }
1508 "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
1509 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1510 | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
1511 "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
1512 "Cargo.toml" => read_cargo_toml_version_from_content(content),
1513 "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
1514 content,
1515 cargo_toml_content,
1516 cargo_package_override,
1517 ),
1518 "pyproject.toml" => read_pyproject_version_from_content(content),
1519 "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
1520 "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
1521 "pom.xml" => {
1522 read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
1523 }
1524 "build.gradle" | "build.gradle.kts" => {
1525 read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1526 }
1527 "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
1528 _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
1529 Some("json") => read_json_root_version_from_content(content),
1530 Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
1531 Some("toml") => read_toml_root_version_from_content(content),
1532 Some("md") => read_readme_version_from_content(content),
1533 _ => Ok(None),
1534 },
1535 }
1536}
1537
1538fn print_version_report(project_root: &Path, report: &VersionReport) {
1539 let dirty_suffix = if report.dirty_files.is_empty() {
1540 "clean".green().to_string()
1541 } else {
1542 format!("dirty ({})", report.dirty_files.len())
1543 .bright_magenta()
1544 .to_string()
1545 };
1546
1547 println!(
1548 "\n{} {}",
1549 "Version Summary".bright_cyan().bold(),
1550 project_root.display().to_string().bright_white()
1551 );
1552 println!("{}", "─".repeat(72).bright_black());
1553 println!(
1554 "{:<20} {}",
1555 "Highest available".bright_white(),
1556 report.highest_available().to_string().bright_green().bold()
1557 );
1558 println!(
1559 "{:<20} {}",
1560 "Worktree".bright_white(),
1561 report
1562 .highest_worktree()
1563 .unwrap_or_else(default_version)
1564 .to_string()
1565 .bright_yellow()
1566 );
1567 println!(
1568 "{:<20} {}",
1569 "Committed HEAD".bright_white(),
1570 report
1571 .highest_head()
1572 .map(|v| v.to_string())
1573 .unwrap_or_else(|| "none".dimmed().to_string())
1574 );
1575 println!(
1576 "{:<20} {}",
1577 "GitHub tags".bright_white(),
1578 report
1579 .highest_remote_tag()
1580 .map(|v| v.to_string())
1581 .unwrap_or_else(|| "none".dimmed().to_string())
1582 );
1583 println!(
1584 "{:<20} {}",
1585 "Registry latest".bright_white(),
1586 report
1587 .highest_registry()
1588 .map(|v| v.to_string())
1589 .unwrap_or_else(|| "none".dimmed().to_string())
1590 );
1591 println!(
1592 "{:<20} {}",
1593 "Local tags".bright_white(),
1594 report
1595 .highest_local_tag()
1596 .map(|v| v.to_string())
1597 .unwrap_or_else(|| "none".dimmed().to_string())
1598 );
1599 println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
1600
1601 print_version_observations(
1602 "Worktree version files",
1603 &report.worktree,
1604 Some(&report.dirty_files),
1605 );
1606 print_version_observations("Committed HEAD version files", &report.head, None);
1607 print_registry_observations("Published package versions", &report.registry_versions);
1608 print_git_tag_observations("GitHub tags", &report.remote_tags);
1609 print_git_tag_observations("Local git tags", &report.local_tags);
1610
1611 let divergent = report.divergent_versions();
1612 let highest = report.highest_available();
1613 let outdated: Vec<_> = divergent
1614 .into_iter()
1615 .filter(|version| version != &highest)
1616 .collect();
1617 println!();
1618 println!("{}", "Divergence".bright_cyan().bold());
1619 println!("{}", "─".repeat(72).bright_black());
1620 println!(
1621 " {:<20} {}",
1622 "latest target".bright_white(),
1623 highest.to_string().bright_green().bold()
1624 );
1625 if !outdated.is_empty() {
1626 for version in outdated {
1627 println!(
1628 " {} {}",
1629 "•".bright_yellow(),
1630 version.to_string().bright_yellow()
1631 );
1632 }
1633 println!();
1634 println!(
1635 "{} {}",
1636 "Fix local files with".bright_white(),
1637 format!("xbp version {}", highest).black().on_bright_green()
1638 );
1639 } else {
1640 println!(" {}", "all relevant sources are aligned".green());
1641 }
1642
1643 if !report.warnings.is_empty() {
1644 println!();
1645 println!("{}", "Warnings".bright_yellow().bold());
1646 println!("{}", "─".repeat(72).bright_black());
1647 for warning in &report.warnings {
1648 println!(" {} {}", "!".bright_yellow(), warning);
1649 }
1650 }
1651}
1652
1653fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
1654 entries.iter().map(|entry| entry.version.clone()).max()
1655}
1656
1657fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
1658 let Some(highest) = highest_version_observation(entries) else {
1659 return Vec::new();
1660 };
1661
1662 entries
1663 .iter()
1664 .filter(|entry| entry.version < highest)
1665 .collect()
1666}
1667
1668fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
1669 println!();
1670 println!("{}", title.bright_cyan().bold());
1671 println!("{}", "─".repeat(72).bright_black());
1672
1673 if entries.is_empty() {
1674 println!(" {}", "none found".dimmed());
1675 return;
1676 }
1677
1678 for entry in entries {
1679 let latest_display = match (&entry.latest, &entry.raw_version) {
1680 (Some(version), _) => version.to_string().bright_green().to_string(),
1681 (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
1682 (None, None) => "unavailable".dimmed().to_string(),
1683 };
1684
1685 let note = entry
1686 .note
1687 .as_ref()
1688 .map(|value| format!(" {}", value.bright_yellow()))
1689 .unwrap_or_default();
1690
1691 println!(
1692 " {:<9} {:<28} {:<16} {}{}",
1693 entry.registry.bright_white(),
1694 entry.package_name.bright_white(),
1695 latest_display,
1696 entry.source_file.dimmed(),
1697 note
1698 );
1699 }
1700}
1701
1702#[cfg(test)]
1703fn write_version_to_configured_files(
1704 project_root: &Path,
1705 invocation_dir: &Path,
1706 registry: &[String],
1707 version_scope: &VersionScope,
1708 version: &Version,
1709) -> Result<usize, String> {
1710 write_version_to_configured_files_with_paths(
1711 project_root,
1712 invocation_dir,
1713 registry,
1714 version_scope,
1715 version,
1716 )
1717 .map(|paths| paths.len())
1718}
1719
1720fn write_version_to_configured_files_with_paths(
1721 project_root: &Path,
1722 invocation_dir: &Path,
1723 registry: &[String],
1724 version_scope: &VersionScope,
1725 version: &Version,
1726) -> Result<Vec<PathBuf>, String> {
1727 write_version_to_configured_files_with_paths_internal(
1728 project_root,
1729 invocation_dir,
1730 registry,
1731 version_scope,
1732 version,
1733 false,
1734 )
1735}
1736
1737fn sync_version_to_configured_files_with_paths(
1738 project_root: &Path,
1739 invocation_dir: &Path,
1740 registry: &[String],
1741 version_scope: &VersionScope,
1742 version: &Version,
1743) -> Result<Vec<PathBuf>, String> {
1744 write_version_to_configured_files_with_paths_internal(
1745 project_root,
1746 invocation_dir,
1747 registry,
1748 version_scope,
1749 version,
1750 true,
1751 )
1752}
1753
1754fn write_version_to_configured_files_with_paths_internal(
1755 project_root: &Path,
1756 invocation_dir: &Path,
1757 registry: &[String],
1758 version_scope: &VersionScope,
1759 version: &Version,
1760 allow_noop_when_targets_exist: bool,
1761) -> Result<Vec<PathBuf>, String> {
1762 let mut updated = 0usize;
1763 let mut matched_targets = 0usize;
1764 let mut updated_paths = Vec::new();
1765 let mut errors = Vec::new();
1766
1767 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1768 let path = &entry.absolute;
1769 if !path.exists() {
1770 continue;
1771 }
1772 matched_targets += 1;
1773
1774 match write_version_to_resolved_path(&entry, version) {
1775 Ok(true) => {
1776 updated += 1;
1777 updated_paths.push(path.clone());
1778 }
1779 Ok(false) => {}
1780 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
1781 }
1782 }
1783
1784 if matched_targets == 0 && errors.is_empty() {
1785 return Err("No configured version files were found to update.".to_string());
1786 }
1787
1788 if !errors.is_empty() {
1789 return Err(format!(
1790 "Updated {} file(s), but some version targets failed:\n{}",
1791 updated,
1792 errors.join("\n")
1793 ));
1794 }
1795
1796 if updated == 0 && allow_noop_when_targets_exist {
1797 return Ok(updated_paths);
1798 }
1799
1800 Ok(updated_paths)
1801}
1802
1803fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
1804 let file_name = path
1805 .file_name()
1806 .and_then(|n| n.to_str())
1807 .unwrap_or_default();
1808
1809 match file_name {
1810 "README.md" => read_readme_version(path),
1811 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1812 read_openapi_version(path)
1813 }
1814 "openapi.json" | "swagger.json" => read_json_openapi_version(path),
1815 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1816 | "xbp.json" => read_json_root_version(path),
1817 "deno.json" => read_json_root_version(path),
1818 "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
1819 "Cargo.toml" => read_cargo_toml_version(path),
1820 "Cargo.lock" => read_cargo_lock_version(path),
1821 "pyproject.toml" => read_pyproject_version(path),
1822 "Chart.yaml" => read_yaml_root_version(path, "version"),
1823 "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
1824 "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
1825 "build.gradle" | "build.gradle.kts" => {
1826 read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1827 }
1828 "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
1829 _ => match path.extension().and_then(|ext| ext.to_str()) {
1830 Some("json") => read_json_root_version(path),
1831 Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
1832 Some("toml") => read_toml_root_version(path),
1833 Some("md") => read_readme_version(path),
1834 _ => Ok(None),
1835 },
1836 }
1837}
1838
1839fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
1840 let path = &entry.absolute;
1841 let file_name = path
1842 .file_name()
1843 .and_then(|n| n.to_str())
1844 .unwrap_or_default();
1845
1846 if file_name == "Cargo.lock" {
1847 if let Some(package_name) = entry.cargo_package_override.as_deref() {
1848 return read_cargo_lock_version_for_package(path, package_name);
1849 }
1850 }
1851
1852 read_version_from_path(path)
1853}
1854
1855fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
1856 let file_name = path
1857 .file_name()
1858 .and_then(|n| n.to_str())
1859 .unwrap_or_default();
1860
1861 match file_name {
1862 "README.md" => write_readme_version(path, version).map(|_| true),
1863 "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1864 write_openapi_version(path, version).map(|_| true)
1865 }
1866 "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
1867 "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1868 | "xbp.json" => write_json_root_version(path, version).map(|_| true),
1869 "deno.json" => write_json_root_version(path, version).map(|_| true),
1870 "deno.jsonc" => {
1871 write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
1872 }
1873 "Cargo.toml" => write_cargo_toml_version(path, version),
1874 "Cargo.lock" => write_cargo_lock_version(path, version),
1875 "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
1876 "Chart.yaml" => write_chart_version(path, version).map(|_| true),
1877 "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
1878 "pom.xml" => {
1879 write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
1880 }
1881 "build.gradle" | "build.gradle.kts" => {
1882 write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
1883 .map(|_| true)
1884 }
1885 "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
1886 _ => match path.extension().and_then(|ext| ext.to_str()) {
1887 Some("json") => write_json_root_version(path, version).map(|_| true),
1888 Some("yaml") | Some("yml") => {
1889 write_yaml_root_version(path, "version", version).map(|_| true)
1890 }
1891 Some("toml") => write_toml_root_version(path, version).map(|_| true),
1892 Some("md") => write_readme_version(path, version).map(|_| true),
1893 _ => Err("Unsupported version file type".to_string()),
1894 },
1895 }
1896}
1897
1898fn write_version_to_resolved_path(
1899 entry: &ResolvedRegistryPath,
1900 version: &Version,
1901) -> Result<bool, String> {
1902 let path = &entry.absolute;
1903 let file_name = path
1904 .file_name()
1905 .and_then(|n| n.to_str())
1906 .unwrap_or_default();
1907
1908 if file_name == "Cargo.lock" {
1909 if let Some(package_name) = entry.cargo_package_override.as_deref() {
1910 return write_cargo_lock_version_for_package(path, Some(package_name), version);
1911 }
1912 }
1913
1914 write_version_to_path(path, version)
1915}
1916
1917fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
1918 let value: JsonValue =
1919 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1920 Ok(value
1921 .get("version")
1922 .and_then(JsonValue::as_str)
1923 .map(|value| value.to_string()))
1924}
1925
1926fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
1927 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1928 read_json_root_version_from_content(&content)
1929}
1930
1931fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
1932 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1933 let mut value: JsonValue =
1934 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1935
1936 let object = value
1937 .as_object_mut()
1938 .ok_or_else(|| "Expected a JSON object".to_string())?;
1939 object.insert(
1940 "version".to_string(),
1941 JsonValue::String(version.to_string()),
1942 );
1943
1944 fs::write(
1945 path,
1946 serde_json::to_string_pretty(&value)
1947 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
1948 )
1949 .map_err(|e| format!("Failed to write file: {}", e))
1950}
1951
1952fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
1953 let value: YamlValue =
1954 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1955 Ok(yaml_get_string(&value, key))
1956}
1957
1958fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
1959 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1960 read_yaml_root_version_from_content(&content, key)
1961}
1962
1963fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
1964 let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1965 let mut value: YamlValue =
1966 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1967
1968 let mapping = yaml_root_mapping_mut(&mut value)?;
1969 mapping.insert(
1970 YamlValue::String(key.to_string()),
1971 YamlValue::String(version.to_string()),
1972 );
1973
1974 fs::write(
1975 path,
1976 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
1977 )
1978 .map_err(|e| format!("Failed to write file: {}", e))
1979}
1980
1981fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
1982 let value: YamlValue =
1983 serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1984
1985 let info = yaml_get_mapping(&value, "info");
1986 Ok(info.and_then(|mapping| {
1987 mapping
1988 .get(YamlValue::String("version".to_string()))
1989 .and_then(YamlValue::as_str)
1990 .map(|value| value.to_string())
1991 }))
1992}
1993
1994fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
1995 let content: String =
1996 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1997 read_openapi_version_from_content(&content)
1998}
1999
2000fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
2001 let value: JsonValue =
2002 serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2003 Ok(value
2004 .get("info")
2005 .and_then(JsonValue::as_object)
2006 .and_then(|info| info.get("version"))
2007 .and_then(JsonValue::as_str)
2008 .map(|value| value.to_string()))
2009}
2010
2011fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
2012 let content: String =
2013 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2014 read_json_openapi_version_from_content(&content)
2015}
2016
2017fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2018 let content: String =
2019 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2020 let mut value: YamlValue =
2021 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2022
2023 let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2024 let info_key: YamlValue = YamlValue::String("info".to_string());
2025 if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
2026 root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
2027 }
2028
2029 let info: &mut YamlMapping = root
2030 .get_mut(&info_key)
2031 .and_then(YamlValue::as_mapping_mut)
2032 .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
2033 info.insert(
2034 YamlValue::String("version".to_string()),
2035 YamlValue::String(version.to_string()),
2036 );
2037
2038 fs::write(
2039 path,
2040 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2041 )
2042 .map_err(|e| format!("Failed to write file: {}", e))
2043}
2044
2045fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2046 let content: String =
2047 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2048 let mut value: JsonValue =
2049 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2050
2051 let root = value
2052 .as_object_mut()
2053 .ok_or_else(|| "Expected a JSON object".to_string())?;
2054 let info = root
2055 .entry("info".to_string())
2056 .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
2057 let info_object = info
2058 .as_object_mut()
2059 .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
2060 info_object.insert(
2061 "version".to_string(),
2062 JsonValue::String(version.to_string()),
2063 );
2064
2065 fs::write(
2066 path,
2067 serde_json::to_string_pretty(&value)
2068 .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
2069 )
2070 .map_err(|e| format!("Failed to write file: {}", e))
2071}
2072
2073fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
2074 let value: TomlValue =
2075 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2076 Ok(value
2077 .get("version")
2078 .and_then(TomlValue::as_str)
2079 .map(|value| value.to_string()))
2080}
2081
2082fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
2083 let content: String =
2084 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2085 read_toml_root_version_from_content(&content)
2086}
2087
2088fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
2089 let content: String =
2090 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2091 let mut value: TomlValue =
2092 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2093 let table = value
2094 .as_table_mut()
2095 .ok_or_else(|| "Expected a TOML table".to_string())?;
2096 table.insert(
2097 "version".to_string(),
2098 TomlValue::String(version.to_string()),
2099 );
2100 fs::write(
2101 path,
2102 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2103 )
2104 .map_err(|e| format!("Failed to write file: {}", e))
2105}
2106
2107fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
2108 let value: TomlValue =
2109 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2110 Ok(value
2111 .get("package")
2112 .and_then(TomlValue::as_table)
2113 .and_then(|package| package.get("version"))
2114 .and_then(TomlValue::as_str)
2115 .map(|value| value.to_string()))
2116}
2117
2118fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
2119 let content: String =
2120 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2121 read_cargo_toml_version_from_content(&content)
2122}
2123
2124fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
2125 let content: String =
2126 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2127 let mut value: TomlValue =
2128 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2129
2130 let Some(package) = value.get_mut("package").and_then(TomlValue::as_table_mut) else {
2131 return Ok(false);
2132 };
2133 package.insert(
2134 "version".to_string(),
2135 TomlValue::String(version.to_string()),
2136 );
2137
2138 fs::write(
2139 path,
2140 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2141 )
2142 .map_err(|e| format!("Failed to write file: {}", e))?;
2143
2144 Ok(true)
2145}
2146
2147fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
2148 let value: TomlValue =
2149 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2150
2151 let project_version = value
2152 .get("project")
2153 .and_then(TomlValue::as_table)
2154 .and_then(|project| project.get("version"))
2155 .and_then(TomlValue::as_str);
2156
2157 let poetry_version = value
2158 .get("tool")
2159 .and_then(TomlValue::as_table)
2160 .and_then(|tool| tool.get("poetry"))
2161 .and_then(TomlValue::as_table)
2162 .and_then(|poetry| poetry.get("version"))
2163 .and_then(TomlValue::as_str);
2164
2165 Ok(project_version
2166 .or(poetry_version)
2167 .map(|value| value.to_string()))
2168}
2169
2170fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
2171 let content: String =
2172 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2173 read_pyproject_version_from_content(&content)
2174}
2175
2176fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
2177 let content: String =
2178 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2179 let mut value: TomlValue =
2180 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2181
2182 if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
2183 project.insert(
2184 "version".to_string(),
2185 TomlValue::String(version.to_string()),
2186 );
2187 } else if let Some(poetry) = value
2188 .get_mut("tool")
2189 .and_then(TomlValue::as_table_mut)
2190 .and_then(|tool| tool.get_mut("poetry"))
2191 .and_then(TomlValue::as_table_mut)
2192 {
2193 poetry.insert(
2194 "version".to_string(),
2195 TomlValue::String(version.to_string()),
2196 );
2197 } else {
2198 let table: &mut toml::map::Map<String, TomlValue> = value
2199 .as_table_mut()
2200 .ok_or_else(|| "Expected a TOML table".to_string())?;
2201 table.insert(
2202 "version".to_string(),
2203 TomlValue::String(version.to_string()),
2204 );
2205 }
2206
2207 fs::write(
2208 path,
2209 toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2210 )
2211 .map_err(|e| format!("Failed to write file: {}", e))
2212}
2213
2214fn read_cargo_lock_version_from_content(
2215 content: &str,
2216 cargo_toml_content: Option<&str>,
2217) -> Result<Option<String>, String> {
2218 read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
2219}
2220
2221fn read_cargo_lock_version_from_content_with_package(
2222 content: &str,
2223 cargo_toml_content: Option<&str>,
2224 package_name_override: Option<&str>,
2225) -> Result<Option<String>, String> {
2226 let value: TomlValue =
2227 toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2228 let package_name = if let Some(package_name_override) = package_name_override {
2229 package_name_override.trim().to_string()
2230 } else {
2231 let cargo_toml_content = cargo_toml_content
2232 .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
2233 cargo_package_name_from_content(cargo_toml_content)?
2234 };
2235
2236 Ok(value
2237 .get("package")
2238 .and_then(TomlValue::as_array)
2239 .and_then(|packages| {
2240 packages.iter().find_map(|package| {
2241 let table = package.as_table()?;
2242 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2243 table
2244 .get("version")
2245 .and_then(TomlValue::as_str)
2246 .map(|value| value.to_string())
2247 } else {
2248 None
2249 }
2250 })
2251 }))
2252}
2253
2254fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
2255 let content: String =
2256 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2257 let cargo_toml: String = fs::read_to_string(
2258 path.parent()
2259 .unwrap_or_else(|| Path::new("."))
2260 .join("Cargo.toml"),
2261 )
2262 .map_err(|e| format!("Failed to read file: {}", e))?;
2263 read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
2264}
2265
2266fn read_cargo_lock_version_for_package(
2267 path: &Path,
2268 package_name: &str,
2269) -> Result<Option<String>, String> {
2270 let content: String =
2271 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2272 read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
2273}
2274
2275fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
2276 write_cargo_lock_version_for_package(path, None, version)
2277}
2278
2279fn write_cargo_lock_version_for_package(
2280 path: &Path,
2281 package_name_override: Option<&str>,
2282 version: &Version,
2283) -> Result<bool, String> {
2284 let content: String =
2285 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2286 let mut value: TomlValue =
2287 toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2288 let package_name = if let Some(package_name_override) = package_name_override {
2289 package_name_override.trim().to_string()
2290 } else {
2291 let Some(package_name) = cargo_package_name(path)? else {
2292 return Ok(false);
2293 };
2294 package_name
2295 };
2296
2297 let packages: &mut Vec<TomlValue> = value
2298 .get_mut("package")
2299 .and_then(TomlValue::as_array_mut)
2300 .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
2301
2302 let mut updated = false;
2303 for package in packages {
2304 if let Some(table) = package.as_table_mut() {
2305 if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2306 table.insert(
2307 "version".to_string(),
2308 TomlValue::String(version.to_string()),
2309 );
2310 updated = true;
2311 }
2312 }
2313 }
2314
2315 if !updated {
2316 return Err(format!(
2317 "Could not find package `{}` in Cargo.lock",
2318 package_name
2319 ));
2320 }
2321
2322 fs::write(
2323 path,
2324 toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2325 )
2326 .map_err(|e| format!("Failed to write file: {}", e))?;
2327
2328 Ok(true)
2329}
2330
2331fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
2332 let cargo_toml: PathBuf = path
2333 .parent()
2334 .unwrap_or_else(|| Path::new("."))
2335 .join("Cargo.toml");
2336 let content: String = fs::read_to_string(&cargo_toml)
2337 .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
2338 cargo_package_name_from_content_optional(&content)
2339}
2340
2341fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
2342 cargo_package_name_from_content_optional(content)?
2343 .ok_or_else(|| "Could not determine Cargo package name".to_string())
2344}
2345
2346fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
2347 let value: TomlValue =
2348 toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
2349 Ok(value
2350 .get("package")
2351 .and_then(TomlValue::as_table)
2352 .and_then(|package| package.get("name"))
2353 .and_then(TomlValue::as_str)
2354 .map(|value| value.to_string()))
2355}
2356
2357fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
2358 read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2359}
2360
2361fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
2362 let content: String =
2363 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2364 read_readme_version_from_content(&content)
2365}
2366
2367fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
2368 let content: String =
2369 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2370 let marker: String = format!("current version: `{}`", version);
2371 let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2372 .map_err(|e| format!("Failed to build README regex: {}", e))?;
2373
2374 let updated: String = if regex.is_match(&content) {
2375 regex.replace(&content, marker.as_str()).to_string()
2376 } else if let Some(first_break) = content.find('\n') {
2377 let mut next = String::new();
2378 next.push_str(&content[..=first_break]);
2379 next.push('\n');
2380 next.push_str(&marker);
2381 next.push('\n');
2382 next.push_str(&content[first_break + 1..]);
2383 next
2384 } else {
2385 format!("{}\n\n{}\n", content, marker)
2386 };
2387
2388 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2389}
2390
2391fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
2392 let content: String =
2393 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2394 read_regex_version_from_content(&content, pattern)
2395}
2396
2397fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
2398 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2399 Ok(regex
2400 .captures(content)
2401 .and_then(|captures| captures.get(1))
2402 .map(|matched| matched.as_str().trim().to_string()))
2403}
2404
2405fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
2406 let content: String =
2407 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2408 let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2409
2410 if !regex.is_match(&content) {
2411 return Err("No version pattern found".to_string());
2412 }
2413
2414 let updated: String = regex
2415 .replace(&content, |caps: ®ex::Captures<'_>| {
2416 caps[0].replace(&caps[1], &version.to_string())
2417 })
2418 .to_string();
2419 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2420}
2421
2422#[cfg(test)]
2423fn write_package_version_to_configured_files(
2424 project_root: &Path,
2425 invocation_dir: &Path,
2426 registry: &[String],
2427 version_scope: &VersionScope,
2428 package_name: &str,
2429 version: &Version,
2430) -> Result<usize, String> {
2431 write_package_version_to_configured_files_with_paths(
2432 project_root,
2433 invocation_dir,
2434 registry,
2435 version_scope,
2436 package_name,
2437 version,
2438 )
2439 .map(|paths| paths.len())
2440}
2441
2442fn write_package_version_to_configured_files_with_paths(
2443 project_root: &Path,
2444 invocation_dir: &Path,
2445 registry: &[String],
2446 version_scope: &VersionScope,
2447 package_name: &str,
2448 version: &Version,
2449) -> Result<Vec<PathBuf>, String> {
2450 let mut updated: usize = 0usize;
2451 let mut updated_paths = Vec::new();
2452 let mut errors: Vec<String> = Vec::new();
2453
2454 for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2455 let path: &PathBuf = &entry.absolute;
2456 if !path.exists() {
2457 continue;
2458 }
2459
2460 match write_package_version_to_path(path, package_name, version) {
2461 Ok(true) => {
2462 updated += 1;
2463 updated_paths.push(path.clone());
2464 }
2465 Ok(false) => {}
2466 Err(err) => errors.push(format!("{}: {}", path.display(), err)),
2467 }
2468 }
2469
2470 if updated == 0 && errors.is_empty() {
2471 return Err(format!(
2472 "No configured TOML files contained package assignment `{}`.",
2473 package_name
2474 ));
2475 }
2476
2477 if !errors.is_empty() {
2478 return Err(format!(
2479 "Updated {} file(s), but some package version targets failed:\n{}",
2480 updated,
2481 errors.join("\n")
2482 ));
2483 }
2484
2485 Ok(updated_paths)
2486}
2487
2488fn write_package_version_to_path(
2489 path: &Path,
2490 package_name: &str,
2491 version: &Version,
2492) -> Result<bool, String> {
2493 let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
2494 if !is_toml {
2495 return Ok(false);
2496 }
2497
2498 let content: String =
2499 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2500 let (updated, changed) =
2501 rewrite_toml_package_assignment_versions(&content, package_name, version)?;
2502 if changed {
2503 fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
2504 }
2505 Ok(changed)
2506}
2507
2508fn rewrite_toml_package_assignment_versions(
2509 content: &str,
2510 package_name: &str,
2511 version: &Version,
2512) -> Result<(String, bool), String> {
2513 let escaped_name: String = regex::escape(package_name);
2514 let inline_pattern: String = format!(
2515 r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
2516 escaped_name
2517 );
2518 let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
2519
2520 let inline_regex: Regex =
2521 Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
2522 let string_regex: Regex =
2523 Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
2524
2525 let replacement: String = version.to_string();
2526
2527 let after_inline: String = inline_regex
2528 .replace_all(content, |caps: ®ex::Captures<'_>| {
2529 format!("{}{}{}", &caps[1], replacement, &caps[3])
2530 })
2531 .to_string();
2532 let after_string: String = string_regex
2533 .replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
2534 format!("{}{}{}", &caps[1], replacement, &caps[3])
2535 })
2536 .to_string();
2537
2538 Ok((after_string.clone(), after_string != content))
2539}
2540
2541fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
2542 let content: String =
2543 fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2544 let mut value: YamlValue =
2545 serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2546 let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2547 mapping.insert(
2548 YamlValue::String("version".to_string()),
2549 YamlValue::String(version.to_string()),
2550 );
2551 if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
2552 mapping.insert(
2553 YamlValue::String("appVersion".to_string()),
2554 YamlValue::String(version.to_string()),
2555 );
2556 }
2557 fs::write(
2558 path,
2559 serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2560 )
2561 .map_err(|e| format!("Failed to write file: {}", e))
2562}
2563
2564fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
2565 value
2566 .as_mapping_mut()
2567 .ok_or_else(|| "Expected a YAML mapping".to_string())
2568}
2569
2570fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
2571 value
2572 .as_mapping()
2573 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2574 .and_then(YamlValue::as_mapping)
2575}
2576
2577fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
2578 value
2579 .as_mapping()
2580 .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2581 .and_then(YamlValue::as_str)
2582 .map(|value| value.to_string())
2583}
2584
2585fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
2586 let mut current: &JsonValue = value;
2587 for part in key_parts {
2588 current = current.get(*part)?;
2589 }
2590 current.as_str().map(|value| value.to_string())
2591}
2592
2593fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
2594 let mut current = value;
2595 for part in key_parts {
2596 let mapping = current.as_mapping()?;
2597 current = mapping.get(YamlValue::String((*part).to_string()))?;
2598 }
2599 current.as_str().map(|value| value.to_string())
2600}
2601
2602fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
2603 let mut current = value;
2604 for part in key_parts {
2605 current = current.get(*part)?;
2606 }
2607 current.as_str().map(|value| value.to_string())
2608}
2609
2610fn parse_version(input: &str) -> Result<Version, String> {
2611 let trimmed: &str = input.trim();
2612 let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
2613 Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
2614}
2615
2616fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
2617 let trimmed = input.trim();
2618 if trimmed.is_empty() {
2619 return Err("Release version cannot be empty.".to_string());
2620 }
2621
2622 if let Ok(version) = parse_version(trimmed) {
2623 return Ok((version.clone(), format!("v{}", version)));
2624 }
2625
2626 let prefixed = Regex::new(
2627 r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
2628 )
2629 .map_err(|e| format!("Failed to build release target parser: {}", e))?;
2630
2631 if let Some(captures) = prefixed.captures(trimmed) {
2632 let semver = captures
2633 .name("semver")
2634 .map(|m| m.as_str())
2635 .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
2636 let version = Version::parse(semver)
2637 .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
2638 return Ok((version, trimmed.to_string()));
2639 }
2640
2641 Err(format!(
2642 "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
2643 input
2644 ))
2645}
2646
2647fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
2648 let Some((raw_package, raw_version)) = input.split_once('=') else {
2649 return Ok(None);
2650 };
2651
2652 let package_name = raw_package.trim();
2653 if package_name.is_empty() {
2654 return Ok(None);
2655 }
2656
2657 let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
2658 .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
2659 if !package_name_regex.is_match(package_name) {
2660 return Err(format!(
2661 "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
2662 input
2663 ));
2664 }
2665
2666 let version = parse_version(raw_version.trim())?;
2667 Ok(Some((package_name.to_string(), version)))
2668}
2669
2670fn bump_version(current: &Version, kind: &str) -> Version {
2671 let mut next = current.clone();
2672 match kind {
2673 "major" => {
2674 next.major += 1;
2675 next.minor = 0;
2676 next.patch = 0;
2677 next.pre = semver::Prerelease::EMPTY;
2678 next.build = semver::BuildMetadata::EMPTY;
2679 }
2680 "minor" => {
2681 next.minor += 1;
2682 next.patch = 0;
2683 next.pre = semver::Prerelease::EMPTY;
2684 next.build = semver::BuildMetadata::EMPTY;
2685 }
2686 _ => {
2687 next.patch += 1;
2688 next.pre = semver::Prerelease::EMPTY;
2689 next.build = semver::BuildMetadata::EMPTY;
2690 }
2691 }
2692 next
2693}
2694
2695fn default_version() -> Version {
2696 Version::new(0, 1, 0)
2697}
2698
2699fn resolve_registry_paths(
2700 project_root: &Path,
2701 invocation_dir: &Path,
2702 registry: &[String],
2703 version_scope: &VersionScope,
2704) -> Vec<ResolvedRegistryPath> {
2705 let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
2706 let mut seen: BTreeSet<String> = BTreeSet::new();
2707 let workspace_primary_target = match version_scope {
2708 VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
2709 VersionScope::Crate { .. } => None,
2710 };
2711
2712 for relative in registry {
2713 match version_scope {
2714 VersionScope::Repository => {
2715 if *relative == *"Cargo.lock" {
2716 let resolved_relative = resolve_registry_relative_path(
2717 project_root,
2718 invocation_dir,
2719 version_scope,
2720 relative,
2721 );
2722 if !seen.insert(resolved_relative.clone()) {
2723 continue;
2724 }
2725
2726 resolved.push(ResolvedRegistryPath {
2727 absolute: project_root.join(&resolved_relative),
2728 relative: resolved_relative,
2729 cargo_package_override: workspace_primary_target
2730 .as_ref()
2731 .map(|target| target.package_name.clone()),
2732 });
2733 continue;
2734 }
2735
2736 if *relative == *"Cargo.toml" {
2737 if let Some(target) = workspace_primary_target.as_ref() {
2738 if !seen.insert(target.manifest_relative.clone()) {
2739 continue;
2740 }
2741
2742 resolved.push(ResolvedRegistryPath {
2743 absolute: target.manifest_absolute.clone(),
2744 relative: target.manifest_relative.clone(),
2745 cargo_package_override: None,
2746 });
2747 continue;
2748 }
2749 }
2750
2751 let resolved_relative = resolve_registry_relative_path(
2752 project_root,
2753 invocation_dir,
2754 version_scope,
2755 relative,
2756 );
2757 if !seen.insert(resolved_relative.clone()) {
2758 continue;
2759 }
2760
2761 resolved.push(ResolvedRegistryPath {
2762 absolute: project_root.join(&resolved_relative),
2763 relative: resolved_relative,
2764 cargo_package_override: None,
2765 });
2766 }
2767 VersionScope::Crate {
2768 crate_root,
2769 package_name,
2770 ..
2771 } => {
2772 if *relative == *"Cargo.lock" {
2773 let cargo_lock = project_root.join("Cargo.lock");
2774 if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
2775 resolved.push(ResolvedRegistryPath {
2776 absolute: cargo_lock,
2777 relative: "Cargo.lock".to_string(),
2778 cargo_package_override: Some(package_name.clone()),
2779 });
2780 }
2781 continue;
2782 }
2783
2784 let preferred = crate_root.join(relative);
2785 if !preferred.exists() {
2786 continue;
2787 }
2788 let Ok(stripped) = preferred.strip_prefix(project_root) else {
2789 continue;
2790 };
2791 let resolved_relative = normalized_relative_path(stripped);
2792 if !seen.insert(resolved_relative.clone()) {
2793 continue;
2794 }
2795
2796 resolved.push(ResolvedRegistryPath {
2797 absolute: preferred,
2798 relative: resolved_relative,
2799 cargo_package_override: None,
2800 });
2801 }
2802 }
2803 }
2804
2805 for configured_path in
2806 resolve_configured_publish_manifest_paths(project_root, invocation_dir, version_scope)
2807 {
2808 if seen.insert(configured_path.relative.clone()) {
2809 resolved.push(configured_path);
2810 }
2811 }
2812
2813 resolved
2814}
2815
2816fn resolve_configured_publish_manifest_paths(
2817 project_root: &Path,
2818 invocation_dir: &Path,
2819 version_scope: &VersionScope,
2820) -> Vec<ResolvedRegistryPath> {
2821 let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
2822 return Vec::new();
2823 };
2824
2825 let manifest_paths = config
2826 .publish
2827 .into_iter()
2828 .flat_map(|publish| [publish.npm, publish.crates])
2829 .flatten()
2830 .filter_map(|target| target.manifest_path)
2831 .map(PathBuf::from)
2832 .collect::<Vec<_>>();
2833
2834 let crate_root = match version_scope {
2835 VersionScope::Repository => None,
2836 VersionScope::Crate { crate_root, .. } => Some(crate_root),
2837 };
2838
2839 manifest_paths
2840 .into_iter()
2841 .filter_map(|manifest_path| {
2842 let absolute = if manifest_path.is_absolute() {
2843 manifest_path
2844 } else {
2845 config_root.join(manifest_path)
2846 };
2847 if !absolute.exists() {
2848 return None;
2849 }
2850 if let Some(crate_root) = crate_root {
2851 if !absolute.starts_with(crate_root) {
2852 return None;
2853 }
2854 }
2855 let relative = absolute
2856 .strip_prefix(project_root)
2857 .ok()
2858 .map(normalized_relative_path)
2859 .unwrap_or_else(|| normalized_relative_path(&absolute));
2860 Some(ResolvedRegistryPath {
2861 relative,
2862 absolute,
2863 cargo_package_override: None,
2864 })
2865 })
2866 .collect()
2867}
2868
2869fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
2870 let found = find_xbp_config_upwards(invocation_dir)?;
2871 let config_path = if found.kind == "json" {
2872 maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
2873 .ok()
2874 .flatten()
2875 .unwrap_or_else(|| found.config_path.clone())
2876 } else {
2877 found.config_path.clone()
2878 };
2879
2880 let kind = if config_path
2881 .extension()
2882 .and_then(|ext| ext.to_str())
2883 .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
2884 .unwrap_or(false)
2885 {
2886 "yaml"
2887 } else {
2888 "json"
2889 };
2890
2891 let content = fs::read_to_string(&config_path).ok()?;
2892 let (mut config, _healed): (XbpConfig, Option<String>) =
2893 parse_config_with_auto_heal(&content, kind).ok()?;
2894 resolve_config_paths_for_runtime(&mut config, &found.project_root);
2895 Some((found.project_root, config))
2896}
2897
2898fn resolve_registry_relative_path(
2899 project_root: &Path,
2900 invocation_dir: &Path,
2901 version_scope: &VersionScope,
2902 relative: &str,
2903) -> String {
2904 if let VersionScope::Crate { crate_root, .. } = version_scope {
2905 let preferred = crate_root.join(relative);
2906 if preferred.exists() {
2907 if let Ok(stripped) = preferred.strip_prefix(project_root) {
2908 return normalized_relative_path(stripped);
2909 }
2910 }
2911 return relative.replace('\\', "/");
2912 }
2913
2914 if relative == "Cargo.toml" {
2915 if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
2916 return target.manifest_relative;
2917 }
2918 }
2919
2920 let preferred: PathBuf = invocation_dir.join(relative);
2921 if preferred.exists() {
2922 if let Ok(stripped) = preferred.strip_prefix(project_root) {
2923 return normalized_relative_path(stripped);
2924 }
2925 }
2926
2927 relative.replace('\\', "/")
2928}
2929
2930fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
2931 if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
2932 return crate_scope;
2933 }
2934
2935 VersionScope::Repository
2936}
2937
2938fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
2939 let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
2940 let cargo_toml = crate_root.join("Cargo.toml");
2941 let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
2942 let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
2943 let crate_relative_root = crate_root
2944 .strip_prefix(project_root)
2945 .ok()
2946 .map(normalized_relative_path)?;
2947
2948 Some(VersionScope::Crate {
2949 crate_root,
2950 crate_relative_root,
2951 tag_prefix: format!("{}-", package_name),
2952 package_name,
2953 })
2954}
2955
2956fn resolve_release_openapi_spec(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
2957 let mut roots: Vec<PathBuf> = Vec::new();
2958 if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
2959 roots.push(crate_root);
2960 }
2961 roots.push(project_root.to_path_buf());
2962
2963 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
2964 for root in roots {
2965 if !seen.insert(root.clone()) {
2966 continue;
2967 }
2968
2969 for file_name in [
2970 "openapi.yaml",
2971 "openapi.yml",
2972 "openapi.json",
2973 "swagger.yaml",
2974 "swagger.yml",
2975 "swagger.json",
2976 ] {
2977 let path = root.join(file_name);
2978 if path.is_file() {
2979 return Some(path);
2980 }
2981 }
2982 }
2983
2984 None
2985}
2986
2987fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
2988 let crates_root = project_root.join("crates");
2989 let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
2990 let mut components = relative.components();
2991 let crate_name = components.next()?;
2992 Some(crates_root.join(crate_name.as_os_str()))
2993}
2994
2995fn resolve_workspace_primary_cargo_target(
2996 project_root: &Path,
2997) -> Option<WorkspacePrimaryCargoTarget> {
2998 let workspace_manifest = project_root.join("Cargo.toml");
2999 let content = fs::read_to_string(&workspace_manifest).ok()?;
3000 let value: TomlValue = toml::from_str(&content).ok()?;
3001 if value.get("package").and_then(TomlValue::as_table).is_some() {
3002 return None;
3003 }
3004
3005 let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
3006 let mut candidate_roots = workspace
3007 .get("default-members")
3008 .and_then(TomlValue::as_array)
3009 .map(|members| {
3010 members
3011 .iter()
3012 .filter_map(TomlValue::as_str)
3013 .map(str::trim)
3014 .filter(|member| !member.is_empty() && !member.contains('*'))
3015 .map(str::to_string)
3016 .collect::<Vec<_>>()
3017 })
3018 .unwrap_or_default();
3019
3020 if candidate_roots.is_empty() {
3021 let members = workspace
3022 .get("members")
3023 .and_then(TomlValue::as_array)
3024 .map(|members| {
3025 members
3026 .iter()
3027 .filter_map(TomlValue::as_str)
3028 .map(str::trim)
3029 .filter(|member| !member.is_empty() && !member.contains('*'))
3030 .map(str::to_string)
3031 .collect::<Vec<_>>()
3032 })
3033 .unwrap_or_default();
3034 if members.len() == 1 {
3035 candidate_roots = members;
3036 }
3037 }
3038
3039 candidate_roots.sort();
3040 candidate_roots.dedup();
3041 if candidate_roots.len() != 1 {
3042 return None;
3043 }
3044
3045 let crate_relative_root = candidate_roots.into_iter().next()?;
3046 let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
3047 let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
3048 let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
3049
3050 Some(WorkspacePrimaryCargoTarget {
3051 manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
3052 manifest_absolute,
3053 package_name,
3054 })
3055}
3056
3057async fn resolve_effective_linear_release_config(
3058 project_root: &Path,
3059 invocation_dir: &Path,
3060) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
3061 let global_config = resolve_global_linear_release_config();
3062 let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
3063 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
3064 config.linear.and_then(|linear| linear.release)
3065 } else {
3066 None
3067 };
3068
3069 Ok(resolve_linear_release_config(global_config, project_config)
3070 .map(|config| resolve_linear_release_placeholders(project_root, config)))
3071}
3072
3073fn resolve_linear_release_placeholders(
3074 project_root: &Path,
3075 mut config: ResolvedLinearReleaseConfig,
3076) -> ResolvedLinearReleaseConfig {
3077 let mut env_map = HashMap::new();
3078 for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
3079 env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
3080 }
3081 if let Some(organization_name) = config.organization_name.clone() {
3082 env_map.insert("organization_name".to_string(), organization_name);
3083 }
3084
3085 let resolved = resolve_env_placeholders(project_root, &env_map);
3086 config.initiative_ids = config
3087 .initiative_ids
3088 .iter()
3089 .enumerate()
3090 .map(|(index, initiative_id)| {
3091 resolved
3092 .get(&format!("initiative_id_{}", index))
3093 .cloned()
3094 .unwrap_or_else(|| initiative_id.clone())
3095 })
3096 .map(|value| value.trim().to_string())
3097 .filter(|value| !value.is_empty())
3098 .collect();
3099 config.organization_name = resolved
3100 .get("organization_name")
3101 .cloned()
3102 .or(config.organization_name)
3103 .map(|value| value.trim().to_string())
3104 .filter(|value| !value.is_empty());
3105 config
3106}
3107
3108async fn resolve_project_github_release_branch_config(
3109 _project_root: &Path,
3110 invocation_dir: &Path,
3111) -> Result<Option<GitHubReleaseBranchSettings>, String> {
3112 if let Some(found) = find_xbp_config_upwards(invocation_dir) {
3113 let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
3114 Ok(config.github_release_branch_settings())
3115 } else {
3116 Ok(None)
3117 }
3118}
3119
3120fn normalized_relative_path(path: &Path) -> String {
3121 path.to_string_lossy().replace('\\', "/")
3122}
3123
3124fn git_repository_root(dir: &Path) -> Option<PathBuf> {
3125 if !command_exists("git") {
3126 return None;
3127 }
3128
3129 let output: std::process::Output = Command::new("git")
3130 .current_dir(dir)
3131 .args(["rev-parse", "--show-toplevel"])
3132 .output()
3133 .ok()?;
3134
3135 if !output.status.success() {
3136 return None;
3137 }
3138
3139 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
3140 if root.is_empty() {
3141 None
3142 } else {
3143 Some(PathBuf::from(root))
3144 }
3145}
3146
3147fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
3148 let output: std::process::Output = Command::new("git")
3149 .current_dir(project_root)
3150 .args(args)
3151 .output()
3152 .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
3153
3154 if !output.status.success() {
3155 let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
3156 if stderr.is_empty() {
3157 return Err(format!(
3158 "`git {}` failed with status {}",
3159 args.join(" "),
3160 output.status
3161 ));
3162 }
3163 return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
3164 }
3165
3166 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
3167}
3168
3169fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
3170 let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
3171 Ok(output
3172 .lines()
3173 .map(|line| line.trim())
3174 .filter(|line| !line.is_empty())
3175 .map(|line| line.to_string())
3176 .collect())
3177}
3178
3179fn version_change_guard_state_path() -> Result<PathBuf, String> {
3180 let paths = global_xbp_paths()?;
3181 Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
3182}
3183
3184fn version_change_guard_repo_key(project_root: &Path) -> String {
3185 fs::canonicalize(project_root)
3186 .unwrap_or_else(|_| project_root.to_path_buf())
3187 .to_string_lossy()
3188 .replace('\\', "/")
3189}
3190
3191fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
3192 if !path.exists() {
3193 return Ok(VersionChangeGuardRegistry::default());
3194 }
3195
3196 let content = fs::read_to_string(path).map_err(|e| {
3197 format!(
3198 "Failed to read version-change guard state {}: {}",
3199 path.display(),
3200 e
3201 )
3202 })?;
3203
3204 Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
3205}
3206
3207fn save_version_change_guard_registry(
3208 path: &Path,
3209 registry: &VersionChangeGuardRegistry,
3210) -> Result<(), String> {
3211 if let Some(parent) = path.parent() {
3212 fs::create_dir_all(parent).map_err(|e| {
3213 format!(
3214 "Failed to create guard state directory {}: {}",
3215 parent.display(),
3216 e
3217 )
3218 })?;
3219 }
3220
3221 let content = serde_yaml::to_string(registry)
3222 .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
3223 fs::write(path, content).map_err(|e| {
3224 format!(
3225 "Failed to write version-change guard state {}: {}",
3226 path.display(),
3227 e
3228 )
3229 })
3230}
3231
3232fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
3233 if !command_exists("git") {
3234 return Ok(None);
3235 }
3236
3237 let status_output: std::process::Output = Command::new("git")
3238 .current_dir(project_root)
3239 .args(["status", "--porcelain"])
3240 .output()
3241 .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
3242 if !status_output.status.success() {
3243 return Ok(None);
3244 }
3245
3246 let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
3247 .lines()
3248 .any(|line| !line.trim().is_empty());
3249
3250 let head_output: std::process::Output = Command::new("git")
3251 .current_dir(project_root)
3252 .args(["rev-parse", "HEAD"])
3253 .output()
3254 .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
3255 let head_commit: Option<String> = if head_output.status.success() {
3256 let value: String = String::from_utf8_lossy(&head_output.stdout)
3257 .trim()
3258 .to_string();
3259 if value.is_empty() {
3260 None
3261 } else {
3262 Some(value)
3263 }
3264 } else {
3265 None
3266 };
3267
3268 Ok(Some(GitWorktreeState {
3269 is_dirty,
3270 head_commit,
3271 }))
3272}
3273
3274fn should_clear_version_change_guard(
3275 entry: &VersionChangeGuardEntry,
3276 state: &GitWorktreeState,
3277) -> bool {
3278 if entry.pending_version_change_count == 0 {
3279 return true;
3280 }
3281 if !state.is_dirty {
3282 return true;
3283 }
3284
3285 match (&entry.head_commit, &state.head_commit) {
3286 (Some(previous), Some(current)) => previous != current,
3287 (Some(_), None) => true,
3288 _ => false,
3289 }
3290}
3291
3292fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
3293 let Some(state) = git_worktree_state(project_root)? else {
3294 return Ok(());
3295 };
3296
3297 let state_path: PathBuf = version_change_guard_state_path()?;
3298 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3299 let repo_key: String = version_change_guard_repo_key(project_root);
3300 let mut changed = false;
3301
3302 if let Some(entry) = registry.entries.get(&repo_key).cloned() {
3303 if should_clear_version_change_guard(&entry, &state) {
3304 registry.entries.remove(&repo_key);
3305 changed = true;
3306 }
3307 }
3308
3309 if changed {
3310 save_version_change_guard_registry(&state_path, ®istry)?;
3311 }
3312
3313 if state.is_dirty {
3314 if let Some(entry) = registry.entries.get(&repo_key) {
3315 if entry.pending_version_change_count >= 1 {
3316 return Err(format!(
3317 "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
3318 entry.pending_version_change_count,
3319 state_path.display()
3320 ));
3321 }
3322 }
3323 }
3324
3325 Ok(())
3326}
3327
3328fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
3329 let Some(state) = git_worktree_state(project_root)? else {
3330 return Ok(());
3331 };
3332
3333 let state_path: PathBuf = version_change_guard_state_path()?;
3334 let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3335 let repo_key: String = version_change_guard_repo_key(project_root);
3336
3337 if state.is_dirty {
3338 registry.entries.insert(
3339 repo_key,
3340 VersionChangeGuardEntry {
3341 pending_version_change_count: 1,
3342 head_commit: state.head_commit,
3343 },
3344 );
3345 } else {
3346 registry.entries.remove(&repo_key);
3347 }
3348
3349 save_version_change_guard_registry(&state_path, ®istry)
3350}
3351
3352fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
3353 let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
3354 Ok(!output.trim().is_empty())
3355}
3356
3357fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
3358 let remotes: String = run_git_command(project_root, &["remote"])?;
3359 let exists: bool = remotes.lines().any(|line| line.trim() == remote);
3360 if exists {
3361 Ok(())
3362 } else {
3363 Err(format!(
3364 "Git remote `{}` is not configured for this repository.",
3365 remote
3366 ))
3367 }
3368}
3369
3370fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
3371 run_git_command(project_root, &["remote", "get-url", remote])
3372}
3373
3374fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
3375 let query: String = format!("refs/tags/{}", tag);
3376 let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
3377 Ok(!output.trim().is_empty())
3378}
3379
3380fn git_head_commitish(project_root: &Path) -> Result<String, String> {
3381 let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
3382 if commitish.is_empty() {
3383 Err("Unable to resolve HEAD commit for release target.".to_string())
3384 } else {
3385 Ok(commitish)
3386 }
3387}
3388
3389fn render_release_branch_name(
3390 naming_template: &str,
3391 release_version: &Version,
3392 tag_name: &str,
3393) -> Result<String, String> {
3394 let branch_name = naming_template
3395 .replace("${GITHUB_VERSION}", &release_version.to_string())
3396 .replace("${GITHUB_TAG}", tag_name);
3397 let branch_name = branch_name.trim();
3398 if branch_name.is_empty() {
3399 return Err(
3400 "GitHub release branch naming template resolved to an empty branch name.".to_string(),
3401 );
3402 }
3403 Ok(branch_name.to_string())
3404}
3405
3406fn git_local_branch_commit(
3407 project_root: &Path,
3408 branch_name: &str,
3409) -> Result<Option<String>, String> {
3410 let output = Command::new("git")
3411 .current_dir(project_root)
3412 .args([
3413 "rev-parse",
3414 "--verify",
3415 &format!("refs/heads/{}", branch_name),
3416 ])
3417 .output()
3418 .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
3419 if !output.status.success() {
3420 return Ok(None);
3421 }
3422 let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
3423 if commit.is_empty() {
3424 Ok(None)
3425 } else {
3426 Ok(Some(commit))
3427 }
3428}
3429
3430fn git_remote_branch_commit(
3431 project_root: &Path,
3432 remote: &str,
3433 branch_name: &str,
3434) -> Result<Option<String>, String> {
3435 let output = run_git_command(
3436 project_root,
3437 &[
3438 "ls-remote",
3439 "--heads",
3440 remote,
3441 &format!("refs/heads/{}", branch_name),
3442 ],
3443 )?;
3444 let commit = output
3445 .split_whitespace()
3446 .next()
3447 .map(str::trim)
3448 .filter(|value| !value.is_empty())
3449 .map(str::to_string);
3450 Ok(commit)
3451}
3452
3453fn ensure_release_branch(
3454 project_root: &Path,
3455 branch_config: &GitHubReleaseBranchSettings,
3456 release_version: &Version,
3457 tag_name: &str,
3458 target_commitish: &str,
3459) -> Result<String, String> {
3460 let branch_name =
3461 render_release_branch_name(&branch_config.naming_template, release_version, tag_name)?;
3462
3463 if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
3464 if existing_local_commit != target_commitish {
3465 return Err(format!(
3466 "Configured release branch `{}` already exists locally at {}, expected {}.",
3467 branch_name, existing_local_commit, target_commitish
3468 ));
3469 }
3470 } else {
3471 run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
3472 }
3473
3474 if let Some(existing_remote_commit) =
3475 git_remote_branch_commit(project_root, "origin", &branch_name)?
3476 {
3477 if existing_remote_commit != target_commitish {
3478 return Err(format!(
3479 "Configured release branch `{}` already exists on origin at {}, expected {}.",
3480 branch_name, existing_remote_commit, target_commitish
3481 ));
3482 }
3483 } else {
3484 run_git_command(
3485 project_root,
3486 &[
3487 "push",
3488 "origin",
3489 &format!("{}:refs/heads/{}", target_commitish, branch_name),
3490 ],
3491 )?;
3492 }
3493
3494 Ok(branch_name)
3495}
3496
3497fn release_tag_family(tag_name: &str) -> String {
3498 if tag_name.starts_with('v')
3499 && tag_name
3500 .chars()
3501 .nth(1)
3502 .map(|ch| ch.is_ascii_digit())
3503 .unwrap_or(false)
3504 {
3505 return "v".to_string();
3506 }
3507
3508 let mut family: String = String::new();
3509 for ch in tag_name.chars() {
3510 if ch.is_ascii_digit() {
3511 break;
3512 }
3513 family.push(ch);
3514 }
3515 family
3516}
3517
3518fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
3519 if family == "v" {
3520 return parse_version(tag).ok();
3521 }
3522
3523 tag.strip_prefix(family)
3524 .and_then(|rest| parse_version(rest).ok())
3525}
3526
3527fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
3528 let range: String = format!("{}..HEAD", tag);
3529 run_git_command(project_root, &["rev-list", "--count", &range])
3530 .ok()
3531 .and_then(|raw| raw.trim().parse::<usize>().ok())
3532}
3533
3534fn previous_release_tag(
3535 project_root: &Path,
3536 current_tag_name: &str,
3537) -> Result<Option<String>, String> {
3538 let family: String = release_tag_family(current_tag_name);
3539 let tag_pattern: String = format!("{}*", family);
3540 let merged_tags: String = run_git_command(
3541 project_root,
3542 &["tag", "--merged", "HEAD", "--list", &tag_pattern],
3543 )?;
3544
3545 let mut best: Option<(usize, String)> = None;
3546 for raw in merged_tags.lines() {
3547 let tag = raw.trim();
3548 if tag.is_empty() || tag == current_tag_name {
3549 continue;
3550 }
3551 if parse_release_family_version(tag, &family).is_none() {
3552 continue;
3553 }
3554
3555 let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
3556 continue;
3557 };
3558 if distance == 0 {
3559 continue;
3560 }
3561
3562 match &best {
3563 None => best = Some((distance, tag.to_string())),
3564 Some((best_distance, _)) if distance < *best_distance => {
3565 best = Some((distance, tag.to_string()))
3566 }
3567 _ => {}
3568 }
3569 }
3570
3571 Ok(best.map(|(_, tag)| tag))
3572}
3573
3574fn default_release_title(version: &Version, repo: &str) -> String {
3575 format!("{} - {}", version, repo)
3576}
3577
3578fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
3579 match version_scope {
3580 VersionScope::Repository => repo,
3581 VersionScope::Crate { package_name, .. } => package_name.as_str(),
3582 }
3583}
3584
3585fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
3586 match version_scope {
3587 VersionScope::Repository => format!("v{}", version),
3588 VersionScope::Crate { tag_prefix, .. } => format!("{}{}", tag_prefix, version),
3589 }
3590}
3591
3592fn scoped_release_tag_name(
3593 version_scope: &VersionScope,
3594 version: &Version,
3595 parsed_tag_name: &str,
3596) -> String {
3597 match version_scope {
3598 VersionScope::Repository => parsed_tag_name.to_string(),
3599 VersionScope::Crate { .. } => {
3600 if release_tag_family(parsed_tag_name) == "v" {
3601 default_release_tag_name(version_scope, version)
3602 } else {
3603 parsed_tag_name.to_string()
3604 }
3605 }
3606 }
3607}
3608
3609fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
3610 match version_scope {
3611 VersionScope::Repository => None,
3612 VersionScope::Crate {
3613 crate_relative_root,
3614 ..
3615 } => Some(crate_relative_root.clone()),
3616 }
3617}
3618
3619fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
3620 let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
3621 let mut rendered_notes: String = notes.trim_end().to_string();
3622 if !rendered_notes.is_empty() {
3623 rendered_notes.push('\n');
3624 }
3625 rendered_notes.push_str("Release label: ");
3626 rendered_notes.push_str(release_label);
3627 rendered_notes.push('\n');
3628 rendered_notes.push_str("Generated by XBP ");
3629 rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
3630 rendered_notes
3631}
3632
3633#[cfg(test)]
3634mod tests {
3635 use super::github_release::{
3636 github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
3637 github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
3638 github_release_update_endpoint,
3639 };
3640 use super::release_docs::{
3641 release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
3642 };
3643 use super::release_notes::{
3644 build_fallback_sections, collect_linear_issue_identifiers,
3645 deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
3646 LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
3647 };
3648 use super::{
3649 append_release_label_footer, bump_version, cargo_package_name, default_release_tag_name,
3650 default_release_title, highest_version_observation, parse_github_repo_from_remote_url,
3651 parse_local_git_tag_output, parse_local_git_tag_output_for_scope,
3652 parse_package_version_target, parse_release_version_target, parse_remote_git_tag_output,
3653 parse_version, read_cargo_lock_version, read_cargo_lock_version_for_package,
3654 read_cargo_toml_version, read_json_openapi_version, read_json_root_version,
3655 read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
3656 read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
3657 read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
3658 render_release_branch_name, resolve_linear_release_placeholders,
3659 resolve_release_openapi_spec, resolve_version_scope,
3660 rewrite_toml_package_assignment_versions, should_clear_version_change_guard,
3661 stale_version_observations, sync_version_to_configured_files_with_paths,
3662 write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
3663 write_json_openapi_version, write_json_root_version, write_openapi_version,
3664 write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
3665 write_regex_version, write_toml_root_version, write_version_to_configured_files,
3666 write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
3667 VersionObservation, VersionScope,
3668 };
3669
3670 use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
3671 use crate::config::PackageNameLookup;
3672 use semver::Version;
3673 use std::collections::BTreeMap;
3674 use std::fs;
3675 use std::path::PathBuf;
3676 use std::time::{SystemTime, UNIX_EPOCH};
3677
3678 fn temp_dir(label: &str) -> PathBuf {
3679 let nanos: u128 = SystemTime::now()
3680 .duration_since(UNIX_EPOCH)
3681 .expect("time")
3682 .as_nanos();
3683 let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
3684 fs::create_dir_all(&dir).expect("create temp dir");
3685 dir
3686 }
3687
3688 #[test]
3689 fn parses_prefixed_semver() {
3690 assert_eq!(
3691 parse_version("v1.2.3").expect("version"),
3692 Version::new(1, 2, 3)
3693 );
3694 }
3695
3696 #[test]
3697 fn rejects_invalid_semver() {
3698 let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
3699 assert!(error.contains("Invalid semantic version"));
3700 }
3701
3702 #[test]
3703 fn release_target_parser_supports_plain_semver() {
3704 let (version, tag_name) =
3705 parse_release_version_target("1.2.3-alpha.1").expect("release target");
3706 assert_eq!(version.major, 1);
3707 assert_eq!(version.minor, 2);
3708 assert_eq!(version.patch, 3);
3709 assert_eq!(version.pre.as_str(), "alpha.1");
3710 assert_eq!(tag_name, "v1.2.3-alpha.1");
3711 }
3712
3713 #[test]
3714 fn release_target_parser_supports_prefixed_semver() {
3715 let (version, tag_name) =
3716 parse_release_version_target("studio-0.3.2-alpha").expect("release target");
3717 assert_eq!(version.major, 0);
3718 assert_eq!(version.minor, 3);
3719 assert_eq!(version.patch, 2);
3720 assert_eq!(version.pre.as_str(), "alpha");
3721 assert_eq!(tag_name, "studio-0.3.2-alpha");
3722 }
3723
3724 #[test]
3725 fn bumps_versions_correctly() {
3726 let base: Version = Version::new(0, 1, 0);
3727 assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
3728 assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
3729 assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
3730 }
3731
3732 #[test]
3733 fn version_change_guard_clears_when_worktree_is_clean() {
3734 let entry = VersionChangeGuardEntry {
3735 pending_version_change_count: 1,
3736 head_commit: Some("abc123".to_string()),
3737 };
3738 let state = GitWorktreeState {
3739 is_dirty: false,
3740 head_commit: Some("abc123".to_string()),
3741 };
3742 assert!(should_clear_version_change_guard(&entry, &state));
3743 }
3744
3745 #[test]
3746 fn version_change_guard_clears_when_head_changes() {
3747 let entry = VersionChangeGuardEntry {
3748 pending_version_change_count: 1,
3749 head_commit: Some("abc123".to_string()),
3750 };
3751 let state = GitWorktreeState {
3752 is_dirty: true,
3753 head_commit: Some("def456".to_string()),
3754 };
3755 assert!(should_clear_version_change_guard(&entry, &state));
3756 }
3757
3758 #[test]
3759 fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
3760 let entry = VersionChangeGuardEntry {
3761 pending_version_change_count: 1,
3762 head_commit: Some("abc123".to_string()),
3763 };
3764 let state = GitWorktreeState {
3765 is_dirty: true,
3766 head_commit: Some("abc123".to_string()),
3767 };
3768 assert!(!should_clear_version_change_guard(&entry, &state));
3769 }
3770
3771 #[test]
3772 fn render_release_branch_name_replaces_supported_tokens() {
3773 let branch = render_release_branch_name(
3774 "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
3775 &Version::new(10, 27, 0),
3776 "v10.27.0",
3777 )
3778 .expect("branch name");
3779
3780 assert_eq!(branch, "releases/10.27.0/v10.27.0");
3781 }
3782
3783 #[test]
3784 fn resolve_linear_release_placeholders_reads_env_files() {
3785 let temp_dir = std::env::temp_dir().join(format!(
3786 "xbp-linear-release-placeholders-{}",
3787 std::time::SystemTime::now()
3788 .duration_since(std::time::UNIX_EPOCH)
3789 .expect("time")
3790 .as_nanos()
3791 ));
3792 fs::create_dir_all(&temp_dir).expect("temp dir");
3793 fs::write(
3794 temp_dir.join(".env.local"),
3795 "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
3796 )
3797 .expect("env file");
3798
3799 let resolved = resolve_linear_release_placeholders(
3800 &temp_dir,
3801 ResolvedLinearReleaseConfig {
3802 initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
3803 organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
3804 health: "on_track".to_string(),
3805 },
3806 );
3807
3808 assert_eq!(
3809 resolved.initiative_ids,
3810 vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
3811 );
3812 assert_eq!(
3813 resolved.organization_name.as_deref(),
3814 Some("suits-formations")
3815 );
3816
3817 let _ = fs::remove_dir_all(temp_dir);
3818 }
3819
3820 #[test]
3821 fn version_change_guard_clears_when_pending_count_is_zero() {
3822 let entry = VersionChangeGuardEntry {
3823 pending_version_change_count: 0,
3824 head_commit: Some("abc123".to_string()),
3825 };
3826 let state = GitWorktreeState {
3827 is_dirty: true,
3828 head_commit: Some("abc123".to_string()),
3829 };
3830 assert!(should_clear_version_change_guard(&entry, &state));
3831 }
3832
3833 #[test]
3834 fn parse_package_version_target_supports_assignment_syntax() {
3835 let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
3836 .expect("parse")
3837 .expect("target");
3838 assert_eq!(parsed.0, "demo_pkg".to_string());
3839 assert_eq!(parsed.1, Version::new(1, 2, 3));
3840 }
3841
3842 #[test]
3843 fn parse_package_version_target_rejects_invalid_package_names() {
3844 let error: String = parse_package_version_target("bad package=1.2.3")
3845 .expect_err("invalid package target should fail");
3846 assert!(error.contains("Invalid package target"));
3847 }
3848
3849 #[test]
3850 fn parse_package_version_target_returns_none_without_assignment() {
3851 assert!(parse_package_version_target("1.2.3")
3852 .expect("parse")
3853 .is_none());
3854 }
3855
3856 #[test]
3857 fn parse_package_version_target_returns_none_for_empty_package_name() {
3858 assert!(parse_package_version_target(" =1.2.3")
3859 .expect("parse")
3860 .is_none());
3861 }
3862
3863 #[test]
3864 fn bumping_clears_prerelease_and_build_metadata() {
3865 let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
3866 assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
3867 assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
3868 assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
3869 }
3870
3871 #[test]
3872 fn cargo_toml_adapter_reads_and_writes() {
3873 let dir: PathBuf = temp_dir("cargo");
3874 let path: PathBuf = dir.join("Cargo.toml");
3875 fs::write(
3876 &path,
3877 r#"[package]
3878 name = "xbp"
3879 version = "1.0.0"
3880 "#,
3881 )
3882 .expect("write Cargo.toml");
3883
3884 assert_eq!(
3885 read_cargo_toml_version(&path).expect("read"),
3886 Some("1.0.0".to_string())
3887 );
3888
3889 write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
3890 assert_eq!(
3891 read_version_from_path(&path).expect("read"),
3892 Some("1.1.0".to_string())
3893 );
3894
3895 let _ = fs::remove_dir_all(dir);
3896 }
3897
3898 #[test]
3899 fn json_root_adapter_reads_and_writes() {
3900 let dir: PathBuf = temp_dir("json");
3901 let path: PathBuf = dir.join("package.json");
3902 fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
3903
3904 assert_eq!(
3905 read_json_root_version(&path).expect("read"),
3906 Some("1.4.0".to_string())
3907 );
3908
3909 write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
3910 assert_eq!(
3911 read_version_from_path(&path).expect("read"),
3912 Some("1.5.0".to_string())
3913 );
3914
3915 let _ = fs::remove_dir_all(dir);
3916 }
3917
3918 #[test]
3919 fn yaml_root_adapter_reads_and_writes() {
3920 let dir: PathBuf = temp_dir("yaml");
3921 let path: PathBuf = dir.join("xbp.yaml");
3922 fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
3923
3924 assert_eq!(
3925 read_yaml_root_version(&path, "version").expect("read"),
3926 Some("0.2.0".to_string())
3927 );
3928
3929 write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
3930 assert_eq!(
3931 read_version_from_path(&path).expect("read"),
3932 Some("0.3.0".to_string())
3933 );
3934
3935 let _ = fs::remove_dir_all(dir);
3936 }
3937
3938 #[test]
3939 fn toml_root_adapter_reads_and_writes() {
3940 let dir: PathBuf = temp_dir("toml");
3941 let path: PathBuf = dir.join("config.toml");
3942 fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
3943
3944 assert_eq!(
3945 read_toml_root_version(&path).expect("read"),
3946 Some("3.1.4".to_string())
3947 );
3948
3949 write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
3950 assert_eq!(
3951 read_toml_root_version(&path).expect("read"),
3952 Some("3.2.0".to_string())
3953 );
3954
3955 let _ = fs::remove_dir_all(dir);
3956 }
3957
3958 #[test]
3959 fn openapi_adapter_reads_and_writes_nested_version() {
3960 let dir: PathBuf = temp_dir("openapi");
3961 let path: PathBuf = dir.join("openapi.yaml");
3962 fs::write(
3963 &path,
3964 "openapi: 3.0.3\ninfo:\n title: Test\n version: 1.2.3\n",
3965 )
3966 .expect("write openapi");
3967
3968 assert_eq!(
3969 read_openapi_version(&path).expect("read"),
3970 Some("1.2.3".to_string())
3971 );
3972
3973 write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
3974 assert_eq!(
3975 read_openapi_version(&path).expect("read"),
3976 Some("2.0.0".to_string())
3977 );
3978
3979 let _ = fs::remove_dir_all(dir);
3980 }
3981
3982 #[test]
3983 fn openapi_writer_creates_missing_info_mapping() {
3984 let dir: PathBuf = temp_dir("openapi-missing-info");
3985 let path: PathBuf = dir.join("openapi.yaml");
3986 fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
3987
3988 write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
3989 assert_eq!(
3990 read_openapi_version(&path).expect("read"),
3991 Some("4.0.0".to_string())
3992 );
3993
3994 let _ = fs::remove_dir_all(dir);
3995 }
3996
3997 #[test]
3998 fn json_openapi_adapter_reads_and_writes_nested_version() {
3999 let dir: PathBuf = temp_dir("openapi-json");
4000 let path: PathBuf = dir.join("openapi.json");
4001 fs::write(
4002 &path,
4003 r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
4004 )
4005 .expect("write openapi json");
4006
4007 assert_eq!(
4008 read_json_openapi_version(&path).expect("read"),
4009 Some("1.2.3".to_string())
4010 );
4011
4012 write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
4013 assert_eq!(
4014 read_json_openapi_version(&path).expect("read"),
4015 Some("2.1.0".to_string())
4016 );
4017
4018 let _ = fs::remove_dir_all(dir);
4019 }
4020
4021 #[test]
4022 fn json_openapi_writer_creates_missing_info_object() {
4023 let dir: PathBuf = temp_dir("openapi-json-missing-info");
4024 let path: PathBuf = dir.join("openapi.json");
4025 fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
4026
4027 write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
4028 assert_eq!(
4029 read_json_openapi_version(&path).expect("read"),
4030 Some("4.0.0".to_string())
4031 );
4032
4033 let _ = fs::remove_dir_all(dir);
4034 }
4035
4036 #[test]
4037 fn pyproject_reader_prefers_project_version() {
4038 let dir: PathBuf = temp_dir("pyproject-project");
4039 let path: PathBuf = dir.join("pyproject.toml");
4040 fs::write(
4041 &path,
4042 "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
4043 )
4044 .expect("write pyproject");
4045
4046 assert_eq!(
4047 read_pyproject_version(&path).expect("read"),
4048 Some("0.8.0".to_string())
4049 );
4050
4051 let _ = fs::remove_dir_all(dir);
4052 }
4053
4054 #[test]
4055 fn pyproject_reader_falls_back_to_poetry_version() {
4056 let dir: PathBuf = temp_dir("pyproject-poetry");
4057 let path: PathBuf = dir.join("pyproject.toml");
4058 fs::write(
4059 &path,
4060 "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
4061 )
4062 .expect("write pyproject");
4063
4064 assert_eq!(
4065 read_pyproject_version(&path).expect("read"),
4066 Some("1.9.0".to_string())
4067 );
4068
4069 let _ = fs::remove_dir_all(dir);
4070 }
4071
4072 #[test]
4073 fn pyproject_writer_updates_project_table() {
4074 let dir: PathBuf = temp_dir("pyproject-write-project");
4075 let path: PathBuf = dir.join("pyproject.toml");
4076 fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
4077 .expect("write pyproject");
4078
4079 write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
4080 assert_eq!(
4081 read_pyproject_version(&path).expect("read"),
4082 Some("1.1.0".to_string())
4083 );
4084
4085 let _ = fs::remove_dir_all(dir);
4086 }
4087
4088 #[test]
4089 fn pyproject_writer_updates_poetry_table() {
4090 let dir: PathBuf = temp_dir("pyproject-write-poetry");
4091 let path: PathBuf = dir.join("pyproject.toml");
4092 fs::write(
4093 &path,
4094 "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
4095 )
4096 .expect("write pyproject");
4097
4098 write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
4099 assert_eq!(
4100 read_pyproject_version(&path).expect("read"),
4101 Some("2.1.0".to_string())
4102 );
4103
4104 let _ = fs::remove_dir_all(dir);
4105 }
4106
4107 #[test]
4108 fn cargo_lock_reader_and_writer_follow_package_name() {
4109 let dir: PathBuf = temp_dir("cargo-lock");
4110 let cargo_toml: PathBuf = dir.join("Cargo.toml");
4111 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4112 fs::write(
4113 &cargo_toml,
4114 r#"[package]
4115 name = "xbp"
4116 version = "1.0.0"
4117 "#,
4118 )
4119 .expect("write Cargo.toml");
4120 fs::write(
4121 &cargo_lock,
4122 r#"version = 4
4123
4124 [[package]]
4125 name = "xbp"
4126 version = "1.0.0"
4127
4128 [[package]]
4129 name = "other"
4130 version = "9.9.9"
4131 "#,
4132 )
4133 .expect("write Cargo.lock");
4134
4135 assert_eq!(
4136 read_cargo_lock_version(&cargo_lock).expect("read"),
4137 Some("1.0.0".to_string())
4138 );
4139
4140 write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
4141 assert_eq!(
4142 read_cargo_lock_version(&cargo_lock).expect("read"),
4143 Some("1.0.1".to_string())
4144 );
4145
4146 let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
4147 assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
4148
4149 let _ = fs::remove_dir_all(dir);
4150 }
4151
4152 #[test]
4153 fn cargo_lock_writer_errors_when_package_missing() {
4154 let dir: PathBuf = temp_dir("cargo-lock-missing");
4155 fs::write(
4156 dir.join("Cargo.toml"),
4157 "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
4158 )
4159 .expect("write Cargo.toml");
4160 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4161 fs::write(
4162 &cargo_lock,
4163 "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
4164 )
4165 .expect("write Cargo.lock");
4166
4167 let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
4168 .expect_err("missing package should fail");
4169 assert!(error.contains("Could not find package `xbp`"));
4170
4171 let _ = fs::remove_dir_all(dir);
4172 }
4173
4174 #[test]
4175 fn cargo_package_name_reads_package_section() {
4176 let dir: PathBuf = temp_dir("cargo-package-name");
4177 let cargo_lock: PathBuf = dir.join("Cargo.lock");
4178 fs::write(
4179 dir.join("Cargo.toml"),
4180 "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
4181 )
4182 .expect("write Cargo.toml");
4183 fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
4184
4185 assert_eq!(
4186 cargo_package_name(&cargo_lock).expect("name"),
4187 Some("xbp-cli".to_string())
4188 );
4189
4190 let _ = fs::remove_dir_all(dir);
4191 }
4192
4193 #[test]
4194 fn cargo_toml_writer_skips_workspace_manifest_without_package() {
4195 let dir: PathBuf = temp_dir("cargo-workspace-manifest");
4196 let path: PathBuf = dir.join("Cargo.toml");
4197 fs::write(
4198 &path,
4199 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
4200 )
4201 .expect("write Cargo.toml");
4202
4203 let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
4204 assert!(!changed);
4205 assert_eq!(
4206 fs::read_to_string(&path).expect("read Cargo.toml"),
4207 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n"
4208 );
4209
4210 let _ = fs::remove_dir_all(dir);
4211 }
4212
4213 #[test]
4214 fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
4215 let dir: PathBuf = temp_dir("workspace-cargo-skip");
4216 fs::write(
4217 dir.join("Cargo.toml"),
4218 "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
4219 )
4220 .expect("write Cargo.toml");
4221 fs::write(
4222 dir.join("Cargo.lock"),
4223 "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
4224 )
4225 .expect("write Cargo.lock");
4226 fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
4227 .expect("write README");
4228
4229 let updated = write_version_to_configured_files(
4230 &dir,
4231 &dir,
4232 &[
4233 "Cargo.toml".to_string(),
4234 "Cargo.lock".to_string(),
4235 "README.md".to_string(),
4236 ],
4237 &VersionScope::Repository,
4238 &Version::new(1, 1, 0),
4239 )
4240 .expect("write versions");
4241
4242 assert_eq!(updated, 1);
4243 assert_eq!(
4244 read_readme_version(&dir.join("README.md")).expect("read"),
4245 Some("1.1.0".to_string())
4246 );
4247
4248 let _ = fs::remove_dir_all(dir);
4249 }
4250
4251 #[test]
4252 fn repository_scope_prefers_workspace_default_member_manifest() {
4253 let dir: PathBuf = temp_dir("workspace-default-member-path");
4254 let crate_dir: PathBuf = dir.join("crates").join("cli");
4255 fs::create_dir_all(&crate_dir).expect("create crate dir");
4256 fs::write(
4257 dir.join("Cargo.toml"),
4258 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4259 )
4260 .expect("write workspace cargo");
4261 fs::write(
4262 crate_dir.join("Cargo.toml"),
4263 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4264 )
4265 .expect("write crate cargo");
4266
4267 let resolved = super::resolve_registry_relative_path(
4268 &dir,
4269 &dir,
4270 &VersionScope::Repository,
4271 "Cargo.toml",
4272 );
4273
4274 assert_eq!(resolved, "crates/cli/Cargo.toml");
4275
4276 let _ = fs::remove_dir_all(dir);
4277 }
4278
4279 #[test]
4280 fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
4281 let dir: PathBuf = temp_dir("workspace-default-member-writer");
4282 let crate_dir: PathBuf = dir.join("crates").join("cli");
4283 fs::create_dir_all(&crate_dir).expect("create crate dir");
4284 fs::write(
4285 dir.join("Cargo.toml"),
4286 "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4287 )
4288 .expect("write workspace cargo");
4289 fs::write(
4290 crate_dir.join("Cargo.toml"),
4291 "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4292 )
4293 .expect("write crate cargo");
4294 fs::write(
4295 dir.join("Cargo.lock"),
4296 "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
4297 )
4298 .expect("write cargo lock");
4299 fs::write(
4300 dir.join("README.md"),
4301 "# XBP\n\ncurrent version: `10.21.0`\n",
4302 )
4303 .expect("write readme");
4304
4305 let updated = write_version_to_configured_files(
4306 &dir,
4307 &dir,
4308 &[
4309 "Cargo.toml".to_string(),
4310 "Cargo.lock".to_string(),
4311 "README.md".to_string(),
4312 ],
4313 &VersionScope::Repository,
4314 &Version::new(10, 22, 0),
4315 )
4316 .expect("write versions");
4317
4318 assert_eq!(updated, 3);
4319 assert_eq!(
4320 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
4321 Some("10.22.0".to_string())
4322 );
4323 assert_eq!(
4324 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
4325 Some("10.22.0".to_string())
4326 );
4327 assert_eq!(
4328 read_readme_version(&dir.join("README.md")).expect("read readme"),
4329 Some("10.22.0".to_string())
4330 );
4331
4332 let _ = fs::remove_dir_all(dir);
4333 }
4334
4335 #[test]
4336 fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
4337 let dir: PathBuf = temp_dir("publish-manifest-version-target");
4338 let package_dir = dir.join("packages").join("heroui");
4339 let xbp_dir = dir.join(".xbp");
4340 fs::create_dir_all(&package_dir).expect("create package dir");
4341 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
4342
4343 fs::write(
4344 xbp_dir.join("xbp.yaml"),
4345 r#"project_name: athena-auth-ui
4346version: 0.3.1
4347port: 4000
4348build_dir: ./
4349publish:
4350 npm:
4351 enabled: true
4352 working_directory: packages/heroui
4353 manifest_path: packages/heroui/package.json
4354"#,
4355 )
4356 .expect("write xbp config");
4357 fs::write(
4358 dir.join("package.json"),
4359 r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
4360 )
4361 .expect("write root package");
4362 fs::write(
4363 package_dir.join("package.json"),
4364 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
4365 )
4366 .expect("write package manifest");
4367
4368 let updated = write_version_to_configured_files(
4369 &dir,
4370 &dir,
4371 &["package.json".to_string()],
4372 &VersionScope::Repository,
4373 &Version::new(0, 3, 1),
4374 )
4375 .expect("write versions");
4376
4377 assert_eq!(updated, 2);
4378 assert_eq!(
4379 read_json_root_version(&dir.join("package.json")).expect("read root package"),
4380 Some("0.3.1".to_string())
4381 );
4382 assert_eq!(
4383 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
4384 Some("0.3.1".to_string())
4385 );
4386
4387 let _ = fs::remove_dir_all(dir);
4388 }
4389
4390 #[test]
4391 fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
4392 let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
4393 let package_dir = dir.join("packages").join("heroui");
4394 let xbp_dir = dir.join(".xbp");
4395 fs::create_dir_all(&package_dir).expect("create package dir");
4396 fs::create_dir_all(&xbp_dir).expect("create xbp dir");
4397
4398 fs::write(
4399 xbp_dir.join("xbp.yaml"),
4400 r#"project_name: athena-auth-ui
4401version: 0.3.0
4402port: 4000
4403build_dir: ./
4404publish:
4405 npm:
4406 enabled: true
4407 working_directory: packages/heroui
4408 manifest_path: packages/heroui/package.json
4409"#,
4410 )
4411 .expect("write xbp config");
4412 fs::write(
4413 dir.join("package.json"),
4414 r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
4415 )
4416 .expect("write root package");
4417 fs::write(
4418 package_dir.join("package.json"),
4419 r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
4420 )
4421 .expect("write package manifest");
4422
4423 let _updated_paths = sync_version_to_configured_files_with_paths(
4424 &dir,
4425 &dir,
4426 &["package.json".to_string()],
4427 &VersionScope::Repository,
4428 &Version::new(0, 3, 0),
4429 )
4430 .expect("sync versions");
4431
4432 assert_eq!(
4433 read_json_root_version(&dir.join("package.json")).expect("read root package"),
4434 Some("0.3.0".to_string())
4435 );
4436 assert_eq!(
4437 read_json_root_version(&package_dir.join("package.json")).expect("read package"),
4438 Some("0.3.0".to_string())
4439 );
4440
4441 let _ = fs::remove_dir_all(dir);
4442 }
4443
4444 #[test]
4445 fn readme_adapter_updates_current_version_marker() {
4446 let dir: PathBuf = temp_dir("readme");
4447 let path: PathBuf = dir.join("README.md");
4448 fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
4449
4450 write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
4451 assert_eq!(
4452 read_readme_version(&path).expect("read"),
4453 Some("1.2.0".to_string())
4454 );
4455
4456 let _ = fs::remove_dir_all(dir);
4457 }
4458
4459 #[test]
4460 fn readme_writer_inserts_marker_when_missing() {
4461 let dir: PathBuf = temp_dir("readme-insert");
4462 let path: PathBuf = dir.join("README.md");
4463 fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
4464
4465 write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
4466 let content: String = fs::read_to_string(&path).expect("read readme");
4467 assert!(content.contains("current version: `3.0.0`"));
4468
4469 let _ = fs::remove_dir_all(dir);
4470 }
4471
4472 #[test]
4473 fn regex_adapter_reads_and_writes_versions() {
4474 let dir: PathBuf = temp_dir("regex");
4475 let path: PathBuf = dir.join("build.gradle");
4476 fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
4477
4478 assert_eq!(
4479 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
4480 Some("5.4.3".to_string())
4481 );
4482
4483 write_regex_version(
4484 &path,
4485 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
4486 &Version::new(5, 5, 0),
4487 )
4488 .expect("write");
4489
4490 assert_eq!(
4491 read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
4492 Some("5.5.0".to_string())
4493 );
4494
4495 let _ = fs::remove_dir_all(dir);
4496 }
4497
4498 #[test]
4499 fn regex_writer_errors_without_matching_pattern() {
4500 let dir: PathBuf = temp_dir("regex-miss");
4501 let path: PathBuf = dir.join("build.gradle");
4502 fs::write(&path, "group = 'demo'\n").expect("write gradle");
4503
4504 let error: String = write_regex_version(
4505 &path,
4506 r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
4507 &Version::new(1, 0, 0),
4508 )
4509 .expect_err("missing version should fail");
4510 assert!(error.contains("No version pattern found"));
4511
4512 let _ = fs::remove_dir_all(dir);
4513 }
4514
4515 #[test]
4516 fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
4517 let original: &str = r#"[dependencies]
4518 serde = "1.0.219"
4519 tokio = { version = "1.44.1", features = ["full"] }
4520 "#;
4521
4522 let (updated, changed) =
4523 rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
4524 .expect("rewrite");
4525 assert!(changed);
4526 assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
4527
4528 let (updated, changed) =
4529 rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
4530 .expect("rewrite");
4531 assert!(changed);
4532 assert!(updated.contains(r#"serde = "1.1.0""#));
4533 }
4534
4535 #[test]
4536 fn package_version_writer_updates_registry_toml_targets() {
4537 let dir: PathBuf = temp_dir("package-version-registry");
4538 let cargo_toml: PathBuf = dir.join("Cargo.toml");
4539 fs::write(
4540 &cargo_toml,
4541 r#"[package]
4542 name = "demo"
4543 version = "0.1.0"
4544
4545 [dependencies]
4546 serde = "1.0.219"
4547 tokio = { version = "1.44.1", features = ["full"] }
4548 "#,
4549 )
4550 .expect("write Cargo.toml");
4551
4552 let updated: usize = write_package_version_to_configured_files(
4553 &dir,
4554 &dir,
4555 &["Cargo.toml".to_string()],
4556 &VersionScope::Repository,
4557 "tokio",
4558 &Version::new(1, 45, 1),
4559 )
4560 .expect("update package assignment");
4561 assert_eq!(updated, 1);
4562
4563 let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
4564 assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
4565
4566 let _ = fs::remove_dir_all(dir);
4567 }
4568
4569 #[test]
4570 fn package_version_writer_errors_when_package_assignment_not_found() {
4571 let dir: PathBuf = temp_dir("package-version-missing");
4572 let cargo_toml: PathBuf = dir.join("Cargo.toml");
4573 fs::write(
4574 &cargo_toml,
4575 r#"[package]
4576 name = "demo"
4577 version = "0.1.0"
4578
4579 [dependencies]
4580 serde = "1.0.219"
4581 "#,
4582 )
4583 .expect("write Cargo.toml");
4584
4585 let error: String = write_package_version_to_configured_files(
4586 &dir,
4587 &dir,
4588 &["Cargo.toml".to_string()],
4589 &VersionScope::Repository,
4590 "tokio",
4591 &Version::new(1, 45, 1),
4592 )
4593 .expect_err("missing package assignment should fail");
4594 assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
4595
4596 let _ = fs::remove_dir_all(dir);
4597 }
4598
4599 #[test]
4600 fn chart_writer_updates_app_version_when_present() {
4601 let dir: PathBuf = temp_dir("chart");
4602 let path: PathBuf = dir.join("Chart.yaml");
4603 fs::write(
4604 &path,
4605 "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
4606 )
4607 .expect("write chart");
4608
4609 write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
4610 let content: String = fs::read_to_string(&path).expect("read chart");
4611 assert!(content.contains("version: 0.2.0"));
4612 assert!(content.contains("appVersion: 0.2.0"));
4613
4614 let _ = fs::remove_dir_all(dir);
4615 }
4616
4617 #[test]
4618 fn configured_file_writer_deduplicates_registry_entries() {
4619 let dir: PathBuf = temp_dir("dedupe");
4620 let readme: PathBuf = dir.join("README.md");
4621 fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
4622
4623 let updated: usize = write_version_to_configured_files(
4624 &dir,
4625 &dir,
4626 &[
4627 "README.md".to_string(),
4628 "README.md".to_string(),
4629 "missing.md".to_string(),
4630 ],
4631 &VersionScope::Repository,
4632 &Version::new(1, 1, 0),
4633 )
4634 .expect("write versions");
4635
4636 assert_eq!(updated, 1);
4637 assert_eq!(
4638 read_readme_version(&readme).expect("read"),
4639 Some("1.1.0".to_string())
4640 );
4641
4642 let _ = fs::remove_dir_all(dir);
4643 }
4644
4645 #[test]
4646 fn configured_file_writer_prefers_invocation_directory_targets() {
4647 let dir: PathBuf = temp_dir("invocation-precedence");
4648 let app_dir: PathBuf = dir.join("apps").join("web");
4649 fs::create_dir_all(&app_dir).expect("create app dir");
4650
4651 let root_package: PathBuf = dir.join("package.json");
4652 let app_package: PathBuf = app_dir.join("package.json");
4653 fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
4654 .expect("write root package");
4655 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
4656 .expect("write app package");
4657
4658 let updated: usize = write_version_to_configured_files(
4659 &dir,
4660 &app_dir,
4661 &["package.json".to_string()],
4662 &VersionScope::Repository,
4663 &Version::new(2, 14, 0),
4664 )
4665 .expect("write versions");
4666 assert_eq!(updated, 1);
4667
4668 assert_eq!(
4669 read_json_root_version(&root_package).expect("read root"),
4670 Some("9.9.9".to_string())
4671 );
4672 assert_eq!(
4673 read_json_root_version(&app_package).expect("read app"),
4674 Some("2.14.0".to_string())
4675 );
4676
4677 let _ = fs::remove_dir_all(dir);
4678 }
4679
4680 #[test]
4681 fn resolve_version_scope_detects_crate_scoped_invocation() {
4682 let dir: PathBuf = temp_dir("crate-scope");
4683 let crate_dir: PathBuf = dir.join("crates").join("alpha");
4684 let nested_dir: PathBuf = crate_dir.join("src");
4685 fs::create_dir_all(&nested_dir).expect("create nested dir");
4686 fs::write(
4687 crate_dir.join("Cargo.toml"),
4688 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
4689 )
4690 .expect("write Cargo.toml");
4691
4692 let scope = resolve_version_scope(&dir, &nested_dir);
4693 match scope {
4694 VersionScope::Crate {
4695 package_name,
4696 crate_relative_root,
4697 tag_prefix,
4698 ..
4699 } => {
4700 assert_eq!(package_name, "alpha-crate");
4701 assert_eq!(crate_relative_root, "crates/alpha");
4702 assert_eq!(tag_prefix, "alpha-crate-");
4703 }
4704 VersionScope::Repository => panic!("expected crate scope"),
4705 }
4706
4707 let _ = fs::remove_dir_all(dir);
4708 }
4709
4710 #[test]
4711 fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
4712 let dir: PathBuf = temp_dir("crate-writer");
4713 let crate_dir: PathBuf = dir.join("crates").join("alpha");
4714 fs::create_dir_all(&crate_dir).expect("create crate dir");
4715 fs::write(
4716 crate_dir.join("Cargo.toml"),
4717 "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
4718 )
4719 .expect("write crate Cargo.toml");
4720 fs::write(
4721 dir.join("Cargo.lock"),
4722 "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
4723 )
4724 .expect("write Cargo.lock");
4725 fs::write(
4726 dir.join("README.md"),
4727 "# root\n\ncurrent version: `9.9.9`\n",
4728 )
4729 .expect("write root readme");
4730
4731 let scope = resolve_version_scope(&dir, &crate_dir);
4732 let updated = write_version_to_configured_files(
4733 &dir,
4734 &crate_dir,
4735 &[
4736 "Cargo.toml".to_string(),
4737 "Cargo.lock".to_string(),
4738 "README.md".to_string(),
4739 ],
4740 &scope,
4741 &Version::new(1, 3, 0),
4742 )
4743 .expect("write versions");
4744
4745 assert_eq!(updated, 2);
4746 assert_eq!(
4747 read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
4748 Some("1.3.0".to_string())
4749 );
4750 assert_eq!(
4751 read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
4752 .expect("read cargo lock"),
4753 Some("1.3.0".to_string())
4754 );
4755 assert_eq!(
4756 read_readme_version(&dir.join("README.md")).expect("read readme"),
4757 Some("9.9.9".to_string())
4758 );
4759
4760 let _ = fs::remove_dir_all(dir);
4761 }
4762
4763 #[test]
4764 fn release_openapi_resolution_prefers_crate_scope() {
4765 let dir: PathBuf = temp_dir("release-openapi-crate");
4766 let crate_dir: PathBuf = dir.join("crates").join("monitor");
4767 let nested_dir: PathBuf = crate_dir.join("src");
4768 fs::create_dir_all(&nested_dir).expect("create nested dir");
4769 fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
4770 let crate_openapi: PathBuf = crate_dir.join("openapi.json");
4771 fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
4772
4773 let resolved =
4774 resolve_release_openapi_spec(&dir, &nested_dir).expect("crate-scoped openapi");
4775 assert_eq!(resolved, crate_openapi);
4776
4777 let _ = fs::remove_dir_all(dir);
4778 }
4779
4780 #[test]
4781 fn release_openapi_resolution_falls_back_to_repo_root() {
4782 let dir: PathBuf = temp_dir("release-openapi-root");
4783 let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
4784 fs::create_dir_all(&crate_dir).expect("create crate dir");
4785 let root_openapi: PathBuf = dir.join("openapi.json");
4786 fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
4787
4788 let resolved = resolve_release_openapi_spec(&dir, &crate_dir).expect("repo root openapi");
4789 assert_eq!(resolved, root_openapi);
4790
4791 let _ = fs::remove_dir_all(dir);
4792 }
4793
4794 #[test]
4795 fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
4796 let dir: PathBuf = temp_dir("invocation-dedupe");
4797 let app_dir: PathBuf = dir.join("apps").join("web");
4798 fs::create_dir_all(&app_dir).expect("create app dir");
4799
4800 let app_package: PathBuf = app_dir.join("package.json");
4801 fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
4802 .expect("write app package");
4803
4804 let updated: usize = write_version_to_configured_files(
4805 &dir,
4806 &app_dir,
4807 &[
4808 "package.json".to_string(),
4809 "apps/web/package.json".to_string(),
4810 ],
4811 &VersionScope::Repository,
4812 &Version::new(2, 14, 0),
4813 )
4814 .expect("write versions");
4815 assert_eq!(updated, 1);
4816
4817 assert_eq!(
4818 read_json_root_version(&app_package).expect("read app"),
4819 Some("2.14.0".to_string())
4820 );
4821
4822 let _ = fs::remove_dir_all(dir);
4823 }
4824
4825 #[test]
4826 fn configured_file_writer_errors_when_no_targets_exist() {
4827 let dir: PathBuf = temp_dir("no-targets");
4828 let error: String = write_version_to_configured_files(
4829 &dir,
4830 &dir,
4831 &["missing.toml".to_string()],
4832 &VersionScope::Repository,
4833 &Version::new(1, 0, 0),
4834 )
4835 .expect_err("missing targets should fail");
4836
4837 assert!(error.contains("No configured version files were found"));
4838
4839 let _ = fs::remove_dir_all(dir);
4840 }
4841
4842 #[test]
4843 fn remote_git_tag_parser_deduplicates_peeled_refs() {
4844 let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
4845 "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
4846 );
4847
4848 assert_eq!(parsed.len(), 2);
4849 assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
4850 assert_eq!(
4851 parsed[1].version,
4852 Version::parse("0.1.7-exp").expect("version")
4853 );
4854 assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
4855 }
4856
4857 #[test]
4858 fn local_git_tag_parser_normalizes_prefixed_versions() {
4859 let parsed: Vec<crate::commands::version::GitTagObservation> =
4860 parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
4861
4862 assert_eq!(parsed.len(), 2);
4863 assert_eq!(parsed[0].version, Version::new(1, 0, 0));
4864 assert_eq!(
4865 parsed[0].raw_tags,
4866 vec!["1.0.0".to_string(), "v1.0.0".to_string()]
4867 );
4868 }
4869
4870 #[test]
4871 fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
4872 let scope = VersionScope::Crate {
4873 crate_root: PathBuf::from("/tmp/crates/alpha"),
4874 crate_relative_root: "crates/alpha".to_string(),
4875 package_name: "alpha-crate".to_string(),
4876 tag_prefix: "alpha-crate-".to_string(),
4877 };
4878
4879 let parsed = parse_local_git_tag_output_for_scope(
4880 "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
4881 &scope,
4882 );
4883
4884 assert_eq!(parsed.len(), 2);
4885 assert_eq!(parsed[0].version, Version::new(1, 2, 0));
4886 assert_eq!(parsed[1].version, Version::new(1, 0, 0));
4887 }
4888
4889 #[test]
4890 fn crate_scoped_release_tags_default_to_package_prefix() {
4891 let scope = VersionScope::Crate {
4892 crate_root: PathBuf::from("/tmp/crates/alpha"),
4893 crate_relative_root: "crates/alpha".to_string(),
4894 package_name: "alpha-crate".to_string(),
4895 tag_prefix: "alpha-crate-".to_string(),
4896 };
4897
4898 assert_eq!(
4899 default_release_tag_name(&scope, &Version::new(1, 2, 3)),
4900 "alpha-crate-1.2.3"
4901 );
4902 }
4903
4904 #[test]
4905 fn blob_reader_handles_head_readme_versions() {
4906 assert_eq!(
4907 read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
4908 .expect("read"),
4909 Some("0.4.0".to_string())
4910 );
4911 }
4912
4913 #[test]
4914 fn blob_reader_handles_head_cargo_lock_versions() {
4915 let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
4916 let cargo_lock: &str =
4917 "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
4918
4919 assert_eq!(
4920 read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
4921 Some("0.2.0".to_string())
4922 );
4923 }
4924
4925 #[test]
4926 fn package_name_lookup_reads_json_name_for_npm() {
4927 let lookup: PackageNameLookup = PackageNameLookup {
4928 file: "package.json".to_string(),
4929 format: "json".to_string(),
4930 key: "name".to_string(),
4931 registry: "npm".to_string(),
4932 };
4933
4934 assert_eq!(
4935 read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
4936 .expect("read"),
4937 Some("@xylex/athena-mcp".to_string())
4938 );
4939 }
4940
4941 #[test]
4942 fn package_name_lookup_reads_toml_nested_package_name() {
4943 let lookup: PackageNameLookup = PackageNameLookup {
4944 file: "Cargo.toml".to_string(),
4945 format: "toml".to_string(),
4946 key: "package.name".to_string(),
4947 registry: "crates.io".to_string(),
4948 };
4949
4950 assert_eq!(
4951 read_package_name_from_lookup(
4952 &lookup,
4953 "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
4954 )
4955 .expect("read"),
4956 Some("athena-mcp".to_string())
4957 );
4958 }
4959
4960 #[test]
4961 fn package_name_lookup_errors_on_unknown_format() {
4962 let lookup: PackageNameLookup = PackageNameLookup {
4963 file: "meta.txt".to_string(),
4964 format: "ini".to_string(),
4965 key: "name".to_string(),
4966 registry: "npm".to_string(),
4967 };
4968
4969 let error = read_package_name_from_lookup(&lookup, "name=demo")
4970 .expect_err("unsupported format should fail");
4971 assert!(error.contains("Unsupported lookup format"));
4972 }
4973
4974 #[test]
4975 fn highest_version_observation_returns_max_version() {
4976 let entries: Vec<VersionObservation> = vec![
4977 VersionObservation {
4978 location: "README.md".to_string(),
4979 version: Version::new(1, 0, 0),
4980 },
4981 VersionObservation {
4982 location: "Cargo.toml".to_string(),
4983 version: Version::new(1, 2, 0),
4984 },
4985 ];
4986
4987 assert_eq!(
4988 highest_version_observation(&entries).expect("max version"),
4989 Version::new(1, 2, 0)
4990 );
4991 }
4992
4993 #[test]
4994 fn stale_version_observations_only_returns_outdated_entries() {
4995 let entries: Vec<VersionObservation> = vec![
4996 VersionObservation {
4997 location: "README.md".to_string(),
4998 version: Version::new(1, 1, 0),
4999 },
5000 VersionObservation {
5001 location: "Cargo.toml".to_string(),
5002 version: Version::new(1, 2, 0),
5003 },
5004 VersionObservation {
5005 location: "openapi.yaml".to_string(),
5006 version: Version::new(1, 0, 5),
5007 },
5008 ];
5009
5010 let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
5011 assert_eq!(stale.len(), 2);
5012 assert!(stale.iter().any(|entry| entry.location == "README.md"));
5013 assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
5014 assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
5015 }
5016
5017 #[test]
5018 fn parses_github_remote_urls() {
5019 assert_eq!(
5020 parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
5021 Some(("xylex-group".to_string(), "xbp".to_string()))
5022 );
5023 assert_eq!(
5024 parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
5025 Some(("xylex-group".to_string(), "xbp".to_string()))
5026 );
5027 assert_eq!(
5028 parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
5029 Some(("xylex-group".to_string(), "xbp".to_string()))
5030 );
5031 assert_eq!(
5032 parse_github_repo_from_remote_url(
5033 "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
5034 ),
5035 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
5036 );
5037 assert_eq!(
5038 parse_github_repo_from_remote_url(
5039 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
5040 ),
5041 Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
5042 );
5043 assert_eq!(
5044 parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
5045 None
5046 );
5047 }
5048
5049 #[test]
5050 fn redacts_credentials_in_remote_urls() {
5051 let redacted = redact_remote_url_credentials(
5052 "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
5053 );
5054 assert!(redacted.contains("REDACTED"));
5055 assert!(!redacted.contains("ghp_secretvalue"));
5056
5057 let username_only = redact_remote_url_credentials(
5058 "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
5059 );
5060 assert!(username_only.contains("REDACTED@github.com"));
5061 assert!(!username_only.contains("floris-xlx@github.com"));
5062
5063 let ssh_remote =
5064 redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
5065 assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
5066 }
5067
5068 #[test]
5069 fn builds_github_release_urls_with_encoded_tag_segments() {
5070 let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
5071 assert_eq!(
5072 create_url.as_str(),
5073 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
5074 );
5075
5076 let lookup_url =
5077 github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
5078 .expect("url");
5079 assert_eq!(
5080 lookup_url.as_str(),
5081 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
5082 );
5083
5084 let update_url =
5085 github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
5086 assert_eq!(
5087 update_url.as_str(),
5088 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
5089 );
5090
5091 let lookup_with_special_tag = github_release_by_tag_endpoint(
5092 "SuitsBooks",
5093 "suits-invoicing",
5094 "release candidate/v0.0.1+build",
5095 )
5096 .expect("url");
5097 assert_eq!(
5098 lookup_with_special_tag.as_str(),
5099 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
5100 );
5101 }
5102
5103 #[test]
5104 fn builds_github_release_asset_urls() {
5105 let list_url =
5106 github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
5107 assert_eq!(
5108 list_url.as_str(),
5109 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
5110 );
5111
5112 let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
5113 .expect("url");
5114 assert_eq!(
5115 delete_url.as_str(),
5116 "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
5117 );
5118
5119 let upload_url = github_release_asset_upload_endpoint(
5120 "SuitsBooks",
5121 "suits-invoicing",
5122 42,
5123 "openapi spec.json",
5124 )
5125 .expect("url");
5126 assert_eq!(
5127 upload_url.as_str(),
5128 "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
5129 );
5130 }
5131
5132 #[test]
5133 fn maps_release_latest_policy_to_github_api_values() {
5134 assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
5135 assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
5136 assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
5137 }
5138
5139 #[test]
5140 fn release_channel_from_semver_prerelease_labels() {
5141 let stable = Version::parse("3.6.2").expect("version");
5142 let nightly = Version::parse("3.6.2-nightly.1").expect("version");
5143 let experimental = Version::parse("0.1.1-alpha.1").expect("version");
5144 assert_eq!(release_channel(&stable), "stable");
5145 assert_eq!(release_channel(&nightly), "nightly");
5146 assert_eq!(release_channel(&experimental), "experimental");
5147 }
5148
5149 #[test]
5150 fn renders_release_docs_from_entries() {
5151 let entries = vec![
5152 ReleaseDocEntry {
5153 tag: "v3.6.2".to_string(),
5154 version: Version::parse("3.6.2").expect("version"),
5155 date: "2026-04-27".to_string(),
5156 },
5157 ReleaseDocEntry {
5158 tag: "docs-0.1.1-alpha.1".to_string(),
5159 version: Version::parse("0.1.1-alpha.1").expect("version"),
5160 date: "2026-04-20".to_string(),
5161 },
5162 ];
5163 let changelog = render_changelog("xylex-group", "athena", &entries);
5164 assert!(changelog.contains("## [3.6.2]"));
5165 assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
5166 assert!(changelog.contains("Release channel: stable"));
5167 assert!(changelog.contains("Release channel: experimental"));
5168
5169 let security = render_security_policy(&entries);
5170 assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
5171 assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
5172 }
5173
5174 #[test]
5175 fn formats_release_commit_lines_with_sha_and_pr_links() {
5176 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
5177 let formatted =
5178 format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
5179 .expect("formatted line");
5180
5181 assert_eq!(
5182 formatted,
5183 "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
5184 );
5185 }
5186
5187 #[test]
5188 fn formats_release_commit_lines_with_linear_links_when_available() {
5189 let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
5190 let issue_infos = BTreeMap::from([(
5191 "SUI-1336".to_string(),
5192 LinearIssueInfo {
5193 title: "Release flow".to_string(),
5194 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
5195 },
5196 )]);
5197 let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
5198 .expect("formatted line");
5199
5200 assert_eq!(
5201 formatted,
5202 "[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)"
5203 );
5204 }
5205
5206 #[test]
5207 fn renders_release_notes_in_requested_layout() {
5208 let commits = vec![
5209 ReleaseCommitEntry {
5210 full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
5211 short_sha: "abcdef1".to_string(),
5212 subject: "Improve release docs (#42)".to_string(),
5213 date: "2026-05-24".to_string(),
5214 },
5215 ReleaseCommitEntry {
5216 full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
5217 short_sha: "fedcba9".to_string(),
5218 subject: "Fix release flow for SUI-1336".to_string(),
5219 date: "2026-05-25".to_string(),
5220 },
5221 ];
5222 let pull_request_infos = BTreeMap::from([(
5223 "42".to_string(),
5224 super::release_notes::GithubPullRequestInfo {
5225 title: "Improve release docs".to_string(),
5226 url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
5227 },
5228 )]);
5229 let issue_infos = BTreeMap::from([(
5230 "SUI-1336".to_string(),
5231 LinearIssueInfo {
5232 title: "Release flow".to_string(),
5233 url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
5234 },
5235 )]);
5236 let sections = build_fallback_sections(&commits);
5237 let rendered = render_release_notes(&ReleaseNotesRenderInput {
5238 release_title: "1.7.0 - athena-auth",
5239 current_tag_name: "v1.7.0",
5240 owner: "xylex-group",
5241 repo: "athena-auth",
5242 previous_tag: Some("v1.6.0"),
5243 sections: §ions,
5244 commit_entries: &commits,
5245 pull_request_infos: &pull_request_infos,
5246 linear_issue_infos: &issue_infos,
5247 });
5248
5249 assert_eq!(
5250 rendered,
5251 "# [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)"
5252 );
5253 }
5254
5255 #[test]
5256 fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
5257 let commits = vec![
5258 ReleaseCommitEntry {
5259 full_sha: "a".repeat(40),
5260 short_sha: "aaaaaaa".to_string(),
5261 subject: "Fix SUI-1336 and SUI-1440".to_string(),
5262 date: "2026-05-24".to_string(),
5263 },
5264 ReleaseCommitEntry {
5265 full_sha: "b".repeat(40),
5266 short_sha: "bbbbbbb".to_string(),
5267 subject: "Touch SUI-1336 again".to_string(),
5268 date: "2026-05-25".to_string(),
5269 },
5270 ];
5271
5272 assert_eq!(
5273 collect_linear_issue_identifiers(&commits),
5274 vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
5275 );
5276 }
5277
5278 #[test]
5279 fn release_title_defaults_to_version_and_repo() {
5280 assert_eq!(
5281 default_release_title(&Version::new(1, 7, 0), "athena-auth"),
5282 "1.7.0 - athena-auth"
5283 );
5284 }
5285
5286 #[test]
5287 fn deduplicates_release_commit_entries_by_exact_subject() {
5288 let commits = vec![
5289 ReleaseCommitEntry {
5290 full_sha: "a".repeat(40),
5291 short_sha: "aaaaaaa".to_string(),
5292 subject: "Improve release docs".to_string(),
5293 date: "2026-05-24".to_string(),
5294 },
5295 ReleaseCommitEntry {
5296 full_sha: "b".repeat(40),
5297 short_sha: "bbbbbbb".to_string(),
5298 subject: "Improve release docs".to_string(),
5299 date: "2026-05-25".to_string(),
5300 },
5301 ];
5302
5303 let deduplicated = deduplicate_release_commit_entries(&commits);
5304 assert_eq!(deduplicated.len(), 1);
5305 assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
5306 }
5307
5308 #[test]
5309 fn fallback_sections_collapse_related_commit_themes() {
5310 let commits = vec![
5311 ReleaseCommitEntry {
5312 full_sha: "a".repeat(40),
5313 short_sha: "chat001".to_string(),
5314 subject: "Add optimistic chat retries".to_string(),
5315 date: "2026-06-01".to_string(),
5316 },
5317 ReleaseCommitEntry {
5318 full_sha: "b".repeat(40),
5319 short_sha: "chat002".to_string(),
5320 subject: "Persist deleted-message state in chat".to_string(),
5321 date: "2026-06-01".to_string(),
5322 },
5323 ReleaseCommitEntry {
5324 full_sha: "c".repeat(40),
5325 short_sha: "file001".to_string(),
5326 subject: "Fix upload UTF-8 audit retry handling".to_string(),
5327 date: "2026-06-01".to_string(),
5328 },
5329 ReleaseCommitEntry {
5330 full_sha: "d".repeat(40),
5331 short_sha: "ath001".to_string(),
5332 subject: "Migrate form progress routes to Athena".to_string(),
5333 date: "2026-06-01".to_string(),
5334 },
5335 ReleaseCommitEntry {
5336 full_sha: "e".repeat(40),
5337 short_sha: "ath002".to_string(),
5338 subject: "Update Athena models and package wiring".to_string(),
5339 date: "2026-06-01".to_string(),
5340 },
5341 ];
5342
5343 let sections = build_fallback_sections(&commits);
5344 assert_eq!(sections.len(), 3);
5345 assert_eq!(sections[0].title, "Cases & Communication");
5346 assert!(!sections[0].summary.is_empty());
5347 assert_eq!(sections[0].bullets.len(), 2);
5348 assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
5349 assert!(sections[0].bullets[0].summary.contains("chat"));
5350 assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
5351 assert!(sections[0].bullets[1].summary.contains("deleted-message"));
5352 assert_eq!(sections[1].title, "Reliability");
5353 assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
5354 assert_eq!(sections[2].title, "Athena Migration");
5355 assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
5356 }
5357
5358 #[test]
5359 fn appends_release_label_footer_for_pre_release() {
5360 let with_label = append_release_label_footer("# Release", true);
5361 assert_eq!(
5362 with_label,
5363 format!(
5364 "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
5365 env!("CARGO_PKG_VERSION")
5366 )
5367 );
5368 }
5369}