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