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