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