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