Skip to main content

xbp_cli/commands/
version.rs

1//! Version management commands and adapters.
2
3use 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, &registry);
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, &registry);
205            let current: Version = report.highest_available();
206            let next: Version = bump_version(&current, bump_target);
207            let updated: usize = write_version_to_configured_files(
208                &project_root,
209                &invocation_dir,
210                &registry,
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                    &registry,
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                    &registry,
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, &registry);
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                // GitHub does not consistently expose the current "make_latest" mode in
402                // release payloads, so enforce explicit user intent by updating whenever
403                // policy differs from "legacy".
404                || 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
433/// Print program version from Cargo metadata.
434pub 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: &regex::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: &regex::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: &regex::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, &registry)?;
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, &registry)
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: &regex::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}