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, heal_project_xbp_config,
22    maybe_auto_convert_legacy_xbp_json_to_yaml, parse_config_with_auto_heal,
23    parse_github_repo_from_remote_url, redact_remote_url_credentials,
24    resolve_cargo_package_version, resolve_env_placeholders, write_cargo_package_version,
25};
26use colored::Colorize;
27use dialoguer::{theme::ColorfulTheme, Confirm, Select};
28use regex::Regex;
29use semver::Version;
30use serde::{Deserialize, Serialize};
31use serde_json::Value as JsonValue;
32use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
33use std::collections::HashMap;
34use std::collections::{BTreeMap, BTreeSet};
35use std::env;
36use std::fs;
37use std::io::IsTerminal;
38use std::path::{Path, PathBuf};
39use std::process::Command;
40use toml::Value as TomlValue;
41
42#[path = "version/bump.rs"]
43mod bump;
44#[path = "version/discover_services.rs"]
45mod discover_services;
46#[path = "version/github_release.rs"]
47mod github_release;
48#[path = "version/release_docs.rs"]
49mod release_docs;
50#[path = "version/release_ledger.rs"]
51mod release_ledger;
52#[path = "version/release_linear.rs"]
53mod release_linear;
54#[path = "version/release_notes.rs"]
55mod release_notes;
56#[path = "version/workspace_release.rs"]
57mod workspace_release;
58
59pub use bump::run_version_bump_command;
60pub use discover_services::run_version_discover_services;
61use github_release::{
62    create_github_release, get_github_release_by_tag, update_github_release, GithubReleaseInput,
63    GithubReleaseResult, GithubReleaseTagResponse,
64};
65use release_docs::sync_release_docs;
66use release_ledger::{
67    find_latest_successful_release_ledger, find_latest_unfinished_release_ledger,
68    load_release_ledger, release_ledger_path, release_ledger_scope_slug, save_release_ledger,
69    ReleaseBaselineRecord, ReleaseLedger, ReleaseLedgerStatus, ReleaseLedgerStepStatus,
70    ReleasePublishTarget, ReleaseSelectionRecord, ReleaseVersionTarget,
71};
72use release_linear::{
73    publish_release_to_linear_initiatives, resolve_linear_release_config,
74    LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
75};
76use release_notes::{generate_release_notes, ReleaseNotesRequest};
77pub(crate) use workspace_release::{
78    inspect_workspace_version_drift, resolve_manifest_workspace_publish, sync_workspace_to_version,
79    ManifestWorkspacePublishResolution,
80};
81pub use workspace_release::{
82    run_version_workspace_command, WorkspacePublishPlanOptions, WorkspacePublishRunOptions,
83    WorkspaceVersionCheckOptions, WorkspaceVersionCommand, WorkspaceVersionCommandOptions,
84    WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
85};
86
87#[derive(Clone, Debug)]
88struct VersionObservation {
89    location: String,
90    version: Version,
91}
92
93#[derive(Clone, Debug)]
94struct GitTagObservation {
95    version: Version,
96    raw_tags: Vec<String>,
97}
98
99#[derive(Clone, Debug)]
100struct RegistryVersionObservation {
101    registry: String,
102    package_name: String,
103    source_file: String,
104    latest: Option<Version>,
105    raw_version: Option<String>,
106    published_at: Option<String>,
107    note: Option<String>,
108}
109
110#[derive(Clone, Debug)]
111struct RegistryVersionDetails {
112    version: String,
113    published_at: Option<String>,
114}
115
116#[derive(Clone, Debug, Default)]
117struct ChangedTargetSelection {
118    changed_files: Vec<String>,
119    version_targets: Vec<ResolvedRegistryPath>,
120    publish_targets: Vec<ReleasePublishTarget>,
121    baseline_source: String,
122    baseline_reference: Option<String>,
123    baseline_tag: Option<String>,
124}
125
126const LEDGER_STEP_SYNC_VERSION: &str = "sync_version";
127const LEDGER_STEP_WORKSPACE_SYNC: &str = "workspace_sync";
128const LEDGER_STEP_PUBLISH_PACKAGES: &str = "publish_packages";
129const LEDGER_STEP_PUSH_TAG: &str = "push_tag";
130const LEDGER_STEP_CREATE_GITHUB_RELEASE: &str = "github_release";
131const LEDGER_STEP_UPLOAD_OPENAPI_ASSETS: &str = "upload_openapi_assets";
132const LEDGER_STEP_PUBLISH_LINEAR: &str = "publish_linear";
133const LEDGER_STEP_SYNC_RELEASE_DOCS: &str = "sync_release_docs";
134const LEDGER_STEP_COMMIT_RELEASE_DOCS: &str = "commit_release_docs";
135
136#[derive(Clone, Debug)]
137struct ResolvedRegistryPath {
138    relative: String,
139    absolute: PathBuf,
140    cargo_package_override: Option<String>,
141}
142
143#[derive(Clone, Debug)]
144struct WorkspacePrimaryCargoTarget {
145    manifest_relative: String,
146    manifest_absolute: PathBuf,
147    package_name: String,
148}
149
150#[derive(Clone, Debug)]
151enum VersionScope {
152    Repository,
153    Crate {
154        crate_root: PathBuf,
155        crate_relative_root: String,
156        package_name: String,
157        tag_prefix: String,
158    },
159    Service {
160        service_root: PathBuf,
161        service_relative_root: String,
162        service_name: String,
163        tag_prefix: String,
164        cargo_package_name: Option<String>,
165        version_targets: Vec<String>,
166    },
167}
168
169#[derive(Default, Debug)]
170struct VersionReport {
171    worktree: Vec<VersionObservation>,
172    head: Vec<VersionObservation>,
173    local_tags: Vec<GitTagObservation>,
174    remote_tags: Vec<GitTagObservation>,
175    registry_versions: Vec<RegistryVersionObservation>,
176    dirty_files: Vec<String>,
177    release_ledger: Option<ReleaseLedger>,
178    warnings: Vec<String>,
179}
180
181const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
182
183#[derive(Clone, Debug, Default, Deserialize, Serialize)]
184struct VersionChangeGuardRegistry {
185    #[serde(default)]
186    entries: BTreeMap<String, VersionChangeGuardEntry>,
187}
188
189#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
190struct VersionChangeGuardEntry {
191    #[serde(default)]
192    pending_version_change_count: usize,
193    #[serde(default)]
194    head_commit: Option<String>,
195}
196
197#[derive(Clone, Debug, Default, PartialEq, Eq)]
198struct GitWorktreeState {
199    is_dirty: bool,
200    head_commit: Option<String>,
201}
202
203impl VersionReport {
204    fn highest_worktree(&self) -> Option<Version> {
205        self.worktree
206            .iter()
207            .map(|entry| entry.version.clone())
208            .max()
209    }
210
211    fn highest_head(&self) -> Option<Version> {
212        self.head.iter().map(|entry| entry.version.clone()).max()
213    }
214
215    fn highest_local_tag(&self) -> Option<Version> {
216        self.local_tags
217            .iter()
218            .map(|entry| entry.version.clone())
219            .max()
220    }
221
222    fn highest_remote_tag(&self) -> Option<Version> {
223        self.remote_tags
224            .iter()
225            .map(|entry| entry.version.clone())
226            .max()
227    }
228
229    fn highest_git(&self) -> Option<Version> {
230        self.highest_remote_tag()
231            .or_else(|| self.highest_local_tag())
232    }
233
234    fn highest_registry(&self) -> Option<Version> {
235        self.registry_versions
236            .iter()
237            .filter_map(|entry| entry.latest.clone())
238            .max()
239    }
240
241    fn highest_available(&self) -> Version {
242        self.highest_worktree()
243            .into_iter()
244            .chain(self.highest_head())
245            .chain(self.highest_git())
246            .chain(self.highest_registry())
247            .max()
248            .unwrap_or_else(default_version)
249    }
250
251    fn divergent_versions(&self) -> Vec<Version> {
252        let mut versions = BTreeSet::new();
253        for entry in &self.worktree {
254            versions.insert(entry.version.clone());
255        }
256        for entry in &self.head {
257            versions.insert(entry.version.clone());
258        }
259        for entry in &self.local_tags {
260            versions.insert(entry.version.clone());
261        }
262        for entry in &self.remote_tags {
263            versions.insert(entry.version.clone());
264        }
265        for entry in &self.registry_versions {
266            if let Some(version) = &entry.latest {
267                versions.insert(version.clone());
268            }
269        }
270        versions.into_iter().collect()
271    }
272}
273
274pub async fn run_version_command(
275    target: Option<String>,
276    git_only: bool,
277    _debug: bool,
278) -> Result<(), String> {
279    if git_only && target.is_some() {
280        return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
281    }
282
283    let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
284    let project_root: PathBuf = resolve_project_root();
285    let version_scope: VersionScope =
286        resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
287    let registry: Vec<String> = load_versioning_files_registry()?;
288
289    if git_only {
290        print_git_versions(&project_root, &version_scope)?;
291        return Ok(());
292    }
293
294    match target.as_deref() {
295        None => {
296            let mut report: VersionReport =
297                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
298            match load_package_name_files_registry() {
299                Ok(lookups) => {
300                    report.registry_versions = collect_registry_versions(
301                        &project_root,
302                        &invocation_dir,
303                        &lookups,
304                        &version_scope,
305                        &mut report.warnings,
306                    )
307                    .await;
308                }
309                Err(err) => report.warnings.push(err),
310            }
311            print_version_report(&project_root, &report);
312            Ok(())
313        }
314        Some(bump_target @ ("major" | "minor" | "patch")) => {
315            enforce_version_change_guard(&project_root)?;
316            let repo = project_root
317                .file_name()
318                .and_then(|value| value.to_str())
319                .unwrap_or("repository")
320                .to_string();
321            let selection = resolve_changed_target_selection(
322                &project_root,
323                &invocation_dir,
324                &registry,
325                &version_scope,
326                &repo,
327            )?;
328            let current: Version = read_highest_version_from_targets(&selection.version_targets)?
329                .unwrap_or_else(|| {
330                    resolve_current_version_for_bump(
331                        &project_root,
332                        &invocation_dir,
333                        &registry,
334                        &version_scope,
335                    )
336                });
337            let next: Version = bump_version(&current, bump_target);
338            let updated_paths =
339                write_version_to_selected_paths(&selection.version_targets, &next, false)?;
340            let updated = updated_paths.len();
341            println!(
342                "Updated {} version file(s) from {} to {}.",
343                updated, current, next
344            );
345            auto_commit_command_paths(
346                &project_root,
347                updated_paths,
348                format!("chore(version): update version to {}", next),
349                "xbp version",
350            )
351            .await;
352            record_version_change_guard(&project_root)?;
353            sync_cli_version_write_activity(
354                &project_root,
355                &version_scope,
356                &next,
357                format!(
358                    "Updated {} version file(s) from {} to {}.",
359                    updated, current, next
360                ),
361            )
362            .await;
363            Ok(())
364        }
365        Some(explicit) => {
366            enforce_version_change_guard(&project_root)?;
367            let repo = project_root
368                .file_name()
369                .and_then(|value| value.to_str())
370                .unwrap_or("repository")
371                .to_string();
372            let selection = resolve_changed_target_selection(
373                &project_root,
374                &invocation_dir,
375                &registry,
376                &version_scope,
377                &repo,
378            )?;
379            if let Some((package_name, version)) = parse_package_version_target(explicit)? {
380                let updated_paths = write_package_version_to_configured_files_with_paths(
381                    &project_root,
382                    &invocation_dir,
383                    &registry,
384                    &version_scope,
385                    &package_name,
386                    &version,
387                )?;
388                let updated = updated_paths.len();
389                println!(
390                    "Updated {} file(s) for package `{}` to {}.",
391                    updated, package_name, version
392                );
393                auto_commit_command_paths(
394                    &project_root,
395                    updated_paths,
396                    format!("chore(version): set {} to {}", package_name, version),
397                    "xbp version",
398                )
399                .await;
400                record_version_change_guard(&project_root)?;
401                sync_cli_version_write_activity(
402                    &project_root,
403                    &version_scope,
404                    &version,
405                    format!(
406                        "Updated {} file(s) for package `{}` to {}.",
407                        updated, package_name, version
408                    ),
409                )
410                .await;
411            } else {
412                let version: Version = parse_version(explicit)?;
413                let updated_paths =
414                    write_version_to_selected_paths(&selection.version_targets, &version, false)?;
415                let updated = updated_paths.len();
416                println!("Updated {} version file(s) to {}.", updated, version);
417                auto_commit_command_paths(
418                    &project_root,
419                    updated_paths,
420                    format!("chore(version): update version to {}", version),
421                    "xbp version",
422                )
423                .await;
424                record_version_change_guard(&project_root)?;
425                sync_cli_version_write_activity(
426                    &project_root,
427                    &version_scope,
428                    &version,
429                    format!("Updated {} version file(s) to {}.", updated, version),
430                )
431                .await;
432            }
433            Ok(())
434        }
435    }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum ReleaseLatestPolicy {
440    True,
441    False,
442    Legacy,
443}
444
445impl ReleaseLatestPolicy {
446    pub(crate) fn as_github_api_value(self) -> &'static str {
447        match self {
448            Self::True => "true",
449            Self::False => "false",
450            Self::Legacy => "legacy",
451        }
452    }
453}
454
455#[derive(Debug, Clone)]
456pub struct VersionReleaseOptions {
457    pub explicit_version: Option<String>,
458    pub allow_dirty: bool,
459    pub title: Option<String>,
460    pub notes: Option<String>,
461    pub notes_file: Option<PathBuf>,
462    pub draft: bool,
463    pub prerelease: bool,
464    pub publish: bool,
465    pub force: bool,
466    pub latest_policy: ReleaseLatestPolicy,
467}
468
469struct ReleaseWorkflowSummary {
470    version: Version,
471    tag_name: String,
472    release_url: String,
473    uploaded_openapi_assets: Vec<String>,
474    release_title: String,
475    release_notes: String,
476    repository_owner: String,
477    repository_name: String,
478    scope_kind: String,
479    scope_label: String,
480    published_initiatives: Vec<PublishedLinearInitiative>,
481    release_branch: Option<String>,
482}
483
484#[derive(Debug, Clone, PartialEq, Eq)]
485struct ReleaseOpenApiAsset {
486    source_path: PathBuf,
487    source_label: String,
488    asset_name: String,
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
492struct DirtyWorktreeAnalysis {
493    all_entries: Vec<String>,
494    safe_entries: Vec<String>,
495    risky_entries: Vec<String>,
496}
497
498fn parse_git_status_path(line: &str) -> Option<String> {
499    let line = line.trim_end();
500    if line.len() < 3 {
501        return None;
502    }
503    let path_part = line.get(2..)?.trim_start();
504    if path_part.is_empty() {
505        return None;
506    }
507    if let Some((_old, new)) = path_part.split_once(" -> ") {
508        return Some(new.trim().replace('\\', "/"));
509    }
510    Some(path_part.replace('\\', "/"))
511}
512
513fn is_safe_dirty_path(path: &str) -> bool {
514    let normalized = path.replace('\\', "/");
515    let segments: Vec<&str> = normalized.split('/').collect();
516
517    if segments.first() == Some(&".xbp") {
518        return true;
519    }
520    if segments.contains(&"target") {
521        return true;
522    }
523    if segments.contains(&"__pycache__") {
524        return true;
525    }
526    if segments.contains(&"node_modules") || segments.contains(&".next") {
527        return true;
528    }
529    if normalized.ends_with(".pyc") || normalized.ends_with(".pyo") {
530        return true;
531    }
532
533    false
534}
535
536fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
537    let mut paths = Vec::new();
538    for entry in entries {
539        if let Some(path) = parse_git_status_path(entry) {
540            paths.push(path);
541        }
542    }
543    paths.sort();
544    paths.dedup();
545
546    let mut safe_entries = Vec::new();
547    let mut risky_entries = Vec::new();
548    for path in paths {
549        if is_safe_dirty_path(&path) {
550            safe_entries.push(path);
551        } else {
552            risky_entries.push(path);
553        }
554    }
555
556    DirtyWorktreeAnalysis {
557        all_entries: entries.to_vec(),
558        safe_entries,
559        risky_entries,
560    }
561}
562
563fn run_release_config_preflight(invocation_dir: &Path) -> Result<(), String> {
564    let Some(heal_result) = heal_project_xbp_config(invocation_dir)? else {
565        return Ok(());
566    };
567
568    println!(
569        "  {} Auto-healed {}",
570        "✓".bright_green(),
571        heal_result.config_path.display()
572    );
573    for fix in &heal_result.fixes {
574        println!("    {} {}", "•".bright_cyan(), fix);
575    }
576    Ok(())
577}
578
579fn resolve_dirty_worktree_for_release(
580    project_root: &Path,
581    force: bool,
582    loader: Option<&Loader>,
583) -> Result<bool, String> {
584    let dirty = git_dirty_entries(project_root)?;
585    if dirty.is_empty() {
586        return Ok(false);
587    }
588
589    let analysis = analyze_dirty_worktree(&dirty);
590    if analysis.risky_entries.is_empty() {
591        println!(
592            "  {} Allowing release with {} generated/auto-healed change(s)",
593            "i".bright_blue(),
594            analysis.safe_entries.len()
595        );
596        for path in analysis.safe_entries.iter().take(4) {
597            println!("    {} {}", "•".bright_black(), path);
598        }
599        if analysis.safe_entries.len() > 4 {
600            println!(
601                "    {} … and {} more",
602                "•".bright_black(),
603                analysis.safe_entries.len() - 4
604            );
605        }
606        return Ok(true);
607    }
608
609    if force {
610        return Ok(true);
611    }
612
613    println!(
614        "  {} Working tree has {} uncommitted change(s)",
615        "!".bright_yellow(),
616        analysis.risky_entries.len()
617    );
618    for path in analysis.risky_entries.iter().take(8) {
619        println!("    {} {}", "!".bright_yellow(), path);
620    }
621    if analysis.risky_entries.len() > 8 {
622        println!(
623            "    {} … and {} more",
624            "!".bright_yellow(),
625            analysis.risky_entries.len() - 8
626        );
627    }
628    if !analysis.safe_entries.is_empty() {
629        println!(
630            "    {} Ignoring {} generated/auto-healed path(s)",
631            "i".bright_blue(),
632            analysis.safe_entries.len()
633        );
634    }
635
636    if std::io::stdin().is_terminal() {
637        let prompt = || {
638            Confirm::with_theme(&ColorfulTheme::default())
639                .with_prompt("Continue release with uncommitted changes?")
640                .default(false)
641                .interact()
642        };
643        let proceed = if let Some(loader) = loader {
644            loader.suspend(prompt)
645        } else {
646            prompt()
647        }
648        .map_err(|e| format!("Failed to read confirmation: {}", e))?;
649        if proceed {
650            return Ok(true);
651        }
652        return Err(
653            "Release cancelled. Commit/stash changes first or pass `--allow-dirty`.".to_string(),
654        );
655    }
656
657    let preview = analysis
658        .risky_entries
659        .into_iter()
660        .take(8)
661        .collect::<Vec<_>>()
662        .join(", ");
663    Err(format!(
664        "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
665        preview
666    ))
667}
668
669async fn maybe_heal_workspace_versions_for_release(
670    project_root: &Path,
671    release_version: &Version,
672    force: bool,
673    loader: &Loader,
674) -> Result<(), String> {
675    let Some(drift) = inspect_workspace_version_drift(project_root, release_version)? else {
676        return Ok(());
677    };
678
679    println!(
680        "  {} Workspace version drift detected ({} mismatch(es) for {})",
681        "!".bright_yellow(),
682        drift.drift_count,
683        drift.expected_version
684    );
685    for line in &drift.preview {
686        println!("    {} {}", "•".bright_yellow(), line);
687    }
688    if drift.drift_count > drift.preview.len() {
689        println!(
690            "    {} … and {} more",
691            "•".bright_yellow(),
692            drift.drift_count - drift.preview.len()
693        );
694    }
695
696    let should_sync = if force {
697        true
698    } else if std::io::stdin().is_terminal() {
699        loader
700            .suspend(|| {
701                Confirm::with_theme(&ColorfulTheme::default())
702                    .with_prompt(format!(
703                        "Sync workspace versions to {} before publishing?",
704                        release_version
705                    ))
706                    .default(true)
707                    .interact()
708            })
709            .map_err(|e| format!("Failed to read confirmation: {}", e))?
710    } else {
711        return Err(format!(
712            "Workspace version drift detected for {}. Run `xbp version workspace sync --write --version {}` or pass `--force` to auto-sync.",
713            release_version, release_version
714        ));
715    };
716
717    if !should_sync {
718        return Err("Release cancelled. Align workspace versions before publishing.".to_string());
719    }
720
721    let changed_files = sync_workspace_to_version(project_root, release_version)?;
722    if changed_files.is_empty() {
723        println!(
724            "  {} Workspace versions already aligned to {}",
725            "✓".bright_green(),
726            release_version
727        );
728        return Ok(());
729    }
730
731    println!(
732        "  {} Synced workspace versions to {} ({} file(s))",
733        "✓".bright_green(),
734        release_version,
735        changed_files.len()
736    );
737    for path in changed_files.iter().take(6) {
738        println!("    {} {}", "•".bright_cyan(), path);
739    }
740    if changed_files.len() > 6 {
741        println!(
742            "    {} … and {} more",
743            "•".bright_cyan(),
744            changed_files.len() - 6
745        );
746    }
747
748    let changed_paths = changed_files
749        .into_iter()
750        .map(|relative| project_root.join(relative))
751        .collect::<Vec<_>>();
752    auto_commit_command_paths(
753        project_root,
754        changed_paths,
755        format!("chore(version): sync workspace to {}", release_version),
756        "xbp version release",
757    )
758    .await;
759
760    Ok(())
761}
762
763pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
764    let loader = Loader::start("Publishing release");
765    let result: Result<ReleaseWorkflowSummary, String> = async {
766        let VersionReleaseOptions {
767            explicit_version,
768            allow_dirty,
769            title,
770            notes,
771            notes_file,
772            draft,
773            prerelease,
774            publish,
775            force,
776            latest_policy,
777        } = options;
778        let mut effective_allow_dirty = allow_dirty || force;
779
780        if notes.is_some() && notes_file.is_some() {
781            return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
782        }
783
784        if !command_exists("git") {
785            return Err(
786                "Git is required for `xbp version release`, but it is not installed.".to_string(),
787            );
788        }
789
790        let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
791        let project_root: PathBuf = resolve_project_root();
792        let version_scope: VersionScope =
793            resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
794        let registry: Vec<String> = load_versioning_files_registry()?;
795        let sync_explicit_version = explicit_version.is_some();
796        let total_steps = 10usize + usize::from(sync_explicit_version) + usize::from(publish) * 2;
797        let mut step = 1usize;
798
799        loader.update(&format!(
800            "[{}/{}] Checking project configuration",
801            step, total_steps
802        ));
803        run_release_config_preflight(&invocation_dir)?;
804
805        step += 1;
806        loader.update(&format!(
807            "[{}/{}] Validating git state and resolving release target",
808            step, total_steps
809        ));
810        if !effective_allow_dirty {
811            effective_allow_dirty =
812                resolve_dirty_worktree_for_release(&project_root, force, Some(&loader))?;
813        }
814
815        let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
816            let (version, parsed_tag_name) = parse_release_version_target(raw)?;
817            (
818                version.clone(),
819                scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
820            )
821        } else {
822            let registry: Vec<String> = load_versioning_files_registry()?;
823            let report: VersionReport =
824                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
825            let release_version: Version = report.highest_available();
826            let tag_name: String = default_release_tag_name(&version_scope, &release_version);
827            (release_version, tag_name)
828        };
829
830        let repo_label = project_root
831            .file_name()
832            .and_then(|value| value.to_str())
833            .unwrap_or("repository")
834            .to_string();
835        let ledger_path =
836            current_scope_ledger_path(&project_root, &version_scope, &repo_label, &release_version);
837        let mut selection = resolve_changed_target_selection(
838            &project_root,
839            &invocation_dir,
840            &registry,
841            &version_scope,
842            &repo_label,
843        )?;
844        let mut release_ledger = match load_release_ledger(&ledger_path)? {
845            Some(existing) if existing.version == release_version.to_string() => {
846                println!(
847                    "{} {}",
848                    "Resuming release ledger".bright_yellow().bold(),
849                    ledger_path.display()
850                );
851                selection.version_targets = ledger_version_targets_to_resolved(&project_root, &existing);
852                if !existing.selection.publish_targets.is_empty() {
853                    selection.publish_targets = existing.selection.publish_targets.clone();
854                }
855                selection.changed_files = existing.selection.changed_files.clone();
856                selection.baseline_source = existing.baseline.source.clone();
857                selection.baseline_reference = existing.baseline.reference.clone();
858                selection.baseline_tag = existing.baseline.tag.clone();
859                existing
860            }
861            _ => {
862                let ledger = initialize_release_ledger(
863                    &version_scope,
864                    &repo_label,
865                    &release_version,
866                    &tag_name,
867                    None,
868                    &selection,
869                );
870                save_release_ledger(&ledger_path, &ledger)?;
871                ledger
872            }
873        };
874        print_selected_release_targets(&selection);
875
876        if sync_explicit_version {
877            step += 1;
878            loader.update(&format!(
879                "[{}/{}] Syncing configured version files",
880                step, total_steps
881            ));
882            if !release_ledger.step_is_done(LEDGER_STEP_SYNC_VERSION) {
883                begin_release_step(
884                    &mut release_ledger,
885                    &ledger_path,
886                    LEDGER_STEP_SYNC_VERSION,
887                    None,
888                )?;
889                let updated_paths =
890                    write_version_to_selected_paths(&selection.version_targets, &release_version, true)
891                        .inspect_err(|error| {
892                            let _ = fail_release_step(
893                                &mut release_ledger,
894                                &ledger_path,
895                                LEDGER_STEP_SYNC_VERSION,
896                                error,
897                            );
898                        })?;
899                if !updated_paths.is_empty() {
900                    auto_commit_command_paths(
901                        &project_root,
902                        updated_paths.clone(),
903                        format!("chore(version): update version to {}", release_version),
904                        "xbp version release",
905                    )
906                    .await;
907                }
908                complete_release_step(
909                    &mut release_ledger,
910                    &ledger_path,
911                    LEDGER_STEP_SYNC_VERSION,
912                    ReleaseLedgerStepStatus::Completed,
913                    Some(serde_json::json!({
914                        "updated_paths": updated_paths
915                            .iter()
916                            .map(|path| path.display().to_string())
917                            .collect::<Vec<_>>()
918                    })),
919                )?;
920            }
921        }
922
923        ensure_remote_exists(&project_root, "origin")?;
924        let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
925        let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
926
927        if publish {
928            step += 1;
929            loader.update(&format!(
930                "[{}/{}] Checking workspace version alignment",
931                step, total_steps
932            ));
933            if !release_ledger.step_is_done(LEDGER_STEP_WORKSPACE_SYNC) {
934                begin_release_step(
935                    &mut release_ledger,
936                    &ledger_path,
937                    LEDGER_STEP_WORKSPACE_SYNC,
938                    None,
939                )?;
940                maybe_heal_workspace_versions_for_release(
941                    &project_root,
942                    &release_version,
943                    force,
944                    &loader,
945                )
946                .await
947                .inspect_err(|error| {
948                    let _ = fail_release_step(
949                        &mut release_ledger,
950                        &ledger_path,
951                        LEDGER_STEP_WORKSPACE_SYNC,
952                        error,
953                    );
954                })?;
955                complete_release_step(
956                    &mut release_ledger,
957                    &ledger_path,
958                    LEDGER_STEP_WORKSPACE_SYNC,
959                    ReleaseLedgerStepStatus::Completed,
960                    None,
961                )?;
962            }
963
964            step += 1;
965            loader.update(&format!(
966                "[{}/{}] Publishing configured packages",
967                step, total_steps
968            ));
969            if !release_ledger.step_is_done(LEDGER_STEP_PUBLISH_PACKAGES) {
970                if selection.publish_targets.is_empty() {
971                    complete_release_step(
972                        &mut release_ledger,
973                        &ledger_path,
974                        LEDGER_STEP_PUBLISH_PACKAGES,
975                        ReleaseLedgerStepStatus::Skipped,
976                        Some(serde_json::json!({ "reason": "no selected publish targets" })),
977                    )?;
978                } else if verify_selected_publish_targets(
979                    &project_root,
980                    &selection.publish_targets,
981                    &release_version,
982                )
983                .await?
984                {
985                    complete_release_step(
986                        &mut release_ledger,
987                        &ledger_path,
988                        LEDGER_STEP_PUBLISH_PACKAGES,
989                        ReleaseLedgerStepStatus::VerifiedRemote,
990                        Some(serde_json::json!({
991                            "targets": selection.publish_targets,
992                            "version": release_version.to_string()
993                        })),
994                    )?;
995                } else {
996                    begin_release_step(
997                        &mut release_ledger,
998                        &ledger_path,
999                        LEDGER_STEP_PUBLISH_PACKAGES,
1000                        None,
1001                    )?;
1002                    let publish_target_filter =
1003                        publish_target_filter_from_selection(&selection.publish_targets)?;
1004                    run_publish_command_with_progress_prefix(
1005                        PublishCommandOptions {
1006                            dry_run: false,
1007                            allow_dirty: effective_allow_dirty,
1008                            force,
1009                            include_prereqs: true,
1010                            target: publish_target_filter,
1011                            manifest_path: None,
1012                            expected_version: Some(release_version.to_string()),
1013                        },
1014                        &loader,
1015                        format!("[{}/{}]", step, total_steps),
1016                    )
1017                    .await
1018                    .inspect_err(|error| {
1019                        let _ = fail_release_step(
1020                            &mut release_ledger,
1021                            &ledger_path,
1022                            LEDGER_STEP_PUBLISH_PACKAGES,
1023                            error,
1024                        );
1025                    })?;
1026                    complete_release_step(
1027                        &mut release_ledger,
1028                        &ledger_path,
1029                        LEDGER_STEP_PUBLISH_PACKAGES,
1030                        ReleaseLedgerStepStatus::Completed,
1031                        Some(serde_json::json!({
1032                            "targets": selection.publish_targets,
1033                            "version": release_version.to_string()
1034                        })),
1035                    )?;
1036                }
1037            }
1038        }
1039
1040        step += 1;
1041        loader.update(&format!(
1042            "[{}/{}] Resolving GitHub repository and auth",
1043            step, total_steps
1044        ));
1045        let origin_url: String = git_remote_url(&project_root, "origin")?;
1046        let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
1047            format!(
1048                "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.",
1049                redact_remote_url_credentials(&origin_url)
1050            )
1051        })?;
1052
1053        let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
1054            "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
1055                .to_string()
1056        })?;
1057        let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
1058            Some(key)
1059        } else {
1060            fetch_linear_api_key_from_dashboard().await?
1061        };
1062        let release_title: String = title.unwrap_or_else(|| {
1063            default_release_title(
1064                &release_version,
1065                release_title_subject(&version_scope, &repo),
1066            )
1067        });
1068        let release_branch_config =
1069            resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
1070
1071        step += 1;
1072        loader.update(&format!(
1073            "[{}/{}] Generating release notes",
1074            step, total_steps
1075        ));
1076        let release_notes_body: String = if let Some(path) = notes_file {
1077            fs::read_to_string(&path).map_err(|e| {
1078                format!(
1079                    "Failed to read release notes file {}: {}",
1080                    path.display(),
1081                    e
1082                )
1083            })?
1084        } else if let Some(body) = notes {
1085            body
1086        } else {
1087            generate_release_notes(&ReleaseNotesRequest {
1088                project_root: &project_root,
1089                release_title: &release_title,
1090                current_tag_name: &tag_name,
1091                owner: &owner,
1092                repo: &repo,
1093                github_token: &github_token,
1094                linear_api_key: linear_api_key.as_deref(),
1095                openrouter_api_key: resolve_openrouter_api_key().as_deref(),
1096                path_filter: release_notes_scope_path(&version_scope).as_deref(),
1097            })
1098            .await?
1099        };
1100        let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
1101
1102        step += 1;
1103        loader.update(&format!(
1104            "[{}/{}] Preparing release OpenAPI assets",
1105            step, total_steps
1106        ));
1107        let release_openapi_assets = prepare_release_openapi_assets(
1108            &project_root,
1109            &invocation_dir,
1110            &version_scope,
1111            &release_version,
1112            &tag_name,
1113        )?;
1114
1115        step += 1;
1116        loader.update(&format!(
1117            "[{}/{}] Creating and pushing release tag",
1118            step, total_steps
1119        ));
1120        let tag_message: String = format!("Release {}", tag_name);
1121        let target_commitish: String = git_head_commitish(&project_root)?;
1122        let created_release_branch = if release_ledger.step_is_done(LEDGER_STEP_PUSH_TAG) {
1123            release_ledger.branch_name.clone()
1124        } else {
1125            begin_release_step(&mut release_ledger, &ledger_path, LEDGER_STEP_PUSH_TAG, None)?;
1126            let branch = if let Some(branch_config) = &release_branch_config {
1127                Some(ensure_release_branch(
1128                    &project_root,
1129                    branch_config,
1130                    &release_version,
1131                    &tag_name,
1132                    &version_scope_label(&version_scope, &repo),
1133                    &target_commitish,
1134                )?)
1135            } else {
1136                None
1137            };
1138            if !tag_exists_local {
1139                run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])
1140                    .inspect_err(|error| {
1141                        let _ = fail_release_step(
1142                            &mut release_ledger,
1143                            &ledger_path,
1144                            LEDGER_STEP_PUSH_TAG,
1145                            error,
1146                        );
1147                    })?;
1148            }
1149            if !tag_exists_remote {
1150                run_git_command(&project_root, &["push", "origin", &tag_name]).inspect_err(|error| {
1151                    let _ = fail_release_step(
1152                        &mut release_ledger,
1153                        &ledger_path,
1154                        LEDGER_STEP_PUSH_TAG,
1155                        error,
1156                    );
1157                })?;
1158            }
1159            release_ledger.branch_name = branch.clone();
1160            complete_release_step(
1161                &mut release_ledger,
1162                &ledger_path,
1163                LEDGER_STEP_PUSH_TAG,
1164                ReleaseLedgerStepStatus::Completed,
1165                Some(serde_json::json!({
1166                    "tag_name": tag_name,
1167                    "branch_name": branch,
1168                })),
1169            )?;
1170            branch
1171        };
1172
1173        let release_input: GithubReleaseInput = GithubReleaseInput {
1174            owner: owner.clone(),
1175            repo: repo.clone(),
1176            token: github_token,
1177            tag_name: tag_name.clone(),
1178            target_commitish,
1179            title: release_title,
1180            notes: release_notes,
1181            draft,
1182            prerelease,
1183            latest_policy,
1184        };
1185
1186        step += 1;
1187        loader.update(&format!(
1188            "[{}/{}] Publishing GitHub release",
1189            step, total_steps
1190        ));
1191        let release_result: GithubReleaseResult = if release_ledger.step_is_done(LEDGER_STEP_CREATE_GITHUB_RELEASE) {
1192            let existing_release = get_github_release_by_tag(&release_input)
1193                .await?
1194                .ok_or_else(|| "Release ledger indicates a completed GitHub release step, but the release could not be found by tag.".to_string())?;
1195            GithubReleaseResult {
1196                id: existing_release.id,
1197                html_url: existing_release.html_url.unwrap_or_else(|| {
1198                    format!(
1199                        "https://github.com/{}/{}/releases/tag/{}",
1200                        release_input.owner, release_input.repo, release_input.tag_name
1201                    )
1202                }),
1203            }
1204        } else if let Some(existing_release) = get_github_release_by_tag(&release_input).await? {
1205            complete_release_step(
1206                &mut release_ledger,
1207                &ledger_path,
1208                LEDGER_STEP_CREATE_GITHUB_RELEASE,
1209                ReleaseLedgerStepStatus::VerifiedRemote,
1210                Some(serde_json::json!({
1211                    "release_id": existing_release.id,
1212                    "release_url": existing_release.html_url,
1213                })),
1214            )?;
1215            GithubReleaseResult {
1216                id: existing_release.id,
1217                html_url: existing_release.html_url.unwrap_or_else(|| {
1218                    format!(
1219                        "https://github.com/{}/{}/releases/tag/{}",
1220                        release_input.owner, release_input.repo, release_input.tag_name
1221                    )
1222                }),
1223            }
1224        } else {
1225            begin_release_step(
1226                &mut release_ledger,
1227                &ledger_path,
1228                LEDGER_STEP_CREATE_GITHUB_RELEASE,
1229                None,
1230            )?;
1231            let created = match create_github_release(&release_input).await {
1232                Ok(result) => result,
1233                Err(create_error) => {
1234                    let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
1235                        format!(
1236                            "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
1237                            create_error, tag_name, e
1238                        )
1239                    })?;
1240
1241                    let Some(existing_release) = existing_release else {
1242                        let error = format!(
1243                            "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
1244                            create_error, tag_name
1245                        );
1246                        let _ = fail_release_step(
1247                            &mut release_ledger,
1248                            &ledger_path,
1249                            LEDGER_STEP_CREATE_GITHUB_RELEASE,
1250                            &error,
1251                        );
1252                        return Err(error);
1253                    };
1254
1255                    let needs_update: bool = existing_release.prerelease.unwrap_or(false)
1256                        != release_input.prerelease
1257                        || existing_release.draft.unwrap_or(false) != release_input.draft
1258                        || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
1259
1260                    if needs_update {
1261                        update_github_release(&release_input, existing_release.id)
1262                            .await
1263                            .map_err(|e| {
1264                                let error = format!(
1265                                    "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
1266                                    create_error, tag_name, e
1267                                );
1268                                let _ = fail_release_step(
1269                                    &mut release_ledger,
1270                                    &ledger_path,
1271                                    LEDGER_STEP_CREATE_GITHUB_RELEASE,
1272                                    &error,
1273                                );
1274                                error
1275                            })?
1276                    } else {
1277                        GithubReleaseResult {
1278                            id: existing_release.id,
1279                            html_url: existing_release.html_url.unwrap_or_else(|| {
1280                                format!(
1281                                    "https://github.com/{}/{}/releases/tag/{}",
1282                                    release_input.owner, release_input.repo, release_input.tag_name
1283                                )
1284                            }),
1285                        }
1286                    }
1287                }
1288            };
1289            complete_release_step(
1290                &mut release_ledger,
1291                &ledger_path,
1292                LEDGER_STEP_CREATE_GITHUB_RELEASE,
1293                ReleaseLedgerStepStatus::Completed,
1294                Some(serde_json::json!({
1295                    "release_id": created.id,
1296                    "release_url": created.html_url,
1297                })),
1298            )?;
1299            created
1300        };
1301        let release_url = release_result.html_url.clone();
1302
1303        step += 1;
1304        loader.update(&format!(
1305            "[{}/{}] Publishing release integrations",
1306            step, total_steps
1307        ));
1308        let linear_release_config =
1309            resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
1310        let uploaded_openapi_assets = if release_ledger.step_is_done(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS) {
1311            release_ledger
1312                .step(LEDGER_STEP_UPLOAD_OPENAPI_ASSETS)
1313                .and_then(|step| step.details.get("uploaded_assets"))
1314                .and_then(serde_json::Value::as_array)
1315                .map(|values| {
1316                    values
1317                        .iter()
1318                        .filter_map(serde_json::Value::as_str)
1319                        .map(str::to_string)
1320                        .collect::<Vec<_>>()
1321                })
1322                .unwrap_or_default()
1323        } else {
1324            begin_release_step(
1325                &mut release_ledger,
1326                &ledger_path,
1327                LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1328                None,
1329            )?;
1330            let mut uploaded_assets: Vec<String> = Vec::new();
1331            for asset in &release_openapi_assets {
1332                github_release::upload_github_release_asset(
1333                    &release_input,
1334                    release_result.id,
1335                    &asset.source_path,
1336                    &asset.asset_name,
1337                )
1338                .await
1339                .map_err(|e| {
1340                    let error = format!(
1341                        "Release `{}` was published, but uploading OpenAPI asset `{}` from `{}` failed: {}",
1342                        tag_name,
1343                        asset.asset_name,
1344                        asset.source_label,
1345                        e
1346                    );
1347                    let _ = fail_release_step(
1348                        &mut release_ledger,
1349                        &ledger_path,
1350                        LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1351                        &error,
1352                    );
1353                    error
1354                })?;
1355                uploaded_assets.push(asset.asset_name.clone());
1356            }
1357            complete_release_step(
1358                &mut release_ledger,
1359                &ledger_path,
1360                LEDGER_STEP_UPLOAD_OPENAPI_ASSETS,
1361                ReleaseLedgerStepStatus::Completed,
1362                Some(serde_json::json!({ "uploaded_assets": uploaded_assets })),
1363            )?;
1364            uploaded_assets
1365        };
1366        let published_initiatives = if release_ledger.step_is_done(LEDGER_STEP_PUBLISH_LINEAR) {
1367            release_ledger
1368                .step(LEDGER_STEP_PUBLISH_LINEAR)
1369                .and_then(|step| step.details.get("initiatives"))
1370                .cloned()
1371                .map(serde_json::from_value)
1372                .transpose()
1373                .map_err(|error| format!("Failed to decode stored Linear release initiatives: {}", error))?
1374                .unwrap_or_default()
1375        } else if let Some(linear_release_config) = linear_release_config {
1376            begin_release_step(
1377                &mut release_ledger,
1378                &ledger_path,
1379                LEDGER_STEP_PUBLISH_LINEAR,
1380                None,
1381            )?;
1382            let linear_api_key: String = linear_api_key.clone().ok_or_else(|| {
1383                "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."
1384                    .to_string()
1385            })?;
1386            let published = publish_release_to_linear_initiatives(&LinearReleasePublishInput {
1387                api_key: linear_api_key,
1388                initiative_ids: linear_release_config.initiative_ids,
1389                organization_name: linear_release_config.organization_name,
1390                health: linear_release_config.health,
1391                release_title: release_input.title.clone(),
1392                release_tag: release_input.tag_name.clone(),
1393                release_url: release_url.clone(),
1394                release_notes: release_input.notes.clone(),
1395            })
1396            .await
1397            .inspect_err(|error| {
1398                let _ = fail_release_step(
1399                    &mut release_ledger,
1400                    &ledger_path,
1401                    LEDGER_STEP_PUBLISH_LINEAR,
1402                    error,
1403                );
1404            })?;
1405            complete_release_step(
1406                &mut release_ledger,
1407                &ledger_path,
1408                LEDGER_STEP_PUBLISH_LINEAR,
1409                ReleaseLedgerStepStatus::Completed,
1410                Some(serde_json::json!({ "initiatives": published })),
1411            )?;
1412            published
1413        } else {
1414            complete_release_step(
1415                &mut release_ledger,
1416                &ledger_path,
1417                LEDGER_STEP_PUBLISH_LINEAR,
1418                ReleaseLedgerStepStatus::Skipped,
1419                Some(serde_json::json!({ "reason": "no linear release config" })),
1420            )?;
1421            Vec::new()
1422        };
1423
1424        step += 1;
1425        loader.update(&format!(
1426            "[{}/{}] Syncing release docs",
1427            step, total_steps
1428        ));
1429        let release_doc_paths = if release_ledger.step_is_done(LEDGER_STEP_SYNC_RELEASE_DOCS) {
1430            release_ledger
1431                .step(LEDGER_STEP_SYNC_RELEASE_DOCS)
1432                .and_then(|step| step.details.get("paths"))
1433                .and_then(serde_json::Value::as_array)
1434                .map(|paths| paths.iter().filter_map(serde_json::Value::as_str).map(PathBuf::from).collect::<Vec<_>>())
1435                .unwrap_or_default()
1436        } else {
1437            begin_release_step(
1438                &mut release_ledger,
1439                &ledger_path,
1440                LEDGER_STEP_SYNC_RELEASE_DOCS,
1441                None,
1442            )?;
1443            let paths = sync_release_docs(&project_root, &owner, &repo).inspect_err(|error| {
1444                let _ = fail_release_step(
1445                    &mut release_ledger,
1446                    &ledger_path,
1447                    LEDGER_STEP_SYNC_RELEASE_DOCS,
1448                    error,
1449                );
1450            })?;
1451            complete_release_step(
1452                &mut release_ledger,
1453                &ledger_path,
1454                LEDGER_STEP_SYNC_RELEASE_DOCS,
1455                ReleaseLedgerStepStatus::Completed,
1456                Some(serde_json::json!({
1457                    "paths": paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
1458                })),
1459            )?;
1460            paths
1461        };
1462        step += 1;
1463        loader.update(&format!(
1464            "[{}/{}] Auto-committing release docs",
1465            step, total_steps
1466        ));
1467        if !release_ledger.step_is_done(LEDGER_STEP_COMMIT_RELEASE_DOCS) {
1468            begin_release_step(
1469                &mut release_ledger,
1470                &ledger_path,
1471                LEDGER_STEP_COMMIT_RELEASE_DOCS,
1472                None,
1473            )?;
1474            auto_commit_command_paths(
1475                &project_root,
1476                release_doc_paths.clone(),
1477                format!("docs(release): sync release docs for {}", tag_name),
1478                "xbp version release",
1479            )
1480            .await;
1481            complete_release_step(
1482                &mut release_ledger,
1483                &ledger_path,
1484                LEDGER_STEP_COMMIT_RELEASE_DOCS,
1485                ReleaseLedgerStepStatus::Completed,
1486                Some(serde_json::json!({
1487                    "paths": release_doc_paths.iter().map(|path| path.display().to_string()).collect::<Vec<_>>()
1488                })),
1489            )?;
1490        }
1491        complete_release_ledger(&mut release_ledger);
1492        save_release_ledger(&ledger_path, &release_ledger)?;
1493        let summary = ReleaseWorkflowSummary {
1494            version: release_version.clone(),
1495            tag_name,
1496            release_url,
1497            uploaded_openapi_assets,
1498            release_title: release_input.title.clone(),
1499            release_notes: release_input.notes.clone(),
1500            repository_owner: owner.clone(),
1501            repository_name: repo.clone(),
1502            scope_kind: version_scope_kind(&version_scope).to_string(),
1503            scope_label: version_scope_label(&version_scope, &repo),
1504            published_initiatives,
1505            release_branch: created_release_branch,
1506        };
1507        sync_cli_release_activity(&summary).await;
1508
1509        Ok(summary)
1510    }
1511    .await;
1512
1513    match result {
1514        Ok(summary) => {
1515            loader.success_with(&format!("Published {}", summary.tag_name));
1516            println!("Released {} successfully.", summary.tag_name);
1517            println!("GitHub release: {}", summary.release_url);
1518            if !summary.uploaded_openapi_assets.is_empty() {
1519                println!(
1520                    "Uploaded OpenAPI assets: {}",
1521                    summary.uploaded_openapi_assets.join(", ")
1522                );
1523            }
1524            if !summary.published_initiatives.is_empty() {
1525                println!(
1526                    "Published release update to Linear initiative(s): {}",
1527                    summary
1528                        .published_initiatives
1529                        .iter()
1530                        .map(|initiative| initiative.name.as_str())
1531                        .collect::<Vec<_>>()
1532                        .join(", ")
1533                );
1534            }
1535            if let Some(release_branch) = summary.release_branch {
1536                println!("Release branch: {}", release_branch);
1537            }
1538            println!("Updated release docs: CHANGELOG.md and SECURITY.md");
1539            Ok(())
1540        }
1541        Err(error) => {
1542            loader.fail(&error);
1543            Err(error)
1544        }
1545    }
1546}
1547
1548/// Print program version from Cargo metadata.
1549pub async fn print_version() {
1550    println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
1551}
1552
1553fn resolve_project_root() -> PathBuf {
1554    let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1555    resolve_project_root_from(&cwd)
1556}
1557
1558fn resolve_project_root_from(cwd: &Path) -> PathBuf {
1559    if let Some(found) = find_xbp_config_upwards(cwd) {
1560        return found.project_root;
1561    }
1562
1563    if let Some(root) = git_repository_root(cwd) {
1564        return root;
1565    }
1566
1567    cwd.to_path_buf()
1568}
1569
1570fn current_scope_slug(version_scope: &VersionScope, repo: &str) -> String {
1571    release_ledger_scope_slug(
1572        version_scope_kind(version_scope),
1573        &version_scope_label(version_scope, repo),
1574    )
1575}
1576
1577fn current_scope_ledger_path(
1578    project_root: &Path,
1579    version_scope: &VersionScope,
1580    repo: &str,
1581    version: &Version,
1582) -> PathBuf {
1583    release_ledger_path(
1584        project_root,
1585        &current_scope_slug(version_scope, repo),
1586        &version.to_string(),
1587    )
1588}
1589
1590fn resolve_change_baseline(
1591    project_root: &Path,
1592    version_scope: &VersionScope,
1593    repo: &str,
1594) -> Result<(String, Option<String>, Option<String>), String> {
1595    let scope_slug = current_scope_slug(version_scope, repo);
1596    if let Some(ledger) = find_latest_successful_release_ledger(project_root, &scope_slug)? {
1597        if let Some(tag) = ledger.baseline.tag.or(Some(ledger.tag_name)) {
1598            return Ok(("release-ledger".to_string(), Some(tag.clone()), Some(tag)));
1599        }
1600    }
1601
1602    let tags = collect_remote_git_versions(project_root, "origin", version_scope)?;
1603    if let Some(latest) = tags.first() {
1604        let tag = latest
1605            .raw_tags
1606            .first()
1607            .cloned()
1608            .unwrap_or_else(|| default_release_tag_name(version_scope, &latest.version));
1609        return Ok(("github-tag".to_string(), Some(tag.clone()), Some(tag)));
1610    }
1611
1612    Ok(("initial-release".to_string(), None, None))
1613}
1614
1615fn collect_changed_files_since_reference(
1616    project_root: &Path,
1617    baseline_ref: Option<&str>,
1618) -> Result<Vec<String>, String> {
1619    let mut args = vec!["diff", "--name-only"];
1620    if let Some(reference) = baseline_ref
1621        .map(str::trim)
1622        .filter(|value| !value.is_empty())
1623    {
1624        args.push(reference);
1625        args.push("HEAD");
1626    }
1627    let output = run_git_command(project_root, &args)?;
1628    let mut changed_files = output
1629        .lines()
1630        .map(str::trim)
1631        .filter(|value| !value.is_empty())
1632        .map(str::to_string)
1633        .collect::<Vec<_>>();
1634    changed_files.sort();
1635    changed_files.dedup();
1636    Ok(changed_files)
1637}
1638
1639fn resolve_changed_target_selection(
1640    project_root: &Path,
1641    invocation_dir: &Path,
1642    registry: &[String],
1643    version_scope: &VersionScope,
1644    repo: &str,
1645) -> Result<ChangedTargetSelection, String> {
1646    let (baseline_source, baseline_reference, baseline_tag) =
1647        resolve_change_baseline(project_root, version_scope, repo)?;
1648    let changed_files =
1649        collect_changed_files_since_reference(project_root, baseline_reference.as_deref())?;
1650
1651    let all_version_targets =
1652        resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1653    if all_version_targets.is_empty() {
1654        return Err(
1655            "No configured version files were found to evaluate for release selection.".to_string(),
1656        );
1657    }
1658
1659    let version_targets = if changed_files.is_empty() || baseline_reference.is_none() {
1660        dedupe_resolved_registry_paths(&all_version_targets)
1661    } else {
1662        let selected = select_changed_version_targets(
1663            project_root,
1664            version_scope,
1665            &all_version_targets,
1666            &changed_files,
1667        );
1668        if selected.is_empty() {
1669            return Err(
1670                "No version targets matched commits since the last release baseline.".to_string(),
1671            );
1672        }
1673        selected
1674    };
1675
1676    let publish_targets = resolve_selected_publish_targets(
1677        project_root,
1678        invocation_dir,
1679        version_scope,
1680        &version_targets,
1681        changed_files.is_empty() || baseline_reference.is_none(),
1682    )?;
1683
1684    Ok(ChangedTargetSelection {
1685        changed_files,
1686        version_targets,
1687        publish_targets,
1688        baseline_source,
1689        baseline_reference,
1690        baseline_tag,
1691    })
1692}
1693
1694fn select_changed_version_targets(
1695    project_root: &Path,
1696    version_scope: &VersionScope,
1697    targets: &[ResolvedRegistryPath],
1698    changed_files: &[String],
1699) -> Vec<ResolvedRegistryPath> {
1700    if !matches!(version_scope, VersionScope::Repository) {
1701        if changed_files
1702            .iter()
1703            .any(|path| scope_matches_changed_path(project_root, version_scope, path))
1704        {
1705            return targets.to_vec();
1706        }
1707        return Vec::new();
1708    }
1709
1710    let mut selected = Vec::new();
1711    let mut shared_root_change = false;
1712    for changed_file in changed_files {
1713        let mut matched_any = false;
1714        for target in targets {
1715            if changed_file_matches_version_target(changed_file, target) {
1716                matched_any = true;
1717                if !selected
1718                    .iter()
1719                    .any(|existing: &ResolvedRegistryPath| existing.relative == target.relative)
1720                {
1721                    selected.push(target.clone());
1722                }
1723            }
1724        }
1725        if !matched_any {
1726            shared_root_change = true;
1727        }
1728    }
1729
1730    if shared_root_change {
1731        return dedupe_resolved_registry_paths(targets);
1732    }
1733    selected
1734}
1735
1736fn dedupe_resolved_registry_paths(targets: &[ResolvedRegistryPath]) -> Vec<ResolvedRegistryPath> {
1737    let mut seen = BTreeSet::new();
1738    targets
1739        .iter()
1740        .filter(|target| seen.insert(target.relative.clone()))
1741        .cloned()
1742        .collect()
1743}
1744
1745fn changed_file_matches_version_target(changed_file: &str, target: &ResolvedRegistryPath) -> bool {
1746    let parent = Path::new(&target.relative)
1747        .parent()
1748        .map(normalized_relative_path)
1749        .unwrap_or_else(|| ".".to_string());
1750    if parent == "." || parent.is_empty() {
1751        return true;
1752    }
1753    changed_file == parent || changed_file.starts_with(&format!("{parent}/"))
1754}
1755
1756fn scope_matches_changed_path(
1757    project_root: &Path,
1758    version_scope: &VersionScope,
1759    changed_file: &str,
1760) -> bool {
1761    match version_scope {
1762        VersionScope::Repository => true,
1763        VersionScope::Crate { crate_root, .. } => {
1764            path_matches_root(project_root, crate_root, changed_file)
1765        }
1766        VersionScope::Service {
1767            service_root,
1768            version_targets,
1769            ..
1770        } => {
1771            path_matches_root(project_root, service_root, changed_file)
1772                || version_targets
1773                    .iter()
1774                    .any(|target| path_matches_target(changed_file, target))
1775        }
1776    }
1777}
1778
1779fn path_matches_root(project_root: &Path, scope_root: &Path, path: &str) -> bool {
1780    let relative_root = scope_root
1781        .strip_prefix(project_root)
1782        .ok()
1783        .map(normalized_relative_path)
1784        .unwrap_or_else(|| path.replace('\\', "/"));
1785
1786    path_matches_target(path, &relative_root)
1787}
1788
1789fn path_matches_target(path: &str, target: &str) -> bool {
1790    let normalized_target = target.replace('\\', "/");
1791    if normalized_target.is_empty() || normalized_target == "." {
1792        return true;
1793    }
1794    path == normalized_target || path.starts_with(&format!("{normalized_target}/"))
1795}
1796
1797fn resolve_selected_publish_targets(
1798    project_root: &Path,
1799    invocation_dir: &Path,
1800    version_scope: &VersionScope,
1801    selected_version_targets: &[ResolvedRegistryPath],
1802    select_all: bool,
1803) -> Result<Vec<ReleasePublishTarget>, String> {
1804    let Some((_, config)) = load_version_target_config(invocation_dir) else {
1805        return Ok(Vec::new());
1806    };
1807    let Some(publish) = config.publish else {
1808        return Ok(Vec::new());
1809    };
1810
1811    let selected_version_paths = selected_version_targets
1812        .iter()
1813        .map(|target| target.relative.as_str())
1814        .collect::<BTreeSet<_>>();
1815    let mut targets = Vec::new();
1816    for (kind, target) in [("npm", publish.npm), ("crates", publish.crates)] {
1817        let Some(target) = target.filter(|value| value.enabled.unwrap_or(true)) else {
1818            continue;
1819        };
1820        let manifest_path = target.manifest_path.clone().map(PathBuf::from).or_else(|| {
1821            target
1822                .working_directory
1823                .clone()
1824                .map(PathBuf::from)
1825                .map(|directory| {
1826                    directory.join(if kind == "npm" {
1827                        "package.json"
1828                    } else {
1829                        "Cargo.toml"
1830                    })
1831                })
1832        });
1833        let Some(manifest_path) = manifest_path else {
1834            continue;
1835        };
1836        let relative = manifest_path
1837            .strip_prefix(project_root)
1838            .ok()
1839            .map(normalized_relative_path)
1840            .unwrap_or_else(|| normalized_relative_path(&manifest_path));
1841        let include = if select_all || selected_version_paths.contains(relative.as_str()) {
1842            true
1843        } else if matches!(version_scope, VersionScope::Service { .. }) {
1844            version_scope_root(version_scope).is_some_and(|root| manifest_path.starts_with(root))
1845        } else {
1846            false
1847        };
1848        if include {
1849            targets.push(ReleasePublishTarget {
1850                kind: kind.to_string(),
1851                manifest_path: relative,
1852                package_name: target
1853                    .package_name
1854                    .clone()
1855                    .filter(|value| !value.trim().is_empty()),
1856            });
1857        }
1858    }
1859    Ok(targets)
1860}
1861
1862fn read_highest_version_from_targets(
1863    targets: &[ResolvedRegistryPath],
1864) -> Result<Option<Version>, String> {
1865    let mut highest = None;
1866    for target in targets {
1867        if let Some(version) = read_version_from_resolved_path(target)? {
1868            let parsed = parse_version(&version)?;
1869            if highest
1870                .as_ref()
1871                .is_none_or(|current: &Version| parsed > *current)
1872            {
1873                highest = Some(parsed);
1874            }
1875        }
1876    }
1877    Ok(highest)
1878}
1879
1880fn write_version_to_selected_paths(
1881    targets: &[ResolvedRegistryPath],
1882    version: &Version,
1883    allow_noop_when_targets_exist: bool,
1884) -> Result<Vec<PathBuf>, String> {
1885    let mut updated = 0usize;
1886    let mut matched_targets = 0usize;
1887    let mut updated_paths = Vec::new();
1888    let mut errors = Vec::new();
1889
1890    for entry in targets {
1891        let path = &entry.absolute;
1892        if !path.exists() {
1893            continue;
1894        }
1895        matched_targets += 1;
1896        match write_version_to_resolved_path(entry, version) {
1897            Ok(true) => {
1898                updated += 1;
1899                updated_paths.push(path.clone());
1900            }
1901            Ok(false) => {}
1902            Err(error) => errors.push(format!("{}: {}", path.display(), error)),
1903        }
1904    }
1905
1906    if matched_targets == 0 && errors.is_empty() {
1907        return Err("No selected version files were found to update.".to_string());
1908    }
1909    if !errors.is_empty() {
1910        return Err(format!(
1911            "Updated {} file(s), but some selected version targets failed:\n{}",
1912            updated,
1913            errors.join("\n")
1914        ));
1915    }
1916    if updated == 0 && allow_noop_when_targets_exist {
1917        return Ok(updated_paths);
1918    }
1919    Ok(updated_paths)
1920}
1921
1922fn initialize_release_ledger(
1923    version_scope: &VersionScope,
1924    repo: &str,
1925    release_version: &Version,
1926    tag_name: &str,
1927    branch_name: Option<String>,
1928    selection: &ChangedTargetSelection,
1929) -> ReleaseLedger {
1930    ReleaseLedger {
1931        scope_kind: version_scope_kind(version_scope).to_string(),
1932        scope_label: version_scope_label(version_scope, repo),
1933        scope_slug: current_scope_slug(version_scope, repo),
1934        version: release_version.to_string(),
1935        tag_name: tag_name.to_string(),
1936        branch_name,
1937        baseline: ReleaseBaselineRecord {
1938            source: selection.baseline_source.clone(),
1939            reference: selection.baseline_reference.clone(),
1940            tag: selection.baseline_tag.clone(),
1941        },
1942        selection: ReleaseSelectionRecord {
1943            changed_files: selection.changed_files.clone(),
1944            version_targets: selection
1945                .version_targets
1946                .iter()
1947                .map(|target| ReleaseVersionTarget {
1948                    path: target.relative.clone(),
1949                })
1950                .collect(),
1951            publish_targets: selection.publish_targets.clone(),
1952        },
1953        status: ReleaseLedgerStatus::InProgress,
1954        steps: BTreeMap::new(),
1955        created_at: Some(chrono::Utc::now()),
1956        updated_at: Some(chrono::Utc::now()),
1957        completed_at: None,
1958    }
1959}
1960
1961fn update_ledger_step(
1962    ledger: &mut ReleaseLedger,
1963    step_name: &str,
1964    status: ReleaseLedgerStepStatus,
1965    details: Option<serde_json::Value>,
1966    last_error: Option<String>,
1967) {
1968    let now = chrono::Utc::now();
1969    let step = ledger.steps.entry(step_name.to_string()).or_default();
1970    if step.started_at.is_none() {
1971        step.started_at = Some(now);
1972    }
1973    if matches!(
1974        status,
1975        ReleaseLedgerStepStatus::Completed
1976            | ReleaseLedgerStepStatus::Failed
1977            | ReleaseLedgerStepStatus::Skipped
1978            | ReleaseLedgerStepStatus::VerifiedRemote
1979    ) {
1980        step.completed_at = Some(now);
1981    }
1982    step.status = status;
1983    if let Some(details) = details {
1984        step.details = details;
1985    }
1986    step.last_error = last_error;
1987    ledger.updated_at = Some(now);
1988    ledger.status = match step.status {
1989        ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
1990        _ => ReleaseLedgerStatus::InProgress,
1991    };
1992}
1993
1994fn complete_release_ledger(ledger: &mut ReleaseLedger) {
1995    let now = chrono::Utc::now();
1996    ledger.status = ReleaseLedgerStatus::Completed;
1997    ledger.updated_at = Some(now);
1998    ledger.completed_at = Some(now);
1999}
2000
2001fn ledger_version_targets_to_resolved(
2002    project_root: &Path,
2003    ledger: &ReleaseLedger,
2004) -> Vec<ResolvedRegistryPath> {
2005    ledger
2006        .selection
2007        .version_targets
2008        .iter()
2009        .map(|target| ResolvedRegistryPath {
2010            relative: target.path.clone(),
2011            absolute: project_root.join(&target.path),
2012            cargo_package_override: None,
2013        })
2014        .collect()
2015}
2016
2017fn print_selected_release_targets(selection: &ChangedTargetSelection) {
2018    println!("{}", "Selected release targets".bright_cyan().bold());
2019    if selection.version_targets.is_empty() {
2020        println!("  {}", "No version targets selected".dimmed());
2021    } else {
2022        println!("  {}", "Version targets".bright_white());
2023        for target in &selection.version_targets {
2024            println!("    {} {}", "•".bright_cyan(), target.relative);
2025        }
2026    }
2027    if selection.publish_targets.is_empty() {
2028        println!("  {}", "Publish targets: none".dimmed());
2029    } else {
2030        println!("  {}", "Publish targets".bright_white());
2031        for target in &selection.publish_targets {
2032            println!(
2033                "    {} {} {}",
2034                "•".bright_cyan(),
2035                target.kind,
2036                target.manifest_path
2037            );
2038        }
2039    }
2040}
2041
2042fn begin_release_step(
2043    ledger: &mut ReleaseLedger,
2044    ledger_path: &Path,
2045    step_name: &str,
2046    details: Option<serde_json::Value>,
2047) -> Result<(), String> {
2048    update_ledger_step(
2049        ledger,
2050        step_name,
2051        ReleaseLedgerStepStatus::Running,
2052        details,
2053        None,
2054    );
2055    save_release_ledger(ledger_path, ledger)
2056}
2057
2058fn complete_release_step(
2059    ledger: &mut ReleaseLedger,
2060    ledger_path: &Path,
2061    step_name: &str,
2062    status: ReleaseLedgerStepStatus,
2063    details: Option<serde_json::Value>,
2064) -> Result<(), String> {
2065    update_ledger_step(ledger, step_name, status, details, None);
2066    save_release_ledger(ledger_path, ledger)
2067}
2068
2069fn fail_release_step(
2070    ledger: &mut ReleaseLedger,
2071    ledger_path: &Path,
2072    step_name: &str,
2073    error: &str,
2074) -> Result<(), String> {
2075    update_ledger_step(
2076        ledger,
2077        step_name,
2078        ReleaseLedgerStepStatus::Failed,
2079        None,
2080        Some(error.to_string()),
2081    );
2082    save_release_ledger(ledger_path, ledger)
2083}
2084
2085async fn verify_selected_publish_targets(
2086    project_root: &Path,
2087    selected_publish_targets: &[ReleasePublishTarget],
2088    expected_version: &Version,
2089) -> Result<bool, String> {
2090    if selected_publish_targets.is_empty() {
2091        return Ok(false);
2092    }
2093    let client = reqwest::Client::new();
2094    for target in selected_publish_targets {
2095        let manifest_path = project_root.join(&target.manifest_path);
2096        let package_name = target
2097            .package_name
2098            .clone()
2099            .or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
2100            .ok_or_else(|| {
2101                format!(
2102                    "Could not resolve package name for selected publish target `{}`.",
2103                    target.manifest_path
2104                )
2105            })?;
2106        let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
2107        if details.version.trim() != expected_version.to_string() {
2108            return Ok(false);
2109        }
2110    }
2111    Ok(true)
2112}
2113
2114fn detect_package_name_from_manifest(kind: &str, manifest_path: &Path) -> Option<String> {
2115    match kind {
2116        "npm" => {
2117            let content = fs::read_to_string(manifest_path).ok()?;
2118            let json: JsonValue = serde_json::from_str(&content).ok()?;
2119            json.get("name")
2120                .and_then(JsonValue::as_str)
2121                .map(str::trim)
2122                .filter(|value| !value.is_empty())
2123                .map(str::to_string)
2124        }
2125        "crates" => cargo_package_name(manifest_path).ok().flatten(),
2126        _ => None,
2127    }
2128}
2129
2130fn publish_target_filter_from_selection(
2131    selected_publish_targets: &[ReleasePublishTarget],
2132) -> Result<Option<String>, String> {
2133    let kinds = selected_publish_targets
2134        .iter()
2135        .map(|target| target.kind.as_str())
2136        .collect::<BTreeSet<_>>();
2137    if kinds.is_empty() || kinds.len() > 1 {
2138        return Ok(None);
2139    }
2140    let kind = kinds.iter().next().copied().unwrap_or_default();
2141    if kind.is_empty() {
2142        return Ok(None);
2143    }
2144    match kind {
2145        "npm" | "crates" => Ok(Some(kind.to_string())),
2146        other => Err(format!(
2147            "Unsupported selected publish target kind `{}` in release ledger.",
2148            other
2149        )),
2150    }
2151}
2152
2153fn collect_version_report(
2154    project_root: &Path,
2155    invocation_dir: &Path,
2156    registry: &[String],
2157    version_scope: &VersionScope,
2158) -> VersionReport {
2159    let mut report: VersionReport = VersionReport::default();
2160    let repo_label = project_root
2161        .file_name()
2162        .and_then(|value| value.to_str())
2163        .unwrap_or("repository");
2164    let scope_slug = current_scope_slug(version_scope, repo_label);
2165    report.worktree = collect_local_versions(
2166        project_root,
2167        invocation_dir,
2168        registry,
2169        version_scope,
2170        &mut report.warnings,
2171    );
2172    match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
2173        Ok(entries) => report.head = entries,
2174        Err(err) => report.warnings.push(err),
2175    }
2176    match collect_git_versions(project_root, version_scope) {
2177        Ok(tags) => report.local_tags = tags,
2178        Err(err) => report.warnings.push(err),
2179    }
2180    match collect_remote_git_versions(project_root, "origin", version_scope) {
2181        Ok(tags) => report.remote_tags = tags,
2182        Err(err) => report.warnings.push(err),
2183    }
2184    match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
2185        Ok(files) => report.dirty_files = files,
2186        Err(err) => report.warnings.push(err),
2187    }
2188    match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
2189        Ok(ledger) => report.release_ledger = ledger,
2190        Err(err) => report.warnings.push(err),
2191    }
2192    report
2193}
2194
2195fn resolve_current_version_for_bump(
2196    project_root: &Path,
2197    invocation_dir: &Path,
2198    registry: &[String],
2199    version_scope: &VersionScope,
2200) -> Version {
2201    let mut _warnings = Vec::new();
2202    let local_versions = collect_local_versions(
2203        project_root,
2204        invocation_dir,
2205        registry,
2206        version_scope,
2207        &mut _warnings,
2208    );
2209    let head_versions =
2210        collect_head_versions(project_root, invocation_dir, registry, version_scope)
2211            .unwrap_or_default();
2212    let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
2213
2214    local_versions
2215        .iter()
2216        .map(|entry| entry.version.clone())
2217        .chain(head_versions.iter().map(|entry| entry.version.clone()))
2218        .chain(local_tags.iter().map(|entry| entry.version.clone()))
2219        .max()
2220        .unwrap_or_else(default_version)
2221}
2222
2223async fn auto_commit_command_paths(
2224    project_root: &Path,
2225    paths: Vec<PathBuf>,
2226    message: String,
2227    action_label: &'static str,
2228) {
2229    match commit_paths(AutoCommitRequest {
2230        project_root,
2231        paths,
2232        message,
2233        action_label,
2234    })
2235    .await
2236    {
2237        Ok(AutoCommitResult::Committed(_)) => {}
2238        Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
2239        Err(e) => print_skip(action_label, &e),
2240    }
2241}
2242
2243async fn collect_registry_versions(
2244    project_root: &Path,
2245    invocation_dir: &Path,
2246    lookups: &[PackageNameLookup],
2247    version_scope: &VersionScope,
2248    warnings: &mut Vec<String>,
2249) -> Vec<RegistryVersionObservation> {
2250    let mut entries: Vec<RegistryVersionObservation> = Vec::new();
2251    let mut seen: BTreeSet<String> = BTreeSet::new();
2252    let client: reqwest::Client = reqwest::Client::new();
2253
2254    for lookup in lookups {
2255        let dedupe_key: String = format!(
2256            "{}|{}|{}|{}",
2257            lookup.file, lookup.format, lookup.key, lookup.registry
2258        );
2259        if !seen.insert(dedupe_key) {
2260            continue;
2261        }
2262
2263        let source_file = resolve_registry_relative_path(
2264            project_root,
2265            invocation_dir,
2266            version_scope,
2267            &lookup.file,
2268        );
2269        let path = project_root.join(&source_file);
2270        if !path.exists() {
2271            continue;
2272        }
2273
2274        let content: String = match fs::read_to_string(&path) {
2275            Ok(content) => content,
2276            Err(err) => {
2277                warnings.push(format!("Failed to read {}: {}", path.display(), err));
2278                continue;
2279            }
2280        };
2281
2282        let package_name: String = match read_package_name_from_lookup(lookup, &content) {
2283            Ok(Some(value)) => value,
2284            Ok(None) => continue,
2285            Err(err) => {
2286                warnings.push(format!("{}: {}", source_file, err));
2287                continue;
2288            }
2289        };
2290
2291        let (latest, raw_version, published_at, note) =
2292            match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
2293                Ok(details) => {
2294                    let parsed: Option<Version> = parse_version(&details.version).ok();
2295                    let note: Option<String> = if parsed.is_none() {
2296                        Some(format!("Non-semver registry version: {}", details.version))
2297                    } else {
2298                        None
2299                    };
2300                    (parsed, Some(details.version), details.published_at, note)
2301                }
2302                Err(err) => (None, None, None, Some(err)),
2303            };
2304
2305        entries.push(RegistryVersionObservation {
2306            registry: lookup.registry.clone(),
2307            package_name,
2308            source_file,
2309            latest,
2310            raw_version,
2311            published_at,
2312            note,
2313        });
2314    }
2315
2316    entries.sort_by(|a, b| {
2317        a.registry
2318            .cmp(&b.registry)
2319            .then_with(|| a.package_name.cmp(&b.package_name))
2320    });
2321    entries
2322}
2323
2324fn read_package_name_from_lookup(
2325    lookup: &PackageNameLookup,
2326    content: &str,
2327) -> Result<Option<String>, String> {
2328    let key_parts: Vec<&str> = lookup
2329        .key
2330        .split('.')
2331        .map(|part| part.trim())
2332        .filter(|part| !part.is_empty())
2333        .collect();
2334    if key_parts.is_empty() {
2335        return Err("Lookup key cannot be empty".to_string());
2336    }
2337
2338    let format: String = lookup.format.trim().to_ascii_lowercase();
2339    match format.as_str() {
2340        "json" => {
2341            let value: JsonValue = serde_json::from_str(content)
2342                .map_err(|e| format!("Failed to parse JSON: {}", e))?;
2343            Ok(json_lookup_string(&value, &key_parts))
2344        }
2345        "yaml" | "yml" => {
2346            let value: YamlValue = serde_yaml::from_str(content)
2347                .map_err(|e| format!("Failed to parse YAML: {}", e))?;
2348            Ok(yaml_lookup_string(&value, &key_parts))
2349        }
2350        "toml" => {
2351            let value: TomlValue =
2352                toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2353            Ok(toml_lookup_string(&value, &key_parts))
2354        }
2355        other => Err(format!("Unsupported lookup format `{}`", other)),
2356    }
2357}
2358
2359async fn fetch_registry_latest_version(
2360    client: &reqwest::Client,
2361    registry: &str,
2362    package_name: &str,
2363) -> Result<RegistryVersionDetails, String> {
2364    let normalized_registry: String = registry.trim().to_ascii_lowercase();
2365    match normalized_registry.as_str() {
2366        "npm" => fetch_npm_latest_version(client, package_name).await,
2367        "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
2368        _ => Err(format!("Unsupported registry `{}`", registry)),
2369    }
2370}
2371
2372#[derive(Debug, Deserialize)]
2373struct NpmLatestResponse {
2374    #[serde(rename = "dist-tags")]
2375    dist_tags: NpmDistTags,
2376    #[serde(default)]
2377    time: BTreeMap<String, String>,
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct NpmDistTags {
2382    latest: String,
2383}
2384
2385async fn fetch_npm_latest_version(
2386    client: &reqwest::Client,
2387    package_name: &str,
2388) -> Result<RegistryVersionDetails, String> {
2389    let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
2390        .map_err(|e| format!("Failed to build npm URL: {}", e))?;
2391    {
2392        let mut segments = url
2393            .path_segments_mut()
2394            .map_err(|_| "Failed to compose npm URL segments".to_string())?;
2395        segments.push(package_name);
2396    }
2397
2398    let response: reqwest::Response = client
2399        .get(url)
2400        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2401        .send()
2402        .await
2403        .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
2404
2405    if !response.status().is_success() {
2406        return Err(format!(
2407            "npm lookup for {} returned status {}",
2408            package_name,
2409            response.status()
2410        ));
2411    }
2412
2413    let payload: NpmLatestResponse = response
2414        .json()
2415        .await
2416        .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
2417    Ok(RegistryVersionDetails {
2418        published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
2419        version: payload.dist_tags.latest,
2420    })
2421}
2422
2423#[derive(Debug, Deserialize)]
2424struct CratesIoMeta {
2425    newest_version: String,
2426}
2427
2428#[derive(Debug, Deserialize)]
2429struct CratesIoVersionMeta {
2430    num: String,
2431    created_at: Option<String>,
2432}
2433
2434#[derive(Debug, Deserialize)]
2435struct CratesIoLatestResponse {
2436    #[serde(rename = "crate")]
2437    crate_meta: CratesIoMeta,
2438    #[serde(default)]
2439    versions: Vec<CratesIoVersionMeta>,
2440}
2441
2442async fn fetch_crates_latest_version(
2443    client: &reqwest::Client,
2444    package_name: &str,
2445) -> Result<RegistryVersionDetails, String> {
2446    let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
2447        .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
2448    {
2449        let mut segments = url
2450            .path_segments_mut()
2451            .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
2452        segments.push(package_name);
2453    }
2454
2455    let response: reqwest::Response = client
2456        .get(url)
2457        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2458        .send()
2459        .await
2460        .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
2461
2462    if !response.status().is_success() {
2463        return Err(format!(
2464            "crates.io lookup for {} returned status {}",
2465            package_name,
2466            response.status()
2467        ));
2468    }
2469
2470    let payload: CratesIoLatestResponse = response.json().await.map_err(|e| {
2471        format!(
2472            "Failed to parse crates.io response for {}: {}",
2473            package_name, e
2474        )
2475    })?;
2476    let published_at = payload
2477        .versions
2478        .iter()
2479        .find(|version| version.num == payload.crate_meta.newest_version)
2480        .and_then(|version| version.created_at.clone());
2481    Ok(RegistryVersionDetails {
2482        version: payload.crate_meta.newest_version,
2483        published_at,
2484    })
2485}
2486
2487fn collect_local_versions(
2488    project_root: &Path,
2489    invocation_dir: &Path,
2490    registry: &[String],
2491    version_scope: &VersionScope,
2492    warnings: &mut Vec<String>,
2493) -> Vec<VersionObservation> {
2494    let mut observed = Vec::new();
2495
2496    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2497        let path: &PathBuf = &entry.absolute;
2498        if !path.exists() {
2499            continue;
2500        }
2501
2502        match read_version_from_resolved_path(&entry) {
2503            Ok(Some(version)) => {
2504                if let Ok(parsed) = parse_version(&version) {
2505                    observed.push(VersionObservation {
2506                        location: entry.relative.clone(),
2507                        version: parsed,
2508                    });
2509                } else {
2510                    warnings.push(format!("Ignoring non-semver version in {}", path.display()));
2511                }
2512            }
2513            Ok(None) => {}
2514            Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
2515        }
2516    }
2517
2518    observed.sort_by(|a, b| a.location.cmp(&b.location));
2519    observed
2520}
2521
2522fn collect_git_versions(
2523    project_root: &Path,
2524    version_scope: &VersionScope,
2525) -> Result<Vec<GitTagObservation>, String> {
2526    if !command_exists("git") {
2527        return Err("Git is not installed; skipping git tag inspection.".to_string());
2528    }
2529
2530    let output: std::process::Output = Command::new("git")
2531        .current_dir(project_root)
2532        .args(["tag", "--list"])
2533        .output()
2534        .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
2535
2536    if !output.status.success() {
2537        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2538        if stderr.is_empty() {
2539            return Err("`git tag --list` failed in the current directory.".to_string());
2540        }
2541        return Err(format!("`git tag --list` failed: {}", stderr));
2542    }
2543
2544    Ok(parse_local_git_tag_output_for_scope(
2545        &String::from_utf8_lossy(&output.stdout),
2546        version_scope,
2547    ))
2548}
2549
2550fn collect_remote_git_versions(
2551    project_root: &Path,
2552    remote: &str,
2553    version_scope: &VersionScope,
2554) -> Result<Vec<GitTagObservation>, String> {
2555    if !command_exists("git") {
2556        return Err("Git is not installed; skipping remote tag inspection.".to_string());
2557    }
2558
2559    let output: std::process::Output = Command::new("git")
2560        .current_dir(project_root)
2561        .args(["ls-remote", "--tags", remote])
2562        .output()
2563        .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
2564
2565    if !output.status.success() {
2566        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2567        if stderr.is_empty() {
2568            return Err(format!("`git ls-remote --tags {}` failed.", remote));
2569        }
2570        return Err(format!(
2571            "`git ls-remote --tags {}` failed: {}",
2572            remote, stderr
2573        ));
2574    }
2575
2576    Ok(parse_remote_git_tag_output_for_scope(
2577        &String::from_utf8_lossy(&output.stdout),
2578        version_scope,
2579    ))
2580}
2581
2582fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
2583    let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
2584
2585    if tags.is_empty() {
2586        println!("No semantic git tags found in {}.", project_root.display());
2587        return Ok(());
2588    }
2589
2590    println!("Git versions from `git tag --list`:");
2591    for tag in tags {
2592        if tag.raw_tags.len() > 1 {
2593            println!("  {}  ({})", tag.version, tag.raw_tags.join(", "));
2594        } else {
2595            println!("  {}", tag.version);
2596        }
2597    }
2598
2599    Ok(())
2600}
2601
2602fn print_version_observations(
2603    title: &str,
2604    entries: &[VersionObservation],
2605    dirty_files: Option<&[String]>,
2606) {
2607    println!();
2608    println!("{}", title.bright_cyan().bold());
2609    println!("{}", "─".repeat(72).bright_black());
2610
2611    if entries.is_empty() {
2612        println!("  {}", "none found".dimmed());
2613        return;
2614    }
2615
2616    let Some(highest) = highest_version_observation(entries) else {
2617        println!("  {}", "none found".dimmed());
2618        return;
2619    };
2620
2621    let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
2622    let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
2623    println!(
2624        "  {:<28} {} ({}/{})",
2625        "latest".bright_white(),
2626        highest.to_string().bright_green().bold(),
2627        latest_count,
2628        entries.len()
2629    );
2630
2631    if stale_entries.is_empty() {
2632        return;
2633    }
2634
2635    println!("  {}", "stale entries".bright_yellow().bold());
2636    for entry in stale_entries {
2637        let dirty: bool = dirty_files
2638            .map(|files| files.iter().any(|file| file == &entry.location))
2639            .unwrap_or(false);
2640        let dirty_suffix: String = if dirty {
2641            format!(" {}", "modified".bright_magenta())
2642        } else {
2643            String::new()
2644        };
2645
2646        println!(
2647            "  {:<28} {}{}",
2648            entry.location.bright_white(),
2649            entry.version.to_string().bright_green(),
2650            dirty_suffix
2651        );
2652    }
2653}
2654
2655fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
2656    println!();
2657    println!("{}", title.bright_cyan().bold());
2658    println!("{}", "─".repeat(72).bright_black());
2659
2660    if tags.is_empty() {
2661        println!("  {}", "none found".dimmed());
2662        return;
2663    }
2664
2665    let latest = &tags[0];
2666    if latest.raw_tags.len() > 1 {
2667        println!(
2668            "  {:<20} {}",
2669            latest.version.to_string().bright_green().bold(),
2670            latest.raw_tags.join(", ").dimmed()
2671        );
2672    } else {
2673        println!("  {}", latest.version.to_string().bright_green().bold());
2674    }
2675
2676    if tags.len() > 1 {
2677        let older = tags
2678            .iter()
2679            .skip(1)
2680            .take(5)
2681            .map(|tag| tag.version.to_string())
2682            .collect::<Vec<_>>();
2683        println!(
2684            "  {:<20} {}",
2685            "older tags".bright_white(),
2686            older.join(", ").dimmed()
2687        );
2688        if tags.len() > 6 {
2689            println!(
2690                "  {:<20} {}",
2691                "older hidden".bright_white(),
2692                format!("{} more", tags.len() - 6).dimmed()
2693            );
2694        }
2695    }
2696}
2697
2698fn collect_head_versions(
2699    project_root: &Path,
2700    invocation_dir: &Path,
2701    registry: &[String],
2702    version_scope: &VersionScope,
2703) -> Result<Vec<VersionObservation>, String> {
2704    if !command_exists("git") {
2705        return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
2706    }
2707
2708    let head_check = Command::new("git")
2709        .current_dir(project_root)
2710        .args(["rev-parse", "--verify", "HEAD"])
2711        .output()
2712        .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
2713
2714    if !head_check.status.success() {
2715        return Ok(Vec::new());
2716    }
2717
2718    let mut observed = Vec::new();
2719    let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
2720
2721    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2722        let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
2723            continue;
2724        };
2725
2726        match read_version_from_blob_with_override(
2727            &entry.relative,
2728            &content,
2729            cargo_toml_content.as_deref(),
2730            entry.cargo_package_override.as_deref(),
2731        ) {
2732            Ok(Some(version)) => {
2733                if let Ok(parsed) = parse_version(&version) {
2734                    observed.push(VersionObservation {
2735                        location: entry.relative.clone(),
2736                        version: parsed,
2737                    });
2738                }
2739            }
2740            Ok(None) => {}
2741            Err(_) => {}
2742        }
2743    }
2744
2745    observed.sort_by(|a, b| a.location.cmp(&b.location));
2746    Ok(observed)
2747}
2748
2749fn collect_dirty_version_files(
2750    project_root: &Path,
2751    invocation_dir: &Path,
2752    registry: &[String],
2753    version_scope: &VersionScope,
2754) -> Result<Vec<String>, String> {
2755    if !command_exists("git") {
2756        return Err("Git is not installed; skipping worktree status inspection.".to_string());
2757    }
2758
2759    let mut args = vec!["status", "--porcelain", "--"];
2760    let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
2761    for entry in &resolved {
2762        args.push(entry.relative.as_str());
2763    }
2764
2765    let output = Command::new("git")
2766        .current_dir(project_root)
2767        .args(&args)
2768        .output()
2769        .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
2770
2771    if !output.status.success() {
2772        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2773        if stderr.is_empty() {
2774            return Err("`git status --porcelain` failed.".to_string());
2775        }
2776        return Err(format!("`git status --porcelain` failed: {}", stderr));
2777    }
2778
2779    let mut dirty = Vec::new();
2780    for line in String::from_utf8_lossy(&output.stdout).lines() {
2781        if line.len() < 4 {
2782            continue;
2783        }
2784        let path = line[3..].trim();
2785        if !path.is_empty() {
2786            dirty.push(path.replace('\\', "/"));
2787        }
2788    }
2789
2790    dirty.sort();
2791    dirty.dedup();
2792    Ok(dirty)
2793}
2794
2795fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
2796    let output = Command::new("git")
2797        .current_dir(project_root)
2798        .args(["show", &format!("HEAD:{}", relative)])
2799        .output()
2800        .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
2801
2802    if !output.status.success() {
2803        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2804        if stderr.is_empty() {
2805            return Err(format!("{} is not present in HEAD", relative));
2806        }
2807        return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
2808    }
2809
2810    String::from_utf8(output.stdout)
2811        .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
2812}
2813
2814fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2815    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2816    for line in output.lines() {
2817        let tag = line.trim();
2818        if tag.is_empty() {
2819            continue;
2820        }
2821        if let Ok(version) = parse_version(tag) {
2822            by_version.entry(version).or_default().push(tag.to_string());
2823        }
2824    }
2825    git_tag_map_to_vec(by_version)
2826}
2827
2828fn parse_local_git_tag_output_for_scope(
2829    output: &str,
2830    version_scope: &VersionScope,
2831) -> Vec<GitTagObservation> {
2832    match version_scope {
2833        VersionScope::Repository => parse_local_git_tag_output(output),
2834        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2835            parse_scoped_git_tag_output(output, tag_prefix)
2836        }
2837    }
2838}
2839
2840fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2841    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2842    for line in output.lines() {
2843        let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2844        let tag = reference
2845            .strip_prefix("refs/tags/")
2846            .unwrap_or(reference)
2847            .trim_end_matches("^{}")
2848            .trim();
2849
2850        if tag.is_empty() {
2851            continue;
2852        }
2853        if let Ok(version) = parse_version(tag) {
2854            by_version.entry(version).or_default().push(tag.to_string());
2855        }
2856    }
2857    git_tag_map_to_vec(by_version)
2858}
2859
2860fn parse_remote_git_tag_output_for_scope(
2861    output: &str,
2862    version_scope: &VersionScope,
2863) -> Vec<GitTagObservation> {
2864    match version_scope {
2865        VersionScope::Repository => parse_remote_git_tag_output(output),
2866        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2867            let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2868            for line in output.lines() {
2869                let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2870                let tag = reference
2871                    .strip_prefix("refs/tags/")
2872                    .unwrap_or(reference)
2873                    .trim_end_matches("^{}")
2874                    .trim();
2875
2876                if tag.is_empty() {
2877                    continue;
2878                }
2879                if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2880                    by_version.entry(version).or_default().push(tag.to_string());
2881                }
2882            }
2883            git_tag_map_to_vec(by_version)
2884        }
2885    }
2886}
2887
2888fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
2889    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2890    for line in output.lines() {
2891        let tag = line.trim();
2892        if tag.is_empty() {
2893            continue;
2894        }
2895        if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2896            by_version.entry(version).or_default().push(tag.to_string());
2897        }
2898    }
2899    git_tag_map_to_vec(by_version)
2900}
2901
2902fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
2903    let mut versions: Vec<GitTagObservation> = by_version
2904        .into_iter()
2905        .map(|(version, mut raw_tags)| {
2906            raw_tags.sort();
2907            raw_tags.dedup();
2908            GitTagObservation { version, raw_tags }
2909        })
2910        .collect();
2911    versions.sort_by(|a, b| b.version.cmp(&a.version));
2912    versions
2913}
2914
2915#[cfg(test)]
2916fn read_version_from_blob(
2917    relative: &str,
2918    content: &str,
2919    cargo_toml_content: Option<&str>,
2920) -> Result<Option<String>, String> {
2921    read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
2922}
2923
2924fn read_version_from_blob_with_override(
2925    relative: &str,
2926    content: &str,
2927    cargo_toml_content: Option<&str>,
2928    cargo_package_override: Option<&str>,
2929) -> Result<Option<String>, String> {
2930    let file_name = Path::new(relative)
2931        .file_name()
2932        .and_then(|n| n.to_str())
2933        .unwrap_or_default();
2934
2935    match file_name {
2936        "README.md" => read_readme_version_from_content(content),
2937        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2938            read_openapi_version_from_content(content)
2939        }
2940        "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
2941        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2942        | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
2943        "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
2944        "Cargo.toml" => read_cargo_toml_version_from_content(content),
2945        "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
2946            content,
2947            cargo_toml_content,
2948            cargo_package_override,
2949        ),
2950        "pyproject.toml" => read_pyproject_version_from_content(content),
2951        "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
2952        "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
2953        "pom.xml" => {
2954            read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
2955        }
2956        "build.gradle" | "build.gradle.kts" => {
2957            read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
2958        }
2959        "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
2960        _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
2961            Some("json") => read_json_root_version_from_content(content),
2962            Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
2963            Some("toml") => read_toml_root_version_from_content(content),
2964            Some("md") => read_readme_version_from_content(content),
2965            _ => Ok(None),
2966        },
2967    }
2968}
2969
2970fn print_version_report(project_root: &Path, report: &VersionReport) {
2971    let dirty_suffix = if report.dirty_files.is_empty() {
2972        "clean".green().to_string()
2973    } else {
2974        format!("dirty ({})", report.dirty_files.len())
2975            .bright_magenta()
2976            .to_string()
2977    };
2978
2979    println!(
2980        "\n{} {}",
2981        "Version Summary".bright_cyan().bold(),
2982        project_root.display().to_string().bright_white()
2983    );
2984    println!("{}", "─".repeat(72).bright_black());
2985    println!(
2986        "{:<20} {}",
2987        "Highest available".bright_white(),
2988        report.highest_available().to_string().bright_green().bold()
2989    );
2990    println!(
2991        "{:<20} {}",
2992        "Worktree".bright_white(),
2993        report
2994            .highest_worktree()
2995            .unwrap_or_else(default_version)
2996            .to_string()
2997            .bright_yellow()
2998    );
2999    println!(
3000        "{:<20} {}",
3001        "Committed HEAD".bright_white(),
3002        report
3003            .highest_head()
3004            .map(|v| v.to_string())
3005            .unwrap_or_else(|| "none".dimmed().to_string())
3006    );
3007    println!(
3008        "{:<20} {}",
3009        "GitHub tags".bright_white(),
3010        report
3011            .highest_remote_tag()
3012            .map(|v| v.to_string())
3013            .unwrap_or_else(|| "none".dimmed().to_string())
3014    );
3015    println!(
3016        "{:<20} {}",
3017        "Registry latest".bright_white(),
3018        report
3019            .highest_registry()
3020            .map(|v| v.to_string())
3021            .unwrap_or_else(|| "none".dimmed().to_string())
3022    );
3023    println!(
3024        "{:<20} {}",
3025        "Local tags".bright_white(),
3026        report
3027            .highest_local_tag()
3028            .map(|v| v.to_string())
3029            .unwrap_or_else(|| "none".dimmed().to_string())
3030    );
3031    if let Some(ledger) = &report.release_ledger {
3032        println!(
3033            "{:<20} {} {}",
3034            "Release ledger".bright_white(),
3035            ledger.version.bright_yellow(),
3036            format!("{:?}", ledger.status).bright_cyan()
3037        );
3038    }
3039    println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
3040
3041    if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
3042        println!(
3043            "{:<20} {}",
3044            "Suggested scope".bright_white(),
3045            review.scope_label.bright_cyan()
3046        );
3047        if !review.groups.is_empty() {
3048            println!("{:<20} {}", "Why".bright_white(), review.summary);
3049            for group in review.groups {
3050                println!("{:<20} {}", "", group);
3051            }
3052        }
3053    }
3054
3055    print_version_observations(
3056        "Worktree version files",
3057        &report.worktree,
3058        Some(&report.dirty_files),
3059    );
3060    print_version_observations("Committed HEAD version files", &report.head, None);
3061    print_registry_observations("Published package versions", &report.registry_versions);
3062    print_git_tag_observations("GitHub tags", &report.remote_tags);
3063    print_git_tag_observations("Local git tags", &report.local_tags);
3064
3065    let divergent = report.divergent_versions();
3066    let highest = report.highest_available();
3067    let outdated: Vec<_> = divergent
3068        .into_iter()
3069        .filter(|version| version != &highest)
3070        .collect();
3071    println!();
3072    println!("{}", "Divergence".bright_cyan().bold());
3073    println!("{}", "─".repeat(72).bright_black());
3074    println!(
3075        "  {:<20} {}",
3076        "latest target".bright_white(),
3077        highest.to_string().bright_green().bold()
3078    );
3079    if !outdated.is_empty() {
3080        for version in outdated {
3081            println!(
3082                "  {} {}",
3083                "•".bright_yellow(),
3084                version.to_string().bright_yellow()
3085            );
3086        }
3087        println!();
3088        println!(
3089            "{} {}",
3090            "Fix local files with".bright_white(),
3091            format!("xbp version {}", highest).black().on_bright_green()
3092        );
3093    } else {
3094        println!("  {}", "all relevant sources are aligned".green());
3095    }
3096
3097    if !report.warnings.is_empty() {
3098        println!();
3099        println!("{}", "Warnings".bright_yellow().bold());
3100        println!("{}", "─".repeat(72).bright_black());
3101        for warning in &report.warnings {
3102            println!("  {} {}", "!".bright_yellow(), warning);
3103        }
3104    }
3105}
3106
3107struct VersionDirtyFileReview {
3108    scope_label: String,
3109    summary: String,
3110    groups: Vec<String>,
3111}
3112
3113fn summarize_version_dirty_files(
3114    project_root: &Path,
3115    dirty_files: &[String],
3116) -> Option<VersionDirtyFileReview> {
3117    if dirty_files.is_empty() {
3118        return None;
3119    }
3120
3121    let mut crate_names = BTreeSet::new();
3122    let mut service_names = BTreeSet::new();
3123    let mut touches_workspace_files = false;
3124    let mut workspace_files = Vec::new();
3125    let mut crate_files = Vec::new();
3126    let mut service_files = Vec::new();
3127    let mut other_files = Vec::new();
3128
3129    for file in dirty_files {
3130        let path = Path::new(file);
3131        if path == Path::new("Cargo.toml")
3132            || path == Path::new("Cargo.lock")
3133            || path == Path::new("README.md")
3134            || path == Path::new("CHANGELOG.md")
3135            || path == Path::new("SECURITY.md")
3136        {
3137            touches_workspace_files = true;
3138            workspace_files.push(file.clone());
3139            continue;
3140        }
3141
3142        if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
3143            if let Some(crate_name) = crate_name {
3144                crate_names.insert(crate_name);
3145                crate_files.push(file.clone());
3146            }
3147            if let Some(service_name) = service_name {
3148                service_names.insert(service_name);
3149                service_files.push(file.clone());
3150            }
3151        } else {
3152            other_files.push(file.clone());
3153        }
3154    }
3155
3156    let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
3157        if crate_names.len() == 1 {
3158            format!("crate `{}`", crate_names.iter().next().unwrap())
3159        } else {
3160            format!("{} crates", crate_names.len())
3161        }
3162    } else if !service_names.is_empty() && crate_names.is_empty() {
3163        if service_names.len() == 1 {
3164            format!("service `{}`", service_names.iter().next().unwrap())
3165        } else {
3166            format!("{} services", service_names.len())
3167        }
3168    } else if touches_workspace_files {
3169        "repository / main crates".to_string()
3170    } else {
3171        "repository".to_string()
3172    };
3173
3174    let summary = if dirty_files.len() <= 8 {
3175        format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
3176    } else {
3177        format!(
3178            "{} file(s): {} ...",
3179            dirty_files.len(),
3180            dirty_files
3181                .iter()
3182                .take(8)
3183                .cloned()
3184                .collect::<Vec<_>>()
3185                .join(", ")
3186        )
3187    };
3188
3189    let mut groups = Vec::new();
3190    if !workspace_files.is_empty() {
3191        groups.push(format!(
3192            "Workspace files: {}",
3193            summarize_file_group(&workspace_files)
3194        ));
3195    }
3196    if !crate_files.is_empty() {
3197        groups.push(format!(
3198            "Crate files: {}",
3199            summarize_file_group(&crate_files)
3200        ));
3201    }
3202    if !service_files.is_empty() {
3203        groups.push(format!(
3204            "Service files: {}",
3205            summarize_file_group(&service_files)
3206        ));
3207    }
3208    if !other_files.is_empty() {
3209        groups.push(format!(
3210            "Other files: {}",
3211            summarize_file_group(&other_files)
3212        ));
3213    }
3214
3215    Some(VersionDirtyFileReview {
3216        scope_label,
3217        summary,
3218        groups,
3219    })
3220}
3221
3222fn summarize_file_group(files: &[String]) -> String {
3223    if files.is_empty() {
3224        return String::new();
3225    }
3226
3227    if files.len() <= 4 {
3228        return files.join(", ");
3229    }
3230
3231    format!(
3232        "{} (and {} more)",
3233        files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
3234        files.len() - 4
3235    )
3236}
3237
3238fn infer_version_scope_name(
3239    project_root: &Path,
3240    path: &Path,
3241) -> Option<(Option<String>, Option<String>)> {
3242    let absolute = if path.is_absolute() {
3243        path.to_path_buf()
3244    } else {
3245        project_root.join(path)
3246    };
3247
3248    let relative = absolute.strip_prefix(project_root).ok()?;
3249    let mut components = relative.components();
3250    let first = components.next()?.as_os_str().to_string_lossy().to_string();
3251    let second = components
3252        .next()
3253        .map(|value| value.as_os_str().to_string_lossy().to_string());
3254
3255    if first == "crates" {
3256        return second.map(|crate_name| (Some(crate_name), None));
3257    }
3258
3259    if first == "apps" {
3260        return second.map(|service_name| (None, Some(service_name)));
3261    }
3262
3263    None
3264}
3265
3266fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
3267    entries.iter().map(|entry| entry.version.clone()).max()
3268}
3269
3270fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
3271    let Some(highest) = highest_version_observation(entries) else {
3272        return Vec::new();
3273    };
3274
3275    entries
3276        .iter()
3277        .filter(|entry| entry.version < highest)
3278        .collect()
3279}
3280
3281fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
3282    println!();
3283    println!("{}", title.bright_cyan().bold());
3284    println!("{}", "─".repeat(72).bright_black());
3285
3286    if entries.is_empty() {
3287        println!("  {}", "none found".dimmed());
3288        return;
3289    }
3290
3291    for entry in entries {
3292        let latest_display = match (&entry.latest, &entry.raw_version) {
3293            (Some(version), _) => version.to_string().bright_green().to_string(),
3294            (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
3295            (None, None) => "unavailable".dimmed().to_string(),
3296        };
3297
3298        let note = entry
3299            .note
3300            .as_ref()
3301            .map(|value| format!(" {}", value.bright_yellow()))
3302            .unwrap_or_default();
3303        let published_at = entry
3304            .published_at
3305            .as_deref()
3306            .map(|value| format!(" {}", format!("published {value}").dimmed()))
3307            .unwrap_or_default();
3308
3309        println!(
3310            "  {:<9} {:<28} {:<16} {}{}{}",
3311            entry.registry.bright_white(),
3312            entry.package_name.bright_white(),
3313            latest_display,
3314            entry.source_file.dimmed(),
3315            published_at,
3316            note
3317        );
3318    }
3319}
3320
3321#[cfg(test)]
3322fn write_version_to_configured_files(
3323    project_root: &Path,
3324    invocation_dir: &Path,
3325    registry: &[String],
3326    version_scope: &VersionScope,
3327    version: &Version,
3328) -> Result<usize, String> {
3329    write_version_to_configured_files_with_paths(
3330        project_root,
3331        invocation_dir,
3332        registry,
3333        version_scope,
3334        version,
3335    )
3336    .map(|paths| paths.len())
3337}
3338
3339fn write_version_to_configured_files_with_paths(
3340    project_root: &Path,
3341    invocation_dir: &Path,
3342    registry: &[String],
3343    version_scope: &VersionScope,
3344    version: &Version,
3345) -> Result<Vec<PathBuf>, String> {
3346    write_version_to_configured_files_with_paths_internal(
3347        project_root,
3348        invocation_dir,
3349        registry,
3350        version_scope,
3351        version,
3352        false,
3353    )
3354}
3355
3356#[cfg(test)]
3357fn sync_version_to_configured_files_with_paths(
3358    project_root: &Path,
3359    invocation_dir: &Path,
3360    registry: &[String],
3361    version_scope: &VersionScope,
3362    version: &Version,
3363) -> Result<Vec<PathBuf>, String> {
3364    write_version_to_configured_files_with_paths_internal(
3365        project_root,
3366        invocation_dir,
3367        registry,
3368        version_scope,
3369        version,
3370        true,
3371    )
3372}
3373
3374fn write_version_to_configured_files_with_paths_internal(
3375    project_root: &Path,
3376    invocation_dir: &Path,
3377    registry: &[String],
3378    version_scope: &VersionScope,
3379    version: &Version,
3380    allow_noop_when_targets_exist: bool,
3381) -> Result<Vec<PathBuf>, String> {
3382    let mut updated = 0usize;
3383    let mut matched_targets = 0usize;
3384    let mut updated_paths = Vec::new();
3385    let mut errors = Vec::new();
3386
3387    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
3388        let path = &entry.absolute;
3389        if !path.exists() {
3390            continue;
3391        }
3392        matched_targets += 1;
3393
3394        match write_version_to_resolved_path(&entry, version) {
3395            Ok(true) => {
3396                updated += 1;
3397                updated_paths.push(path.clone());
3398            }
3399            Ok(false) => {}
3400            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
3401        }
3402    }
3403
3404    if matched_targets == 0 && errors.is_empty() {
3405        return Err("No configured version files were found to update.".to_string());
3406    }
3407
3408    if !errors.is_empty() {
3409        return Err(format!(
3410            "Updated {} file(s), but some version targets failed:\n{}",
3411            updated,
3412            errors.join("\n")
3413        ));
3414    }
3415
3416    if updated == 0 && allow_noop_when_targets_exist {
3417        return Ok(updated_paths);
3418    }
3419
3420    Ok(updated_paths)
3421}
3422
3423fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
3424    let file_name = path
3425        .file_name()
3426        .and_then(|n| n.to_str())
3427        .unwrap_or_default();
3428
3429    match file_name {
3430        "README.md" => read_readme_version(path),
3431        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3432            read_openapi_version(path)
3433        }
3434        "openapi.json" | "swagger.json" => read_json_openapi_version(path),
3435        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3436        | "xbp.json" => read_json_root_version(path),
3437        "deno.json" => read_json_root_version(path),
3438        "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
3439        "Cargo.toml" => read_cargo_toml_version(path),
3440        "Cargo.lock" => read_cargo_lock_version(path),
3441        "pyproject.toml" => read_pyproject_version(path),
3442        "Chart.yaml" => read_yaml_root_version(path, "version"),
3443        "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
3444        "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
3445        "build.gradle" | "build.gradle.kts" => {
3446            read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
3447        }
3448        "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
3449        _ => match path.extension().and_then(|ext| ext.to_str()) {
3450            Some("json") => read_json_root_version(path),
3451            Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
3452            Some("toml") => read_toml_root_version(path),
3453            Some("md") => read_readme_version(path),
3454            _ => Ok(None),
3455        },
3456    }
3457}
3458
3459fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
3460    let path = &entry.absolute;
3461    let file_name = path
3462        .file_name()
3463        .and_then(|n| n.to_str())
3464        .unwrap_or_default();
3465
3466    if file_name == "Cargo.lock" {
3467        if let Some(package_name) = entry.cargo_package_override.as_deref() {
3468            return read_cargo_lock_version_for_package(path, package_name);
3469        }
3470    }
3471
3472    read_version_from_path(path)
3473}
3474
3475fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
3476    let file_name = path
3477        .file_name()
3478        .and_then(|n| n.to_str())
3479        .unwrap_or_default();
3480
3481    match file_name {
3482        "README.md" => write_readme_version(path, version).map(|_| true),
3483        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3484            write_openapi_version(path, version).map(|_| true)
3485        }
3486        "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
3487        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3488        | "xbp.json" => write_json_root_version(path, version).map(|_| true),
3489        "deno.json" => write_json_root_version(path, version).map(|_| true),
3490        "deno.jsonc" => {
3491            write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
3492        }
3493        "Cargo.toml" => write_cargo_toml_version(path, version),
3494        "Cargo.lock" => write_cargo_lock_version(path, version),
3495        "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
3496        "Chart.yaml" => write_chart_version(path, version).map(|_| true),
3497        "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
3498        "pom.xml" => {
3499            write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
3500        }
3501        "build.gradle" | "build.gradle.kts" => {
3502            write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
3503                .map(|_| true)
3504        }
3505        "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
3506        _ => match path.extension().and_then(|ext| ext.to_str()) {
3507            Some("json") => write_json_root_version(path, version).map(|_| true),
3508            Some("yaml") | Some("yml") => {
3509                write_yaml_root_version(path, "version", version).map(|_| true)
3510            }
3511            Some("toml") => write_toml_root_version(path, version).map(|_| true),
3512            Some("md") => write_readme_version(path, version).map(|_| true),
3513            _ => Err("Unsupported version file type".to_string()),
3514        },
3515    }
3516}
3517
3518fn write_version_to_resolved_path(
3519    entry: &ResolvedRegistryPath,
3520    version: &Version,
3521) -> Result<bool, String> {
3522    let path = &entry.absolute;
3523    let file_name = path
3524        .file_name()
3525        .and_then(|n| n.to_str())
3526        .unwrap_or_default();
3527
3528    if file_name == "Cargo.lock" {
3529        if let Some(package_name) = entry.cargo_package_override.as_deref() {
3530            return write_cargo_lock_version_for_package(path, Some(package_name), version);
3531        }
3532    }
3533
3534    write_version_to_path(path, version)
3535}
3536
3537fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3538    let value: JsonValue =
3539        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3540    Ok(value
3541        .get("version")
3542        .and_then(JsonValue::as_str)
3543        .map(|value| value.to_string()))
3544}
3545
3546fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
3547    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3548    read_json_root_version_from_content(&content)
3549}
3550
3551fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
3552    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3553    let mut value: JsonValue =
3554        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3555
3556    let object = value
3557        .as_object_mut()
3558        .ok_or_else(|| "Expected a JSON object".to_string())?;
3559    object.insert(
3560        "version".to_string(),
3561        JsonValue::String(version.to_string()),
3562    );
3563
3564    fs::write(
3565        path,
3566        serde_json::to_string_pretty(&value)
3567            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3568    )
3569    .map_err(|e| format!("Failed to write file: {}", e))
3570}
3571
3572fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
3573    let value: YamlValue =
3574        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3575    Ok(yaml_get_string(&value, key))
3576}
3577
3578fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
3579    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3580    read_yaml_root_version_from_content(&content, key)
3581}
3582
3583fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
3584    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3585    let mut value: YamlValue =
3586        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3587
3588    let mapping = yaml_root_mapping_mut(&mut value)?;
3589    mapping.insert(
3590        YamlValue::String(key.to_string()),
3591        YamlValue::String(version.to_string()),
3592    );
3593
3594    fs::write(
3595        path,
3596        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3597    )
3598    .map_err(|e| format!("Failed to write file: {}", e))
3599}
3600
3601fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3602    let value: YamlValue =
3603        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3604
3605    let info = yaml_get_mapping(&value, "info");
3606    Ok(info.and_then(|mapping| {
3607        mapping
3608            .get(YamlValue::String("version".to_string()))
3609            .and_then(YamlValue::as_str)
3610            .map(|value| value.to_string())
3611    }))
3612}
3613
3614fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
3615    let content: String =
3616        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3617    read_openapi_version_from_content(&content)
3618}
3619
3620fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3621    let value: JsonValue =
3622        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3623    Ok(value
3624        .get("info")
3625        .and_then(JsonValue::as_object)
3626        .and_then(|info| info.get("version"))
3627        .and_then(JsonValue::as_str)
3628        .map(|value| value.to_string()))
3629}
3630
3631fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
3632    let content: String =
3633        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3634    read_json_openapi_version_from_content(&content)
3635}
3636
3637fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3638    let content: String =
3639        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3640    let mut value: YamlValue =
3641        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3642
3643    let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
3644    let info_key: YamlValue = YamlValue::String("info".to_string());
3645    if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
3646        root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
3647    }
3648
3649    let info: &mut YamlMapping = root
3650        .get_mut(&info_key)
3651        .and_then(YamlValue::as_mapping_mut)
3652        .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
3653    info.insert(
3654        YamlValue::String("version".to_string()),
3655        YamlValue::String(version.to_string()),
3656    );
3657
3658    fs::write(
3659        path,
3660        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3661    )
3662    .map_err(|e| format!("Failed to write file: {}", e))
3663}
3664
3665fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3666    let content: String =
3667        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3668    let mut value: JsonValue =
3669        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3670
3671    let root = value
3672        .as_object_mut()
3673        .ok_or_else(|| "Expected a JSON object".to_string())?;
3674    let info = root
3675        .entry("info".to_string())
3676        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
3677    let info_object = info
3678        .as_object_mut()
3679        .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
3680    info_object.insert(
3681        "version".to_string(),
3682        JsonValue::String(version.to_string()),
3683    );
3684
3685    fs::write(
3686        path,
3687        serde_json::to_string_pretty(&value)
3688            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3689    )
3690    .map_err(|e| format!("Failed to write file: {}", e))
3691}
3692
3693fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3694    let value: TomlValue =
3695        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3696    Ok(value
3697        .get("version")
3698        .and_then(TomlValue::as_str)
3699        .map(|value| value.to_string()))
3700}
3701
3702fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
3703    let content: String =
3704        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3705    read_toml_root_version_from_content(&content)
3706}
3707
3708fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
3709    let content: String =
3710        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3711    let mut value: TomlValue =
3712        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3713    let table = value
3714        .as_table_mut()
3715        .ok_or_else(|| "Expected a TOML table".to_string())?;
3716    table.insert(
3717        "version".to_string(),
3718        TomlValue::String(version.to_string()),
3719    );
3720    fs::write(
3721        path,
3722        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3723    )
3724    .map_err(|e| format!("Failed to write file: {}", e))
3725}
3726
3727fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
3728    crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
3729}
3730
3731fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
3732    resolve_cargo_package_version(path)
3733}
3734
3735fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
3736    write_cargo_package_version(path, version)
3737}
3738
3739fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
3740    let value: TomlValue =
3741        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3742
3743    let project_version = value
3744        .get("project")
3745        .and_then(TomlValue::as_table)
3746        .and_then(|project| project.get("version"))
3747        .and_then(TomlValue::as_str);
3748
3749    let poetry_version = value
3750        .get("tool")
3751        .and_then(TomlValue::as_table)
3752        .and_then(|tool| tool.get("poetry"))
3753        .and_then(TomlValue::as_table)
3754        .and_then(|poetry| poetry.get("version"))
3755        .and_then(TomlValue::as_str);
3756
3757    Ok(project_version
3758        .or(poetry_version)
3759        .map(|value| value.to_string()))
3760}
3761
3762fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
3763    let content: String =
3764        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3765    read_pyproject_version_from_content(&content)
3766}
3767
3768fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
3769    let content: String =
3770        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3771    let mut value: TomlValue =
3772        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3773
3774    if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
3775        project.insert(
3776            "version".to_string(),
3777            TomlValue::String(version.to_string()),
3778        );
3779    } else if let Some(poetry) = value
3780        .get_mut("tool")
3781        .and_then(TomlValue::as_table_mut)
3782        .and_then(|tool| tool.get_mut("poetry"))
3783        .and_then(TomlValue::as_table_mut)
3784    {
3785        poetry.insert(
3786            "version".to_string(),
3787            TomlValue::String(version.to_string()),
3788        );
3789    } else {
3790        let table: &mut toml::map::Map<String, TomlValue> = value
3791            .as_table_mut()
3792            .ok_or_else(|| "Expected a TOML table".to_string())?;
3793        table.insert(
3794            "version".to_string(),
3795            TomlValue::String(version.to_string()),
3796        );
3797    }
3798
3799    fs::write(
3800        path,
3801        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3802    )
3803    .map_err(|e| format!("Failed to write file: {}", e))
3804}
3805
3806fn read_cargo_lock_version_from_content(
3807    content: &str,
3808    cargo_toml_content: Option<&str>,
3809) -> Result<Option<String>, String> {
3810    read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
3811}
3812
3813fn read_cargo_lock_version_from_content_with_package(
3814    content: &str,
3815    cargo_toml_content: Option<&str>,
3816    package_name_override: Option<&str>,
3817) -> Result<Option<String>, String> {
3818    let value: TomlValue =
3819        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3820    let package_name = if let Some(package_name_override) = package_name_override {
3821        package_name_override.trim().to_string()
3822    } else {
3823        let cargo_toml_content = cargo_toml_content
3824            .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
3825        cargo_package_name_from_content(cargo_toml_content)?
3826    };
3827
3828    Ok(value
3829        .get("package")
3830        .and_then(TomlValue::as_array)
3831        .and_then(|packages| {
3832            packages.iter().find_map(|package| {
3833                let table = package.as_table()?;
3834                if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3835                    table
3836                        .get("version")
3837                        .and_then(TomlValue::as_str)
3838                        .map(|value| value.to_string())
3839                } else {
3840                    None
3841                }
3842            })
3843        }))
3844}
3845
3846fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
3847    let content: String =
3848        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3849    let cargo_toml: String = fs::read_to_string(
3850        path.parent()
3851            .unwrap_or_else(|| Path::new("."))
3852            .join("Cargo.toml"),
3853    )
3854    .map_err(|e| format!("Failed to read file: {}", e))?;
3855    read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
3856}
3857
3858fn read_cargo_lock_version_for_package(
3859    path: &Path,
3860    package_name: &str,
3861) -> Result<Option<String>, String> {
3862    let content: String =
3863        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3864    read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
3865}
3866
3867fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
3868    write_cargo_lock_version_for_package(path, None, version)
3869}
3870
3871fn write_cargo_lock_version_for_package(
3872    path: &Path,
3873    package_name_override: Option<&str>,
3874    version: &Version,
3875) -> Result<bool, String> {
3876    let content: String =
3877        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3878    let mut value: TomlValue =
3879        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3880    let package_name = if let Some(package_name_override) = package_name_override {
3881        package_name_override.trim().to_string()
3882    } else {
3883        let Some(package_name) = cargo_package_name(path)? else {
3884            return Ok(false);
3885        };
3886        package_name
3887    };
3888
3889    let packages: &mut Vec<TomlValue> = value
3890        .get_mut("package")
3891        .and_then(TomlValue::as_array_mut)
3892        .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
3893
3894    let mut updated = false;
3895    for package in packages {
3896        if let Some(table) = package.as_table_mut() {
3897            if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3898                table.insert(
3899                    "version".to_string(),
3900                    TomlValue::String(version.to_string()),
3901                );
3902                updated = true;
3903            }
3904        }
3905    }
3906
3907    if !updated {
3908        return Err(format!(
3909            "Could not find package `{}` in Cargo.lock",
3910            package_name
3911        ));
3912    }
3913
3914    fs::write(
3915        path,
3916        toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3917    )
3918    .map_err(|e| format!("Failed to write file: {}", e))?;
3919
3920    Ok(true)
3921}
3922
3923fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
3924    let cargo_toml: PathBuf = path
3925        .parent()
3926        .unwrap_or_else(|| Path::new("."))
3927        .join("Cargo.toml");
3928    let content: String = fs::read_to_string(&cargo_toml)
3929        .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
3930    cargo_package_name_from_content_optional(&content)
3931}
3932
3933fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
3934    cargo_package_name_from_content_optional(content)?
3935        .ok_or_else(|| "Could not determine Cargo package name".to_string())
3936}
3937
3938fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
3939    let value: TomlValue =
3940        toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
3941    Ok(value
3942        .get("package")
3943        .and_then(TomlValue::as_table)
3944        .and_then(|package| package.get("name"))
3945        .and_then(TomlValue::as_str)
3946        .map(|value| value.to_string()))
3947}
3948
3949fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
3950    read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3951}
3952
3953fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
3954    let content: String =
3955        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3956    read_readme_version_from_content(&content)
3957}
3958
3959fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
3960    let content: String =
3961        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3962    let marker: String = format!("current version: `{}`", version);
3963    let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3964        .map_err(|e| format!("Failed to build README regex: {}", e))?;
3965
3966    let updated: String = if regex.is_match(&content) {
3967        regex.replace(&content, marker.as_str()).to_string()
3968    } else if let Some(first_break) = content.find('\n') {
3969        let mut next = String::new();
3970        next.push_str(&content[..=first_break]);
3971        next.push('\n');
3972        next.push_str(&marker);
3973        next.push('\n');
3974        next.push_str(&content[first_break + 1..]);
3975        next
3976    } else {
3977        format!("{}\n\n{}\n", content, marker)
3978    };
3979
3980    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
3981}
3982
3983fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
3984    let content: String =
3985        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3986    read_regex_version_from_content(&content, pattern)
3987}
3988
3989fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
3990    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
3991    Ok(regex
3992        .captures(content)
3993        .and_then(|captures| captures.get(1))
3994        .map(|matched| matched.as_str().trim().to_string()))
3995}
3996
3997fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
3998    let content: String =
3999        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4000    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
4001
4002    if !regex.is_match(&content) {
4003        return Err("No version pattern found".to_string());
4004    }
4005
4006    let updated: String = regex
4007        .replace(&content, |caps: &regex::Captures<'_>| {
4008            caps[0].replace(&caps[1], &version.to_string())
4009        })
4010        .to_string();
4011    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
4012}
4013
4014#[cfg(test)]
4015fn write_package_version_to_configured_files(
4016    project_root: &Path,
4017    invocation_dir: &Path,
4018    registry: &[String],
4019    version_scope: &VersionScope,
4020    package_name: &str,
4021    version: &Version,
4022) -> Result<usize, String> {
4023    write_package_version_to_configured_files_with_paths(
4024        project_root,
4025        invocation_dir,
4026        registry,
4027        version_scope,
4028        package_name,
4029        version,
4030    )
4031    .map(|paths| paths.len())
4032}
4033
4034fn write_package_version_to_configured_files_with_paths(
4035    project_root: &Path,
4036    invocation_dir: &Path,
4037    registry: &[String],
4038    version_scope: &VersionScope,
4039    package_name: &str,
4040    version: &Version,
4041) -> Result<Vec<PathBuf>, String> {
4042    let mut updated: usize = 0usize;
4043    let mut updated_paths = Vec::new();
4044    let mut errors: Vec<String> = Vec::new();
4045
4046    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
4047        let path: &PathBuf = &entry.absolute;
4048        if !path.exists() {
4049            continue;
4050        }
4051
4052        match write_package_version_to_path(path, package_name, version) {
4053            Ok(true) => {
4054                updated += 1;
4055                updated_paths.push(path.clone());
4056            }
4057            Ok(false) => {}
4058            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
4059        }
4060    }
4061
4062    if updated == 0 && errors.is_empty() {
4063        return Err(format!(
4064            "No configured TOML files contained package assignment `{}`.",
4065            package_name
4066        ));
4067    }
4068
4069    if !errors.is_empty() {
4070        return Err(format!(
4071            "Updated {} file(s), but some package version targets failed:\n{}",
4072            updated,
4073            errors.join("\n")
4074        ));
4075    }
4076
4077    Ok(updated_paths)
4078}
4079
4080fn write_package_version_to_path(
4081    path: &Path,
4082    package_name: &str,
4083    version: &Version,
4084) -> Result<bool, String> {
4085    let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
4086    if !is_toml {
4087        return Ok(false);
4088    }
4089
4090    let content: String =
4091        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4092    let (updated, changed) =
4093        rewrite_toml_package_assignment_versions(&content, package_name, version)?;
4094    if changed {
4095        fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
4096    }
4097    Ok(changed)
4098}
4099
4100fn rewrite_toml_package_assignment_versions(
4101    content: &str,
4102    package_name: &str,
4103    version: &Version,
4104) -> Result<(String, bool), String> {
4105    let escaped_name: String = regex::escape(package_name);
4106    let inline_pattern: String = format!(
4107        r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
4108        escaped_name
4109    );
4110    let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
4111
4112    let inline_regex: Regex =
4113        Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
4114    let string_regex: Regex =
4115        Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
4116
4117    let replacement: String = version.to_string();
4118
4119    let after_inline: String = inline_regex
4120        .replace_all(content, |caps: &regex::Captures<'_>| {
4121            format!("{}{}{}", &caps[1], replacement, &caps[3])
4122        })
4123        .to_string();
4124    let after_string: String = string_regex
4125        .replace_all(&after_inline, |caps: &regex::Captures<'_>| {
4126            format!("{}{}{}", &caps[1], replacement, &caps[3])
4127        })
4128        .to_string();
4129
4130    Ok((after_string.clone(), after_string != content))
4131}
4132
4133fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
4134    let content: String =
4135        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4136    let mut value: YamlValue =
4137        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
4138    let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
4139    mapping.insert(
4140        YamlValue::String("version".to_string()),
4141        YamlValue::String(version.to_string()),
4142    );
4143    if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
4144        mapping.insert(
4145            YamlValue::String("appVersion".to_string()),
4146            YamlValue::String(version.to_string()),
4147        );
4148    }
4149    fs::write(
4150        path,
4151        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
4152    )
4153    .map_err(|e| format!("Failed to write file: {}", e))
4154}
4155
4156fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
4157    value
4158        .as_mapping_mut()
4159        .ok_or_else(|| "Expected a YAML mapping".to_string())
4160}
4161
4162fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
4163    value
4164        .as_mapping()
4165        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4166        .and_then(YamlValue::as_mapping)
4167}
4168
4169fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
4170    value
4171        .as_mapping()
4172        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4173        .and_then(YamlValue::as_str)
4174        .map(|value| value.to_string())
4175}
4176
4177fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
4178    let mut current: &JsonValue = value;
4179    for part in key_parts {
4180        current = current.get(*part)?;
4181    }
4182    current.as_str().map(|value| value.to_string())
4183}
4184
4185fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
4186    let mut current = value;
4187    for part in key_parts {
4188        let mapping = current.as_mapping()?;
4189        current = mapping.get(YamlValue::String((*part).to_string()))?;
4190    }
4191    current.as_str().map(|value| value.to_string())
4192}
4193
4194fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
4195    let mut current = value;
4196    for part in key_parts {
4197        current = current.get(*part)?;
4198    }
4199    current.as_str().map(|value| value.to_string())
4200}
4201
4202fn parse_version(input: &str) -> Result<Version, String> {
4203    let trimmed: &str = input.trim();
4204    let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
4205    Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
4206}
4207
4208fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
4209    let trimmed = input.trim();
4210    if trimmed.is_empty() {
4211        return Err("Release version cannot be empty.".to_string());
4212    }
4213
4214    if let Ok(version) = parse_version(trimmed) {
4215        return Ok((version.clone(), format!("v{}", version)));
4216    }
4217
4218    let prefixed = Regex::new(
4219        r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
4220    )
4221    .map_err(|e| format!("Failed to build release target parser: {}", e))?;
4222
4223    if let Some(captures) = prefixed.captures(trimmed) {
4224        let semver = captures
4225            .name("semver")
4226            .map(|m| m.as_str())
4227            .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
4228        let version = Version::parse(semver)
4229            .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
4230        return Ok((version, trimmed.to_string()));
4231    }
4232
4233    Err(format!(
4234        "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
4235        input
4236    ))
4237}
4238
4239fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
4240    let Some((raw_package, raw_version)) = input.split_once('=') else {
4241        return Ok(None);
4242    };
4243
4244    let package_name = raw_package.trim();
4245    if package_name.is_empty() {
4246        return Ok(None);
4247    }
4248
4249    let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
4250        .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
4251    if !package_name_regex.is_match(package_name) {
4252        return Err(format!(
4253            "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
4254            input
4255        ));
4256    }
4257
4258    let version = parse_version(raw_version.trim())?;
4259    Ok(Some((package_name.to_string(), version)))
4260}
4261
4262fn bump_version(current: &Version, kind: &str) -> Version {
4263    let mut next = current.clone();
4264    match kind {
4265        "major" => {
4266            next.major += 1;
4267            next.minor = 0;
4268            next.patch = 0;
4269            next.pre = semver::Prerelease::EMPTY;
4270            next.build = semver::BuildMetadata::EMPTY;
4271        }
4272        "minor" => {
4273            next.minor += 1;
4274            next.patch = 0;
4275            next.pre = semver::Prerelease::EMPTY;
4276            next.build = semver::BuildMetadata::EMPTY;
4277        }
4278        _ => {
4279            next.patch += 1;
4280            next.pre = semver::Prerelease::EMPTY;
4281            next.build = semver::BuildMetadata::EMPTY;
4282        }
4283    }
4284    next
4285}
4286
4287fn default_version() -> Version {
4288    Version::new(0, 1, 0)
4289}
4290
4291fn resolve_registry_paths(
4292    project_root: &Path,
4293    invocation_dir: &Path,
4294    registry: &[String],
4295    version_scope: &VersionScope,
4296) -> Vec<ResolvedRegistryPath> {
4297    let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
4298    let mut seen: BTreeSet<String> = BTreeSet::new();
4299    let workspace_primary_target = match version_scope {
4300        VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
4301        VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
4302    };
4303
4304    for relative in registry {
4305        match version_scope {
4306            VersionScope::Repository => {
4307                if *relative == *"Cargo.lock" {
4308                    let resolved_relative = resolve_registry_relative_path(
4309                        project_root,
4310                        invocation_dir,
4311                        version_scope,
4312                        relative,
4313                    );
4314                    if !seen.insert(resolved_relative.clone()) {
4315                        continue;
4316                    }
4317
4318                    resolved.push(ResolvedRegistryPath {
4319                        absolute: project_root.join(&resolved_relative),
4320                        relative: resolved_relative,
4321                        cargo_package_override: workspace_primary_target
4322                            .as_ref()
4323                            .map(|target| target.package_name.clone()),
4324                    });
4325                    continue;
4326                }
4327
4328                if *relative == *"Cargo.toml" {
4329                    if let Some(target) = workspace_primary_target.as_ref() {
4330                        if !seen.insert(target.manifest_relative.clone()) {
4331                            continue;
4332                        }
4333
4334                        resolved.push(ResolvedRegistryPath {
4335                            absolute: target.manifest_absolute.clone(),
4336                            relative: target.manifest_relative.clone(),
4337                            cargo_package_override: None,
4338                        });
4339                        continue;
4340                    }
4341                }
4342
4343                let resolved_relative = resolve_registry_relative_path(
4344                    project_root,
4345                    invocation_dir,
4346                    version_scope,
4347                    relative,
4348                );
4349                if !seen.insert(resolved_relative.clone()) {
4350                    continue;
4351                }
4352
4353                resolved.push(ResolvedRegistryPath {
4354                    absolute: project_root.join(&resolved_relative),
4355                    relative: resolved_relative,
4356                    cargo_package_override: None,
4357                });
4358            }
4359            VersionScope::Crate {
4360                crate_root,
4361                package_name,
4362                ..
4363            } => {
4364                if *relative == *"Cargo.lock" {
4365                    let cargo_lock = project_root.join("Cargo.lock");
4366                    if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4367                        resolved.push(ResolvedRegistryPath {
4368                            absolute: cargo_lock,
4369                            relative: "Cargo.lock".to_string(),
4370                            cargo_package_override: Some(package_name.clone()),
4371                        });
4372                    }
4373                    continue;
4374                }
4375
4376                let preferred = crate_root.join(relative);
4377                if !preferred.exists() {
4378                    continue;
4379                }
4380                let Ok(stripped) = preferred.strip_prefix(project_root) else {
4381                    continue;
4382                };
4383                let resolved_relative = normalized_relative_path(stripped);
4384                if !seen.insert(resolved_relative.clone()) {
4385                    continue;
4386                }
4387
4388                resolved.push(ResolvedRegistryPath {
4389                    absolute: preferred,
4390                    relative: resolved_relative,
4391                    cargo_package_override: None,
4392                });
4393            }
4394            VersionScope::Service {
4395                service_root,
4396                cargo_package_name,
4397                ..
4398            } => {
4399                if *relative == *"Cargo.lock" {
4400                    let local_cargo_lock = service_root.join("Cargo.lock");
4401                    if local_cargo_lock.exists() {
4402                        let relative_path = local_cargo_lock
4403                            .strip_prefix(project_root)
4404                            .ok()
4405                            .map(normalized_relative_path)
4406                            .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
4407                        if seen.insert(relative_path.clone()) {
4408                            resolved.push(ResolvedRegistryPath {
4409                                absolute: local_cargo_lock,
4410                                relative: relative_path,
4411                                cargo_package_override: cargo_package_name.clone(),
4412                            });
4413                        }
4414                    } else if let Some(package_name) = cargo_package_name.as_ref() {
4415                        let workspace_cargo_lock = project_root.join("Cargo.lock");
4416                        if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4417                            resolved.push(ResolvedRegistryPath {
4418                                absolute: workspace_cargo_lock,
4419                                relative: "Cargo.lock".to_string(),
4420                                cargo_package_override: Some(package_name.clone()),
4421                            });
4422                        }
4423                    }
4424                    continue;
4425                }
4426
4427                let preferred = service_root.join(relative);
4428                if !preferred.exists() {
4429                    continue;
4430                }
4431                let Ok(stripped) = preferred.strip_prefix(project_root) else {
4432                    continue;
4433                };
4434                let resolved_relative = normalized_relative_path(stripped);
4435                if !seen.insert(resolved_relative.clone()) {
4436                    continue;
4437                }
4438
4439                resolved.push(ResolvedRegistryPath {
4440                    absolute: preferred,
4441                    relative: resolved_relative,
4442                    cargo_package_override: None,
4443                });
4444            }
4445        }
4446    }
4447
4448    for configured_path in
4449        resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
4450    {
4451        if seen.insert(configured_path.relative.clone()) {
4452            resolved.push(configured_path);
4453        }
4454    }
4455
4456    resolved
4457}
4458
4459fn resolve_configured_version_target_paths(
4460    project_root: &Path,
4461    invocation_dir: &Path,
4462    version_scope: &VersionScope,
4463) -> Vec<ResolvedRegistryPath> {
4464    let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
4465        return Vec::new();
4466    };
4467
4468    let service_targets = match version_scope {
4469        VersionScope::Service {
4470            version_targets, ..
4471        } if !version_targets.is_empty() => Some(version_targets.clone()),
4472        _ => None,
4473    };
4474
4475    let manifest_paths = if let Some(service_targets) = &service_targets {
4476        service_targets
4477            .iter()
4478            .cloned()
4479            .map(PathBuf::from)
4480            .collect::<Vec<_>>()
4481    } else {
4482        let mut targets = config
4483            .version_targets
4484            .into_iter()
4485            .map(PathBuf::from)
4486            .collect::<Vec<_>>();
4487        targets.extend(
4488            config
4489                .publish
4490                .into_iter()
4491                .flat_map(|publish| [publish.npm, publish.crates])
4492                .flatten()
4493                .filter_map(|target| target.manifest_path)
4494                .map(PathBuf::from),
4495        );
4496        targets
4497    };
4498
4499    let scope_root = match version_scope {
4500        VersionScope::Repository => None,
4501        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4502        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4503    };
4504
4505    manifest_paths
4506        .into_iter()
4507        .filter_map(|manifest_path| {
4508            let absolute = if manifest_path.is_absolute() {
4509                manifest_path
4510            } else {
4511                config_root.join(manifest_path)
4512            };
4513            if !absolute.exists() {
4514                return None;
4515            }
4516            if let Some(scope_root) = scope_root {
4517                if !absolute.starts_with(scope_root) {
4518                    return None;
4519                }
4520            }
4521            let relative = absolute
4522                .strip_prefix(project_root)
4523                .ok()
4524                .map(normalized_relative_path)
4525                .unwrap_or_else(|| normalized_relative_path(&absolute));
4526            Some(ResolvedRegistryPath {
4527                relative,
4528                absolute,
4529                cargo_package_override: None,
4530            })
4531        })
4532        .collect()
4533}
4534
4535fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
4536    let found = find_xbp_config_upwards(invocation_dir)?;
4537    let config_path = if found.kind == "json" {
4538        maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
4539            .ok()
4540            .flatten()
4541            .unwrap_or_else(|| found.config_path.clone())
4542    } else {
4543        found.config_path.clone()
4544    };
4545
4546    let kind = if config_path
4547        .extension()
4548        .and_then(|ext| ext.to_str())
4549        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4550        .unwrap_or(false)
4551    {
4552        "yaml"
4553    } else {
4554        "json"
4555    };
4556
4557    let content = fs::read_to_string(&config_path).ok()?;
4558    let (mut config, healed): (XbpConfig, Option<String>) =
4559        parse_config_with_auto_heal(&content, kind).ok()?;
4560    if let Some(healed_content) = healed {
4561        let _ = fs::write(&config_path, healed_content);
4562    }
4563    resolve_config_paths_for_runtime(&mut config, &found.project_root);
4564    Some((found.project_root, config))
4565}
4566
4567fn resolve_registry_relative_path(
4568    project_root: &Path,
4569    invocation_dir: &Path,
4570    version_scope: &VersionScope,
4571    relative: &str,
4572) -> String {
4573    if let Some(scope_root) = version_scope_root(version_scope) {
4574        let preferred = scope_root.join(relative);
4575        if preferred.exists() {
4576            if let Ok(stripped) = preferred.strip_prefix(project_root) {
4577                return normalized_relative_path(stripped);
4578            }
4579        }
4580        return relative.replace('\\', "/");
4581    }
4582
4583    if relative == "Cargo.toml" {
4584        if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
4585            return target.manifest_relative;
4586        }
4587    }
4588
4589    let preferred: PathBuf = invocation_dir.join(relative);
4590    if preferred.exists() {
4591        if let Ok(stripped) = preferred.strip_prefix(project_root) {
4592            return normalized_relative_path(stripped);
4593        }
4594    }
4595
4596    relative.replace('\\', "/")
4597}
4598
4599fn resolve_version_scope_with_prompt(
4600    project_root: &Path,
4601    invocation_dir: &Path,
4602) -> Result<VersionScope, String> {
4603    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4604    if let Some(scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
4605        return Ok(scope);
4606    }
4607
4608    if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
4609    {
4610        return prompt_for_version_scope_selection(&service_scopes);
4611    }
4612
4613    Ok(resolve_version_scope(project_root, invocation_dir))
4614}
4615
4616fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
4617    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4618    if let Some(service_scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
4619        return service_scope;
4620    }
4621
4622    if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
4623        return crate_scope;
4624    }
4625
4626    VersionScope::Repository
4627}
4628
4629fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
4630    let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
4631        return Vec::new();
4632    };
4633    let Some(services) = config.services else {
4634        return Vec::new();
4635    };
4636
4637    let mut scopes = services
4638        .into_iter()
4639        .filter_map(|service| build_service_scope(project_root, service))
4640        .collect::<Vec<_>>();
4641    scopes.sort_by(|left, right| {
4642        version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
4643    });
4644    scopes
4645}
4646
4647fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
4648    let version_targets = service
4649        .version_targets
4650        .clone()
4651        .unwrap_or_default()
4652        .into_iter()
4653        .map(normalized_path_string)
4654        .collect::<Vec<_>>();
4655    let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
4656    let service_relative_root = service_root
4657        .strip_prefix(project_root)
4658        .ok()
4659        .map(normalized_relative_path)
4660        .filter(|value| !value.is_empty())
4661        .unwrap_or_else(|| ".".to_string());
4662    let cargo_package_name =
4663        resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
4664    let tag_identity = cargo_package_name
4665        .clone()
4666        .unwrap_or_else(|| service.name.clone());
4667
4668    Some(VersionScope::Service {
4669        service_root,
4670        service_relative_root,
4671        service_name: service.name,
4672        tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
4673        cargo_package_name,
4674        version_targets,
4675    })
4676}
4677
4678fn resolve_service_scope_root(
4679    project_root: &Path,
4680    service: &ServiceConfig,
4681    version_targets: &[String],
4682) -> Option<PathBuf> {
4683    if !version_targets.is_empty() {
4684        return common_parent_from_targets(project_root, version_targets);
4685    }
4686
4687    service.root_directory.as_ref().map(|root| {
4688        let path = PathBuf::from(root);
4689        if path.is_absolute() {
4690            path
4691        } else {
4692            project_root.join(path)
4693        }
4694    })
4695}
4696
4697fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
4698    let mut parents = version_targets.iter().filter_map(|target| {
4699        let path = PathBuf::from(target);
4700        let absolute = if path.is_absolute() {
4701            path
4702        } else {
4703            project_root.join(path)
4704        };
4705        absolute.parent().map(Path::to_path_buf)
4706    });
4707
4708    let mut common = parents.next()?;
4709    for parent in parents {
4710        while !parent.starts_with(&common) {
4711            common = common.parent()?.to_path_buf();
4712        }
4713    }
4714    Some(common)
4715}
4716
4717fn resolve_service_scope_cargo_package_name(
4718    project_root: &Path,
4719    service_root: &Path,
4720    version_targets: &[String],
4721) -> Option<String> {
4722    let mut cargo_paths = version_targets
4723        .iter()
4724        .filter(|target| {
4725            Path::new(target)
4726                .file_name()
4727                .and_then(|value| value.to_str())
4728                == Some("Cargo.toml")
4729        })
4730        .map(|target| {
4731            let path = PathBuf::from(target);
4732            if path.is_absolute() {
4733                path
4734            } else {
4735                project_root.join(path)
4736            }
4737        })
4738        .collect::<Vec<_>>();
4739    if cargo_paths.is_empty() {
4740        cargo_paths.push(service_root.join("Cargo.toml"));
4741    }
4742
4743    cargo_paths.into_iter().find_map(|path| {
4744        let content = fs::read_to_string(path).ok()?;
4745        cargo_package_name_from_content_optional(&content)
4746            .ok()
4747            .flatten()
4748    })
4749}
4750
4751fn select_matching_service_scope(
4752    scopes: &[VersionScope],
4753    invocation_dir: &Path,
4754) -> Option<VersionScope> {
4755    scopes
4756        .iter()
4757        .filter_map(|scope| {
4758            let root = version_scope_root(scope)?;
4759            if invocation_dir.starts_with(root) {
4760                Some((root.components().count(), scope.clone()))
4761            } else {
4762                None
4763            }
4764        })
4765        .max_by_key(|(depth, _)| *depth)
4766        .map(|(_, scope)| scope)
4767}
4768
4769fn prompt_for_version_scope_selection(
4770    service_scopes: &[VersionScope],
4771) -> Result<VersionScope, String> {
4772    let mut items = service_scopes
4773        .iter()
4774        .map(version_scope_prompt_label)
4775        .collect::<Vec<_>>();
4776    items.push("Repository (all configured version targets)".to_string());
4777
4778    let selection = Select::with_theme(&ColorfulTheme::default())
4779        .with_prompt("Choose the project/service to version or release")
4780        .items(&items)
4781        .default(items.len().saturating_sub(1))
4782        .interact_opt()
4783        .map_err(|e| format!("Prompt failed: {}", e))?;
4784
4785    match selection {
4786        Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
4787        _ => Ok(VersionScope::Repository),
4788    }
4789}
4790
4791fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
4792    let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
4793    let cargo_toml = crate_root.join("Cargo.toml");
4794    let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
4795    let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
4796    let crate_relative_root = crate_root
4797        .strip_prefix(project_root)
4798        .ok()
4799        .map(normalized_relative_path)?;
4800
4801    Some(VersionScope::Crate {
4802        crate_root,
4803        crate_relative_root,
4804        tag_prefix: format!("{}-", package_name),
4805        package_name,
4806    })
4807}
4808
4809fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
4810    let crates_root = project_root.join("crates");
4811    let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
4812    let mut components = relative.components();
4813    let crate_name = components.next()?;
4814    Some(crates_root.join(crate_name.as_os_str()))
4815}
4816
4817fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
4818    match version_scope {
4819        VersionScope::Repository => None,
4820        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4821        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4822    }
4823}
4824
4825fn prepare_release_openapi_assets(
4826    project_root: &Path,
4827    invocation_dir: &Path,
4828    version_scope: &VersionScope,
4829    release_version: &Version,
4830    tag_name: &str,
4831) -> Result<Vec<ReleaseOpenApiAsset>, String> {
4832    let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
4833    validate_release_openapi_assets(&spec_paths, release_version)?;
4834
4835    spec_paths
4836        .into_iter()
4837        .map(|source_path| {
4838            let file_name = source_path
4839                .file_name()
4840                .and_then(|value| value.to_str())
4841                .ok_or_else(|| {
4842                    format!(
4843                        "Invalid OpenAPI asset path for release upload: {}",
4844                        source_path.display()
4845                    )
4846                })?;
4847            let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
4848            let source_label = source_path
4849                .strip_prefix(project_root)
4850                .map(normalized_relative_path)
4851                .unwrap_or_else(|_| normalized_relative_path(&source_path));
4852            Ok(ReleaseOpenApiAsset {
4853                source_path,
4854                source_label,
4855                asset_name,
4856            })
4857        })
4858        .collect()
4859}
4860
4861fn resolve_release_openapi_specs(
4862    project_root: &Path,
4863    invocation_dir: &Path,
4864    version_scope: &VersionScope,
4865) -> Result<Vec<PathBuf>, String> {
4866    const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
4867        "openapi.yaml",
4868        "openapi.yml",
4869        "openapi.json",
4870        "swagger.yaml",
4871        "swagger.yml",
4872        "swagger.json",
4873    ];
4874
4875    let mut roots: Vec<PathBuf> = Vec::new();
4876    if let Some(scope_root) = version_scope_root(version_scope) {
4877        roots.push(scope_root.to_path_buf());
4878    } else {
4879        if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
4880            roots.push(crate_root);
4881        }
4882        roots.push(project_root.to_path_buf());
4883    }
4884
4885    let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
4886    let mut seen_names: BTreeSet<String> = BTreeSet::new();
4887    let mut resolved_paths: Vec<PathBuf> = Vec::new();
4888
4889    for root in roots {
4890        if !seen_roots.insert(root.clone()) {
4891            continue;
4892        }
4893
4894        let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
4895        for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
4896            let path = root.join(file_name);
4897            if path.is_file() {
4898                root_paths.push(((*file_name).to_string(), path));
4899            }
4900        }
4901
4902        let entries = match fs::read_dir(&root) {
4903            Ok(entries) => entries,
4904            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
4905            Err(error) => {
4906                return Err(format!(
4907                    "Failed to inspect OpenAPI release assets in {}: {}",
4908                    root.display(),
4909                    error
4910                ));
4911            }
4912        };
4913
4914        let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
4915        for entry in entries {
4916            let entry = entry.map_err(|error| {
4917                format!(
4918                    "Failed to inspect OpenAPI release assets in {}: {}",
4919                    root.display(),
4920                    error
4921                )
4922            })?;
4923            let path = entry.path();
4924            if !path.is_file() {
4925                continue;
4926            }
4927            let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4928                continue;
4929            };
4930            if !is_release_openapi_variant_file_name(file_name) {
4931                continue;
4932            }
4933            additional_specs.push((file_name.to_string(), path));
4934        }
4935        additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
4936
4937        root_paths.extend(additional_specs);
4938
4939        for (file_name, path) in root_paths {
4940            if seen_names.insert(file_name) {
4941                resolved_paths.push(path);
4942            }
4943        }
4944    }
4945
4946    Ok(resolved_paths)
4947}
4948
4949fn validate_release_openapi_assets(
4950    spec_paths: &[PathBuf],
4951    release_version: &Version,
4952) -> Result<(), String> {
4953    let Some(primary_spec_path) = spec_paths.iter().find(|path| {
4954        path.file_name()
4955            .and_then(|value| value.to_str())
4956            .map(is_primary_release_openapi_file_name)
4957            .unwrap_or(false)
4958    }) else {
4959        return Ok(());
4960    };
4961
4962    let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
4963        format!(
4964            "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
4965            primary_spec_path.display(),
4966            release_version
4967        )
4968    })?;
4969    let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
4970        format!(
4971            "Release OpenAPI spec `{}` has invalid version `{}`: {}",
4972            primary_spec_path.display(),
4973            declared_version,
4974            error
4975        )
4976    })?;
4977
4978    if &parsed_declared_version != release_version {
4979        return Err(format!(
4980            "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
4981            primary_spec_path.display(),
4982            declared_version,
4983            release_version
4984        ));
4985    }
4986
4987    Ok(())
4988}
4989
4990fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
4991    let path = Path::new(file_name);
4992    let stem = path
4993        .file_stem()
4994        .and_then(|value| value.to_str())
4995        .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
4996    let extension = path
4997        .extension()
4998        .and_then(|value| value.to_str())
4999        .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
5000    Ok(format!("{}-{}.{}", stem, tag_name, extension))
5001}
5002
5003fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
5004    matches!(
5005        file_name,
5006        "openapi.yaml"
5007            | "openapi.yml"
5008            | "openapi.json"
5009            | "swagger.yaml"
5010            | "swagger.yml"
5011            | "swagger.json"
5012    )
5013}
5014
5015fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
5016    if is_primary_release_openapi_file_name(file_name) {
5017        return false;
5018    }
5019
5020    let Some((stem, extension)) = file_name.rsplit_once('.') else {
5021        return false;
5022    };
5023    if !matches!(extension, "yaml" | "yml" | "json") {
5024        return false;
5025    }
5026
5027    stem.starts_with("openapi-") || stem.starts_with("swagger-")
5028}
5029
5030fn normalized_path_string(path: String) -> String {
5031    path.replace('\\', "/")
5032}
5033
5034fn slugify_scope_name(value: &str) -> String {
5035    let mut slug = value
5036        .chars()
5037        .map(|ch| {
5038            if ch.is_ascii_alphanumeric() {
5039                ch.to_ascii_lowercase()
5040            } else {
5041                '-'
5042            }
5043        })
5044        .collect::<String>();
5045    while slug.contains("--") {
5046        slug = slug.replace("--", "-");
5047    }
5048    slug.trim_matches('-').to_string()
5049}
5050
5051fn resolve_workspace_primary_cargo_target(
5052    project_root: &Path,
5053) -> Option<WorkspacePrimaryCargoTarget> {
5054    let workspace_manifest = project_root.join("Cargo.toml");
5055    let content = fs::read_to_string(&workspace_manifest).ok()?;
5056    let value: TomlValue = toml::from_str(&content).ok()?;
5057    if value.get("package").and_then(TomlValue::as_table).is_some() {
5058        return None;
5059    }
5060
5061    let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
5062    let mut candidate_roots = workspace
5063        .get("default-members")
5064        .and_then(TomlValue::as_array)
5065        .map(|members| {
5066            members
5067                .iter()
5068                .filter_map(TomlValue::as_str)
5069                .map(str::trim)
5070                .filter(|member| !member.is_empty() && !member.contains('*'))
5071                .map(str::to_string)
5072                .collect::<Vec<_>>()
5073        })
5074        .unwrap_or_default();
5075
5076    if candidate_roots.is_empty() {
5077        let members = workspace
5078            .get("members")
5079            .and_then(TomlValue::as_array)
5080            .map(|members| {
5081                members
5082                    .iter()
5083                    .filter_map(TomlValue::as_str)
5084                    .map(str::trim)
5085                    .filter(|member| !member.is_empty() && !member.contains('*'))
5086                    .map(str::to_string)
5087                    .collect::<Vec<_>>()
5088            })
5089            .unwrap_or_default();
5090        if members.len() == 1 {
5091            candidate_roots = members;
5092        }
5093    }
5094
5095    candidate_roots.sort();
5096    candidate_roots.dedup();
5097    if candidate_roots.len() != 1 {
5098        return None;
5099    }
5100
5101    let crate_relative_root = candidate_roots.into_iter().next()?;
5102    let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
5103    let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
5104    let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
5105
5106    Some(WorkspacePrimaryCargoTarget {
5107        manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
5108        manifest_absolute,
5109        package_name,
5110    })
5111}
5112
5113#[cfg(test)]
5114fn resolve_release_publish_target_filter(
5115    invocation_dir: &Path,
5116    version_scope: &VersionScope,
5117) -> Result<Option<String>, String> {
5118    let VersionScope::Service {
5119        service_name,
5120        service_root,
5121        version_targets,
5122        ..
5123    } = version_scope
5124    else {
5125        return Ok(None);
5126    };
5127
5128    let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
5129        return Ok(None);
5130    };
5131    let Some(publish) = config.publish else {
5132        return Ok(None);
5133    };
5134
5135    let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
5136    let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
5137        .into_iter()
5138        .filter_map(|(kind, target)| {
5139            let target = target?;
5140            if target.enabled.unwrap_or(true) {
5141                Some((kind, target))
5142            } else {
5143                None
5144            }
5145        })
5146        .collect::<Vec<_>>();
5147
5148    if enabled_targets.is_empty() {
5149        return Ok(None);
5150    }
5151
5152    let matched = enabled_targets
5153        .iter()
5154        .filter_map(|(kind, target)| {
5155            let manifest_path = target.manifest_path.as_ref()?;
5156            let absolute = if Path::new(manifest_path).is_absolute() {
5157                PathBuf::from(manifest_path)
5158            } else {
5159                project_root.join(manifest_path)
5160            };
5161            let relative = absolute
5162                .strip_prefix(&project_root)
5163                .ok()
5164                .map(normalized_relative_path)
5165                .unwrap_or_else(|| normalized_relative_path(&absolute));
5166
5167            if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
5168            {
5169                Some((*kind).to_string())
5170            } else {
5171                None
5172            }
5173        })
5174        .collect::<Vec<_>>();
5175
5176    if matched.is_empty() {
5177        return Err(format!(
5178            "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
5179            service_name
5180        ));
5181    }
5182
5183    if matched.len() == enabled_targets.len() {
5184        if matched.len() == 1 {
5185            return Ok(matched.into_iter().next());
5186        }
5187        return Ok(None);
5188    }
5189
5190    if matched.len() == 1 {
5191        return Ok(matched.into_iter().next());
5192    }
5193
5194    Err(format!(
5195        "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.",
5196        service_name
5197    ))
5198}
5199
5200async fn resolve_effective_linear_release_config(
5201    project_root: &Path,
5202    invocation_dir: &Path,
5203) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
5204    let global_config = resolve_global_linear_release_config();
5205    let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5206        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5207        config.linear.and_then(|linear| linear.release)
5208    } else {
5209        None
5210    };
5211
5212    Ok(resolve_linear_release_config(global_config, project_config)
5213        .map(|config| resolve_linear_release_placeholders(project_root, config)))
5214}
5215
5216fn resolve_linear_release_placeholders(
5217    project_root: &Path,
5218    mut config: ResolvedLinearReleaseConfig,
5219) -> ResolvedLinearReleaseConfig {
5220    let mut env_map = HashMap::new();
5221    for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
5222        env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
5223    }
5224    if let Some(organization_name) = config.organization_name.clone() {
5225        env_map.insert("organization_name".to_string(), organization_name);
5226    }
5227
5228    let resolved = resolve_env_placeholders(project_root, &env_map);
5229    config.initiative_ids = config
5230        .initiative_ids
5231        .iter()
5232        .enumerate()
5233        .map(|(index, initiative_id)| {
5234            resolved
5235                .get(&format!("initiative_id_{}", index))
5236                .cloned()
5237                .unwrap_or_else(|| initiative_id.clone())
5238        })
5239        .map(|value| value.trim().to_string())
5240        .filter(|value| !value.is_empty())
5241        .collect();
5242    config.organization_name = resolved
5243        .get("organization_name")
5244        .cloned()
5245        .or(config.organization_name)
5246        .map(|value| value.trim().to_string())
5247        .filter(|value| !value.is_empty());
5248    config
5249}
5250
5251async fn resolve_project_github_release_branch_config(
5252    _project_root: &Path,
5253    invocation_dir: &Path,
5254) -> Result<Option<GitHubReleaseBranchSettings>, String> {
5255    if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5256        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5257        Ok(config.github_release_branch_settings())
5258    } else {
5259        Ok(None)
5260    }
5261}
5262
5263fn normalized_relative_path(path: &Path) -> String {
5264    path.to_string_lossy().replace('\\', "/")
5265}
5266
5267fn git_repository_root(dir: &Path) -> Option<PathBuf> {
5268    if !command_exists("git") {
5269        return None;
5270    }
5271
5272    let output: std::process::Output = Command::new("git")
5273        .current_dir(dir)
5274        .args(["rev-parse", "--show-toplevel"])
5275        .output()
5276        .ok()?;
5277
5278    if !output.status.success() {
5279        return None;
5280    }
5281
5282    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5283    if root.is_empty() {
5284        None
5285    } else {
5286        Some(PathBuf::from(root))
5287    }
5288}
5289
5290fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
5291    let output: std::process::Output = Command::new("git")
5292        .current_dir(project_root)
5293        .args(args)
5294        .output()
5295        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
5296
5297    if !output.status.success() {
5298        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
5299        if stderr.is_empty() {
5300            return Err(format!(
5301                "`git {}` failed with status {}",
5302                args.join(" "),
5303                output.status
5304            ));
5305        }
5306        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
5307    }
5308
5309    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5310}
5311
5312fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
5313    let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
5314    Ok(output
5315        .lines()
5316        .map(|line| line.trim())
5317        .filter(|line| !line.is_empty())
5318        .map(|line| line.to_string())
5319        .collect())
5320}
5321
5322fn version_change_guard_state_path() -> Result<PathBuf, String> {
5323    let paths = global_xbp_paths()?;
5324    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
5325}
5326
5327fn version_change_guard_repo_key(project_root: &Path) -> String {
5328    fs::canonicalize(project_root)
5329        .unwrap_or_else(|_| project_root.to_path_buf())
5330        .to_string_lossy()
5331        .replace('\\', "/")
5332}
5333
5334fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
5335    if !path.exists() {
5336        return Ok(VersionChangeGuardRegistry::default());
5337    }
5338
5339    let content = fs::read_to_string(path).map_err(|e| {
5340        format!(
5341            "Failed to read version-change guard state {}: {}",
5342            path.display(),
5343            e
5344        )
5345    })?;
5346
5347    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
5348}
5349
5350fn save_version_change_guard_registry(
5351    path: &Path,
5352    registry: &VersionChangeGuardRegistry,
5353) -> Result<(), String> {
5354    if let Some(parent) = path.parent() {
5355        fs::create_dir_all(parent).map_err(|e| {
5356            format!(
5357                "Failed to create guard state directory {}: {}",
5358                parent.display(),
5359                e
5360            )
5361        })?;
5362    }
5363
5364    let content = serde_yaml::to_string(registry)
5365        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
5366    fs::write(path, content).map_err(|e| {
5367        format!(
5368            "Failed to write version-change guard state {}: {}",
5369            path.display(),
5370            e
5371        )
5372    })
5373}
5374
5375fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
5376    if !command_exists("git") {
5377        return Ok(None);
5378    }
5379
5380    let status_output: std::process::Output = Command::new("git")
5381        .current_dir(project_root)
5382        .args(["status", "--porcelain"])
5383        .output()
5384        .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
5385    if !status_output.status.success() {
5386        return Ok(None);
5387    }
5388
5389    let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
5390        .lines()
5391        .any(|line| !line.trim().is_empty());
5392
5393    let head_output: std::process::Output = Command::new("git")
5394        .current_dir(project_root)
5395        .args(["rev-parse", "HEAD"])
5396        .output()
5397        .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
5398    let head_commit: Option<String> = if head_output.status.success() {
5399        let value: String = String::from_utf8_lossy(&head_output.stdout)
5400            .trim()
5401            .to_string();
5402        if value.is_empty() {
5403            None
5404        } else {
5405            Some(value)
5406        }
5407    } else {
5408        None
5409    };
5410
5411    Ok(Some(GitWorktreeState {
5412        is_dirty,
5413        head_commit,
5414    }))
5415}
5416
5417fn should_clear_version_change_guard(
5418    entry: &VersionChangeGuardEntry,
5419    state: &GitWorktreeState,
5420) -> bool {
5421    if entry.pending_version_change_count == 0 {
5422        return true;
5423    }
5424    if !state.is_dirty {
5425        return true;
5426    }
5427
5428    match (&entry.head_commit, &state.head_commit) {
5429        (Some(previous), Some(current)) => previous != current,
5430        (Some(_), None) => true,
5431        _ => false,
5432    }
5433}
5434
5435fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
5436    let Some(state) = git_worktree_state(project_root)? else {
5437        return Ok(());
5438    };
5439
5440    let state_path: PathBuf = version_change_guard_state_path()?;
5441    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5442    let repo_key: String = version_change_guard_repo_key(project_root);
5443    let mut changed = false;
5444
5445    if let Some(entry) = registry.entries.get(&repo_key).cloned() {
5446        if should_clear_version_change_guard(&entry, &state) {
5447            registry.entries.remove(&repo_key);
5448            changed = true;
5449        }
5450    }
5451
5452    if changed {
5453        save_version_change_guard_registry(&state_path, &registry)?;
5454    }
5455
5456    if state.is_dirty {
5457        if let Some(entry) = registry.entries.get(&repo_key) {
5458            if entry.pending_version_change_count >= 1 {
5459                return Err(format!(
5460                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
5461                    entry.pending_version_change_count,
5462                    state_path.display()
5463                ));
5464            }
5465        }
5466    }
5467
5468    Ok(())
5469}
5470
5471fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
5472    let Some(state) = git_worktree_state(project_root)? else {
5473        return Ok(());
5474    };
5475
5476    let state_path: PathBuf = version_change_guard_state_path()?;
5477    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5478    let repo_key: String = version_change_guard_repo_key(project_root);
5479
5480    if state.is_dirty {
5481        registry.entries.insert(
5482            repo_key,
5483            VersionChangeGuardEntry {
5484                pending_version_change_count: 1,
5485                head_commit: state.head_commit,
5486            },
5487        );
5488    } else {
5489        registry.entries.remove(&repo_key);
5490    }
5491
5492    save_version_change_guard_registry(&state_path, &registry)
5493}
5494
5495fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
5496    let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
5497    Ok(!output.trim().is_empty())
5498}
5499
5500fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
5501    let remotes: String = run_git_command(project_root, &["remote"])?;
5502    let exists: bool = remotes.lines().any(|line| line.trim() == remote);
5503    if exists {
5504        Ok(())
5505    } else {
5506        Err(format!(
5507            "Git remote `{}` is not configured for this repository.",
5508            remote
5509        ))
5510    }
5511}
5512
5513fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
5514    run_git_command(project_root, &["remote", "get-url", remote])
5515}
5516
5517fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
5518    let query: String = format!("refs/tags/{}", tag);
5519    let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
5520    Ok(!output.trim().is_empty())
5521}
5522
5523fn git_head_commitish(project_root: &Path) -> Result<String, String> {
5524    let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
5525    if commitish.is_empty() {
5526        Err("Unable to resolve HEAD commit for release target.".to_string())
5527    } else {
5528        Ok(commitish)
5529    }
5530}
5531
5532fn render_release_branch_name(
5533    naming_template: &str,
5534    release_version: &Version,
5535    tag_name: &str,
5536    service_name: &str,
5537) -> Result<String, String> {
5538    let service_name = sanitize_release_branch_segment(service_name);
5539    let branch_name = naming_template
5540        .replace("{VERSION}", &release_version.to_string())
5541        .replace("{TAG}", tag_name)
5542        .replace("{SERVICE_NAME}", &service_name)
5543        .replace("${GITHUB_VERSION}", &release_version.to_string())
5544        .replace("${GITHUB_TAG}", tag_name)
5545        .replace("${SERVICE_NAME}", &service_name);
5546    let branch_name = branch_name.trim();
5547    if branch_name.is_empty() {
5548        return Err(
5549            "GitHub release branch naming template resolved to an empty branch name.".to_string(),
5550        );
5551    }
5552    Ok(branch_name.to_string())
5553}
5554
5555fn sanitize_release_branch_segment(value: &str) -> String {
5556    value
5557        .trim()
5558        .chars()
5559        .map(|character| match character {
5560            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
5561            _ => '-',
5562        })
5563        .collect::<String>()
5564        .trim_matches('-')
5565        .to_string()
5566}
5567
5568fn git_local_branch_commit(
5569    project_root: &Path,
5570    branch_name: &str,
5571) -> Result<Option<String>, String> {
5572    let output = Command::new("git")
5573        .current_dir(project_root)
5574        .args([
5575            "rev-parse",
5576            "--verify",
5577            &format!("refs/heads/{}", branch_name),
5578        ])
5579        .output()
5580        .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
5581    if !output.status.success() {
5582        return Ok(None);
5583    }
5584    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
5585    if commit.is_empty() {
5586        Ok(None)
5587    } else {
5588        Ok(Some(commit))
5589    }
5590}
5591
5592fn git_remote_branch_commit(
5593    project_root: &Path,
5594    remote: &str,
5595    branch_name: &str,
5596) -> Result<Option<String>, String> {
5597    let output = run_git_command(
5598        project_root,
5599        &[
5600            "ls-remote",
5601            "--heads",
5602            remote,
5603            &format!("refs/heads/{}", branch_name),
5604        ],
5605    )?;
5606    let commit = output
5607        .split_whitespace()
5608        .next()
5609        .map(str::trim)
5610        .filter(|value| !value.is_empty())
5611        .map(str::to_string);
5612    Ok(commit)
5613}
5614
5615fn ensure_release_branch(
5616    project_root: &Path,
5617    branch_config: &GitHubReleaseBranchSettings,
5618    release_version: &Version,
5619    tag_name: &str,
5620    service_name: &str,
5621    target_commitish: &str,
5622) -> Result<String, String> {
5623    let branch_name = render_release_branch_name(
5624        &branch_config.naming_template,
5625        release_version,
5626        tag_name,
5627        service_name,
5628    )?;
5629
5630    if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
5631        if existing_local_commit != target_commitish {
5632            return Err(format!(
5633                "Configured release branch `{}` already exists locally at {}, expected {}.",
5634                branch_name, existing_local_commit, target_commitish
5635            ));
5636        }
5637    } else {
5638        run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
5639    }
5640
5641    if let Some(existing_remote_commit) =
5642        git_remote_branch_commit(project_root, "origin", &branch_name)?
5643    {
5644        if existing_remote_commit != target_commitish {
5645            return Err(format!(
5646                "Configured release branch `{}` already exists on origin at {}, expected {}.",
5647                branch_name, existing_remote_commit, target_commitish
5648            ));
5649        }
5650    } else {
5651        run_git_command(
5652            project_root,
5653            &[
5654                "push",
5655                "origin",
5656                &format!("{}:refs/heads/{}", target_commitish, branch_name),
5657            ],
5658        )?;
5659    }
5660
5661    Ok(branch_name)
5662}
5663
5664fn release_tag_family(tag_name: &str) -> String {
5665    if tag_name.starts_with('v')
5666        && tag_name
5667            .chars()
5668            .nth(1)
5669            .map(|ch| ch.is_ascii_digit())
5670            .unwrap_or(false)
5671    {
5672        return "v".to_string();
5673    }
5674
5675    let mut family: String = String::new();
5676    for ch in tag_name.chars() {
5677        if ch.is_ascii_digit() {
5678            break;
5679        }
5680        family.push(ch);
5681    }
5682    family
5683}
5684
5685fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
5686    if family == "v" {
5687        return parse_version(tag).ok();
5688    }
5689
5690    tag.strip_prefix(family)
5691        .and_then(|rest| parse_version(rest).ok())
5692}
5693
5694fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
5695    let range: String = format!("{}..HEAD", tag);
5696    run_git_command(project_root, &["rev-list", "--count", &range])
5697        .ok()
5698        .and_then(|raw| raw.trim().parse::<usize>().ok())
5699}
5700
5701fn previous_release_tag(
5702    project_root: &Path,
5703    current_tag_name: &str,
5704) -> Result<Option<String>, String> {
5705    let family: String = release_tag_family(current_tag_name);
5706    let tag_pattern: String = format!("{}*", family);
5707    let merged_tags: String = run_git_command(
5708        project_root,
5709        &["tag", "--merged", "HEAD", "--list", &tag_pattern],
5710    )?;
5711
5712    let mut best: Option<(usize, String)> = None;
5713    for raw in merged_tags.lines() {
5714        let tag = raw.trim();
5715        if tag.is_empty() || tag == current_tag_name {
5716            continue;
5717        }
5718        if parse_release_family_version(tag, &family).is_none() {
5719            continue;
5720        }
5721
5722        let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
5723            continue;
5724        };
5725        if distance == 0 {
5726            continue;
5727        }
5728
5729        match &best {
5730            None => best = Some((distance, tag.to_string())),
5731            Some((best_distance, _)) if distance < *best_distance => {
5732                best = Some((distance, tag.to_string()))
5733            }
5734            _ => {}
5735        }
5736    }
5737
5738    Ok(best.map(|(_, tag)| tag))
5739}
5740
5741fn default_release_title(version: &Version, repo: &str) -> String {
5742    format!("{} - {}", version, repo)
5743}
5744
5745fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
5746    match version_scope {
5747        VersionScope::Repository => repo,
5748        VersionScope::Crate { package_name, .. } => package_name.as_str(),
5749        VersionScope::Service { service_name, .. } => service_name.as_str(),
5750    }
5751}
5752
5753fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
5754    match version_scope {
5755        VersionScope::Repository => "repository",
5756        VersionScope::Crate { .. } => "crate",
5757        VersionScope::Service { .. } => "service",
5758    }
5759}
5760
5761fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
5762    match version_scope {
5763        VersionScope::Repository => repo.to_string(),
5764        VersionScope::Crate { package_name, .. } => package_name.clone(),
5765        VersionScope::Service { service_name, .. } => service_name.clone(),
5766    }
5767}
5768
5769fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
5770    match version_scope {
5771        VersionScope::Repository => "Repository".to_string(),
5772        VersionScope::Crate {
5773            package_name,
5774            crate_relative_root,
5775            ..
5776        } => format!("{} ({})", package_name, crate_relative_root),
5777        VersionScope::Service {
5778            service_name,
5779            service_relative_root,
5780            ..
5781        } => format!("{} ({})", service_name, service_relative_root),
5782    }
5783}
5784
5785fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
5786    match version_scope {
5787        VersionScope::Repository => format!("v{}", version),
5788        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
5789            format!("{}{}", tag_prefix, version)
5790        }
5791    }
5792}
5793
5794fn scoped_release_tag_name(
5795    version_scope: &VersionScope,
5796    version: &Version,
5797    parsed_tag_name: &str,
5798) -> String {
5799    match version_scope {
5800        VersionScope::Repository => parsed_tag_name.to_string(),
5801        VersionScope::Crate { .. } | VersionScope::Service { .. } => {
5802            if release_tag_family(parsed_tag_name) == "v" {
5803                default_release_tag_name(version_scope, version)
5804            } else {
5805                parsed_tag_name.to_string()
5806            }
5807        }
5808    }
5809}
5810
5811fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
5812    match version_scope {
5813        VersionScope::Repository => None,
5814        VersionScope::Crate {
5815            crate_relative_root,
5816            ..
5817        } => Some(crate_relative_root.clone()),
5818        VersionScope::Service {
5819            service_relative_root,
5820            ..
5821        } if service_relative_root == "." => None,
5822        VersionScope::Service {
5823            service_relative_root,
5824            ..
5825        } => Some(service_relative_root.clone()),
5826    }
5827}
5828
5829fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
5830    let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
5831        return (None, None);
5832    };
5833    let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
5834        return (None, None);
5835    };
5836
5837    (Some(owner), Some(repo))
5838}
5839
5840fn published_initiatives_to_activity(
5841    initiatives: &[PublishedLinearInitiative],
5842) -> Vec<VersionActivityLinearInitiative> {
5843    initiatives
5844        .iter()
5845        .map(|initiative| VersionActivityLinearInitiative {
5846            id: initiative.id.clone(),
5847            name: initiative.name.clone(),
5848            url: initiative.url.clone(),
5849        })
5850        .collect()
5851}
5852
5853async fn sync_cli_version_write_activity(
5854    project_root: &Path,
5855    version_scope: &VersionScope,
5856    version: &Version,
5857    message: String,
5858) {
5859    let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
5860    let scope_label = version_scope_label(
5861        version_scope,
5862        repository_name.as_deref().unwrap_or_else(|| {
5863            project_root
5864                .file_name()
5865                .and_then(|value| value.to_str())
5866                .unwrap_or("repository")
5867        }),
5868    );
5869
5870    let payload = CliVersionActivityPayload {
5871        command_kind: "version".to_string(),
5872        repository_owner,
5873        repository_name,
5874        scope_kind: version_scope_kind(version_scope).to_string(),
5875        scope_label,
5876        version: version.to_string(),
5877        tag_name: None,
5878        title: None,
5879        release_url: None,
5880        message_markdown: Some(message),
5881        published_initiatives: Vec::new(),
5882    };
5883
5884    if let Err(error) = post_version_activity(&payload).await {
5885        eprintln!("Warning: {}", error);
5886    }
5887}
5888
5889async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
5890    let payload = CliVersionActivityPayload {
5891        command_kind: "version_release".to_string(),
5892        repository_owner: Some(summary.repository_owner.clone()),
5893        repository_name: Some(summary.repository_name.clone()),
5894        scope_kind: summary.scope_kind.clone(),
5895        scope_label: summary.scope_label.clone(),
5896        version: summary.version.to_string(),
5897        tag_name: Some(summary.tag_name.clone()),
5898        title: Some(summary.release_title.clone()),
5899        release_url: Some(summary.release_url.clone()),
5900        message_markdown: Some(summary.release_notes.clone()),
5901        published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
5902    };
5903
5904    if let Err(error) = post_version_activity(&payload).await {
5905        eprintln!("Warning: {}", error);
5906    }
5907}
5908
5909fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
5910    let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
5911    let mut rendered_notes: String = notes.trim_end().to_string();
5912    if !rendered_notes.is_empty() {
5913        rendered_notes.push('\n');
5914    }
5915    rendered_notes.push_str("Release label: ");
5916    rendered_notes.push_str(release_label);
5917    rendered_notes.push('\n');
5918    rendered_notes.push_str("Generated by XBP ");
5919    rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
5920    rendered_notes
5921}
5922
5923#[cfg(test)]
5924mod tests {
5925    use super::github_release::{
5926        github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
5927        github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
5928        github_release_update_endpoint,
5929    };
5930    use super::release_docs::{
5931        release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
5932    };
5933    use super::release_notes::{
5934        build_fallback_sections, collect_linear_issue_identifiers,
5935        deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
5936        LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
5937    };
5938    use super::{
5939        analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
5940        current_scope_ledger_path, default_release_tag_name, default_release_title,
5941        highest_version_observation, is_safe_dirty_path, normalized_relative_path,
5942        parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
5943        parse_local_git_tag_output_for_scope, parse_package_version_target,
5944        parse_release_version_target, parse_remote_git_tag_output, parse_version,
5945        prepare_release_openapi_assets, read_cargo_lock_version,
5946        read_cargo_lock_version_for_package, read_cargo_toml_version, read_json_openapi_version,
5947        read_json_root_version, read_openapi_version, read_package_name_from_lookup,
5948        read_pyproject_version, read_readme_version, read_regex_version, read_toml_root_version,
5949        read_version_from_blob, read_version_from_path, read_yaml_root_version,
5950        redact_remote_url_credentials, render_release_branch_name,
5951        resolve_configured_version_target_paths, resolve_linear_release_placeholders,
5952        resolve_project_root_from, resolve_release_openapi_specs,
5953        resolve_release_publish_target_filter, resolve_version_scope,
5954        rewrite_toml_package_assignment_versions, select_changed_version_targets,
5955        should_clear_version_change_guard, stale_version_observations,
5956        sync_version_to_configured_files_with_paths, write_cargo_lock_version,
5957        write_cargo_toml_version, write_chart_version, write_json_openapi_version,
5958        write_json_root_version, write_openapi_version, write_package_version_to_configured_files,
5959        write_pyproject_version, write_readme_version, write_regex_version,
5960        write_toml_root_version, write_version_to_configured_files,
5961        write_version_to_configured_files_with_paths, write_version_to_selected_paths,
5962        write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
5963        VersionObservation, VersionScope,
5964    };
5965
5966    use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
5967    use crate::config::PackageNameLookup;
5968    use semver::Version;
5969    use std::collections::BTreeMap;
5970    use std::fs;
5971    use std::path::{Path, PathBuf};
5972    use std::time::{SystemTime, UNIX_EPOCH};
5973
5974    fn temp_dir(label: &str) -> PathBuf {
5975        let nanos: u128 = SystemTime::now()
5976            .duration_since(UNIX_EPOCH)
5977            .expect("time")
5978            .as_nanos();
5979        let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
5980        fs::create_dir_all(&dir).expect("create temp dir");
5981        dir
5982    }
5983
5984    #[test]
5985    fn parse_git_status_path_handles_renames() {
5986        assert_eq!(
5987            parse_git_status_path("R  old-name -> new-name/Cargo.toml"),
5988            Some("new-name/Cargo.toml".to_string())
5989        );
5990        assert_eq!(
5991            parse_git_status_path(" M .xbp/xbp.yaml"),
5992            Some(".xbp/xbp.yaml".to_string())
5993        );
5994    }
5995
5996    #[test]
5997    fn is_safe_dirty_path_recognizes_generated_paths() {
5998        assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
5999        assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
6000        assert!(is_safe_dirty_path(
6001            "scripts/__pycache__/publish.cpython-312.pyc"
6002        ));
6003        assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
6004        assert!(!is_safe_dirty_path("Cargo.toml"));
6005    }
6006
6007    #[test]
6008    fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
6009        let analysis = analyze_dirty_worktree(&[
6010            " M .xbp/xbp.yaml".to_string(),
6011            " M crates/cli/src/main.rs".to_string(),
6012        ]);
6013        assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
6014        assert_eq!(
6015            analysis.risky_entries,
6016            vec!["crates/cli/src/main.rs".to_string()]
6017        );
6018    }
6019
6020    fn write_multi_service_config(dir: &Path) {
6021        let xbp_dir = dir.join(".xbp");
6022        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6023        fs::write(
6024            xbp_dir.join("xbp.yaml"),
6025            r#"project_name: xbp
6026version: 10.30.0
6027port: 3000
6028build_dir: ./
6029services:
6030  - name: cli
6031    target: rust
6032    branch: main
6033    port: 8080
6034    root_directory: ./
6035    version_targets:
6036      - crates/cli/Cargo.toml
6037  - name: web
6038    target: nextjs
6039    branch: main
6040    port: 3001
6041    root_directory: apps/web
6042    version_targets:
6043      - apps/web/package.json
6044publish:
6045  npm:
6046    enabled: true
6047    manifest_path: apps/web/package.json
6048  crates:
6049    enabled: true
6050    manifest_path: crates/cli/Cargo.toml
6051version_targets:
6052  - crates/cli/Cargo.toml
6053  - apps/web/package.json
6054"#,
6055        )
6056        .expect("write xbp config");
6057    }
6058
6059    #[test]
6060    fn parses_prefixed_semver() {
6061        assert_eq!(
6062            parse_version("v1.2.3").expect("version"),
6063            Version::new(1, 2, 3)
6064        );
6065    }
6066
6067    #[test]
6068    fn rejects_invalid_semver() {
6069        let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
6070        assert!(error.contains("Invalid semantic version"));
6071    }
6072
6073    #[test]
6074    fn release_target_parser_supports_plain_semver() {
6075        let (version, tag_name) =
6076            parse_release_version_target("1.2.3-alpha.1").expect("release target");
6077        assert_eq!(version.major, 1);
6078        assert_eq!(version.minor, 2);
6079        assert_eq!(version.patch, 3);
6080        assert_eq!(version.pre.as_str(), "alpha.1");
6081        assert_eq!(tag_name, "v1.2.3-alpha.1");
6082    }
6083
6084    #[test]
6085    fn release_target_parser_supports_prefixed_semver() {
6086        let (version, tag_name) =
6087            parse_release_version_target("studio-0.3.2-alpha").expect("release target");
6088        assert_eq!(version.major, 0);
6089        assert_eq!(version.minor, 3);
6090        assert_eq!(version.patch, 2);
6091        assert_eq!(version.pre.as_str(), "alpha");
6092        assert_eq!(tag_name, "studio-0.3.2-alpha");
6093    }
6094
6095    #[test]
6096    fn bumps_versions_correctly() {
6097        let base: Version = Version::new(0, 1, 0);
6098        assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
6099        assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
6100        assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
6101    }
6102
6103    #[test]
6104    fn version_change_guard_clears_when_worktree_is_clean() {
6105        let entry = VersionChangeGuardEntry {
6106            pending_version_change_count: 1,
6107            head_commit: Some("abc123".to_string()),
6108        };
6109        let state = GitWorktreeState {
6110            is_dirty: false,
6111            head_commit: Some("abc123".to_string()),
6112        };
6113        assert!(should_clear_version_change_guard(&entry, &state));
6114    }
6115
6116    #[test]
6117    fn version_change_guard_clears_when_head_changes() {
6118        let entry = VersionChangeGuardEntry {
6119            pending_version_change_count: 1,
6120            head_commit: Some("abc123".to_string()),
6121        };
6122        let state = GitWorktreeState {
6123            is_dirty: true,
6124            head_commit: Some("def456".to_string()),
6125        };
6126        assert!(should_clear_version_change_guard(&entry, &state));
6127    }
6128
6129    #[test]
6130    fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
6131        let entry = VersionChangeGuardEntry {
6132            pending_version_change_count: 1,
6133            head_commit: Some("abc123".to_string()),
6134        };
6135        let state = GitWorktreeState {
6136            is_dirty: true,
6137            head_commit: Some("abc123".to_string()),
6138        };
6139        assert!(!should_clear_version_change_guard(&entry, &state));
6140    }
6141
6142    #[test]
6143    fn render_release_branch_name_replaces_supported_tokens() {
6144        let branch = render_release_branch_name(
6145            "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
6146            &Version::new(10, 27, 0),
6147            "v10.27.0",
6148            "xbp",
6149        )
6150        .expect("branch name");
6151
6152        assert_eq!(branch, "releases/10.27.0/v10.27.0");
6153    }
6154
6155    #[test]
6156    fn render_release_branch_name_supports_service_name_template() {
6157        let branch = render_release_branch_name(
6158            "releases/{VERSION}-{SERVICE_NAME}",
6159            &Version::new(10, 27, 0),
6160            "v10.27.0",
6161            "athena/api",
6162        )
6163        .expect("branch name");
6164
6165        assert_eq!(branch, "releases/10.27.0-athena-api");
6166    }
6167
6168    #[test]
6169    fn resolve_linear_release_placeholders_reads_env_files() {
6170        let temp_dir = std::env::temp_dir().join(format!(
6171            "xbp-linear-release-placeholders-{}",
6172            std::time::SystemTime::now()
6173                .duration_since(std::time::UNIX_EPOCH)
6174                .expect("time")
6175                .as_nanos()
6176        ));
6177        fs::create_dir_all(&temp_dir).expect("temp dir");
6178        fs::write(
6179            temp_dir.join(".env.local"),
6180            "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
6181        )
6182        .expect("env file");
6183
6184        let resolved = resolve_linear_release_placeholders(
6185            &temp_dir,
6186            ResolvedLinearReleaseConfig {
6187                initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
6188                organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
6189                health: "on_track".to_string(),
6190            },
6191        );
6192
6193        assert_eq!(
6194            resolved.initiative_ids,
6195            vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
6196        );
6197        assert_eq!(
6198            resolved.organization_name.as_deref(),
6199            Some("suits-formations")
6200        );
6201
6202        let _ = fs::remove_dir_all(temp_dir);
6203    }
6204
6205    #[test]
6206    fn version_change_guard_clears_when_pending_count_is_zero() {
6207        let entry = VersionChangeGuardEntry {
6208            pending_version_change_count: 0,
6209            head_commit: Some("abc123".to_string()),
6210        };
6211        let state = GitWorktreeState {
6212            is_dirty: true,
6213            head_commit: Some("abc123".to_string()),
6214        };
6215        assert!(should_clear_version_change_guard(&entry, &state));
6216    }
6217
6218    #[test]
6219    fn parse_package_version_target_supports_assignment_syntax() {
6220        let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
6221            .expect("parse")
6222            .expect("target");
6223        assert_eq!(parsed.0, "demo_pkg".to_string());
6224        assert_eq!(parsed.1, Version::new(1, 2, 3));
6225    }
6226
6227    #[test]
6228    fn parse_package_version_target_rejects_invalid_package_names() {
6229        let error: String = parse_package_version_target("bad package=1.2.3")
6230            .expect_err("invalid package target should fail");
6231        assert!(error.contains("Invalid package target"));
6232    }
6233
6234    #[test]
6235    fn parse_package_version_target_returns_none_without_assignment() {
6236        assert!(parse_package_version_target("1.2.3")
6237            .expect("parse")
6238            .is_none());
6239    }
6240
6241    #[test]
6242    fn parse_package_version_target_returns_none_for_empty_package_name() {
6243        assert!(parse_package_version_target(" =1.2.3")
6244            .expect("parse")
6245            .is_none());
6246    }
6247
6248    #[test]
6249    fn bumping_clears_prerelease_and_build_metadata() {
6250        let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
6251        assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
6252        assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
6253        assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
6254    }
6255
6256    #[test]
6257    fn cargo_toml_adapter_reads_and_writes() {
6258        let dir: PathBuf = temp_dir("cargo");
6259        let path: PathBuf = dir.join("Cargo.toml");
6260        fs::write(
6261            &path,
6262            r#"[package]
6263            name = "xbp"
6264            version = "1.0.0"
6265            "#,
6266        )
6267        .expect("write Cargo.toml");
6268
6269        assert_eq!(
6270            read_cargo_toml_version(&path).expect("read"),
6271            Some("1.0.0".to_string())
6272        );
6273
6274        write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
6275        assert_eq!(
6276            read_version_from_path(&path).expect("read"),
6277            Some("1.1.0".to_string())
6278        );
6279
6280        let _ = fs::remove_dir_all(dir);
6281    }
6282
6283    #[test]
6284    fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
6285        let dir: PathBuf = temp_dir("workspace-root-version");
6286        let crate_dir = dir.join("crates").join("demo");
6287        fs::create_dir_all(&crate_dir).expect("create crate dir");
6288        fs::write(
6289            dir.join("Cargo.toml"),
6290            r#"[workspace]
6291members = ["crates/demo"]
6292resolver = "2"
6293
6294[workspace.package]
6295version = "0.1.0"
6296"#,
6297        )
6298        .expect("write workspace root");
6299        fs::write(
6300            crate_dir.join("Cargo.toml"),
6301            r#"[package]
6302name = "demo"
6303version = { workspace = true }
6304"#,
6305        )
6306        .expect("write crate manifest");
6307
6308        assert_eq!(
6309            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
6310            Some("0.1.0".to_string())
6311        );
6312        assert_eq!(
6313            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6314            Some("0.1.0".to_string())
6315        );
6316
6317        write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
6318        assert_eq!(
6319            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6320            Some("0.2.0".to_string())
6321        );
6322
6323        let _ = fs::remove_dir_all(dir);
6324    }
6325
6326    #[test]
6327    fn json_root_adapter_reads_and_writes() {
6328        let dir: PathBuf = temp_dir("json");
6329        let path: PathBuf = dir.join("package.json");
6330        fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
6331
6332        assert_eq!(
6333            read_json_root_version(&path).expect("read"),
6334            Some("1.4.0".to_string())
6335        );
6336
6337        write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
6338        assert_eq!(
6339            read_version_from_path(&path).expect("read"),
6340            Some("1.5.0".to_string())
6341        );
6342
6343        let _ = fs::remove_dir_all(dir);
6344    }
6345
6346    #[test]
6347    fn yaml_root_adapter_reads_and_writes() {
6348        let dir: PathBuf = temp_dir("yaml");
6349        let path: PathBuf = dir.join("xbp.yaml");
6350        fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
6351
6352        assert_eq!(
6353            read_yaml_root_version(&path, "version").expect("read"),
6354            Some("0.2.0".to_string())
6355        );
6356
6357        write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
6358        assert_eq!(
6359            read_version_from_path(&path).expect("read"),
6360            Some("0.3.0".to_string())
6361        );
6362
6363        let _ = fs::remove_dir_all(dir);
6364    }
6365
6366    #[test]
6367    fn toml_root_adapter_reads_and_writes() {
6368        let dir: PathBuf = temp_dir("toml");
6369        let path: PathBuf = dir.join("config.toml");
6370        fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
6371
6372        assert_eq!(
6373            read_toml_root_version(&path).expect("read"),
6374            Some("3.1.4".to_string())
6375        );
6376
6377        write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
6378        assert_eq!(
6379            read_toml_root_version(&path).expect("read"),
6380            Some("3.2.0".to_string())
6381        );
6382
6383        let _ = fs::remove_dir_all(dir);
6384    }
6385
6386    #[test]
6387    fn openapi_adapter_reads_and_writes_nested_version() {
6388        let dir: PathBuf = temp_dir("openapi");
6389        let path: PathBuf = dir.join("openapi.yaml");
6390        fs::write(
6391            &path,
6392            "openapi: 3.0.3\ninfo:\n  title: Test\n  version: 1.2.3\n",
6393        )
6394        .expect("write openapi");
6395
6396        assert_eq!(
6397            read_openapi_version(&path).expect("read"),
6398            Some("1.2.3".to_string())
6399        );
6400
6401        write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
6402        assert_eq!(
6403            read_openapi_version(&path).expect("read"),
6404            Some("2.0.0".to_string())
6405        );
6406
6407        let _ = fs::remove_dir_all(dir);
6408    }
6409
6410    #[test]
6411    fn openapi_writer_creates_missing_info_mapping() {
6412        let dir: PathBuf = temp_dir("openapi-missing-info");
6413        let path: PathBuf = dir.join("openapi.yaml");
6414        fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
6415
6416        write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6417        assert_eq!(
6418            read_openapi_version(&path).expect("read"),
6419            Some("4.0.0".to_string())
6420        );
6421
6422        let _ = fs::remove_dir_all(dir);
6423    }
6424
6425    #[test]
6426    fn json_openapi_adapter_reads_and_writes_nested_version() {
6427        let dir: PathBuf = temp_dir("openapi-json");
6428        let path: PathBuf = dir.join("openapi.json");
6429        fs::write(
6430            &path,
6431            r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
6432        )
6433        .expect("write openapi json");
6434
6435        assert_eq!(
6436            read_json_openapi_version(&path).expect("read"),
6437            Some("1.2.3".to_string())
6438        );
6439
6440        write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
6441        assert_eq!(
6442            read_json_openapi_version(&path).expect("read"),
6443            Some("2.1.0".to_string())
6444        );
6445
6446        let _ = fs::remove_dir_all(dir);
6447    }
6448
6449    #[test]
6450    fn json_openapi_writer_creates_missing_info_object() {
6451        let dir: PathBuf = temp_dir("openapi-json-missing-info");
6452        let path: PathBuf = dir.join("openapi.json");
6453        fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
6454
6455        write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6456        assert_eq!(
6457            read_json_openapi_version(&path).expect("read"),
6458            Some("4.0.0".to_string())
6459        );
6460
6461        let _ = fs::remove_dir_all(dir);
6462    }
6463
6464    #[test]
6465    fn pyproject_reader_prefers_project_version() {
6466        let dir: PathBuf = temp_dir("pyproject-project");
6467        let path: PathBuf = dir.join("pyproject.toml");
6468        fs::write(
6469            &path,
6470            "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
6471        )
6472        .expect("write pyproject");
6473
6474        assert_eq!(
6475            read_pyproject_version(&path).expect("read"),
6476            Some("0.8.0".to_string())
6477        );
6478
6479        let _ = fs::remove_dir_all(dir);
6480    }
6481
6482    #[test]
6483    fn pyproject_reader_falls_back_to_poetry_version() {
6484        let dir: PathBuf = temp_dir("pyproject-poetry");
6485        let path: PathBuf = dir.join("pyproject.toml");
6486        fs::write(
6487            &path,
6488            "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
6489        )
6490        .expect("write pyproject");
6491
6492        assert_eq!(
6493            read_pyproject_version(&path).expect("read"),
6494            Some("1.9.0".to_string())
6495        );
6496
6497        let _ = fs::remove_dir_all(dir);
6498    }
6499
6500    #[test]
6501    fn pyproject_writer_updates_project_table() {
6502        let dir: PathBuf = temp_dir("pyproject-write-project");
6503        let path: PathBuf = dir.join("pyproject.toml");
6504        fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
6505            .expect("write pyproject");
6506
6507        write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
6508        assert_eq!(
6509            read_pyproject_version(&path).expect("read"),
6510            Some("1.1.0".to_string())
6511        );
6512
6513        let _ = fs::remove_dir_all(dir);
6514    }
6515
6516    #[test]
6517    fn pyproject_writer_updates_poetry_table() {
6518        let dir: PathBuf = temp_dir("pyproject-write-poetry");
6519        let path: PathBuf = dir.join("pyproject.toml");
6520        fs::write(
6521            &path,
6522            "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
6523        )
6524        .expect("write pyproject");
6525
6526        write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
6527        assert_eq!(
6528            read_pyproject_version(&path).expect("read"),
6529            Some("2.1.0".to_string())
6530        );
6531
6532        let _ = fs::remove_dir_all(dir);
6533    }
6534
6535    #[test]
6536    fn cargo_lock_reader_and_writer_follow_package_name() {
6537        let dir: PathBuf = temp_dir("cargo-lock");
6538        let cargo_toml: PathBuf = dir.join("Cargo.toml");
6539        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6540        fs::write(
6541            &cargo_toml,
6542            r#"[package]
6543            name = "xbp"
6544            version = "1.0.0"
6545            "#,
6546        )
6547        .expect("write Cargo.toml");
6548        fs::write(
6549            &cargo_lock,
6550            r#"version = 4
6551
6552            [[package]]
6553            name = "xbp"
6554            version = "1.0.0"
6555
6556            [[package]]
6557            name = "other"
6558            version = "9.9.9"
6559            "#,
6560        )
6561        .expect("write Cargo.lock");
6562
6563        assert_eq!(
6564            read_cargo_lock_version(&cargo_lock).expect("read"),
6565            Some("1.0.0".to_string())
6566        );
6567
6568        write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
6569        assert_eq!(
6570            read_cargo_lock_version(&cargo_lock).expect("read"),
6571            Some("1.0.1".to_string())
6572        );
6573
6574        let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
6575        assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
6576
6577        let _ = fs::remove_dir_all(dir);
6578    }
6579
6580    #[test]
6581    fn cargo_lock_writer_errors_when_package_missing() {
6582        let dir: PathBuf = temp_dir("cargo-lock-missing");
6583        fs::write(
6584            dir.join("Cargo.toml"),
6585            "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
6586        )
6587        .expect("write Cargo.toml");
6588        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6589        fs::write(
6590            &cargo_lock,
6591            "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
6592        )
6593        .expect("write Cargo.lock");
6594
6595        let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
6596            .expect_err("missing package should fail");
6597        assert!(error.contains("Could not find package `xbp`"));
6598
6599        let _ = fs::remove_dir_all(dir);
6600    }
6601
6602    #[test]
6603    fn cargo_package_name_reads_package_section() {
6604        let dir: PathBuf = temp_dir("cargo-package-name");
6605        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6606        fs::write(
6607            dir.join("Cargo.toml"),
6608            "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
6609        )
6610        .expect("write Cargo.toml");
6611        fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
6612
6613        assert_eq!(
6614            cargo_package_name(&cargo_lock).expect("name"),
6615            Some("xbp-cli".to_string())
6616        );
6617
6618        let _ = fs::remove_dir_all(dir);
6619    }
6620
6621    #[test]
6622    fn cargo_toml_writer_skips_workspace_manifest_without_package() {
6623        let dir: PathBuf = temp_dir("cargo-workspace-manifest");
6624        let path: PathBuf = dir.join("Cargo.toml");
6625        fs::write(
6626            &path,
6627            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6628        )
6629        .expect("write Cargo.toml");
6630
6631        let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
6632        assert!(changed);
6633        let content = fs::read_to_string(&path).expect("read Cargo.toml");
6634        assert!(content.contains("[workspace.package]"));
6635        assert!(content.contains("version = \"2.0.0\""));
6636
6637        let _ = fs::remove_dir_all(dir);
6638    }
6639
6640    #[test]
6641    fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
6642        let dir: PathBuf = temp_dir("workspace-cargo-skip");
6643        fs::write(
6644            dir.join("Cargo.toml"),
6645            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6646        )
6647        .expect("write Cargo.toml");
6648        fs::write(
6649            dir.join("Cargo.lock"),
6650            "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
6651        )
6652        .expect("write Cargo.lock");
6653        fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
6654            .expect("write README");
6655
6656        let updated = write_version_to_configured_files(
6657            &dir,
6658            &dir,
6659            &[
6660                "Cargo.toml".to_string(),
6661                "Cargo.lock".to_string(),
6662                "README.md".to_string(),
6663            ],
6664            &VersionScope::Repository,
6665            &Version::new(1, 1, 0),
6666        )
6667        .expect("write versions");
6668
6669        assert_eq!(updated, 2);
6670        assert_eq!(
6671            read_readme_version(&dir.join("README.md")).expect("read"),
6672            Some("1.1.0".to_string())
6673        );
6674        let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
6675        assert!(cargo_content.contains("version = \"1.1.0\""));
6676
6677        let _ = fs::remove_dir_all(dir);
6678    }
6679
6680    #[test]
6681    fn repository_scope_prefers_workspace_default_member_manifest() {
6682        let dir: PathBuf = temp_dir("workspace-default-member-path");
6683        let crate_dir: PathBuf = dir.join("crates").join("cli");
6684        fs::create_dir_all(&crate_dir).expect("create crate dir");
6685        fs::write(
6686            dir.join("Cargo.toml"),
6687            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6688        )
6689        .expect("write workspace cargo");
6690        fs::write(
6691            crate_dir.join("Cargo.toml"),
6692            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6693        )
6694        .expect("write crate cargo");
6695
6696        let resolved = super::resolve_registry_relative_path(
6697            &dir,
6698            &dir,
6699            &VersionScope::Repository,
6700            "Cargo.toml",
6701        );
6702
6703        assert_eq!(resolved, "crates/cli/Cargo.toml");
6704
6705        let _ = fs::remove_dir_all(dir);
6706    }
6707
6708    #[test]
6709    fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
6710        let dir: PathBuf = temp_dir("workspace-default-member-writer");
6711        let crate_dir: PathBuf = dir.join("crates").join("cli");
6712        fs::create_dir_all(&crate_dir).expect("create crate dir");
6713        fs::write(
6714            dir.join("Cargo.toml"),
6715            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6716        )
6717        .expect("write workspace cargo");
6718        fs::write(
6719            crate_dir.join("Cargo.toml"),
6720            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6721        )
6722        .expect("write crate cargo");
6723        fs::write(
6724            dir.join("Cargo.lock"),
6725            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
6726        )
6727        .expect("write cargo lock");
6728        fs::write(
6729            dir.join("README.md"),
6730            "# XBP\n\ncurrent version: `10.21.0`\n",
6731        )
6732        .expect("write readme");
6733
6734        let updated = write_version_to_configured_files(
6735            &dir,
6736            &dir,
6737            &[
6738                "Cargo.toml".to_string(),
6739                "Cargo.lock".to_string(),
6740                "README.md".to_string(),
6741            ],
6742            &VersionScope::Repository,
6743            &Version::new(10, 22, 0),
6744        )
6745        .expect("write versions");
6746
6747        assert_eq!(updated, 3);
6748        assert_eq!(
6749            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
6750            Some("10.22.0".to_string())
6751        );
6752        assert_eq!(
6753            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
6754            Some("10.22.0".to_string())
6755        );
6756        assert_eq!(
6757            read_readme_version(&dir.join("README.md")).expect("read readme"),
6758            Some("10.22.0".to_string())
6759        );
6760
6761        let _ = fs::remove_dir_all(dir);
6762    }
6763
6764    #[test]
6765    fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
6766        let dir: PathBuf = temp_dir("publish-manifest-version-target");
6767        let package_dir = dir.join("packages").join("heroui");
6768        let xbp_dir = dir.join(".xbp");
6769        fs::create_dir_all(&package_dir).expect("create package dir");
6770        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6771
6772        fs::write(
6773            xbp_dir.join("xbp.yaml"),
6774            r#"project_name: athena-auth-ui
6775version: 0.3.1
6776port: 4000
6777build_dir: ./
6778publish:
6779  npm:
6780    enabled: true
6781    working_directory: packages/heroui
6782    manifest_path: packages/heroui/package.json
6783"#,
6784        )
6785        .expect("write xbp config");
6786        fs::write(
6787            dir.join("package.json"),
6788            r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
6789        )
6790        .expect("write root package");
6791        fs::write(
6792            package_dir.join("package.json"),
6793            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
6794        )
6795        .expect("write package manifest");
6796
6797        let updated = write_version_to_configured_files(
6798            &dir,
6799            &dir,
6800            &["package.json".to_string()],
6801            &VersionScope::Repository,
6802            &Version::new(0, 3, 1),
6803        )
6804        .expect("write versions");
6805
6806        assert_eq!(updated, 2);
6807        assert_eq!(
6808            read_json_root_version(&dir.join("package.json")).expect("read root package"),
6809            Some("0.3.1".to_string())
6810        );
6811        assert_eq!(
6812            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6813            Some("0.3.1".to_string())
6814        );
6815
6816        let _ = fs::remove_dir_all(dir);
6817    }
6818
6819    #[test]
6820    fn configured_writer_updates_version_targets_from_xbp_config() {
6821        let dir: PathBuf = temp_dir("explicit-version-targets");
6822        let app_dir = dir.join("apps").join("web");
6823        let cli_dir = dir.join("crates").join("cli");
6824        let xbp_dir = dir.join(".xbp");
6825        fs::create_dir_all(&app_dir).expect("create app dir");
6826        fs::create_dir_all(&cli_dir).expect("create cli dir");
6827        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6828
6829        fs::write(
6830            xbp_dir.join("xbp.yaml"),
6831            r#"project_name: xbp
6832version: 10.30.0
6833port: 3000
6834build_dir: ./
6835version_targets:
6836  - crates/cli/Cargo.toml
6837  - apps/web/package.json
6838"#,
6839        )
6840        .expect("write xbp config");
6841        fs::write(
6842            cli_dir.join("Cargo.toml"),
6843            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6844        )
6845        .expect("write cli Cargo.toml");
6846        fs::write(
6847            app_dir.join("package.json"),
6848            r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
6849        )
6850        .expect("write app package");
6851
6852        let updated = write_version_to_configured_files(
6853            &dir,
6854            &dir,
6855            &[".xbp/xbp.yaml".to_string()],
6856            &VersionScope::Repository,
6857            &Version::new(10, 30, 0),
6858        )
6859        .expect("write versions");
6860
6861        assert_eq!(updated, 3);
6862        assert_eq!(
6863            read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
6864            Some("10.30.0".to_string())
6865        );
6866        assert_eq!(
6867            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
6868            Some("10.30.0".to_string())
6869        );
6870        assert_eq!(
6871            read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
6872            Some("10.30.0".to_string())
6873        );
6874
6875        let _ = fs::remove_dir_all(dir);
6876    }
6877
6878    #[test]
6879    fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
6880        let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
6881        let package_dir = dir.join("packages").join("heroui");
6882        let xbp_dir = dir.join(".xbp");
6883        fs::create_dir_all(&package_dir).expect("create package dir");
6884        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6885
6886        fs::write(
6887            xbp_dir.join("xbp.yaml"),
6888            r#"project_name: athena-auth-ui
6889version: 0.3.0
6890port: 4000
6891build_dir: ./
6892publish:
6893  npm:
6894    enabled: true
6895    working_directory: packages/heroui
6896    manifest_path: packages/heroui/package.json
6897"#,
6898        )
6899        .expect("write xbp config");
6900        fs::write(
6901            dir.join("package.json"),
6902            r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
6903        )
6904        .expect("write root package");
6905        fs::write(
6906            package_dir.join("package.json"),
6907            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
6908        )
6909        .expect("write package manifest");
6910
6911        let _updated_paths = sync_version_to_configured_files_with_paths(
6912            &dir,
6913            &dir,
6914            &["package.json".to_string()],
6915            &VersionScope::Repository,
6916            &Version::new(0, 3, 0),
6917        )
6918        .expect("sync versions");
6919
6920        assert_eq!(
6921            read_json_root_version(&dir.join("package.json")).expect("read root package"),
6922            Some("0.3.0".to_string())
6923        );
6924        assert_eq!(
6925            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6926            Some("0.3.0".to_string())
6927        );
6928
6929        let _ = fs::remove_dir_all(dir);
6930    }
6931
6932    #[test]
6933    fn readme_adapter_updates_current_version_marker() {
6934        let dir: PathBuf = temp_dir("readme");
6935        let path: PathBuf = dir.join("README.md");
6936        fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
6937
6938        write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
6939        assert_eq!(
6940            read_readme_version(&path).expect("read"),
6941            Some("1.2.0".to_string())
6942        );
6943
6944        let _ = fs::remove_dir_all(dir);
6945    }
6946
6947    #[test]
6948    fn readme_writer_inserts_marker_when_missing() {
6949        let dir: PathBuf = temp_dir("readme-insert");
6950        let path: PathBuf = dir.join("README.md");
6951        fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
6952
6953        write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
6954        let content: String = fs::read_to_string(&path).expect("read readme");
6955        assert!(content.contains("current version: `3.0.0`"));
6956
6957        let _ = fs::remove_dir_all(dir);
6958    }
6959
6960    #[test]
6961    fn regex_adapter_reads_and_writes_versions() {
6962        let dir: PathBuf = temp_dir("regex");
6963        let path: PathBuf = dir.join("build.gradle");
6964        fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
6965
6966        assert_eq!(
6967            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
6968            Some("5.4.3".to_string())
6969        );
6970
6971        write_regex_version(
6972            &path,
6973            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
6974            &Version::new(5, 5, 0),
6975        )
6976        .expect("write");
6977
6978        assert_eq!(
6979            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
6980            Some("5.5.0".to_string())
6981        );
6982
6983        let _ = fs::remove_dir_all(dir);
6984    }
6985
6986    #[test]
6987    fn regex_writer_errors_without_matching_pattern() {
6988        let dir: PathBuf = temp_dir("regex-miss");
6989        let path: PathBuf = dir.join("build.gradle");
6990        fs::write(&path, "group = 'demo'\n").expect("write gradle");
6991
6992        let error: String = write_regex_version(
6993            &path,
6994            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
6995            &Version::new(1, 0, 0),
6996        )
6997        .expect_err("missing version should fail");
6998        assert!(error.contains("No version pattern found"));
6999
7000        let _ = fs::remove_dir_all(dir);
7001    }
7002
7003    #[test]
7004    fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
7005        let original: &str = r#"[dependencies]
7006            serde = "1.0.219"
7007            tokio = { version = "1.44.1", features = ["full"] }
7008            "#;
7009
7010        let (updated, changed) =
7011            rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
7012                .expect("rewrite");
7013        assert!(changed);
7014        assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
7015
7016        let (updated, changed) =
7017            rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
7018                .expect("rewrite");
7019        assert!(changed);
7020        assert!(updated.contains(r#"serde = "1.1.0""#));
7021    }
7022
7023    #[test]
7024    fn package_version_writer_updates_registry_toml_targets() {
7025        let dir: PathBuf = temp_dir("package-version-registry");
7026        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7027        fs::write(
7028            &cargo_toml,
7029            r#"[package]
7030            name = "demo"
7031            version = "0.1.0"
7032
7033            [dependencies]
7034            serde = "1.0.219"
7035            tokio = { version = "1.44.1", features = ["full"] }
7036            "#,
7037        )
7038        .expect("write Cargo.toml");
7039
7040        let updated: usize = write_package_version_to_configured_files(
7041            &dir,
7042            &dir,
7043            &["Cargo.toml".to_string()],
7044            &VersionScope::Repository,
7045            "tokio",
7046            &Version::new(1, 45, 1),
7047        )
7048        .expect("update package assignment");
7049        assert_eq!(updated, 1);
7050
7051        let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
7052        assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
7053
7054        let _ = fs::remove_dir_all(dir);
7055    }
7056
7057    #[test]
7058    fn package_version_writer_errors_when_package_assignment_not_found() {
7059        let dir: PathBuf = temp_dir("package-version-missing");
7060        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7061        fs::write(
7062            &cargo_toml,
7063            r#"[package]
7064        name = "demo"
7065        version = "0.1.0"
7066
7067        [dependencies]
7068        serde = "1.0.219"
7069        "#,
7070        )
7071        .expect("write Cargo.toml");
7072
7073        let error: String = write_package_version_to_configured_files(
7074            &dir,
7075            &dir,
7076            &["Cargo.toml".to_string()],
7077            &VersionScope::Repository,
7078            "tokio",
7079            &Version::new(1, 45, 1),
7080        )
7081        .expect_err("missing package assignment should fail");
7082        assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
7083
7084        let _ = fs::remove_dir_all(dir);
7085    }
7086
7087    #[test]
7088    fn chart_writer_updates_app_version_when_present() {
7089        let dir: PathBuf = temp_dir("chart");
7090        let path: PathBuf = dir.join("Chart.yaml");
7091        fs::write(
7092            &path,
7093            "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
7094        )
7095        .expect("write chart");
7096
7097        write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
7098        let content: String = fs::read_to_string(&path).expect("read chart");
7099        assert!(content.contains("version: 0.2.0"));
7100        assert!(content.contains("appVersion: 0.2.0"));
7101
7102        let _ = fs::remove_dir_all(dir);
7103    }
7104
7105    #[test]
7106    fn configured_file_writer_deduplicates_registry_entries() {
7107        let dir: PathBuf = temp_dir("dedupe");
7108        let readme: PathBuf = dir.join("README.md");
7109        fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7110
7111        let updated: usize = write_version_to_configured_files(
7112            &dir,
7113            &dir,
7114            &[
7115                "README.md".to_string(),
7116                "README.md".to_string(),
7117                "missing.md".to_string(),
7118            ],
7119            &VersionScope::Repository,
7120            &Version::new(1, 1, 0),
7121        )
7122        .expect("write versions");
7123
7124        assert_eq!(updated, 1);
7125        assert_eq!(
7126            read_readme_version(&readme).expect("read"),
7127            Some("1.1.0".to_string())
7128        );
7129
7130        let _ = fs::remove_dir_all(dir);
7131    }
7132
7133    #[test]
7134    fn configured_file_writer_prefers_invocation_directory_targets() {
7135        let dir: PathBuf = temp_dir("invocation-precedence");
7136        let app_dir: PathBuf = dir.join("apps").join("web");
7137        fs::create_dir_all(&app_dir).expect("create app dir");
7138
7139        let root_package: PathBuf = dir.join("package.json");
7140        let app_package: PathBuf = app_dir.join("package.json");
7141        fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
7142            .expect("write root package");
7143        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7144            .expect("write app package");
7145
7146        let updated: usize = write_version_to_configured_files(
7147            &dir,
7148            &app_dir,
7149            &["package.json".to_string()],
7150            &VersionScope::Repository,
7151            &Version::new(2, 14, 0),
7152        )
7153        .expect("write versions");
7154        assert_eq!(updated, 1);
7155
7156        assert_eq!(
7157            read_json_root_version(&root_package).expect("read root"),
7158            Some("9.9.9".to_string())
7159        );
7160        assert_eq!(
7161            read_json_root_version(&app_package).expect("read app"),
7162            Some("2.14.0".to_string())
7163        );
7164
7165        let _ = fs::remove_dir_all(dir);
7166    }
7167
7168    #[test]
7169    fn resolve_project_root_prefers_nearest_xbp_config() {
7170        let dir: PathBuf = temp_dir("nested-xbp-project-root");
7171        let service_root: PathBuf = dir.join("services").join("athena-auth");
7172        let nested_dir: PathBuf = service_root.join("src");
7173        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7174        fs::create_dir_all(&nested_dir).expect("create nested dir");
7175        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7176        fs::write(
7177            service_xbp_dir.join("xbp.yaml"),
7178            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7179        )
7180        .expect("write service xbp config");
7181
7182        assert_eq!(resolve_project_root_from(&nested_dir), service_root);
7183
7184        let _ = fs::remove_dir_all(dir);
7185    }
7186
7187    #[test]
7188    fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
7189        let dir: PathBuf = temp_dir("nested-xbp-version-scope");
7190        let outer_xbp_dir = dir.join(".xbp");
7191        let service_root: PathBuf = dir.join("services").join("athena-auth");
7192        let nested_dir: PathBuf = service_root.join("src");
7193        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7194        fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
7195        fs::create_dir_all(&nested_dir).expect("create nested dir");
7196        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7197
7198        fs::write(
7199            outer_xbp_dir.join("xbp.yaml"),
7200            "project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
7201        )
7202        .expect("write outer xbp config");
7203        fs::write(
7204            dir.join("Cargo.toml"),
7205            "[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
7206        )
7207        .expect("write outer cargo");
7208        fs::write(
7209            dir.join("Cargo.lock"),
7210            "version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
7211        )
7212        .expect("write outer cargo lock");
7213        fs::write(
7214            dir.join("README.md"),
7215            "# root\n\ncurrent version: `9.9.9`\n",
7216        )
7217        .expect("write outer readme");
7218        fs::write(
7219            dir.join("openapi.yaml"),
7220            "openapi: 3.0.3\ninfo:\n  title: Root\n  version: 9.9.9\n",
7221        )
7222        .expect("write outer openapi");
7223        fs::write(
7224            dir.join("package.json"),
7225            r#"{ "name": "root", "version": "9.9.9" }"#,
7226        )
7227        .expect("write outer package");
7228
7229        fs::write(
7230            service_xbp_dir.join("xbp.yaml"),
7231            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7232        )
7233        .expect("write service xbp config");
7234        fs::write(
7235            service_root.join("Cargo.toml"),
7236            "[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7237        )
7238        .expect("write service cargo");
7239        fs::write(
7240            service_root.join("Cargo.lock"),
7241            "version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7242        )
7243        .expect("write service cargo lock");
7244        fs::write(
7245            service_root.join("README.md"),
7246            "# athena-auth\n\ncurrent version: `3.22.0`\n",
7247        )
7248        .expect("write service readme");
7249        fs::write(
7250            service_root.join("openapi.yaml"),
7251            "openapi: 3.0.3\ninfo:\n  title: Athena Auth\n  version: 3.22.0\n",
7252        )
7253        .expect("write service openapi");
7254        fs::write(
7255            service_root.join("package.json"),
7256            r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
7257        )
7258        .expect("write service package");
7259
7260        let project_root = resolve_project_root_from(&nested_dir);
7261        let updated_paths = write_version_to_configured_files_with_paths(
7262            &project_root,
7263            &nested_dir,
7264            &[
7265                ".xbp/xbp.yaml".to_string(),
7266                "Cargo.toml".to_string(),
7267                "Cargo.lock".to_string(),
7268                "README.md".to_string(),
7269                "openapi.yaml".to_string(),
7270                "package.json".to_string(),
7271            ],
7272            &VersionScope::Repository,
7273            &Version::new(3, 22, 1),
7274        )
7275        .expect("write nested project versions");
7276
7277        let updated_relative = updated_paths
7278            .iter()
7279            .map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
7280            .map(normalized_relative_path)
7281            .collect::<Vec<_>>();
7282
7283        assert_eq!(
7284            updated_relative,
7285            vec![
7286                ".xbp/xbp.yaml".to_string(),
7287                "Cargo.toml".to_string(),
7288                "Cargo.lock".to_string(),
7289                "README.md".to_string(),
7290                "openapi.yaml".to_string(),
7291                "package.json".to_string(),
7292            ]
7293        );
7294
7295        assert_eq!(
7296            read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
7297                .expect("read outer xbp"),
7298            Some("9.9.9".to_string())
7299        );
7300        assert_eq!(
7301            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
7302            Some("9.9.9".to_string())
7303        );
7304        assert_eq!(
7305            read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
7306            Some("9.9.9".to_string())
7307        );
7308        assert_eq!(
7309            read_readme_version(&dir.join("README.md")).expect("read outer readme"),
7310            Some("9.9.9".to_string())
7311        );
7312        assert_eq!(
7313            read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
7314            Some("9.9.9".to_string())
7315        );
7316        assert_eq!(
7317            read_json_root_version(&dir.join("package.json")).expect("read outer package"),
7318            Some("9.9.9".to_string())
7319        );
7320
7321        assert_eq!(
7322            read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
7323                .expect("read service xbp"),
7324            Some("3.22.1".to_string())
7325        );
7326        assert_eq!(
7327            read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
7328            Some("3.22.1".to_string())
7329        );
7330        assert_eq!(
7331            read_cargo_lock_version(&service_root.join("Cargo.lock"))
7332                .expect("read service cargo lock"),
7333            Some("3.22.1".to_string())
7334        );
7335        assert_eq!(
7336            read_readme_version(&service_root.join("README.md")).expect("read service readme"),
7337            Some("3.22.1".to_string())
7338        );
7339        assert_eq!(
7340            read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
7341            Some("3.22.1".to_string())
7342        );
7343        assert_eq!(
7344            read_json_root_version(&service_root.join("package.json"))
7345                .expect("read service package"),
7346            Some("3.22.1".to_string())
7347        );
7348
7349        let _ = fs::remove_dir_all(dir);
7350    }
7351
7352    #[test]
7353    fn resolve_version_scope_detects_crate_scoped_invocation() {
7354        let dir: PathBuf = temp_dir("crate-scope");
7355        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7356        let nested_dir: PathBuf = crate_dir.join("src");
7357        fs::create_dir_all(&nested_dir).expect("create nested dir");
7358        fs::write(
7359            crate_dir.join("Cargo.toml"),
7360            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7361        )
7362        .expect("write Cargo.toml");
7363
7364        let scope = resolve_version_scope(&dir, &nested_dir);
7365        match scope {
7366            VersionScope::Crate {
7367                package_name,
7368                crate_relative_root,
7369                tag_prefix,
7370                ..
7371            } => {
7372                assert_eq!(package_name, "alpha-crate");
7373                assert_eq!(crate_relative_root, "crates/alpha");
7374                assert_eq!(tag_prefix, "alpha-crate-");
7375            }
7376            _ => panic!("expected crate scope"),
7377        }
7378
7379        let _ = fs::remove_dir_all(dir);
7380    }
7381
7382    #[test]
7383    fn resolve_version_scope_detects_service_scoped_invocation() {
7384        let dir: PathBuf = temp_dir("service-scope");
7385        let service_dir: PathBuf = dir.join("apps").join("web");
7386        let nested_dir: PathBuf = service_dir.join("src");
7387        let cli_dir: PathBuf = dir.join("crates").join("cli");
7388        fs::create_dir_all(&nested_dir).expect("create nested dir");
7389        fs::create_dir_all(&cli_dir).expect("create cli dir");
7390        write_multi_service_config(&dir);
7391        fs::write(
7392            service_dir.join("package.json"),
7393            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7394        )
7395        .expect("write package");
7396        fs::write(
7397            cli_dir.join("Cargo.toml"),
7398            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7399        )
7400        .expect("write cargo");
7401
7402        let scope = resolve_version_scope(&dir, &nested_dir);
7403        match scope {
7404            VersionScope::Service {
7405                service_name,
7406                service_relative_root,
7407                tag_prefix,
7408                ..
7409            } => {
7410                assert_eq!(service_name, "web");
7411                assert_eq!(service_relative_root, "apps/web");
7412                assert_eq!(tag_prefix, "web-");
7413            }
7414            _ => panic!("expected service scope"),
7415        }
7416
7417        let _ = fs::remove_dir_all(dir);
7418    }
7419
7420    #[test]
7421    fn service_scoped_configured_targets_only_include_selected_service() {
7422        let dir: PathBuf = temp_dir("service-targets");
7423        let service_dir: PathBuf = dir.join("apps").join("web");
7424        let nested_dir: PathBuf = service_dir.join("src");
7425        let cli_dir: PathBuf = dir.join("crates").join("cli");
7426        fs::create_dir_all(&nested_dir).expect("create nested dir");
7427        fs::create_dir_all(&cli_dir).expect("create cli dir");
7428        write_multi_service_config(&dir);
7429        fs::write(
7430            service_dir.join("package.json"),
7431            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7432        )
7433        .expect("write package");
7434        fs::write(
7435            cli_dir.join("Cargo.toml"),
7436            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7437        )
7438        .expect("write cargo");
7439
7440        let scope = resolve_version_scope(&dir, &nested_dir);
7441        let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
7442        assert_eq!(targets.len(), 1);
7443        assert_eq!(targets[0].relative, "apps/web/package.json");
7444
7445        let _ = fs::remove_dir_all(dir);
7446    }
7447
7448    #[test]
7449    fn service_scoped_release_publish_filter_selects_matching_target() {
7450        let dir: PathBuf = temp_dir("service-publish-filter");
7451        let service_dir: PathBuf = dir.join("apps").join("web");
7452        let nested_dir: PathBuf = service_dir.join("src");
7453        let cli_dir: PathBuf = dir.join("crates").join("cli");
7454        fs::create_dir_all(&nested_dir).expect("create nested dir");
7455        fs::create_dir_all(&cli_dir).expect("create cli dir");
7456        write_multi_service_config(&dir);
7457        fs::write(
7458            service_dir.join("package.json"),
7459            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7460        )
7461        .expect("write package");
7462        fs::write(
7463            cli_dir.join("Cargo.toml"),
7464            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7465        )
7466        .expect("write cargo");
7467
7468        let scope = resolve_version_scope(&dir, &nested_dir);
7469        let publish_target =
7470            resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
7471        assert_eq!(publish_target.as_deref(), Some("npm"));
7472
7473        let _ = fs::remove_dir_all(dir);
7474    }
7475
7476    #[test]
7477    fn current_scope_ledger_path_uses_repo_local_scope_directory() {
7478        let dir: PathBuf = temp_dir("ledger-path");
7479        let scope = resolve_version_scope(&dir, &dir);
7480        let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
7481
7482        assert_eq!(
7483            path,
7484            dir.join(".xbp")
7485                .join("releases")
7486                .join("repository-xbp")
7487                .join("10.30.0.yaml")
7488        );
7489
7490        let _ = fs::remove_dir_all(dir);
7491    }
7492
7493    #[test]
7494    fn repository_change_selection_matches_only_changed_target_manifest_root() {
7495        let dir: PathBuf = temp_dir("selection-direct");
7496        let service_dir: PathBuf = dir.join("apps").join("web");
7497        let cli_dir: PathBuf = dir.join("crates").join("cli");
7498        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7499        fs::create_dir_all(&cli_dir).expect("create cli dir");
7500        write_multi_service_config(&dir);
7501        fs::write(
7502            service_dir.join("package.json"),
7503            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7504        )
7505        .expect("write package");
7506        fs::write(
7507            cli_dir.join("Cargo.toml"),
7508            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7509        )
7510        .expect("write cargo");
7511
7512        let scope = resolve_version_scope(&dir, &dir);
7513        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7514        let selected = select_changed_version_targets(
7515            &dir,
7516            &scope,
7517            &targets,
7518            &["apps/web/src/app.tsx".into()],
7519        );
7520
7521        assert_eq!(selected.len(), 1);
7522        assert_eq!(selected[0].relative, "apps/web/package.json");
7523
7524        let _ = fs::remove_dir_all(dir);
7525    }
7526
7527    #[test]
7528    fn repository_change_selection_fans_out_on_shared_root_change() {
7529        let dir: PathBuf = temp_dir("selection-shared");
7530        let service_dir: PathBuf = dir.join("apps").join("web");
7531        let cli_dir: PathBuf = dir.join("crates").join("cli");
7532        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7533        fs::create_dir_all(&cli_dir).expect("create cli dir");
7534        write_multi_service_config(&dir);
7535        fs::write(
7536            service_dir.join("package.json"),
7537            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7538        )
7539        .expect("write package");
7540        fs::write(
7541            cli_dir.join("Cargo.toml"),
7542            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7543        )
7544        .expect("write cargo");
7545
7546        let scope = resolve_version_scope(&dir, &dir);
7547        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7548        let selected =
7549            select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
7550
7551        assert_eq!(selected.len(), 2);
7552        assert_eq!(
7553            selected
7554                .iter()
7555                .map(|target| target.relative.as_str())
7556                .collect::<Vec<_>>(),
7557            vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
7558        );
7559
7560        let _ = fs::remove_dir_all(dir);
7561    }
7562
7563    #[test]
7564    fn selected_version_writer_updates_only_selected_targets() {
7565        let dir: PathBuf = temp_dir("selection-write");
7566        let service_dir: PathBuf = dir.join("apps").join("web");
7567        let cli_dir: PathBuf = dir.join("crates").join("cli");
7568        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7569        fs::create_dir_all(&cli_dir).expect("create cli dir");
7570        write_multi_service_config(&dir);
7571        fs::write(
7572            service_dir.join("package.json"),
7573            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7574        )
7575        .expect("write package");
7576        fs::write(
7577            cli_dir.join("Cargo.toml"),
7578            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7579        )
7580        .expect("write cargo");
7581
7582        let scope = resolve_version_scope(&dir, &dir);
7583        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7584        let selected = select_changed_version_targets(
7585            &dir,
7586            &scope,
7587            &targets,
7588            &["apps/web/src/app.tsx".into()],
7589        );
7590
7591        let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
7592            .expect("write selected targets");
7593        assert_eq!(updated.len(), 1);
7594        assert_eq!(
7595            read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
7596            Some("10.30.0".to_string())
7597        );
7598        assert_eq!(
7599            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
7600            Some("10.29.0".to_string())
7601        );
7602
7603        let _ = fs::remove_dir_all(dir);
7604    }
7605
7606    #[test]
7607    fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
7608        let dir: PathBuf = temp_dir("crate-writer");
7609        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7610        fs::create_dir_all(&crate_dir).expect("create crate dir");
7611        fs::write(
7612            crate_dir.join("Cargo.toml"),
7613            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7614        )
7615        .expect("write crate Cargo.toml");
7616        fs::write(
7617            dir.join("Cargo.lock"),
7618            "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
7619        )
7620        .expect("write Cargo.lock");
7621        fs::write(
7622            dir.join("README.md"),
7623            "# root\n\ncurrent version: `9.9.9`\n",
7624        )
7625        .expect("write root readme");
7626
7627        let scope = resolve_version_scope(&dir, &crate_dir);
7628        let updated = write_version_to_configured_files(
7629            &dir,
7630            &crate_dir,
7631            &[
7632                "Cargo.toml".to_string(),
7633                "Cargo.lock".to_string(),
7634                "README.md".to_string(),
7635            ],
7636            &scope,
7637            &Version::new(1, 3, 0),
7638        )
7639        .expect("write versions");
7640
7641        assert_eq!(updated, 2);
7642        assert_eq!(
7643            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
7644            Some("1.3.0".to_string())
7645        );
7646        assert_eq!(
7647            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
7648                .expect("read cargo lock"),
7649            Some("1.3.0".to_string())
7650        );
7651        assert_eq!(
7652            read_readme_version(&dir.join("README.md")).expect("read readme"),
7653            Some("9.9.9".to_string())
7654        );
7655
7656        let _ = fs::remove_dir_all(dir);
7657    }
7658
7659    #[test]
7660    fn release_openapi_resolution_prefers_crate_scope() {
7661        let dir: PathBuf = temp_dir("release-openapi-crate");
7662        let crate_dir: PathBuf = dir.join("crates").join("monitor");
7663        let nested_dir: PathBuf = crate_dir.join("src");
7664        fs::create_dir_all(&nested_dir).expect("create nested dir");
7665        fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
7666        let crate_openapi: PathBuf = crate_dir.join("openapi.json");
7667        fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
7668
7669        let scope = resolve_version_scope(&dir, &nested_dir);
7670        let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
7671            .expect("crate-scoped openapi")
7672            .into_iter()
7673            .next()
7674            .expect("crate-scoped openapi");
7675        assert_eq!(resolved, crate_openapi);
7676
7677        let _ = fs::remove_dir_all(dir);
7678    }
7679
7680    #[test]
7681    fn release_openapi_resolution_falls_back_to_repo_root() {
7682        let dir: PathBuf = temp_dir("release-openapi-root");
7683        let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
7684        fs::create_dir_all(&crate_dir).expect("create crate dir");
7685        let root_openapi: PathBuf = dir.join("openapi.json");
7686        fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
7687
7688        let scope = resolve_version_scope(&dir, &dir);
7689        let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
7690            .expect("repo root openapi")
7691            .into_iter()
7692            .next()
7693            .expect("repo root openapi");
7694        assert_eq!(resolved, root_openapi);
7695
7696        let _ = fs::remove_dir_all(dir);
7697    }
7698
7699    #[test]
7700    fn release_openapi_assets_use_versioned_release_tag_names() {
7701        let dir: PathBuf = temp_dir("release-openapi-assets");
7702        fs::write(
7703            dir.join("openapi.yaml"),
7704            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.2\n",
7705        )
7706        .expect("write http openapi");
7707        fs::write(
7708            dir.join("openapi-wss.yaml"),
7709            "openapi: 3.0.3\ninfo:\n  title: Athena WSS\n  version: 1.3.0\n",
7710        )
7711        .expect("write wss openapi");
7712
7713        let assets = prepare_release_openapi_assets(
7714            &dir,
7715            &dir,
7716            &VersionScope::Repository,
7717            &Version::new(3, 15, 2),
7718            "v3.15.2",
7719        )
7720        .expect("prepare release assets");
7721
7722        assert_eq!(
7723            assets
7724                .iter()
7725                .map(|asset| asset.asset_name.as_str())
7726                .collect::<Vec<_>>(),
7727            vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
7728        );
7729        assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
7730        assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
7731
7732        let _ = fs::remove_dir_all(dir);
7733    }
7734
7735    #[test]
7736    fn release_openapi_assets_reject_http_version_mismatch() {
7737        let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
7738        fs::write(
7739            dir.join("openapi.yaml"),
7740            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.1\n",
7741        )
7742        .expect("write http openapi");
7743
7744        let error = prepare_release_openapi_assets(
7745            &dir,
7746            &dir,
7747            &VersionScope::Repository,
7748            &Version::new(3, 15, 2),
7749            "v3.15.2",
7750        )
7751        .expect_err("mismatched OpenAPI version should fail");
7752
7753        assert!(error.contains("does not match release version"));
7754
7755        let _ = fs::remove_dir_all(dir);
7756    }
7757
7758    #[test]
7759    fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
7760        let dir: PathBuf = temp_dir("invocation-dedupe");
7761        let app_dir: PathBuf = dir.join("apps").join("web");
7762        fs::create_dir_all(&app_dir).expect("create app dir");
7763
7764        let app_package: PathBuf = app_dir.join("package.json");
7765        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7766            .expect("write app package");
7767
7768        let updated: usize = write_version_to_configured_files(
7769            &dir,
7770            &app_dir,
7771            &[
7772                "package.json".to_string(),
7773                "apps/web/package.json".to_string(),
7774            ],
7775            &VersionScope::Repository,
7776            &Version::new(2, 14, 0),
7777        )
7778        .expect("write versions");
7779        assert_eq!(updated, 1);
7780
7781        assert_eq!(
7782            read_json_root_version(&app_package).expect("read app"),
7783            Some("2.14.0".to_string())
7784        );
7785
7786        let _ = fs::remove_dir_all(dir);
7787    }
7788
7789    #[test]
7790    fn configured_file_writer_errors_when_no_targets_exist() {
7791        let dir: PathBuf = temp_dir("no-targets");
7792        let error: String = write_version_to_configured_files(
7793            &dir,
7794            &dir,
7795            &["missing.toml".to_string()],
7796            &VersionScope::Repository,
7797            &Version::new(1, 0, 0),
7798        )
7799        .expect_err("missing targets should fail");
7800
7801        assert!(error.contains("No configured version files were found"));
7802
7803        let _ = fs::remove_dir_all(dir);
7804    }
7805
7806    #[test]
7807    fn remote_git_tag_parser_deduplicates_peeled_refs() {
7808        let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
7809            "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
7810        );
7811
7812        assert_eq!(parsed.len(), 2);
7813        assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
7814        assert_eq!(
7815            parsed[1].version,
7816            Version::parse("0.1.7-exp").expect("version")
7817        );
7818        assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
7819    }
7820
7821    #[test]
7822    fn local_git_tag_parser_normalizes_prefixed_versions() {
7823        let parsed: Vec<crate::commands::version::GitTagObservation> =
7824            parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
7825
7826        assert_eq!(parsed.len(), 2);
7827        assert_eq!(parsed[0].version, Version::new(1, 0, 0));
7828        assert_eq!(
7829            parsed[0].raw_tags,
7830            vec!["1.0.0".to_string(), "v1.0.0".to_string()]
7831        );
7832    }
7833
7834    #[test]
7835    fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
7836        let scope = VersionScope::Crate {
7837            crate_root: PathBuf::from("/tmp/crates/alpha"),
7838            crate_relative_root: "crates/alpha".to_string(),
7839            package_name: "alpha-crate".to_string(),
7840            tag_prefix: "alpha-crate-".to_string(),
7841        };
7842
7843        let parsed = parse_local_git_tag_output_for_scope(
7844            "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
7845            &scope,
7846        );
7847
7848        assert_eq!(parsed.len(), 2);
7849        assert_eq!(parsed[0].version, Version::new(1, 2, 0));
7850        assert_eq!(parsed[1].version, Version::new(1, 0, 0));
7851    }
7852
7853    #[test]
7854    fn crate_scoped_release_tags_default_to_package_prefix() {
7855        let scope = VersionScope::Crate {
7856            crate_root: PathBuf::from("/tmp/crates/alpha"),
7857            crate_relative_root: "crates/alpha".to_string(),
7858            package_name: "alpha-crate".to_string(),
7859            tag_prefix: "alpha-crate-".to_string(),
7860        };
7861
7862        assert_eq!(
7863            default_release_tag_name(&scope, &Version::new(1, 2, 3)),
7864            "alpha-crate-1.2.3"
7865        );
7866    }
7867
7868    #[test]
7869    fn blob_reader_handles_head_readme_versions() {
7870        assert_eq!(
7871            read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
7872                .expect("read"),
7873            Some("0.4.0".to_string())
7874        );
7875    }
7876
7877    #[test]
7878    fn blob_reader_handles_head_cargo_lock_versions() {
7879        let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
7880        let cargo_lock: &str =
7881            "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
7882
7883        assert_eq!(
7884            read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
7885            Some("0.2.0".to_string())
7886        );
7887    }
7888
7889    #[test]
7890    fn package_name_lookup_reads_json_name_for_npm() {
7891        let lookup: PackageNameLookup = PackageNameLookup {
7892            file: "package.json".to_string(),
7893            format: "json".to_string(),
7894            key: "name".to_string(),
7895            registry: "npm".to_string(),
7896        };
7897
7898        assert_eq!(
7899            read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
7900                .expect("read"),
7901            Some("@xylex/athena-mcp".to_string())
7902        );
7903    }
7904
7905    #[test]
7906    fn package_name_lookup_reads_toml_nested_package_name() {
7907        let lookup: PackageNameLookup = PackageNameLookup {
7908            file: "Cargo.toml".to_string(),
7909            format: "toml".to_string(),
7910            key: "package.name".to_string(),
7911            registry: "crates.io".to_string(),
7912        };
7913
7914        assert_eq!(
7915            read_package_name_from_lookup(
7916                &lookup,
7917                "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
7918            )
7919            .expect("read"),
7920            Some("athena-mcp".to_string())
7921        );
7922    }
7923
7924    #[test]
7925    fn package_name_lookup_errors_on_unknown_format() {
7926        let lookup: PackageNameLookup = PackageNameLookup {
7927            file: "meta.txt".to_string(),
7928            format: "ini".to_string(),
7929            key: "name".to_string(),
7930            registry: "npm".to_string(),
7931        };
7932
7933        let error = read_package_name_from_lookup(&lookup, "name=demo")
7934            .expect_err("unsupported format should fail");
7935        assert!(error.contains("Unsupported lookup format"));
7936    }
7937
7938    #[test]
7939    fn highest_version_observation_returns_max_version() {
7940        let entries: Vec<VersionObservation> = vec![
7941            VersionObservation {
7942                location: "README.md".to_string(),
7943                version: Version::new(1, 0, 0),
7944            },
7945            VersionObservation {
7946                location: "Cargo.toml".to_string(),
7947                version: Version::new(1, 2, 0),
7948            },
7949        ];
7950
7951        assert_eq!(
7952            highest_version_observation(&entries).expect("max version"),
7953            Version::new(1, 2, 0)
7954        );
7955    }
7956
7957    #[test]
7958    fn stale_version_observations_only_returns_outdated_entries() {
7959        let entries: Vec<VersionObservation> = vec![
7960            VersionObservation {
7961                location: "README.md".to_string(),
7962                version: Version::new(1, 1, 0),
7963            },
7964            VersionObservation {
7965                location: "Cargo.toml".to_string(),
7966                version: Version::new(1, 2, 0),
7967            },
7968            VersionObservation {
7969                location: "openapi.yaml".to_string(),
7970                version: Version::new(1, 0, 5),
7971            },
7972        ];
7973
7974        let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
7975        assert_eq!(stale.len(), 2);
7976        assert!(stale.iter().any(|entry| entry.location == "README.md"));
7977        assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
7978        assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
7979    }
7980
7981    #[test]
7982    fn parses_github_remote_urls() {
7983        assert_eq!(
7984            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
7985            Some(("xylex-group".to_string(), "xbp".to_string()))
7986        );
7987        assert_eq!(
7988            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
7989            Some(("xylex-group".to_string(), "xbp".to_string()))
7990        );
7991        assert_eq!(
7992            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
7993            Some(("xylex-group".to_string(), "xbp".to_string()))
7994        );
7995        assert_eq!(
7996            parse_github_repo_from_remote_url(
7997                "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
7998            ),
7999            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8000        );
8001        assert_eq!(
8002            parse_github_repo_from_remote_url(
8003                "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
8004            ),
8005            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8006        );
8007        assert_eq!(
8008            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
8009            None
8010        );
8011    }
8012
8013    #[test]
8014    fn redacts_credentials_in_remote_urls() {
8015        let redacted = redact_remote_url_credentials(
8016            "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
8017        );
8018        assert!(redacted.contains("REDACTED"));
8019        assert!(!redacted.contains("ghp_secretvalue"));
8020
8021        let username_only = redact_remote_url_credentials(
8022            "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
8023        );
8024        assert!(username_only.contains("REDACTED@github.com"));
8025        assert!(!username_only.contains("floris-xlx@github.com"));
8026
8027        let ssh_remote =
8028            redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
8029        assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
8030    }
8031
8032    #[test]
8033    fn builds_github_release_urls_with_encoded_tag_segments() {
8034        let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
8035        assert_eq!(
8036            create_url.as_str(),
8037            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
8038        );
8039
8040        let lookup_url =
8041            github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
8042                .expect("url");
8043        assert_eq!(
8044            lookup_url.as_str(),
8045            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
8046        );
8047
8048        let update_url =
8049            github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8050        assert_eq!(
8051            update_url.as_str(),
8052            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
8053        );
8054
8055        let lookup_with_special_tag = github_release_by_tag_endpoint(
8056            "SuitsBooks",
8057            "suits-invoicing",
8058            "release candidate/v0.0.1+build",
8059        )
8060        .expect("url");
8061        assert_eq!(
8062            lookup_with_special_tag.as_str(),
8063            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
8064        );
8065    }
8066
8067    #[test]
8068    fn builds_github_release_asset_urls() {
8069        let list_url =
8070            github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8071        assert_eq!(
8072            list_url.as_str(),
8073            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
8074        );
8075
8076        let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
8077            .expect("url");
8078        assert_eq!(
8079            delete_url.as_str(),
8080            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
8081        );
8082
8083        let upload_url = github_release_asset_upload_endpoint(
8084            "SuitsBooks",
8085            "suits-invoicing",
8086            42,
8087            "openapi spec.json",
8088        )
8089        .expect("url");
8090        assert_eq!(
8091            upload_url.as_str(),
8092            "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
8093        );
8094    }
8095
8096    #[test]
8097    fn maps_release_latest_policy_to_github_api_values() {
8098        assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
8099        assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
8100        assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
8101    }
8102
8103    #[test]
8104    fn release_channel_from_semver_prerelease_labels() {
8105        let stable = Version::parse("3.6.2").expect("version");
8106        let nightly = Version::parse("3.6.2-nightly.1").expect("version");
8107        let experimental = Version::parse("0.1.1-alpha.1").expect("version");
8108        assert_eq!(release_channel(&stable), "stable");
8109        assert_eq!(release_channel(&nightly), "nightly");
8110        assert_eq!(release_channel(&experimental), "experimental");
8111    }
8112
8113    #[test]
8114    fn renders_release_docs_from_entries() {
8115        let entries = vec![
8116            ReleaseDocEntry {
8117                tag: "v3.6.2".to_string(),
8118                version: Version::parse("3.6.2").expect("version"),
8119                date: "2026-04-27".to_string(),
8120            },
8121            ReleaseDocEntry {
8122                tag: "docs-0.1.1-alpha.1".to_string(),
8123                version: Version::parse("0.1.1-alpha.1").expect("version"),
8124                date: "2026-04-20".to_string(),
8125            },
8126        ];
8127        let changelog = render_changelog("xylex-group", "athena", &entries);
8128        assert!(changelog.contains("## [3.6.2]"));
8129        assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
8130        assert!(changelog.contains("Release channel: stable"));
8131        assert!(changelog.contains("Release channel: experimental"));
8132
8133        let security = render_security_policy(&entries);
8134        assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
8135        assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
8136    }
8137
8138    #[test]
8139    fn formats_release_commit_lines_with_sha_and_pr_links() {
8140        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
8141        let formatted =
8142            format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
8143                .expect("formatted line");
8144
8145        assert_eq!(
8146            formatted,
8147            "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
8148        );
8149    }
8150
8151    #[test]
8152    fn formats_release_commit_lines_with_linear_links_when_available() {
8153        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
8154        let issue_infos = BTreeMap::from([(
8155            "SUI-1336".to_string(),
8156            LinearIssueInfo {
8157                title: "Release flow".to_string(),
8158                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8159            },
8160        )]);
8161        let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
8162            .expect("formatted line");
8163
8164        assert_eq!(
8165            formatted,
8166            "[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)"
8167        );
8168    }
8169
8170    #[test]
8171    fn renders_release_notes_in_requested_layout() {
8172        let commits = vec![
8173            ReleaseCommitEntry {
8174                full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
8175                short_sha: "abcdef1".to_string(),
8176                subject: "Improve release docs (#42)".to_string(),
8177                date: "2026-05-24".to_string(),
8178            },
8179            ReleaseCommitEntry {
8180                full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
8181                short_sha: "fedcba9".to_string(),
8182                subject: "Fix release flow for SUI-1336".to_string(),
8183                date: "2026-05-25".to_string(),
8184            },
8185        ];
8186        let pull_request_infos = BTreeMap::from([(
8187            "42".to_string(),
8188            super::release_notes::GithubPullRequestInfo {
8189                title: "Improve release docs".to_string(),
8190                url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
8191            },
8192        )]);
8193        let issue_infos = BTreeMap::from([(
8194            "SUI-1336".to_string(),
8195            LinearIssueInfo {
8196                title: "Release flow".to_string(),
8197                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8198            },
8199        )]);
8200        let sections = build_fallback_sections(&commits);
8201        let rendered = render_release_notes(&ReleaseNotesRenderInput {
8202            release_title: "1.7.0 - athena-auth",
8203            current_tag_name: "v1.7.0",
8204            owner: "xylex-group",
8205            repo: "athena-auth",
8206            previous_tag: Some("v1.6.0"),
8207            sections: &sections,
8208            commit_entries: &commits,
8209            pull_request_infos: &pull_request_infos,
8210            linear_issue_infos: &issue_infos,
8211        });
8212
8213        assert_eq!(
8214            rendered,
8215            "# [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)"
8216        );
8217    }
8218
8219    #[test]
8220    fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
8221        let commits = vec![
8222            ReleaseCommitEntry {
8223                full_sha: "a".repeat(40),
8224                short_sha: "aaaaaaa".to_string(),
8225                subject: "Fix SUI-1336 and SUI-1440".to_string(),
8226                date: "2026-05-24".to_string(),
8227            },
8228            ReleaseCommitEntry {
8229                full_sha: "b".repeat(40),
8230                short_sha: "bbbbbbb".to_string(),
8231                subject: "Touch SUI-1336 again".to_string(),
8232                date: "2026-05-25".to_string(),
8233            },
8234        ];
8235
8236        assert_eq!(
8237            collect_linear_issue_identifiers(&commits),
8238            vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
8239        );
8240    }
8241
8242    #[test]
8243    fn release_title_defaults_to_version_and_repo() {
8244        assert_eq!(
8245            default_release_title(&Version::new(1, 7, 0), "athena-auth"),
8246            "1.7.0 - athena-auth"
8247        );
8248    }
8249
8250    #[test]
8251    fn deduplicates_release_commit_entries_by_exact_subject() {
8252        let commits = vec![
8253            ReleaseCommitEntry {
8254                full_sha: "a".repeat(40),
8255                short_sha: "aaaaaaa".to_string(),
8256                subject: "Improve release docs".to_string(),
8257                date: "2026-05-24".to_string(),
8258            },
8259            ReleaseCommitEntry {
8260                full_sha: "b".repeat(40),
8261                short_sha: "bbbbbbb".to_string(),
8262                subject: "Improve release docs".to_string(),
8263                date: "2026-05-25".to_string(),
8264            },
8265        ];
8266
8267        let deduplicated = deduplicate_release_commit_entries(&commits);
8268        assert_eq!(deduplicated.len(), 1);
8269        assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
8270    }
8271
8272    #[test]
8273    fn fallback_sections_collapse_related_commit_themes() {
8274        let commits = vec![
8275            ReleaseCommitEntry {
8276                full_sha: "a".repeat(40),
8277                short_sha: "chat001".to_string(),
8278                subject: "Add optimistic chat retries".to_string(),
8279                date: "2026-06-01".to_string(),
8280            },
8281            ReleaseCommitEntry {
8282                full_sha: "b".repeat(40),
8283                short_sha: "chat002".to_string(),
8284                subject: "Persist deleted-message state in chat".to_string(),
8285                date: "2026-06-01".to_string(),
8286            },
8287            ReleaseCommitEntry {
8288                full_sha: "c".repeat(40),
8289                short_sha: "file001".to_string(),
8290                subject: "Fix upload UTF-8 audit retry handling".to_string(),
8291                date: "2026-06-01".to_string(),
8292            },
8293            ReleaseCommitEntry {
8294                full_sha: "d".repeat(40),
8295                short_sha: "ath001".to_string(),
8296                subject: "Migrate form progress routes to Athena".to_string(),
8297                date: "2026-06-01".to_string(),
8298            },
8299            ReleaseCommitEntry {
8300                full_sha: "e".repeat(40),
8301                short_sha: "ath002".to_string(),
8302                subject: "Update Athena models and package wiring".to_string(),
8303                date: "2026-06-01".to_string(),
8304            },
8305        ];
8306
8307        let sections = build_fallback_sections(&commits);
8308        assert_eq!(sections.len(), 3);
8309        assert_eq!(sections[0].title, "Cases & Communication");
8310        assert!(!sections[0].summary.is_empty());
8311        assert_eq!(sections[0].bullets.len(), 2);
8312        assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
8313        assert!(sections[0].bullets[0].summary.contains("chat"));
8314        assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
8315        assert!(sections[0].bullets[1].summary.contains("deleted-message"));
8316        assert_eq!(sections[1].title, "Reliability");
8317        assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
8318        assert_eq!(sections[2].title, "Athena Migration");
8319        assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
8320    }
8321
8322    #[test]
8323    fn appends_release_label_footer_for_pre_release() {
8324        let with_label = append_release_label_footer("# Release", true);
8325        assert_eq!(
8326            with_label,
8327            format!(
8328                "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
8329                env!("CARGO_PKG_VERSION")
8330            )
8331        );
8332    }
8333}