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