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