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