Skip to main content

xbp_cli/commands/
version.rs

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