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