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