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) =
4605        select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4606    {
4607        return Ok(scope);
4608    }
4609
4610    if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
4611    {
4612        return prompt_for_version_scope_selection(&service_scopes);
4613    }
4614
4615    Ok(resolve_version_scope(project_root, invocation_dir))
4616}
4617
4618fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
4619    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4620    if let Some(service_scope) =
4621        select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4622    {
4623        return service_scope;
4624    }
4625
4626    if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
4627        return crate_scope;
4628    }
4629
4630    VersionScope::Repository
4631}
4632
4633fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
4634    let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
4635        return Vec::new();
4636    };
4637    let Some(services) = config.services else {
4638        return Vec::new();
4639    };
4640
4641    let mut scopes = services
4642        .into_iter()
4643        .filter_map(|service| build_service_scope(project_root, service))
4644        .collect::<Vec<_>>();
4645    scopes.sort_by(|left, right| {
4646        version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
4647    });
4648    scopes
4649}
4650
4651fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
4652    let version_targets = service
4653        .version_targets
4654        .clone()
4655        .unwrap_or_default()
4656        .into_iter()
4657        .map(normalized_path_string)
4658        .collect::<Vec<_>>();
4659    let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
4660    let service_relative_root = service_root
4661        .strip_prefix(project_root)
4662        .ok()
4663        .map(normalized_relative_path)
4664        .filter(|value| !value.is_empty())
4665        .unwrap_or_else(|| ".".to_string());
4666    let cargo_package_name =
4667        resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
4668    let tag_identity = cargo_package_name
4669        .clone()
4670        .unwrap_or_else(|| service.name.clone());
4671
4672    Some(VersionScope::Service {
4673        service_root,
4674        service_relative_root,
4675        service_name: service.name,
4676        tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
4677        cargo_package_name,
4678        version_targets,
4679    })
4680}
4681
4682fn resolve_service_scope_root(
4683    project_root: &Path,
4684    service: &ServiceConfig,
4685    version_targets: &[String],
4686) -> Option<PathBuf> {
4687    if !version_targets.is_empty() {
4688        return common_parent_from_targets(project_root, version_targets);
4689    }
4690
4691    service.root_directory.as_ref().map(|root| {
4692        let path = PathBuf::from(root);
4693        if path.is_absolute() {
4694            path
4695        } else {
4696            project_root.join(path)
4697        }
4698    })
4699}
4700
4701fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
4702    let mut parents = version_targets.iter().filter_map(|target| {
4703        let path = PathBuf::from(target);
4704        let absolute = if path.is_absolute() {
4705            path
4706        } else {
4707            project_root.join(path)
4708        };
4709        absolute.parent().map(Path::to_path_buf)
4710    });
4711
4712    let mut common = parents.next()?;
4713    for parent in parents {
4714        while !parent.starts_with(&common) {
4715            common = common.parent()?.to_path_buf();
4716        }
4717    }
4718    Some(common)
4719}
4720
4721fn resolve_service_scope_cargo_package_name(
4722    project_root: &Path,
4723    service_root: &Path,
4724    version_targets: &[String],
4725) -> Option<String> {
4726    let mut cargo_paths = version_targets
4727        .iter()
4728        .filter(|target| {
4729            Path::new(target)
4730                .file_name()
4731                .and_then(|value| value.to_str())
4732                == Some("Cargo.toml")
4733        })
4734        .map(|target| {
4735            let path = PathBuf::from(target);
4736            if path.is_absolute() {
4737                path
4738            } else {
4739                project_root.join(path)
4740            }
4741        })
4742        .collect::<Vec<_>>();
4743    if cargo_paths.is_empty() {
4744        cargo_paths.push(service_root.join("Cargo.toml"));
4745    }
4746
4747    cargo_paths.into_iter().find_map(|path| {
4748        let content = fs::read_to_string(path).ok()?;
4749        cargo_package_name_from_content_optional(&content)
4750            .ok()
4751            .flatten()
4752    })
4753}
4754
4755fn select_matching_service_scope(
4756    scopes: &[VersionScope],
4757    project_root: &Path,
4758    invocation_dir: &Path,
4759) -> Option<VersionScope> {
4760    scopes
4761        .iter()
4762        .filter_map(|scope| {
4763            let root = version_scope_root(scope)?;
4764            if invocation_dir == project_root && root == project_root {
4765                return None;
4766            }
4767            if invocation_dir.starts_with(root) {
4768                Some((root.components().count(), scope.clone()))
4769            } else {
4770                None
4771            }
4772        })
4773        .max_by_key(|(depth, _)| *depth)
4774        .map(|(_, scope)| scope)
4775}
4776
4777fn prompt_for_version_scope_selection(
4778    service_scopes: &[VersionScope],
4779) -> Result<VersionScope, String> {
4780    let mut items = service_scopes
4781        .iter()
4782        .map(version_scope_prompt_label)
4783        .collect::<Vec<_>>();
4784    items.push("Repository (all configured version targets)".to_string());
4785
4786    let selection = Select::with_theme(&ColorfulTheme::default())
4787        .with_prompt("Choose the project/service to version or release")
4788        .items(&items)
4789        .default(items.len().saturating_sub(1))
4790        .interact_opt()
4791        .map_err(|e| format!("Prompt failed: {}", e))?;
4792
4793    match selection {
4794        Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
4795        _ => Ok(VersionScope::Repository),
4796    }
4797}
4798
4799fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
4800    let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
4801    let cargo_toml = crate_root.join("Cargo.toml");
4802    let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
4803    let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
4804    let crate_relative_root = crate_root
4805        .strip_prefix(project_root)
4806        .ok()
4807        .map(normalized_relative_path)?;
4808
4809    Some(VersionScope::Crate {
4810        crate_root,
4811        crate_relative_root,
4812        tag_prefix: format!("{}-", package_name),
4813        package_name,
4814    })
4815}
4816
4817fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
4818    let crates_root = project_root.join("crates");
4819    let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
4820    let mut components = relative.components();
4821    let crate_name = components.next()?;
4822    Some(crates_root.join(crate_name.as_os_str()))
4823}
4824
4825fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
4826    match version_scope {
4827        VersionScope::Repository => None,
4828        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4829        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4830    }
4831}
4832
4833fn prepare_release_openapi_assets(
4834    project_root: &Path,
4835    invocation_dir: &Path,
4836    version_scope: &VersionScope,
4837    release_version: &Version,
4838    tag_name: &str,
4839) -> Result<Vec<ReleaseOpenApiAsset>, String> {
4840    let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
4841    validate_release_openapi_assets(&spec_paths, release_version)?;
4842
4843    spec_paths
4844        .into_iter()
4845        .map(|source_path| {
4846            let file_name = source_path
4847                .file_name()
4848                .and_then(|value| value.to_str())
4849                .ok_or_else(|| {
4850                    format!(
4851                        "Invalid OpenAPI asset path for release upload: {}",
4852                        source_path.display()
4853                    )
4854                })?;
4855            let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
4856            let source_label = source_path
4857                .strip_prefix(project_root)
4858                .map(normalized_relative_path)
4859                .unwrap_or_else(|_| normalized_relative_path(&source_path));
4860            Ok(ReleaseOpenApiAsset {
4861                source_path,
4862                source_label,
4863                asset_name,
4864            })
4865        })
4866        .collect()
4867}
4868
4869fn resolve_release_openapi_specs(
4870    project_root: &Path,
4871    invocation_dir: &Path,
4872    version_scope: &VersionScope,
4873) -> Result<Vec<PathBuf>, String> {
4874    const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
4875        "openapi.yaml",
4876        "openapi.yml",
4877        "openapi.json",
4878        "swagger.yaml",
4879        "swagger.yml",
4880        "swagger.json",
4881    ];
4882
4883    let mut roots: Vec<PathBuf> = Vec::new();
4884    if let Some(scope_root) = version_scope_root(version_scope) {
4885        roots.push(scope_root.to_path_buf());
4886    } else {
4887        if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
4888            roots.push(crate_root);
4889        }
4890        roots.push(project_root.to_path_buf());
4891    }
4892
4893    let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
4894    let mut seen_names: BTreeSet<String> = BTreeSet::new();
4895    let mut resolved_paths: Vec<PathBuf> = Vec::new();
4896
4897    for root in roots {
4898        if !seen_roots.insert(root.clone()) {
4899            continue;
4900        }
4901
4902        let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
4903        for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
4904            let path = root.join(file_name);
4905            if path.is_file() {
4906                root_paths.push(((*file_name).to_string(), path));
4907            }
4908        }
4909
4910        let entries = match fs::read_dir(&root) {
4911            Ok(entries) => entries,
4912            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
4913            Err(error) => {
4914                return Err(format!(
4915                    "Failed to inspect OpenAPI release assets in {}: {}",
4916                    root.display(),
4917                    error
4918                ));
4919            }
4920        };
4921
4922        let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
4923        for entry in entries {
4924            let entry = entry.map_err(|error| {
4925                format!(
4926                    "Failed to inspect OpenAPI release assets in {}: {}",
4927                    root.display(),
4928                    error
4929                )
4930            })?;
4931            let path = entry.path();
4932            if !path.is_file() {
4933                continue;
4934            }
4935            let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4936                continue;
4937            };
4938            if !is_release_openapi_variant_file_name(file_name) {
4939                continue;
4940            }
4941            additional_specs.push((file_name.to_string(), path));
4942        }
4943        additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
4944
4945        root_paths.extend(additional_specs);
4946
4947        for (file_name, path) in root_paths {
4948            if seen_names.insert(file_name) {
4949                resolved_paths.push(path);
4950            }
4951        }
4952    }
4953
4954    Ok(resolved_paths)
4955}
4956
4957fn validate_release_openapi_assets(
4958    spec_paths: &[PathBuf],
4959    release_version: &Version,
4960) -> Result<(), String> {
4961    let Some(primary_spec_path) = spec_paths.iter().find(|path| {
4962        path.file_name()
4963            .and_then(|value| value.to_str())
4964            .map(is_primary_release_openapi_file_name)
4965            .unwrap_or(false)
4966    }) else {
4967        return Ok(());
4968    };
4969
4970    let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
4971        format!(
4972            "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
4973            primary_spec_path.display(),
4974            release_version
4975        )
4976    })?;
4977    let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
4978        format!(
4979            "Release OpenAPI spec `{}` has invalid version `{}`: {}",
4980            primary_spec_path.display(),
4981            declared_version,
4982            error
4983        )
4984    })?;
4985
4986    if &parsed_declared_version != release_version {
4987        return Err(format!(
4988            "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
4989            primary_spec_path.display(),
4990            declared_version,
4991            release_version
4992        ));
4993    }
4994
4995    Ok(())
4996}
4997
4998fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
4999    let path = Path::new(file_name);
5000    let stem = path
5001        .file_stem()
5002        .and_then(|value| value.to_str())
5003        .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
5004    let extension = path
5005        .extension()
5006        .and_then(|value| value.to_str())
5007        .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
5008    Ok(format!("{}-{}.{}", stem, tag_name, extension))
5009}
5010
5011fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
5012    matches!(
5013        file_name,
5014        "openapi.yaml"
5015            | "openapi.yml"
5016            | "openapi.json"
5017            | "swagger.yaml"
5018            | "swagger.yml"
5019            | "swagger.json"
5020    )
5021}
5022
5023fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
5024    if is_primary_release_openapi_file_name(file_name) {
5025        return false;
5026    }
5027
5028    let Some((stem, extension)) = file_name.rsplit_once('.') else {
5029        return false;
5030    };
5031    if !matches!(extension, "yaml" | "yml" | "json") {
5032        return false;
5033    }
5034
5035    stem.starts_with("openapi-") || stem.starts_with("swagger-")
5036}
5037
5038fn normalized_path_string(path: String) -> String {
5039    path.replace('\\', "/")
5040}
5041
5042fn slugify_scope_name(value: &str) -> String {
5043    let mut slug = value
5044        .chars()
5045        .map(|ch| {
5046            if ch.is_ascii_alphanumeric() {
5047                ch.to_ascii_lowercase()
5048            } else {
5049                '-'
5050            }
5051        })
5052        .collect::<String>();
5053    while slug.contains("--") {
5054        slug = slug.replace("--", "-");
5055    }
5056    slug.trim_matches('-').to_string()
5057}
5058
5059fn resolve_workspace_primary_cargo_target(
5060    project_root: &Path,
5061) -> Option<WorkspacePrimaryCargoTarget> {
5062    let workspace_manifest = project_root.join("Cargo.toml");
5063    let content = fs::read_to_string(&workspace_manifest).ok()?;
5064    let value: TomlValue = toml::from_str(&content).ok()?;
5065    if value.get("package").and_then(TomlValue::as_table).is_some() {
5066        return None;
5067    }
5068
5069    let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
5070    let mut candidate_roots = workspace
5071        .get("default-members")
5072        .and_then(TomlValue::as_array)
5073        .map(|members| {
5074            members
5075                .iter()
5076                .filter_map(TomlValue::as_str)
5077                .map(str::trim)
5078                .filter(|member| !member.is_empty() && !member.contains('*'))
5079                .map(str::to_string)
5080                .collect::<Vec<_>>()
5081        })
5082        .unwrap_or_default();
5083
5084    if candidate_roots.is_empty() {
5085        let members = workspace
5086            .get("members")
5087            .and_then(TomlValue::as_array)
5088            .map(|members| {
5089                members
5090                    .iter()
5091                    .filter_map(TomlValue::as_str)
5092                    .map(str::trim)
5093                    .filter(|member| !member.is_empty() && !member.contains('*'))
5094                    .map(str::to_string)
5095                    .collect::<Vec<_>>()
5096            })
5097            .unwrap_or_default();
5098        if members.len() == 1 {
5099            candidate_roots = members;
5100        }
5101    }
5102
5103    candidate_roots.sort();
5104    candidate_roots.dedup();
5105    if candidate_roots.len() != 1 {
5106        return None;
5107    }
5108
5109    let crate_relative_root = candidate_roots.into_iter().next()?;
5110    let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
5111    let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
5112    let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
5113
5114    Some(WorkspacePrimaryCargoTarget {
5115        manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
5116        manifest_absolute,
5117        package_name,
5118    })
5119}
5120
5121#[cfg(test)]
5122fn resolve_release_publish_target_filter(
5123    invocation_dir: &Path,
5124    version_scope: &VersionScope,
5125) -> Result<Option<String>, String> {
5126    let VersionScope::Service {
5127        service_name,
5128        service_root,
5129        version_targets,
5130        ..
5131    } = version_scope
5132    else {
5133        return Ok(None);
5134    };
5135
5136    let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
5137        return Ok(None);
5138    };
5139    let Some(publish) = config.publish else {
5140        return Ok(None);
5141    };
5142
5143    let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
5144    let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
5145        .into_iter()
5146        .filter_map(|(kind, target)| {
5147            let target = target?;
5148            if target.enabled.unwrap_or(true) {
5149                Some((kind, target))
5150            } else {
5151                None
5152            }
5153        })
5154        .collect::<Vec<_>>();
5155
5156    if enabled_targets.is_empty() {
5157        return Ok(None);
5158    }
5159
5160    let matched = enabled_targets
5161        .iter()
5162        .filter_map(|(kind, target)| {
5163            let manifest_path = target.manifest_path.as_ref()?;
5164            let absolute = if Path::new(manifest_path).is_absolute() {
5165                PathBuf::from(manifest_path)
5166            } else {
5167                project_root.join(manifest_path)
5168            };
5169            let relative = absolute
5170                .strip_prefix(&project_root)
5171                .ok()
5172                .map(normalized_relative_path)
5173                .unwrap_or_else(|| normalized_relative_path(&absolute));
5174
5175            if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
5176            {
5177                Some((*kind).to_string())
5178            } else {
5179                None
5180            }
5181        })
5182        .collect::<Vec<_>>();
5183
5184    if matched.is_empty() {
5185        return Err(format!(
5186            "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
5187            service_name
5188        ));
5189    }
5190
5191    if matched.len() == enabled_targets.len() {
5192        if matched.len() == 1 {
5193            return Ok(matched.into_iter().next());
5194        }
5195        return Ok(None);
5196    }
5197
5198    if matched.len() == 1 {
5199        return Ok(matched.into_iter().next());
5200    }
5201
5202    Err(format!(
5203        "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.",
5204        service_name
5205    ))
5206}
5207
5208async fn resolve_effective_linear_release_config(
5209    project_root: &Path,
5210    invocation_dir: &Path,
5211) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
5212    let global_config = resolve_global_linear_release_config();
5213    let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5214        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5215        config.linear.and_then(|linear| linear.release)
5216    } else {
5217        None
5218    };
5219
5220    Ok(resolve_linear_release_config(global_config, project_config)
5221        .map(|config| resolve_linear_release_placeholders(project_root, config)))
5222}
5223
5224fn resolve_linear_release_placeholders(
5225    project_root: &Path,
5226    mut config: ResolvedLinearReleaseConfig,
5227) -> ResolvedLinearReleaseConfig {
5228    let mut env_map = HashMap::new();
5229    for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
5230        env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
5231    }
5232    if let Some(organization_name) = config.organization_name.clone() {
5233        env_map.insert("organization_name".to_string(), organization_name);
5234    }
5235
5236    let resolved = resolve_env_placeholders(project_root, &env_map);
5237    config.initiative_ids = config
5238        .initiative_ids
5239        .iter()
5240        .enumerate()
5241        .map(|(index, initiative_id)| {
5242            resolved
5243                .get(&format!("initiative_id_{}", index))
5244                .cloned()
5245                .unwrap_or_else(|| initiative_id.clone())
5246        })
5247        .map(|value| value.trim().to_string())
5248        .filter(|value| !value.is_empty())
5249        .collect();
5250    config.organization_name = resolved
5251        .get("organization_name")
5252        .cloned()
5253        .or(config.organization_name)
5254        .map(|value| value.trim().to_string())
5255        .filter(|value| !value.is_empty());
5256    config
5257}
5258
5259async fn resolve_project_github_release_branch_config(
5260    _project_root: &Path,
5261    invocation_dir: &Path,
5262) -> Result<Option<GitHubReleaseBranchSettings>, String> {
5263    if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5264        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5265        Ok(config.github_release_branch_settings())
5266    } else {
5267        Ok(None)
5268    }
5269}
5270
5271fn normalized_relative_path(path: &Path) -> String {
5272    path.to_string_lossy().replace('\\', "/")
5273}
5274
5275fn git_repository_root(dir: &Path) -> Option<PathBuf> {
5276    if !command_exists("git") {
5277        return None;
5278    }
5279
5280    let output: std::process::Output = Command::new("git")
5281        .current_dir(dir)
5282        .args(["rev-parse", "--show-toplevel"])
5283        .output()
5284        .ok()?;
5285
5286    if !output.status.success() {
5287        return None;
5288    }
5289
5290    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5291    if root.is_empty() {
5292        None
5293    } else {
5294        Some(PathBuf::from(root))
5295    }
5296}
5297
5298fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
5299    let output: std::process::Output = Command::new("git")
5300        .current_dir(project_root)
5301        .args(args)
5302        .output()
5303        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
5304
5305    if !output.status.success() {
5306        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
5307        if stderr.is_empty() {
5308            return Err(format!(
5309                "`git {}` failed with status {}",
5310                args.join(" "),
5311                output.status
5312            ));
5313        }
5314        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
5315    }
5316
5317    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5318}
5319
5320fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
5321    let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
5322    Ok(output
5323        .lines()
5324        .map(|line| line.trim())
5325        .filter(|line| !line.is_empty())
5326        .map(|line| line.to_string())
5327        .collect())
5328}
5329
5330fn version_change_guard_state_path() -> Result<PathBuf, String> {
5331    let paths = global_xbp_paths()?;
5332    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
5333}
5334
5335fn version_change_guard_repo_key(project_root: &Path) -> String {
5336    fs::canonicalize(project_root)
5337        .unwrap_or_else(|_| project_root.to_path_buf())
5338        .to_string_lossy()
5339        .replace('\\', "/")
5340}
5341
5342fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
5343    if !path.exists() {
5344        return Ok(VersionChangeGuardRegistry::default());
5345    }
5346
5347    let content = fs::read_to_string(path).map_err(|e| {
5348        format!(
5349            "Failed to read version-change guard state {}: {}",
5350            path.display(),
5351            e
5352        )
5353    })?;
5354
5355    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
5356}
5357
5358fn save_version_change_guard_registry(
5359    path: &Path,
5360    registry: &VersionChangeGuardRegistry,
5361) -> Result<(), String> {
5362    if let Some(parent) = path.parent() {
5363        fs::create_dir_all(parent).map_err(|e| {
5364            format!(
5365                "Failed to create guard state directory {}: {}",
5366                parent.display(),
5367                e
5368            )
5369        })?;
5370    }
5371
5372    let content = serde_yaml::to_string(registry)
5373        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
5374    fs::write(path, content).map_err(|e| {
5375        format!(
5376            "Failed to write version-change guard state {}: {}",
5377            path.display(),
5378            e
5379        )
5380    })
5381}
5382
5383fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
5384    if !command_exists("git") {
5385        return Ok(None);
5386    }
5387
5388    let status_output: std::process::Output = Command::new("git")
5389        .current_dir(project_root)
5390        .args(["status", "--porcelain"])
5391        .output()
5392        .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
5393    if !status_output.status.success() {
5394        return Ok(None);
5395    }
5396
5397    let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
5398        .lines()
5399        .any(|line| !line.trim().is_empty());
5400
5401    let head_output: std::process::Output = Command::new("git")
5402        .current_dir(project_root)
5403        .args(["rev-parse", "HEAD"])
5404        .output()
5405        .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
5406    let head_commit: Option<String> = if head_output.status.success() {
5407        let value: String = String::from_utf8_lossy(&head_output.stdout)
5408            .trim()
5409            .to_string();
5410        if value.is_empty() {
5411            None
5412        } else {
5413            Some(value)
5414        }
5415    } else {
5416        None
5417    };
5418
5419    Ok(Some(GitWorktreeState {
5420        is_dirty,
5421        head_commit,
5422    }))
5423}
5424
5425fn should_clear_version_change_guard(
5426    entry: &VersionChangeGuardEntry,
5427    state: &GitWorktreeState,
5428) -> bool {
5429    if entry.pending_version_change_count == 0 {
5430        return true;
5431    }
5432    if !state.is_dirty {
5433        return true;
5434    }
5435
5436    match (&entry.head_commit, &state.head_commit) {
5437        (Some(previous), Some(current)) => previous != current,
5438        (Some(_), None) => true,
5439        _ => false,
5440    }
5441}
5442
5443fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
5444    let Some(state) = git_worktree_state(project_root)? else {
5445        return Ok(());
5446    };
5447
5448    let state_path: PathBuf = version_change_guard_state_path()?;
5449    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5450    let repo_key: String = version_change_guard_repo_key(project_root);
5451    let mut changed = false;
5452
5453    if let Some(entry) = registry.entries.get(&repo_key).cloned() {
5454        if should_clear_version_change_guard(&entry, &state) {
5455            registry.entries.remove(&repo_key);
5456            changed = true;
5457        }
5458    }
5459
5460    if changed {
5461        save_version_change_guard_registry(&state_path, &registry)?;
5462    }
5463
5464    if state.is_dirty {
5465        if let Some(entry) = registry.entries.get(&repo_key) {
5466            if entry.pending_version_change_count >= 1 {
5467                return Err(format!(
5468                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
5469                    entry.pending_version_change_count,
5470                    state_path.display()
5471                ));
5472            }
5473        }
5474    }
5475
5476    Ok(())
5477}
5478
5479fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
5480    let Some(state) = git_worktree_state(project_root)? else {
5481        return Ok(());
5482    };
5483
5484    let state_path: PathBuf = version_change_guard_state_path()?;
5485    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5486    let repo_key: String = version_change_guard_repo_key(project_root);
5487
5488    if state.is_dirty {
5489        registry.entries.insert(
5490            repo_key,
5491            VersionChangeGuardEntry {
5492                pending_version_change_count: 1,
5493                head_commit: state.head_commit,
5494            },
5495        );
5496    } else {
5497        registry.entries.remove(&repo_key);
5498    }
5499
5500    save_version_change_guard_registry(&state_path, &registry)
5501}
5502
5503fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
5504    let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
5505    Ok(!output.trim().is_empty())
5506}
5507
5508fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
5509    let remotes: String = run_git_command(project_root, &["remote"])?;
5510    let exists: bool = remotes.lines().any(|line| line.trim() == remote);
5511    if exists {
5512        Ok(())
5513    } else {
5514        Err(format!(
5515            "Git remote `{}` is not configured for this repository.",
5516            remote
5517        ))
5518    }
5519}
5520
5521fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
5522    run_git_command(project_root, &["remote", "get-url", remote])
5523}
5524
5525fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
5526    let query: String = format!("refs/tags/{}", tag);
5527    let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
5528    Ok(!output.trim().is_empty())
5529}
5530
5531fn git_head_commitish(project_root: &Path) -> Result<String, String> {
5532    let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
5533    if commitish.is_empty() {
5534        Err("Unable to resolve HEAD commit for release target.".to_string())
5535    } else {
5536        Ok(commitish)
5537    }
5538}
5539
5540fn render_release_branch_name(
5541    naming_template: &str,
5542    release_version: &Version,
5543    tag_name: &str,
5544    service_name: &str,
5545) -> Result<String, String> {
5546    let service_name = sanitize_release_branch_segment(service_name);
5547    let branch_name = naming_template
5548        .replace("{VERSION}", &release_version.to_string())
5549        .replace("{TAG}", tag_name)
5550        .replace("{SERVICE_NAME}", &service_name)
5551        .replace("${GITHUB_VERSION}", &release_version.to_string())
5552        .replace("${GITHUB_TAG}", tag_name)
5553        .replace("${SERVICE_NAME}", &service_name);
5554    let branch_name = branch_name.trim();
5555    if branch_name.is_empty() {
5556        return Err(
5557            "GitHub release branch naming template resolved to an empty branch name.".to_string(),
5558        );
5559    }
5560    Ok(branch_name.to_string())
5561}
5562
5563fn sanitize_release_branch_segment(value: &str) -> String {
5564    value
5565        .trim()
5566        .chars()
5567        .map(|character| match character {
5568            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
5569            _ => '-',
5570        })
5571        .collect::<String>()
5572        .trim_matches('-')
5573        .to_string()
5574}
5575
5576fn git_local_branch_commit(
5577    project_root: &Path,
5578    branch_name: &str,
5579) -> Result<Option<String>, String> {
5580    let output = Command::new("git")
5581        .current_dir(project_root)
5582        .args([
5583            "rev-parse",
5584            "--verify",
5585            &format!("refs/heads/{}", branch_name),
5586        ])
5587        .output()
5588        .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
5589    if !output.status.success() {
5590        return Ok(None);
5591    }
5592    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
5593    if commit.is_empty() {
5594        Ok(None)
5595    } else {
5596        Ok(Some(commit))
5597    }
5598}
5599
5600fn git_remote_branch_commit(
5601    project_root: &Path,
5602    remote: &str,
5603    branch_name: &str,
5604) -> Result<Option<String>, String> {
5605    let output = run_git_command(
5606        project_root,
5607        &[
5608            "ls-remote",
5609            "--heads",
5610            remote,
5611            &format!("refs/heads/{}", branch_name),
5612        ],
5613    )?;
5614    let commit = output
5615        .split_whitespace()
5616        .next()
5617        .map(str::trim)
5618        .filter(|value| !value.is_empty())
5619        .map(str::to_string);
5620    Ok(commit)
5621}
5622
5623fn ensure_release_branch(
5624    project_root: &Path,
5625    branch_config: &GitHubReleaseBranchSettings,
5626    release_version: &Version,
5627    tag_name: &str,
5628    service_name: &str,
5629    target_commitish: &str,
5630) -> Result<String, String> {
5631    let branch_name = render_release_branch_name(
5632        &branch_config.naming_template,
5633        release_version,
5634        tag_name,
5635        service_name,
5636    )?;
5637
5638    if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
5639        if existing_local_commit != target_commitish {
5640            return Err(format!(
5641                "Configured release branch `{}` already exists locally at {}, expected {}.",
5642                branch_name, existing_local_commit, target_commitish
5643            ));
5644        }
5645    } else {
5646        run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
5647    }
5648
5649    if let Some(existing_remote_commit) =
5650        git_remote_branch_commit(project_root, "origin", &branch_name)?
5651    {
5652        if existing_remote_commit != target_commitish {
5653            return Err(format!(
5654                "Configured release branch `{}` already exists on origin at {}, expected {}.",
5655                branch_name, existing_remote_commit, target_commitish
5656            ));
5657        }
5658    } else {
5659        run_git_command(
5660            project_root,
5661            &[
5662                "push",
5663                "origin",
5664                &format!("{}:refs/heads/{}", target_commitish, branch_name),
5665            ],
5666        )?;
5667    }
5668
5669    Ok(branch_name)
5670}
5671
5672fn release_tag_family(tag_name: &str) -> String {
5673    if tag_name.starts_with('v')
5674        && tag_name
5675            .chars()
5676            .nth(1)
5677            .map(|ch| ch.is_ascii_digit())
5678            .unwrap_or(false)
5679    {
5680        return "v".to_string();
5681    }
5682
5683    let mut family: String = String::new();
5684    for ch in tag_name.chars() {
5685        if ch.is_ascii_digit() {
5686            break;
5687        }
5688        family.push(ch);
5689    }
5690    family
5691}
5692
5693fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
5694    if family == "v" {
5695        return parse_version(tag).ok();
5696    }
5697
5698    tag.strip_prefix(family)
5699        .and_then(|rest| parse_version(rest).ok())
5700}
5701
5702fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
5703    let range: String = format!("{}..HEAD", tag);
5704    run_git_command(project_root, &["rev-list", "--count", &range])
5705        .ok()
5706        .and_then(|raw| raw.trim().parse::<usize>().ok())
5707}
5708
5709fn previous_release_tag(
5710    project_root: &Path,
5711    current_tag_name: &str,
5712) -> Result<Option<String>, String> {
5713    let family: String = release_tag_family(current_tag_name);
5714    let tag_pattern: String = format!("{}*", family);
5715    let merged_tags: String = run_git_command(
5716        project_root,
5717        &["tag", "--merged", "HEAD", "--list", &tag_pattern],
5718    )?;
5719
5720    let mut best: Option<(usize, String)> = None;
5721    for raw in merged_tags.lines() {
5722        let tag = raw.trim();
5723        if tag.is_empty() || tag == current_tag_name {
5724            continue;
5725        }
5726        if parse_release_family_version(tag, &family).is_none() {
5727            continue;
5728        }
5729
5730        let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
5731            continue;
5732        };
5733        if distance == 0 {
5734            continue;
5735        }
5736
5737        match &best {
5738            None => best = Some((distance, tag.to_string())),
5739            Some((best_distance, _)) if distance < *best_distance => {
5740                best = Some((distance, tag.to_string()))
5741            }
5742            _ => {}
5743        }
5744    }
5745
5746    Ok(best.map(|(_, tag)| tag))
5747}
5748
5749fn default_release_title(version: &Version, repo: &str) -> String {
5750    format!("{} - {}", version, repo)
5751}
5752
5753fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
5754    match version_scope {
5755        VersionScope::Repository => repo,
5756        VersionScope::Crate { package_name, .. } => package_name.as_str(),
5757        VersionScope::Service { service_name, .. } => service_name.as_str(),
5758    }
5759}
5760
5761fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
5762    match version_scope {
5763        VersionScope::Repository => "repository",
5764        VersionScope::Crate { .. } => "crate",
5765        VersionScope::Service { .. } => "service",
5766    }
5767}
5768
5769fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
5770    match version_scope {
5771        VersionScope::Repository => repo.to_string(),
5772        VersionScope::Crate { package_name, .. } => package_name.clone(),
5773        VersionScope::Service { service_name, .. } => service_name.clone(),
5774    }
5775}
5776
5777fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
5778    match version_scope {
5779        VersionScope::Repository => "Repository".to_string(),
5780        VersionScope::Crate {
5781            package_name,
5782            crate_relative_root,
5783            ..
5784        } => format!("{} ({})", package_name, crate_relative_root),
5785        VersionScope::Service {
5786            service_name,
5787            service_relative_root,
5788            ..
5789        } => format!("{} ({})", service_name, service_relative_root),
5790    }
5791}
5792
5793fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
5794    match version_scope {
5795        VersionScope::Repository => format!("v{}", version),
5796        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
5797            format!("{}{}", tag_prefix, version)
5798        }
5799    }
5800}
5801
5802fn scoped_release_tag_name(
5803    version_scope: &VersionScope,
5804    version: &Version,
5805    parsed_tag_name: &str,
5806) -> String {
5807    match version_scope {
5808        VersionScope::Repository => parsed_tag_name.to_string(),
5809        VersionScope::Crate { .. } | VersionScope::Service { .. } => {
5810            if release_tag_family(parsed_tag_name) == "v" {
5811                default_release_tag_name(version_scope, version)
5812            } else {
5813                parsed_tag_name.to_string()
5814            }
5815        }
5816    }
5817}
5818
5819fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
5820    match version_scope {
5821        VersionScope::Repository => None,
5822        VersionScope::Crate {
5823            crate_relative_root,
5824            ..
5825        } => Some(crate_relative_root.clone()),
5826        VersionScope::Service {
5827            service_relative_root,
5828            ..
5829        } if service_relative_root == "." => None,
5830        VersionScope::Service {
5831            service_relative_root,
5832            ..
5833        } => Some(service_relative_root.clone()),
5834    }
5835}
5836
5837fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
5838    let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
5839        return (None, None);
5840    };
5841    let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
5842        return (None, None);
5843    };
5844
5845    (Some(owner), Some(repo))
5846}
5847
5848fn published_initiatives_to_activity(
5849    initiatives: &[PublishedLinearInitiative],
5850) -> Vec<VersionActivityLinearInitiative> {
5851    initiatives
5852        .iter()
5853        .map(|initiative| VersionActivityLinearInitiative {
5854            id: initiative.id.clone(),
5855            name: initiative.name.clone(),
5856            url: initiative.url.clone(),
5857        })
5858        .collect()
5859}
5860
5861async fn sync_cli_version_write_activity(
5862    project_root: &Path,
5863    version_scope: &VersionScope,
5864    version: &Version,
5865    message: String,
5866) {
5867    let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
5868    let scope_label = version_scope_label(
5869        version_scope,
5870        repository_name.as_deref().unwrap_or_else(|| {
5871            project_root
5872                .file_name()
5873                .and_then(|value| value.to_str())
5874                .unwrap_or("repository")
5875        }),
5876    );
5877
5878    let payload = CliVersionActivityPayload {
5879        command_kind: "version".to_string(),
5880        repository_owner,
5881        repository_name,
5882        scope_kind: version_scope_kind(version_scope).to_string(),
5883        scope_label,
5884        version: version.to_string(),
5885        tag_name: None,
5886        title: None,
5887        release_url: None,
5888        message_markdown: Some(message),
5889        published_initiatives: Vec::new(),
5890    };
5891
5892    if let Err(error) = post_version_activity(&payload).await {
5893        eprintln!("Warning: {}", error);
5894    }
5895}
5896
5897async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
5898    let payload = CliVersionActivityPayload {
5899        command_kind: "version_release".to_string(),
5900        repository_owner: Some(summary.repository_owner.clone()),
5901        repository_name: Some(summary.repository_name.clone()),
5902        scope_kind: summary.scope_kind.clone(),
5903        scope_label: summary.scope_label.clone(),
5904        version: summary.version.to_string(),
5905        tag_name: Some(summary.tag_name.clone()),
5906        title: Some(summary.release_title.clone()),
5907        release_url: Some(summary.release_url.clone()),
5908        message_markdown: Some(summary.release_notes.clone()),
5909        published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
5910    };
5911
5912    if let Err(error) = post_version_activity(&payload).await {
5913        eprintln!("Warning: {}", error);
5914    }
5915}
5916
5917fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
5918    let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
5919    let mut rendered_notes: String = notes.trim_end().to_string();
5920    if !rendered_notes.is_empty() {
5921        rendered_notes.push('\n');
5922    }
5923    rendered_notes.push_str("Release label: ");
5924    rendered_notes.push_str(release_label);
5925    rendered_notes.push('\n');
5926    rendered_notes.push_str("Generated by XBP ");
5927    rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
5928    rendered_notes
5929}
5930
5931#[cfg(test)]
5932mod tests {
5933    use super::github_release::{
5934        github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
5935        github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
5936        github_release_update_endpoint,
5937    };
5938    use super::release_docs::{
5939        release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
5940    };
5941    use super::release_notes::{
5942        build_fallback_sections, collect_linear_issue_identifiers,
5943        deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
5944        LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
5945    };
5946    use super::{
5947        analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
5948        current_scope_ledger_path, default_release_tag_name, default_release_title,
5949        highest_version_observation, is_safe_dirty_path, normalized_relative_path,
5950        parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
5951        parse_local_git_tag_output_for_scope, parse_package_version_target,
5952        parse_release_version_target, parse_remote_git_tag_output, parse_version,
5953        prepare_release_openapi_assets, read_cargo_lock_version,
5954        read_cargo_lock_version_for_package, read_cargo_toml_version,
5955        read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
5956        read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
5957        read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
5958        read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
5959        render_release_branch_name, resolve_configured_version_target_paths,
5960        resolve_linear_release_placeholders, resolve_project_root_from, resolve_registry_paths,
5961        resolve_release_openapi_specs, resolve_release_publish_target_filter,
5962        resolve_version_scope, rewrite_toml_package_assignment_versions,
5963        select_changed_version_targets, should_clear_version_change_guard,
5964        stale_version_observations, sync_version_to_configured_files_with_paths,
5965        write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
5966        write_json_openapi_version, write_json_root_version, write_openapi_version,
5967        write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
5968        write_regex_version, write_toml_root_version, write_version_to_configured_files,
5969        write_version_to_configured_files_with_paths, write_version_to_selected_paths,
5970        write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
5971        VersionObservation, VersionScope,
5972    };
5973
5974    use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
5975    use crate::config::PackageNameLookup;
5976    use semver::Version;
5977    use std::collections::BTreeMap;
5978    use std::fs;
5979    use std::path::{Path, PathBuf};
5980    use std::time::{SystemTime, UNIX_EPOCH};
5981
5982    fn temp_dir(label: &str) -> PathBuf {
5983        let nanos: u128 = SystemTime::now()
5984            .duration_since(UNIX_EPOCH)
5985            .expect("time")
5986            .as_nanos();
5987        let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
5988        fs::create_dir_all(&dir).expect("create temp dir");
5989        dir
5990    }
5991
5992    #[test]
5993    fn parse_git_status_path_handles_renames() {
5994        assert_eq!(
5995            parse_git_status_path("R  old-name -> new-name/Cargo.toml"),
5996            Some("new-name/Cargo.toml".to_string())
5997        );
5998        assert_eq!(
5999            parse_git_status_path(" M .xbp/xbp.yaml"),
6000            Some(".xbp/xbp.yaml".to_string())
6001        );
6002    }
6003
6004    #[test]
6005    fn is_safe_dirty_path_recognizes_generated_paths() {
6006        assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
6007        assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
6008        assert!(is_safe_dirty_path(
6009            "scripts/__pycache__/publish.cpython-312.pyc"
6010        ));
6011        assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
6012        assert!(!is_safe_dirty_path("Cargo.toml"));
6013    }
6014
6015    #[test]
6016    fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
6017        let analysis = analyze_dirty_worktree(&[
6018            " M .xbp/xbp.yaml".to_string(),
6019            " M crates/cli/src/main.rs".to_string(),
6020        ]);
6021        assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
6022        assert_eq!(
6023            analysis.risky_entries,
6024            vec!["crates/cli/src/main.rs".to_string()]
6025        );
6026    }
6027
6028    fn write_multi_service_config(dir: &Path) {
6029        let xbp_dir = dir.join(".xbp");
6030        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6031        fs::write(
6032            xbp_dir.join("xbp.yaml"),
6033            r#"project_name: xbp
6034version: 10.30.0
6035port: 3000
6036build_dir: ./
6037services:
6038  - name: cli
6039    target: rust
6040    branch: main
6041    port: 8080
6042    root_directory: ./
6043    version_targets:
6044      - crates/cli/Cargo.toml
6045  - name: web
6046    target: nextjs
6047    branch: main
6048    port: 3001
6049    root_directory: apps/web
6050    version_targets:
6051      - apps/web/package.json
6052publish:
6053  npm:
6054    enabled: true
6055    manifest_path: apps/web/package.json
6056  crates:
6057    enabled: true
6058    manifest_path: crates/cli/Cargo.toml
6059version_targets:
6060  - crates/cli/Cargo.toml
6061  - apps/web/package.json
6062"#,
6063        )
6064        .expect("write xbp config");
6065    }
6066
6067    #[test]
6068    fn parses_prefixed_semver() {
6069        assert_eq!(
6070            parse_version("v1.2.3").expect("version"),
6071            Version::new(1, 2, 3)
6072        );
6073    }
6074
6075    #[test]
6076    fn rejects_invalid_semver() {
6077        let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
6078        assert!(error.contains("Invalid semantic version"));
6079    }
6080
6081    #[test]
6082    fn release_target_parser_supports_plain_semver() {
6083        let (version, tag_name) =
6084            parse_release_version_target("1.2.3-alpha.1").expect("release target");
6085        assert_eq!(version.major, 1);
6086        assert_eq!(version.minor, 2);
6087        assert_eq!(version.patch, 3);
6088        assert_eq!(version.pre.as_str(), "alpha.1");
6089        assert_eq!(tag_name, "v1.2.3-alpha.1");
6090    }
6091
6092    #[test]
6093    fn release_target_parser_supports_prefixed_semver() {
6094        let (version, tag_name) =
6095            parse_release_version_target("studio-0.3.2-alpha").expect("release target");
6096        assert_eq!(version.major, 0);
6097        assert_eq!(version.minor, 3);
6098        assert_eq!(version.patch, 2);
6099        assert_eq!(version.pre.as_str(), "alpha");
6100        assert_eq!(tag_name, "studio-0.3.2-alpha");
6101    }
6102
6103    #[test]
6104    fn bumps_versions_correctly() {
6105        let base: Version = Version::new(0, 1, 0);
6106        assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
6107        assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
6108        assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
6109    }
6110
6111    #[test]
6112    fn version_change_guard_clears_when_worktree_is_clean() {
6113        let entry = VersionChangeGuardEntry {
6114            pending_version_change_count: 1,
6115            head_commit: Some("abc123".to_string()),
6116        };
6117        let state = GitWorktreeState {
6118            is_dirty: false,
6119            head_commit: Some("abc123".to_string()),
6120        };
6121        assert!(should_clear_version_change_guard(&entry, &state));
6122    }
6123
6124    #[test]
6125    fn version_change_guard_clears_when_head_changes() {
6126        let entry = VersionChangeGuardEntry {
6127            pending_version_change_count: 1,
6128            head_commit: Some("abc123".to_string()),
6129        };
6130        let state = GitWorktreeState {
6131            is_dirty: true,
6132            head_commit: Some("def456".to_string()),
6133        };
6134        assert!(should_clear_version_change_guard(&entry, &state));
6135    }
6136
6137    #[test]
6138    fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
6139        let entry = VersionChangeGuardEntry {
6140            pending_version_change_count: 1,
6141            head_commit: Some("abc123".to_string()),
6142        };
6143        let state = GitWorktreeState {
6144            is_dirty: true,
6145            head_commit: Some("abc123".to_string()),
6146        };
6147        assert!(!should_clear_version_change_guard(&entry, &state));
6148    }
6149
6150    #[test]
6151    fn render_release_branch_name_replaces_supported_tokens() {
6152        let branch = render_release_branch_name(
6153            "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
6154            &Version::new(10, 27, 0),
6155            "v10.27.0",
6156            "xbp",
6157        )
6158        .expect("branch name");
6159
6160        assert_eq!(branch, "releases/10.27.0/v10.27.0");
6161    }
6162
6163    #[test]
6164    fn render_release_branch_name_supports_service_name_template() {
6165        let branch = render_release_branch_name(
6166            "releases/{VERSION}-{SERVICE_NAME}",
6167            &Version::new(10, 27, 0),
6168            "v10.27.0",
6169            "athena/api",
6170        )
6171        .expect("branch name");
6172
6173        assert_eq!(branch, "releases/10.27.0-athena-api");
6174    }
6175
6176    #[test]
6177    fn resolve_linear_release_placeholders_reads_env_files() {
6178        let temp_dir = std::env::temp_dir().join(format!(
6179            "xbp-linear-release-placeholders-{}",
6180            std::time::SystemTime::now()
6181                .duration_since(std::time::UNIX_EPOCH)
6182                .expect("time")
6183                .as_nanos()
6184        ));
6185        fs::create_dir_all(&temp_dir).expect("temp dir");
6186        fs::write(
6187            temp_dir.join(".env.local"),
6188            "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
6189        )
6190        .expect("env file");
6191
6192        let resolved = resolve_linear_release_placeholders(
6193            &temp_dir,
6194            ResolvedLinearReleaseConfig {
6195                initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
6196                organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
6197                health: "on_track".to_string(),
6198            },
6199        );
6200
6201        assert_eq!(
6202            resolved.initiative_ids,
6203            vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
6204        );
6205        assert_eq!(
6206            resolved.organization_name.as_deref(),
6207            Some("suits-formations")
6208        );
6209
6210        let _ = fs::remove_dir_all(temp_dir);
6211    }
6212
6213    #[test]
6214    fn version_change_guard_clears_when_pending_count_is_zero() {
6215        let entry = VersionChangeGuardEntry {
6216            pending_version_change_count: 0,
6217            head_commit: Some("abc123".to_string()),
6218        };
6219        let state = GitWorktreeState {
6220            is_dirty: true,
6221            head_commit: Some("abc123".to_string()),
6222        };
6223        assert!(should_clear_version_change_guard(&entry, &state));
6224    }
6225
6226    #[test]
6227    fn parse_package_version_target_supports_assignment_syntax() {
6228        let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
6229            .expect("parse")
6230            .expect("target");
6231        assert_eq!(parsed.0, "demo_pkg".to_string());
6232        assert_eq!(parsed.1, Version::new(1, 2, 3));
6233    }
6234
6235    #[test]
6236    fn parse_package_version_target_rejects_invalid_package_names() {
6237        let error: String = parse_package_version_target("bad package=1.2.3")
6238            .expect_err("invalid package target should fail");
6239        assert!(error.contains("Invalid package target"));
6240    }
6241
6242    #[test]
6243    fn parse_package_version_target_returns_none_without_assignment() {
6244        assert!(parse_package_version_target("1.2.3")
6245            .expect("parse")
6246            .is_none());
6247    }
6248
6249    #[test]
6250    fn parse_package_version_target_returns_none_for_empty_package_name() {
6251        assert!(parse_package_version_target(" =1.2.3")
6252            .expect("parse")
6253            .is_none());
6254    }
6255
6256    #[test]
6257    fn bumping_clears_prerelease_and_build_metadata() {
6258        let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
6259        assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
6260        assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
6261        assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
6262    }
6263
6264    #[test]
6265    fn cargo_toml_adapter_reads_and_writes() {
6266        let dir: PathBuf = temp_dir("cargo");
6267        let path: PathBuf = dir.join("Cargo.toml");
6268        fs::write(
6269            &path,
6270            r#"[package]
6271            name = "xbp"
6272            version = "1.0.0"
6273            "#,
6274        )
6275        .expect("write Cargo.toml");
6276
6277        assert_eq!(
6278            read_cargo_toml_version(&path).expect("read"),
6279            Some("1.0.0".to_string())
6280        );
6281
6282        write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
6283        assert_eq!(
6284            read_version_from_path(&path).expect("read"),
6285            Some("1.1.0".to_string())
6286        );
6287
6288        let _ = fs::remove_dir_all(dir);
6289    }
6290
6291    #[test]
6292    fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
6293        let dir: PathBuf = temp_dir("workspace-root-version");
6294        let crate_dir = dir.join("crates").join("demo");
6295        fs::create_dir_all(&crate_dir).expect("create crate dir");
6296        fs::write(
6297            dir.join("Cargo.toml"),
6298            r#"[workspace]
6299members = ["crates/demo"]
6300resolver = "2"
6301
6302[workspace.package]
6303version = "0.1.0"
6304"#,
6305        )
6306        .expect("write workspace root");
6307        fs::write(
6308            crate_dir.join("Cargo.toml"),
6309            r#"[package]
6310name = "demo"
6311version = { workspace = true }
6312"#,
6313        )
6314        .expect("write crate manifest");
6315
6316        assert_eq!(
6317            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
6318            Some("0.1.0".to_string())
6319        );
6320        assert_eq!(
6321            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6322            Some("0.1.0".to_string())
6323        );
6324
6325        write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
6326        assert_eq!(
6327            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6328            Some("0.2.0".to_string())
6329        );
6330
6331        let _ = fs::remove_dir_all(dir);
6332    }
6333
6334    #[test]
6335    fn json_root_adapter_reads_and_writes() {
6336        let dir: PathBuf = temp_dir("json");
6337        let path: PathBuf = dir.join("package.json");
6338        fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
6339
6340        assert_eq!(
6341            read_json_root_version(&path).expect("read"),
6342            Some("1.4.0".to_string())
6343        );
6344
6345        write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
6346        assert_eq!(
6347            read_version_from_path(&path).expect("read"),
6348            Some("1.5.0".to_string())
6349        );
6350
6351        let _ = fs::remove_dir_all(dir);
6352    }
6353
6354    #[test]
6355    fn yaml_root_adapter_reads_and_writes() {
6356        let dir: PathBuf = temp_dir("yaml");
6357        let path: PathBuf = dir.join("xbp.yaml");
6358        fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
6359
6360        assert_eq!(
6361            read_yaml_root_version(&path, "version").expect("read"),
6362            Some("0.2.0".to_string())
6363        );
6364
6365        write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
6366        assert_eq!(
6367            read_version_from_path(&path).expect("read"),
6368            Some("0.3.0".to_string())
6369        );
6370
6371        let _ = fs::remove_dir_all(dir);
6372    }
6373
6374    #[test]
6375    fn toml_root_adapter_reads_and_writes() {
6376        let dir: PathBuf = temp_dir("toml");
6377        let path: PathBuf = dir.join("config.toml");
6378        fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
6379
6380        assert_eq!(
6381            read_toml_root_version(&path).expect("read"),
6382            Some("3.1.4".to_string())
6383        );
6384
6385        write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
6386        assert_eq!(
6387            read_toml_root_version(&path).expect("read"),
6388            Some("3.2.0".to_string())
6389        );
6390
6391        let _ = fs::remove_dir_all(dir);
6392    }
6393
6394    #[test]
6395    fn openapi_adapter_reads_and_writes_nested_version() {
6396        let dir: PathBuf = temp_dir("openapi");
6397        let path: PathBuf = dir.join("openapi.yaml");
6398        fs::write(
6399            &path,
6400            "openapi: 3.0.3\ninfo:\n  title: Test\n  version: 1.2.3\n",
6401        )
6402        .expect("write openapi");
6403
6404        assert_eq!(
6405            read_openapi_version(&path).expect("read"),
6406            Some("1.2.3".to_string())
6407        );
6408
6409        write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
6410        assert_eq!(
6411            read_openapi_version(&path).expect("read"),
6412            Some("2.0.0".to_string())
6413        );
6414
6415        let _ = fs::remove_dir_all(dir);
6416    }
6417
6418    #[test]
6419    fn openapi_writer_creates_missing_info_mapping() {
6420        let dir: PathBuf = temp_dir("openapi-missing-info");
6421        let path: PathBuf = dir.join("openapi.yaml");
6422        fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
6423
6424        write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6425        assert_eq!(
6426            read_openapi_version(&path).expect("read"),
6427            Some("4.0.0".to_string())
6428        );
6429
6430        let _ = fs::remove_dir_all(dir);
6431    }
6432
6433    #[test]
6434    fn json_openapi_adapter_reads_and_writes_nested_version() {
6435        let dir: PathBuf = temp_dir("openapi-json");
6436        let path: PathBuf = dir.join("openapi.json");
6437        fs::write(
6438            &path,
6439            r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
6440        )
6441        .expect("write openapi json");
6442
6443        assert_eq!(
6444            read_json_openapi_version(&path).expect("read"),
6445            Some("1.2.3".to_string())
6446        );
6447
6448        write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
6449        assert_eq!(
6450            read_json_openapi_version(&path).expect("read"),
6451            Some("2.1.0".to_string())
6452        );
6453
6454        let _ = fs::remove_dir_all(dir);
6455    }
6456
6457    #[test]
6458    fn json_openapi_writer_creates_missing_info_object() {
6459        let dir: PathBuf = temp_dir("openapi-json-missing-info");
6460        let path: PathBuf = dir.join("openapi.json");
6461        fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
6462
6463        write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6464        assert_eq!(
6465            read_json_openapi_version(&path).expect("read"),
6466            Some("4.0.0".to_string())
6467        );
6468
6469        let _ = fs::remove_dir_all(dir);
6470    }
6471
6472    #[test]
6473    fn pyproject_reader_prefers_project_version() {
6474        let dir: PathBuf = temp_dir("pyproject-project");
6475        let path: PathBuf = dir.join("pyproject.toml");
6476        fs::write(
6477            &path,
6478            "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
6479        )
6480        .expect("write pyproject");
6481
6482        assert_eq!(
6483            read_pyproject_version(&path).expect("read"),
6484            Some("0.8.0".to_string())
6485        );
6486
6487        let _ = fs::remove_dir_all(dir);
6488    }
6489
6490    #[test]
6491    fn pyproject_reader_falls_back_to_poetry_version() {
6492        let dir: PathBuf = temp_dir("pyproject-poetry");
6493        let path: PathBuf = dir.join("pyproject.toml");
6494        fs::write(
6495            &path,
6496            "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
6497        )
6498        .expect("write pyproject");
6499
6500        assert_eq!(
6501            read_pyproject_version(&path).expect("read"),
6502            Some("1.9.0".to_string())
6503        );
6504
6505        let _ = fs::remove_dir_all(dir);
6506    }
6507
6508    #[test]
6509    fn pyproject_writer_updates_project_table() {
6510        let dir: PathBuf = temp_dir("pyproject-write-project");
6511        let path: PathBuf = dir.join("pyproject.toml");
6512        fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
6513            .expect("write pyproject");
6514
6515        write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
6516        assert_eq!(
6517            read_pyproject_version(&path).expect("read"),
6518            Some("1.1.0".to_string())
6519        );
6520
6521        let _ = fs::remove_dir_all(dir);
6522    }
6523
6524    #[test]
6525    fn pyproject_writer_updates_poetry_table() {
6526        let dir: PathBuf = temp_dir("pyproject-write-poetry");
6527        let path: PathBuf = dir.join("pyproject.toml");
6528        fs::write(
6529            &path,
6530            "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
6531        )
6532        .expect("write pyproject");
6533
6534        write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
6535        assert_eq!(
6536            read_pyproject_version(&path).expect("read"),
6537            Some("2.1.0".to_string())
6538        );
6539
6540        let _ = fs::remove_dir_all(dir);
6541    }
6542
6543    #[test]
6544    fn cargo_lock_reader_and_writer_follow_package_name() {
6545        let dir: PathBuf = temp_dir("cargo-lock");
6546        let cargo_toml: PathBuf = dir.join("Cargo.toml");
6547        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6548        fs::write(
6549            &cargo_toml,
6550            r#"[package]
6551            name = "xbp"
6552            version = "1.0.0"
6553            "#,
6554        )
6555        .expect("write Cargo.toml");
6556        fs::write(
6557            &cargo_lock,
6558            r#"version = 4
6559
6560            [[package]]
6561            name = "xbp"
6562            version = "1.0.0"
6563
6564            [[package]]
6565            name = "other"
6566            version = "9.9.9"
6567            "#,
6568        )
6569        .expect("write Cargo.lock");
6570
6571        assert_eq!(
6572            read_cargo_lock_version(&cargo_lock).expect("read"),
6573            Some("1.0.0".to_string())
6574        );
6575
6576        write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
6577        assert_eq!(
6578            read_cargo_lock_version(&cargo_lock).expect("read"),
6579            Some("1.0.1".to_string())
6580        );
6581
6582        let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
6583        assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
6584
6585        let _ = fs::remove_dir_all(dir);
6586    }
6587
6588    #[test]
6589    fn cargo_lock_writer_errors_when_package_missing() {
6590        let dir: PathBuf = temp_dir("cargo-lock-missing");
6591        fs::write(
6592            dir.join("Cargo.toml"),
6593            "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
6594        )
6595        .expect("write Cargo.toml");
6596        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6597        fs::write(
6598            &cargo_lock,
6599            "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
6600        )
6601        .expect("write Cargo.lock");
6602
6603        let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
6604            .expect_err("missing package should fail");
6605        assert!(error.contains("Could not find package `xbp`"));
6606
6607        let _ = fs::remove_dir_all(dir);
6608    }
6609
6610    #[test]
6611    fn cargo_package_name_reads_package_section() {
6612        let dir: PathBuf = temp_dir("cargo-package-name");
6613        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6614        fs::write(
6615            dir.join("Cargo.toml"),
6616            "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
6617        )
6618        .expect("write Cargo.toml");
6619        fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
6620
6621        assert_eq!(
6622            cargo_package_name(&cargo_lock).expect("name"),
6623            Some("xbp-cli".to_string())
6624        );
6625
6626        let _ = fs::remove_dir_all(dir);
6627    }
6628
6629    #[test]
6630    fn cargo_toml_writer_skips_workspace_manifest_without_package() {
6631        let dir: PathBuf = temp_dir("cargo-workspace-manifest");
6632        let path: PathBuf = dir.join("Cargo.toml");
6633        fs::write(
6634            &path,
6635            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6636        )
6637        .expect("write Cargo.toml");
6638
6639        let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
6640        assert!(changed);
6641        let content = fs::read_to_string(&path).expect("read Cargo.toml");
6642        assert!(content.contains("[workspace.package]"));
6643        assert!(content.contains("version = \"2.0.0\""));
6644
6645        let _ = fs::remove_dir_all(dir);
6646    }
6647
6648    #[test]
6649    fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
6650        let dir: PathBuf = temp_dir("workspace-cargo-skip");
6651        fs::write(
6652            dir.join("Cargo.toml"),
6653            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6654        )
6655        .expect("write Cargo.toml");
6656        fs::write(
6657            dir.join("Cargo.lock"),
6658            "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
6659        )
6660        .expect("write Cargo.lock");
6661        fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
6662            .expect("write README");
6663
6664        let updated = write_version_to_configured_files(
6665            &dir,
6666            &dir,
6667            &[
6668                "Cargo.toml".to_string(),
6669                "Cargo.lock".to_string(),
6670                "README.md".to_string(),
6671            ],
6672            &VersionScope::Repository,
6673            &Version::new(1, 1, 0),
6674        )
6675        .expect("write versions");
6676
6677        assert_eq!(updated, 2);
6678        assert_eq!(
6679            read_readme_version(&dir.join("README.md")).expect("read"),
6680            Some("1.1.0".to_string())
6681        );
6682        let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
6683        assert!(cargo_content.contains("version = \"1.1.0\""));
6684
6685        let _ = fs::remove_dir_all(dir);
6686    }
6687
6688    #[test]
6689    fn repository_scope_prefers_workspace_default_member_manifest() {
6690        let dir: PathBuf = temp_dir("workspace-default-member-path");
6691        let crate_dir: PathBuf = dir.join("crates").join("cli");
6692        fs::create_dir_all(&crate_dir).expect("create crate dir");
6693        fs::write(
6694            dir.join("Cargo.toml"),
6695            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6696        )
6697        .expect("write workspace cargo");
6698        fs::write(
6699            crate_dir.join("Cargo.toml"),
6700            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6701        )
6702        .expect("write crate cargo");
6703
6704        let resolved = super::resolve_registry_relative_path(
6705            &dir,
6706            &dir,
6707            &VersionScope::Repository,
6708            "Cargo.toml",
6709        );
6710
6711        assert_eq!(resolved, "crates/cli/Cargo.toml");
6712
6713        let _ = fs::remove_dir_all(dir);
6714    }
6715
6716    #[test]
6717    fn root_discovered_service_does_not_hijack_repository_scope() {
6718        let dir: PathBuf = temp_dir("root-discovered-service-scope");
6719        let crate_dir: PathBuf = dir.join("crates").join("cli");
6720        let xbp_dir: PathBuf = dir.join(".xbp");
6721        fs::create_dir_all(&crate_dir).expect("create crate dir");
6722        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6723        fs::write(
6724            dir.join("Cargo.toml"),
6725            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6726        )
6727        .expect("write workspace cargo");
6728        fs::write(
6729            crate_dir.join("Cargo.toml"),
6730            "[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6731        )
6732        .expect("write crate cargo");
6733        fs::write(
6734            dir.join("Cargo.lock"),
6735            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6736        )
6737        .expect("write cargo lock");
6738        fs::write(
6739            xbp_dir.join("xbp.yaml"),
6740            r#"project_name: xbp
6741version: 10.35.0
6742port: 3000
6743build_dir: ./
6744services:
6745  - name: xbp-2
6746    target: python
6747    branch: main
6748    port: 8001
6749    root_directory: ./
6750    version_targets:
6751      - Cargo.toml
6752  - name: crates-cli
6753    target: rust
6754    branch: main
6755    port: 8002
6756    root_directory: crates/cli
6757    version_targets:
6758      - crates/cli/Cargo.toml
6759publish:
6760  crates:
6761    enabled: true
6762    package_name: xbp
6763    working_directory: crates/cli
6764    manifest_path: crates/cli/Cargo.toml
6765version_targets:
6766  - Cargo.toml
6767  - crates/cli/Cargo.toml
6768"#,
6769        )
6770        .expect("write xbp config");
6771
6772        assert!(matches!(
6773            resolve_version_scope(&dir, &dir),
6774            VersionScope::Repository
6775        ));
6776
6777        let resolved = resolve_registry_paths(
6778            &dir,
6779            &dir,
6780            &["Cargo.toml".to_string(), "Cargo.lock".to_string()],
6781            &VersionScope::Repository,
6782        );
6783        assert!(resolved
6784            .iter()
6785            .any(|path| path.relative == "crates/cli/Cargo.toml"));
6786        assert_eq!(
6787            read_highest_version_from_targets(&resolved).expect("read selected versions"),
6788            Some(Version::new(10, 35, 0))
6789        );
6790
6791        let _ = fs::remove_dir_all(dir);
6792    }
6793
6794    #[test]
6795    fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
6796        let dir: PathBuf = temp_dir("workspace-default-member-writer");
6797        let crate_dir: PathBuf = dir.join("crates").join("cli");
6798        fs::create_dir_all(&crate_dir).expect("create crate dir");
6799        fs::write(
6800            dir.join("Cargo.toml"),
6801            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6802        )
6803        .expect("write workspace cargo");
6804        fs::write(
6805            crate_dir.join("Cargo.toml"),
6806            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6807        )
6808        .expect("write crate cargo");
6809        fs::write(
6810            dir.join("Cargo.lock"),
6811            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
6812        )
6813        .expect("write cargo lock");
6814        fs::write(
6815            dir.join("README.md"),
6816            "# XBP\n\ncurrent version: `10.21.0`\n",
6817        )
6818        .expect("write readme");
6819
6820        let updated = write_version_to_configured_files(
6821            &dir,
6822            &dir,
6823            &[
6824                "Cargo.toml".to_string(),
6825                "Cargo.lock".to_string(),
6826                "README.md".to_string(),
6827            ],
6828            &VersionScope::Repository,
6829            &Version::new(10, 22, 0),
6830        )
6831        .expect("write versions");
6832
6833        assert_eq!(updated, 3);
6834        assert_eq!(
6835            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
6836            Some("10.22.0".to_string())
6837        );
6838        assert_eq!(
6839            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
6840            Some("10.22.0".to_string())
6841        );
6842        assert_eq!(
6843            read_readme_version(&dir.join("README.md")).expect("read readme"),
6844            Some("10.22.0".to_string())
6845        );
6846
6847        let _ = fs::remove_dir_all(dir);
6848    }
6849
6850    #[test]
6851    fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
6852        let dir: PathBuf = temp_dir("publish-manifest-version-target");
6853        let package_dir = dir.join("packages").join("heroui");
6854        let xbp_dir = dir.join(".xbp");
6855        fs::create_dir_all(&package_dir).expect("create package dir");
6856        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6857
6858        fs::write(
6859            xbp_dir.join("xbp.yaml"),
6860            r#"project_name: athena-auth-ui
6861version: 0.3.1
6862port: 4000
6863build_dir: ./
6864publish:
6865  npm:
6866    enabled: true
6867    working_directory: packages/heroui
6868    manifest_path: packages/heroui/package.json
6869"#,
6870        )
6871        .expect("write xbp config");
6872        fs::write(
6873            dir.join("package.json"),
6874            r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
6875        )
6876        .expect("write root package");
6877        fs::write(
6878            package_dir.join("package.json"),
6879            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
6880        )
6881        .expect("write package manifest");
6882
6883        let updated = write_version_to_configured_files(
6884            &dir,
6885            &dir,
6886            &["package.json".to_string()],
6887            &VersionScope::Repository,
6888            &Version::new(0, 3, 1),
6889        )
6890        .expect("write versions");
6891
6892        assert_eq!(updated, 2);
6893        assert_eq!(
6894            read_json_root_version(&dir.join("package.json")).expect("read root package"),
6895            Some("0.3.1".to_string())
6896        );
6897        assert_eq!(
6898            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6899            Some("0.3.1".to_string())
6900        );
6901
6902        let _ = fs::remove_dir_all(dir);
6903    }
6904
6905    #[test]
6906    fn configured_writer_updates_version_targets_from_xbp_config() {
6907        let dir: PathBuf = temp_dir("explicit-version-targets");
6908        let app_dir = dir.join("apps").join("web");
6909        let cli_dir = dir.join("crates").join("cli");
6910        let xbp_dir = dir.join(".xbp");
6911        fs::create_dir_all(&app_dir).expect("create app dir");
6912        fs::create_dir_all(&cli_dir).expect("create cli dir");
6913        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6914
6915        fs::write(
6916            xbp_dir.join("xbp.yaml"),
6917            r#"project_name: xbp
6918version: 10.30.0
6919port: 3000
6920build_dir: ./
6921version_targets:
6922  - crates/cli/Cargo.toml
6923  - apps/web/package.json
6924"#,
6925        )
6926        .expect("write xbp config");
6927        fs::write(
6928            cli_dir.join("Cargo.toml"),
6929            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6930        )
6931        .expect("write cli Cargo.toml");
6932        fs::write(
6933            app_dir.join("package.json"),
6934            r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
6935        )
6936        .expect("write app package");
6937
6938        let updated = write_version_to_configured_files(
6939            &dir,
6940            &dir,
6941            &[".xbp/xbp.yaml".to_string()],
6942            &VersionScope::Repository,
6943            &Version::new(10, 30, 0),
6944        )
6945        .expect("write versions");
6946
6947        assert_eq!(updated, 3);
6948        assert_eq!(
6949            read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
6950            Some("10.30.0".to_string())
6951        );
6952        assert_eq!(
6953            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
6954            Some("10.30.0".to_string())
6955        );
6956        assert_eq!(
6957            read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
6958            Some("10.30.0".to_string())
6959        );
6960
6961        let _ = fs::remove_dir_all(dir);
6962    }
6963
6964    #[test]
6965    fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
6966        let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
6967        let package_dir = dir.join("packages").join("heroui");
6968        let xbp_dir = dir.join(".xbp");
6969        fs::create_dir_all(&package_dir).expect("create package dir");
6970        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6971
6972        fs::write(
6973            xbp_dir.join("xbp.yaml"),
6974            r#"project_name: athena-auth-ui
6975version: 0.3.0
6976port: 4000
6977build_dir: ./
6978publish:
6979  npm:
6980    enabled: true
6981    working_directory: packages/heroui
6982    manifest_path: packages/heroui/package.json
6983"#,
6984        )
6985        .expect("write xbp config");
6986        fs::write(
6987            dir.join("package.json"),
6988            r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
6989        )
6990        .expect("write root package");
6991        fs::write(
6992            package_dir.join("package.json"),
6993            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
6994        )
6995        .expect("write package manifest");
6996
6997        let _updated_paths = sync_version_to_configured_files_with_paths(
6998            &dir,
6999            &dir,
7000            &["package.json".to_string()],
7001            &VersionScope::Repository,
7002            &Version::new(0, 3, 0),
7003        )
7004        .expect("sync versions");
7005
7006        assert_eq!(
7007            read_json_root_version(&dir.join("package.json")).expect("read root package"),
7008            Some("0.3.0".to_string())
7009        );
7010        assert_eq!(
7011            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
7012            Some("0.3.0".to_string())
7013        );
7014
7015        let _ = fs::remove_dir_all(dir);
7016    }
7017
7018    #[test]
7019    fn readme_adapter_updates_current_version_marker() {
7020        let dir: PathBuf = temp_dir("readme");
7021        let path: PathBuf = dir.join("README.md");
7022        fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7023
7024        write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
7025        assert_eq!(
7026            read_readme_version(&path).expect("read"),
7027            Some("1.2.0".to_string())
7028        );
7029
7030        let _ = fs::remove_dir_all(dir);
7031    }
7032
7033    #[test]
7034    fn readme_writer_inserts_marker_when_missing() {
7035        let dir: PathBuf = temp_dir("readme-insert");
7036        let path: PathBuf = dir.join("README.md");
7037        fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
7038
7039        write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
7040        let content: String = fs::read_to_string(&path).expect("read readme");
7041        assert!(content.contains("current version: `3.0.0`"));
7042
7043        let _ = fs::remove_dir_all(dir);
7044    }
7045
7046    #[test]
7047    fn regex_adapter_reads_and_writes_versions() {
7048        let dir: PathBuf = temp_dir("regex");
7049        let path: PathBuf = dir.join("build.gradle");
7050        fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
7051
7052        assert_eq!(
7053            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7054            Some("5.4.3".to_string())
7055        );
7056
7057        write_regex_version(
7058            &path,
7059            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7060            &Version::new(5, 5, 0),
7061        )
7062        .expect("write");
7063
7064        assert_eq!(
7065            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7066            Some("5.5.0".to_string())
7067        );
7068
7069        let _ = fs::remove_dir_all(dir);
7070    }
7071
7072    #[test]
7073    fn regex_writer_errors_without_matching_pattern() {
7074        let dir: PathBuf = temp_dir("regex-miss");
7075        let path: PathBuf = dir.join("build.gradle");
7076        fs::write(&path, "group = 'demo'\n").expect("write gradle");
7077
7078        let error: String = write_regex_version(
7079            &path,
7080            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7081            &Version::new(1, 0, 0),
7082        )
7083        .expect_err("missing version should fail");
7084        assert!(error.contains("No version pattern found"));
7085
7086        let _ = fs::remove_dir_all(dir);
7087    }
7088
7089    #[test]
7090    fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
7091        let original: &str = r#"[dependencies]
7092            serde = "1.0.219"
7093            tokio = { version = "1.44.1", features = ["full"] }
7094            "#;
7095
7096        let (updated, changed) =
7097            rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
7098                .expect("rewrite");
7099        assert!(changed);
7100        assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
7101
7102        let (updated, changed) =
7103            rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
7104                .expect("rewrite");
7105        assert!(changed);
7106        assert!(updated.contains(r#"serde = "1.1.0""#));
7107    }
7108
7109    #[test]
7110    fn package_version_writer_updates_registry_toml_targets() {
7111        let dir: PathBuf = temp_dir("package-version-registry");
7112        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7113        fs::write(
7114            &cargo_toml,
7115            r#"[package]
7116            name = "demo"
7117            version = "0.1.0"
7118
7119            [dependencies]
7120            serde = "1.0.219"
7121            tokio = { version = "1.44.1", features = ["full"] }
7122            "#,
7123        )
7124        .expect("write Cargo.toml");
7125
7126        let updated: usize = write_package_version_to_configured_files(
7127            &dir,
7128            &dir,
7129            &["Cargo.toml".to_string()],
7130            &VersionScope::Repository,
7131            "tokio",
7132            &Version::new(1, 45, 1),
7133        )
7134        .expect("update package assignment");
7135        assert_eq!(updated, 1);
7136
7137        let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
7138        assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
7139
7140        let _ = fs::remove_dir_all(dir);
7141    }
7142
7143    #[test]
7144    fn package_version_writer_errors_when_package_assignment_not_found() {
7145        let dir: PathBuf = temp_dir("package-version-missing");
7146        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7147        fs::write(
7148            &cargo_toml,
7149            r#"[package]
7150        name = "demo"
7151        version = "0.1.0"
7152
7153        [dependencies]
7154        serde = "1.0.219"
7155        "#,
7156        )
7157        .expect("write Cargo.toml");
7158
7159        let error: String = write_package_version_to_configured_files(
7160            &dir,
7161            &dir,
7162            &["Cargo.toml".to_string()],
7163            &VersionScope::Repository,
7164            "tokio",
7165            &Version::new(1, 45, 1),
7166        )
7167        .expect_err("missing package assignment should fail");
7168        assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
7169
7170        let _ = fs::remove_dir_all(dir);
7171    }
7172
7173    #[test]
7174    fn chart_writer_updates_app_version_when_present() {
7175        let dir: PathBuf = temp_dir("chart");
7176        let path: PathBuf = dir.join("Chart.yaml");
7177        fs::write(
7178            &path,
7179            "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
7180        )
7181        .expect("write chart");
7182
7183        write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
7184        let content: String = fs::read_to_string(&path).expect("read chart");
7185        assert!(content.contains("version: 0.2.0"));
7186        assert!(content.contains("appVersion: 0.2.0"));
7187
7188        let _ = fs::remove_dir_all(dir);
7189    }
7190
7191    #[test]
7192    fn configured_file_writer_deduplicates_registry_entries() {
7193        let dir: PathBuf = temp_dir("dedupe");
7194        let readme: PathBuf = dir.join("README.md");
7195        fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7196
7197        let updated: usize = write_version_to_configured_files(
7198            &dir,
7199            &dir,
7200            &[
7201                "README.md".to_string(),
7202                "README.md".to_string(),
7203                "missing.md".to_string(),
7204            ],
7205            &VersionScope::Repository,
7206            &Version::new(1, 1, 0),
7207        )
7208        .expect("write versions");
7209
7210        assert_eq!(updated, 1);
7211        assert_eq!(
7212            read_readme_version(&readme).expect("read"),
7213            Some("1.1.0".to_string())
7214        );
7215
7216        let _ = fs::remove_dir_all(dir);
7217    }
7218
7219    #[test]
7220    fn configured_file_writer_prefers_invocation_directory_targets() {
7221        let dir: PathBuf = temp_dir("invocation-precedence");
7222        let app_dir: PathBuf = dir.join("apps").join("web");
7223        fs::create_dir_all(&app_dir).expect("create app dir");
7224
7225        let root_package: PathBuf = dir.join("package.json");
7226        let app_package: PathBuf = app_dir.join("package.json");
7227        fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
7228            .expect("write root package");
7229        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7230            .expect("write app package");
7231
7232        let updated: usize = write_version_to_configured_files(
7233            &dir,
7234            &app_dir,
7235            &["package.json".to_string()],
7236            &VersionScope::Repository,
7237            &Version::new(2, 14, 0),
7238        )
7239        .expect("write versions");
7240        assert_eq!(updated, 1);
7241
7242        assert_eq!(
7243            read_json_root_version(&root_package).expect("read root"),
7244            Some("9.9.9".to_string())
7245        );
7246        assert_eq!(
7247            read_json_root_version(&app_package).expect("read app"),
7248            Some("2.14.0".to_string())
7249        );
7250
7251        let _ = fs::remove_dir_all(dir);
7252    }
7253
7254    #[test]
7255    fn resolve_project_root_prefers_nearest_xbp_config() {
7256        let dir: PathBuf = temp_dir("nested-xbp-project-root");
7257        let service_root: PathBuf = dir.join("services").join("athena-auth");
7258        let nested_dir: PathBuf = service_root.join("src");
7259        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7260        fs::create_dir_all(&nested_dir).expect("create nested dir");
7261        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7262        fs::write(
7263            service_xbp_dir.join("xbp.yaml"),
7264            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7265        )
7266        .expect("write service xbp config");
7267
7268        assert_eq!(resolve_project_root_from(&nested_dir), service_root);
7269
7270        let _ = fs::remove_dir_all(dir);
7271    }
7272
7273    #[test]
7274    fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
7275        let dir: PathBuf = temp_dir("nested-xbp-version-scope");
7276        let outer_xbp_dir = dir.join(".xbp");
7277        let service_root: PathBuf = dir.join("services").join("athena-auth");
7278        let nested_dir: PathBuf = service_root.join("src");
7279        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7280        fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
7281        fs::create_dir_all(&nested_dir).expect("create nested dir");
7282        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7283
7284        fs::write(
7285            outer_xbp_dir.join("xbp.yaml"),
7286            "project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
7287        )
7288        .expect("write outer xbp config");
7289        fs::write(
7290            dir.join("Cargo.toml"),
7291            "[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
7292        )
7293        .expect("write outer cargo");
7294        fs::write(
7295            dir.join("Cargo.lock"),
7296            "version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
7297        )
7298        .expect("write outer cargo lock");
7299        fs::write(
7300            dir.join("README.md"),
7301            "# root\n\ncurrent version: `9.9.9`\n",
7302        )
7303        .expect("write outer readme");
7304        fs::write(
7305            dir.join("openapi.yaml"),
7306            "openapi: 3.0.3\ninfo:\n  title: Root\n  version: 9.9.9\n",
7307        )
7308        .expect("write outer openapi");
7309        fs::write(
7310            dir.join("package.json"),
7311            r#"{ "name": "root", "version": "9.9.9" }"#,
7312        )
7313        .expect("write outer package");
7314
7315        fs::write(
7316            service_xbp_dir.join("xbp.yaml"),
7317            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7318        )
7319        .expect("write service xbp config");
7320        fs::write(
7321            service_root.join("Cargo.toml"),
7322            "[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7323        )
7324        .expect("write service cargo");
7325        fs::write(
7326            service_root.join("Cargo.lock"),
7327            "version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7328        )
7329        .expect("write service cargo lock");
7330        fs::write(
7331            service_root.join("README.md"),
7332            "# athena-auth\n\ncurrent version: `3.22.0`\n",
7333        )
7334        .expect("write service readme");
7335        fs::write(
7336            service_root.join("openapi.yaml"),
7337            "openapi: 3.0.3\ninfo:\n  title: Athena Auth\n  version: 3.22.0\n",
7338        )
7339        .expect("write service openapi");
7340        fs::write(
7341            service_root.join("package.json"),
7342            r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
7343        )
7344        .expect("write service package");
7345
7346        let project_root = resolve_project_root_from(&nested_dir);
7347        let updated_paths = write_version_to_configured_files_with_paths(
7348            &project_root,
7349            &nested_dir,
7350            &[
7351                ".xbp/xbp.yaml".to_string(),
7352                "Cargo.toml".to_string(),
7353                "Cargo.lock".to_string(),
7354                "README.md".to_string(),
7355                "openapi.yaml".to_string(),
7356                "package.json".to_string(),
7357            ],
7358            &VersionScope::Repository,
7359            &Version::new(3, 22, 1),
7360        )
7361        .expect("write nested project versions");
7362
7363        let updated_relative = updated_paths
7364            .iter()
7365            .map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
7366            .map(normalized_relative_path)
7367            .collect::<Vec<_>>();
7368
7369        assert_eq!(
7370            updated_relative,
7371            vec![
7372                ".xbp/xbp.yaml".to_string(),
7373                "Cargo.toml".to_string(),
7374                "Cargo.lock".to_string(),
7375                "README.md".to_string(),
7376                "openapi.yaml".to_string(),
7377                "package.json".to_string(),
7378            ]
7379        );
7380
7381        assert_eq!(
7382            read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
7383                .expect("read outer xbp"),
7384            Some("9.9.9".to_string())
7385        );
7386        assert_eq!(
7387            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
7388            Some("9.9.9".to_string())
7389        );
7390        assert_eq!(
7391            read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
7392            Some("9.9.9".to_string())
7393        );
7394        assert_eq!(
7395            read_readme_version(&dir.join("README.md")).expect("read outer readme"),
7396            Some("9.9.9".to_string())
7397        );
7398        assert_eq!(
7399            read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
7400            Some("9.9.9".to_string())
7401        );
7402        assert_eq!(
7403            read_json_root_version(&dir.join("package.json")).expect("read outer package"),
7404            Some("9.9.9".to_string())
7405        );
7406
7407        assert_eq!(
7408            read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
7409                .expect("read service xbp"),
7410            Some("3.22.1".to_string())
7411        );
7412        assert_eq!(
7413            read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
7414            Some("3.22.1".to_string())
7415        );
7416        assert_eq!(
7417            read_cargo_lock_version(&service_root.join("Cargo.lock"))
7418                .expect("read service cargo lock"),
7419            Some("3.22.1".to_string())
7420        );
7421        assert_eq!(
7422            read_readme_version(&service_root.join("README.md")).expect("read service readme"),
7423            Some("3.22.1".to_string())
7424        );
7425        assert_eq!(
7426            read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
7427            Some("3.22.1".to_string())
7428        );
7429        assert_eq!(
7430            read_json_root_version(&service_root.join("package.json"))
7431                .expect("read service package"),
7432            Some("3.22.1".to_string())
7433        );
7434
7435        let _ = fs::remove_dir_all(dir);
7436    }
7437
7438    #[test]
7439    fn resolve_version_scope_detects_crate_scoped_invocation() {
7440        let dir: PathBuf = temp_dir("crate-scope");
7441        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7442        let nested_dir: PathBuf = crate_dir.join("src");
7443        fs::create_dir_all(&nested_dir).expect("create nested dir");
7444        fs::write(
7445            crate_dir.join("Cargo.toml"),
7446            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7447        )
7448        .expect("write Cargo.toml");
7449
7450        let scope = resolve_version_scope(&dir, &nested_dir);
7451        match scope {
7452            VersionScope::Crate {
7453                package_name,
7454                crate_relative_root,
7455                tag_prefix,
7456                ..
7457            } => {
7458                assert_eq!(package_name, "alpha-crate");
7459                assert_eq!(crate_relative_root, "crates/alpha");
7460                assert_eq!(tag_prefix, "alpha-crate-");
7461            }
7462            _ => panic!("expected crate scope"),
7463        }
7464
7465        let _ = fs::remove_dir_all(dir);
7466    }
7467
7468    #[test]
7469    fn resolve_version_scope_detects_service_scoped_invocation() {
7470        let dir: PathBuf = temp_dir("service-scope");
7471        let service_dir: PathBuf = dir.join("apps").join("web");
7472        let nested_dir: PathBuf = service_dir.join("src");
7473        let cli_dir: PathBuf = dir.join("crates").join("cli");
7474        fs::create_dir_all(&nested_dir).expect("create nested dir");
7475        fs::create_dir_all(&cli_dir).expect("create cli dir");
7476        write_multi_service_config(&dir);
7477        fs::write(
7478            service_dir.join("package.json"),
7479            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7480        )
7481        .expect("write package");
7482        fs::write(
7483            cli_dir.join("Cargo.toml"),
7484            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7485        )
7486        .expect("write cargo");
7487
7488        let scope = resolve_version_scope(&dir, &nested_dir);
7489        match scope {
7490            VersionScope::Service {
7491                service_name,
7492                service_relative_root,
7493                tag_prefix,
7494                ..
7495            } => {
7496                assert_eq!(service_name, "web");
7497                assert_eq!(service_relative_root, "apps/web");
7498                assert_eq!(tag_prefix, "web-");
7499            }
7500            _ => panic!("expected service scope"),
7501        }
7502
7503        let _ = fs::remove_dir_all(dir);
7504    }
7505
7506    #[test]
7507    fn service_scoped_configured_targets_only_include_selected_service() {
7508        let dir: PathBuf = temp_dir("service-targets");
7509        let service_dir: PathBuf = dir.join("apps").join("web");
7510        let nested_dir: PathBuf = service_dir.join("src");
7511        let cli_dir: PathBuf = dir.join("crates").join("cli");
7512        fs::create_dir_all(&nested_dir).expect("create nested dir");
7513        fs::create_dir_all(&cli_dir).expect("create cli dir");
7514        write_multi_service_config(&dir);
7515        fs::write(
7516            service_dir.join("package.json"),
7517            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7518        )
7519        .expect("write package");
7520        fs::write(
7521            cli_dir.join("Cargo.toml"),
7522            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7523        )
7524        .expect("write cargo");
7525
7526        let scope = resolve_version_scope(&dir, &nested_dir);
7527        let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
7528        assert_eq!(targets.len(), 1);
7529        assert_eq!(targets[0].relative, "apps/web/package.json");
7530
7531        let _ = fs::remove_dir_all(dir);
7532    }
7533
7534    #[test]
7535    fn service_scoped_release_publish_filter_selects_matching_target() {
7536        let dir: PathBuf = temp_dir("service-publish-filter");
7537        let service_dir: PathBuf = dir.join("apps").join("web");
7538        let nested_dir: PathBuf = service_dir.join("src");
7539        let cli_dir: PathBuf = dir.join("crates").join("cli");
7540        fs::create_dir_all(&nested_dir).expect("create nested dir");
7541        fs::create_dir_all(&cli_dir).expect("create cli dir");
7542        write_multi_service_config(&dir);
7543        fs::write(
7544            service_dir.join("package.json"),
7545            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7546        )
7547        .expect("write package");
7548        fs::write(
7549            cli_dir.join("Cargo.toml"),
7550            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7551        )
7552        .expect("write cargo");
7553
7554        let scope = resolve_version_scope(&dir, &nested_dir);
7555        let publish_target =
7556            resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
7557        assert_eq!(publish_target.as_deref(), Some("npm"));
7558
7559        let _ = fs::remove_dir_all(dir);
7560    }
7561
7562    #[test]
7563    fn current_scope_ledger_path_uses_repo_local_scope_directory() {
7564        let dir: PathBuf = temp_dir("ledger-path");
7565        let scope = resolve_version_scope(&dir, &dir);
7566        let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
7567
7568        assert_eq!(
7569            path,
7570            dir.join(".xbp")
7571                .join("releases")
7572                .join("repository-xbp")
7573                .join("10.30.0.yaml")
7574        );
7575
7576        let _ = fs::remove_dir_all(dir);
7577    }
7578
7579    #[test]
7580    fn repository_change_selection_matches_only_changed_target_manifest_root() {
7581        let dir: PathBuf = temp_dir("selection-direct");
7582        let service_dir: PathBuf = dir.join("apps").join("web");
7583        let cli_dir: PathBuf = dir.join("crates").join("cli");
7584        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7585        fs::create_dir_all(&cli_dir).expect("create cli dir");
7586        write_multi_service_config(&dir);
7587        fs::write(
7588            service_dir.join("package.json"),
7589            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7590        )
7591        .expect("write package");
7592        fs::write(
7593            cli_dir.join("Cargo.toml"),
7594            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7595        )
7596        .expect("write cargo");
7597
7598        let scope = resolve_version_scope(&dir, &dir);
7599        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7600        let selected = select_changed_version_targets(
7601            &dir,
7602            &scope,
7603            &targets,
7604            &["apps/web/src/app.tsx".into()],
7605        );
7606
7607        assert_eq!(selected.len(), 1);
7608        assert_eq!(selected[0].relative, "apps/web/package.json");
7609
7610        let _ = fs::remove_dir_all(dir);
7611    }
7612
7613    #[test]
7614    fn repository_change_selection_fans_out_on_shared_root_change() {
7615        let dir: PathBuf = temp_dir("selection-shared");
7616        let service_dir: PathBuf = dir.join("apps").join("web");
7617        let cli_dir: PathBuf = dir.join("crates").join("cli");
7618        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7619        fs::create_dir_all(&cli_dir).expect("create cli dir");
7620        write_multi_service_config(&dir);
7621        fs::write(
7622            service_dir.join("package.json"),
7623            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7624        )
7625        .expect("write package");
7626        fs::write(
7627            cli_dir.join("Cargo.toml"),
7628            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7629        )
7630        .expect("write cargo");
7631
7632        let scope = resolve_version_scope(&dir, &dir);
7633        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7634        let selected =
7635            select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
7636
7637        assert_eq!(selected.len(), 2);
7638        assert_eq!(
7639            selected
7640                .iter()
7641                .map(|target| target.relative.as_str())
7642                .collect::<Vec<_>>(),
7643            vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
7644        );
7645
7646        let _ = fs::remove_dir_all(dir);
7647    }
7648
7649    #[test]
7650    fn selected_version_writer_updates_only_selected_targets() {
7651        let dir: PathBuf = temp_dir("selection-write");
7652        let service_dir: PathBuf = dir.join("apps").join("web");
7653        let cli_dir: PathBuf = dir.join("crates").join("cli");
7654        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7655        fs::create_dir_all(&cli_dir).expect("create cli dir");
7656        write_multi_service_config(&dir);
7657        fs::write(
7658            service_dir.join("package.json"),
7659            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7660        )
7661        .expect("write package");
7662        fs::write(
7663            cli_dir.join("Cargo.toml"),
7664            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7665        )
7666        .expect("write cargo");
7667
7668        let scope = resolve_version_scope(&dir, &dir);
7669        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7670        let selected = select_changed_version_targets(
7671            &dir,
7672            &scope,
7673            &targets,
7674            &["apps/web/src/app.tsx".into()],
7675        );
7676
7677        let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
7678            .expect("write selected targets");
7679        assert_eq!(updated.len(), 1);
7680        assert_eq!(
7681            read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
7682            Some("10.30.0".to_string())
7683        );
7684        assert_eq!(
7685            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
7686            Some("10.29.0".to_string())
7687        );
7688
7689        let _ = fs::remove_dir_all(dir);
7690    }
7691
7692    #[test]
7693    fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
7694        let dir: PathBuf = temp_dir("crate-writer");
7695        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7696        fs::create_dir_all(&crate_dir).expect("create crate dir");
7697        fs::write(
7698            crate_dir.join("Cargo.toml"),
7699            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7700        )
7701        .expect("write crate Cargo.toml");
7702        fs::write(
7703            dir.join("Cargo.lock"),
7704            "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
7705        )
7706        .expect("write Cargo.lock");
7707        fs::write(
7708            dir.join("README.md"),
7709            "# root\n\ncurrent version: `9.9.9`\n",
7710        )
7711        .expect("write root readme");
7712
7713        let scope = resolve_version_scope(&dir, &crate_dir);
7714        let updated = write_version_to_configured_files(
7715            &dir,
7716            &crate_dir,
7717            &[
7718                "Cargo.toml".to_string(),
7719                "Cargo.lock".to_string(),
7720                "README.md".to_string(),
7721            ],
7722            &scope,
7723            &Version::new(1, 3, 0),
7724        )
7725        .expect("write versions");
7726
7727        assert_eq!(updated, 2);
7728        assert_eq!(
7729            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
7730            Some("1.3.0".to_string())
7731        );
7732        assert_eq!(
7733            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
7734                .expect("read cargo lock"),
7735            Some("1.3.0".to_string())
7736        );
7737        assert_eq!(
7738            read_readme_version(&dir.join("README.md")).expect("read readme"),
7739            Some("9.9.9".to_string())
7740        );
7741
7742        let _ = fs::remove_dir_all(dir);
7743    }
7744
7745    #[test]
7746    fn release_openapi_resolution_prefers_crate_scope() {
7747        let dir: PathBuf = temp_dir("release-openapi-crate");
7748        let crate_dir: PathBuf = dir.join("crates").join("monitor");
7749        let nested_dir: PathBuf = crate_dir.join("src");
7750        fs::create_dir_all(&nested_dir).expect("create nested dir");
7751        fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
7752        let crate_openapi: PathBuf = crate_dir.join("openapi.json");
7753        fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
7754
7755        let scope = resolve_version_scope(&dir, &nested_dir);
7756        let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
7757            .expect("crate-scoped openapi")
7758            .into_iter()
7759            .next()
7760            .expect("crate-scoped openapi");
7761        assert_eq!(resolved, crate_openapi);
7762
7763        let _ = fs::remove_dir_all(dir);
7764    }
7765
7766    #[test]
7767    fn release_openapi_resolution_falls_back_to_repo_root() {
7768        let dir: PathBuf = temp_dir("release-openapi-root");
7769        let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
7770        fs::create_dir_all(&crate_dir).expect("create crate dir");
7771        let root_openapi: PathBuf = dir.join("openapi.json");
7772        fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
7773
7774        let scope = resolve_version_scope(&dir, &dir);
7775        let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
7776            .expect("repo root openapi")
7777            .into_iter()
7778            .next()
7779            .expect("repo root openapi");
7780        assert_eq!(resolved, root_openapi);
7781
7782        let _ = fs::remove_dir_all(dir);
7783    }
7784
7785    #[test]
7786    fn release_openapi_assets_use_versioned_release_tag_names() {
7787        let dir: PathBuf = temp_dir("release-openapi-assets");
7788        fs::write(
7789            dir.join("openapi.yaml"),
7790            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.2\n",
7791        )
7792        .expect("write http openapi");
7793        fs::write(
7794            dir.join("openapi-wss.yaml"),
7795            "openapi: 3.0.3\ninfo:\n  title: Athena WSS\n  version: 1.3.0\n",
7796        )
7797        .expect("write wss openapi");
7798
7799        let assets = prepare_release_openapi_assets(
7800            &dir,
7801            &dir,
7802            &VersionScope::Repository,
7803            &Version::new(3, 15, 2),
7804            "v3.15.2",
7805        )
7806        .expect("prepare release assets");
7807
7808        assert_eq!(
7809            assets
7810                .iter()
7811                .map(|asset| asset.asset_name.as_str())
7812                .collect::<Vec<_>>(),
7813            vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
7814        );
7815        assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
7816        assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
7817
7818        let _ = fs::remove_dir_all(dir);
7819    }
7820
7821    #[test]
7822    fn release_openapi_assets_reject_http_version_mismatch() {
7823        let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
7824        fs::write(
7825            dir.join("openapi.yaml"),
7826            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.1\n",
7827        )
7828        .expect("write http openapi");
7829
7830        let error = prepare_release_openapi_assets(
7831            &dir,
7832            &dir,
7833            &VersionScope::Repository,
7834            &Version::new(3, 15, 2),
7835            "v3.15.2",
7836        )
7837        .expect_err("mismatched OpenAPI version should fail");
7838
7839        assert!(error.contains("does not match release version"));
7840
7841        let _ = fs::remove_dir_all(dir);
7842    }
7843
7844    #[test]
7845    fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
7846        let dir: PathBuf = temp_dir("invocation-dedupe");
7847        let app_dir: PathBuf = dir.join("apps").join("web");
7848        fs::create_dir_all(&app_dir).expect("create app dir");
7849
7850        let app_package: PathBuf = app_dir.join("package.json");
7851        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7852            .expect("write app package");
7853
7854        let updated: usize = write_version_to_configured_files(
7855            &dir,
7856            &app_dir,
7857            &[
7858                "package.json".to_string(),
7859                "apps/web/package.json".to_string(),
7860            ],
7861            &VersionScope::Repository,
7862            &Version::new(2, 14, 0),
7863        )
7864        .expect("write versions");
7865        assert_eq!(updated, 1);
7866
7867        assert_eq!(
7868            read_json_root_version(&app_package).expect("read app"),
7869            Some("2.14.0".to_string())
7870        );
7871
7872        let _ = fs::remove_dir_all(dir);
7873    }
7874
7875    #[test]
7876    fn configured_file_writer_errors_when_no_targets_exist() {
7877        let dir: PathBuf = temp_dir("no-targets");
7878        let error: String = write_version_to_configured_files(
7879            &dir,
7880            &dir,
7881            &["missing.toml".to_string()],
7882            &VersionScope::Repository,
7883            &Version::new(1, 0, 0),
7884        )
7885        .expect_err("missing targets should fail");
7886
7887        assert!(error.contains("No configured version files were found"));
7888
7889        let _ = fs::remove_dir_all(dir);
7890    }
7891
7892    #[test]
7893    fn remote_git_tag_parser_deduplicates_peeled_refs() {
7894        let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
7895            "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
7896        );
7897
7898        assert_eq!(parsed.len(), 2);
7899        assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
7900        assert_eq!(
7901            parsed[1].version,
7902            Version::parse("0.1.7-exp").expect("version")
7903        );
7904        assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
7905    }
7906
7907    #[test]
7908    fn local_git_tag_parser_normalizes_prefixed_versions() {
7909        let parsed: Vec<crate::commands::version::GitTagObservation> =
7910            parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
7911
7912        assert_eq!(parsed.len(), 2);
7913        assert_eq!(parsed[0].version, Version::new(1, 0, 0));
7914        assert_eq!(
7915            parsed[0].raw_tags,
7916            vec!["1.0.0".to_string(), "v1.0.0".to_string()]
7917        );
7918    }
7919
7920    #[test]
7921    fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
7922        let scope = VersionScope::Crate {
7923            crate_root: PathBuf::from("/tmp/crates/alpha"),
7924            crate_relative_root: "crates/alpha".to_string(),
7925            package_name: "alpha-crate".to_string(),
7926            tag_prefix: "alpha-crate-".to_string(),
7927        };
7928
7929        let parsed = parse_local_git_tag_output_for_scope(
7930            "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
7931            &scope,
7932        );
7933
7934        assert_eq!(parsed.len(), 2);
7935        assert_eq!(parsed[0].version, Version::new(1, 2, 0));
7936        assert_eq!(parsed[1].version, Version::new(1, 0, 0));
7937    }
7938
7939    #[test]
7940    fn crate_scoped_release_tags_default_to_package_prefix() {
7941        let scope = VersionScope::Crate {
7942            crate_root: PathBuf::from("/tmp/crates/alpha"),
7943            crate_relative_root: "crates/alpha".to_string(),
7944            package_name: "alpha-crate".to_string(),
7945            tag_prefix: "alpha-crate-".to_string(),
7946        };
7947
7948        assert_eq!(
7949            default_release_tag_name(&scope, &Version::new(1, 2, 3)),
7950            "alpha-crate-1.2.3"
7951        );
7952    }
7953
7954    #[test]
7955    fn blob_reader_handles_head_readme_versions() {
7956        assert_eq!(
7957            read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
7958                .expect("read"),
7959            Some("0.4.0".to_string())
7960        );
7961    }
7962
7963    #[test]
7964    fn blob_reader_handles_head_cargo_lock_versions() {
7965        let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
7966        let cargo_lock: &str =
7967            "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
7968
7969        assert_eq!(
7970            read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
7971            Some("0.2.0".to_string())
7972        );
7973    }
7974
7975    #[test]
7976    fn package_name_lookup_reads_json_name_for_npm() {
7977        let lookup: PackageNameLookup = PackageNameLookup {
7978            file: "package.json".to_string(),
7979            format: "json".to_string(),
7980            key: "name".to_string(),
7981            registry: "npm".to_string(),
7982        };
7983
7984        assert_eq!(
7985            read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
7986                .expect("read"),
7987            Some("@xylex/athena-mcp".to_string())
7988        );
7989    }
7990
7991    #[test]
7992    fn package_name_lookup_reads_toml_nested_package_name() {
7993        let lookup: PackageNameLookup = PackageNameLookup {
7994            file: "Cargo.toml".to_string(),
7995            format: "toml".to_string(),
7996            key: "package.name".to_string(),
7997            registry: "crates.io".to_string(),
7998        };
7999
8000        assert_eq!(
8001            read_package_name_from_lookup(
8002                &lookup,
8003                "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
8004            )
8005            .expect("read"),
8006            Some("athena-mcp".to_string())
8007        );
8008    }
8009
8010    #[test]
8011    fn package_name_lookup_errors_on_unknown_format() {
8012        let lookup: PackageNameLookup = PackageNameLookup {
8013            file: "meta.txt".to_string(),
8014            format: "ini".to_string(),
8015            key: "name".to_string(),
8016            registry: "npm".to_string(),
8017        };
8018
8019        let error = read_package_name_from_lookup(&lookup, "name=demo")
8020            .expect_err("unsupported format should fail");
8021        assert!(error.contains("Unsupported lookup format"));
8022    }
8023
8024    #[test]
8025    fn highest_version_observation_returns_max_version() {
8026        let entries: Vec<VersionObservation> = vec![
8027            VersionObservation {
8028                location: "README.md".to_string(),
8029                version: Version::new(1, 0, 0),
8030            },
8031            VersionObservation {
8032                location: "Cargo.toml".to_string(),
8033                version: Version::new(1, 2, 0),
8034            },
8035        ];
8036
8037        assert_eq!(
8038            highest_version_observation(&entries).expect("max version"),
8039            Version::new(1, 2, 0)
8040        );
8041    }
8042
8043    #[test]
8044    fn stale_version_observations_only_returns_outdated_entries() {
8045        let entries: Vec<VersionObservation> = vec![
8046            VersionObservation {
8047                location: "README.md".to_string(),
8048                version: Version::new(1, 1, 0),
8049            },
8050            VersionObservation {
8051                location: "Cargo.toml".to_string(),
8052                version: Version::new(1, 2, 0),
8053            },
8054            VersionObservation {
8055                location: "openapi.yaml".to_string(),
8056                version: Version::new(1, 0, 5),
8057            },
8058        ];
8059
8060        let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
8061        assert_eq!(stale.len(), 2);
8062        assert!(stale.iter().any(|entry| entry.location == "README.md"));
8063        assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
8064        assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
8065    }
8066
8067    #[test]
8068    fn parses_github_remote_urls() {
8069        assert_eq!(
8070            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
8071            Some(("xylex-group".to_string(), "xbp".to_string()))
8072        );
8073        assert_eq!(
8074            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
8075            Some(("xylex-group".to_string(), "xbp".to_string()))
8076        );
8077        assert_eq!(
8078            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
8079            Some(("xylex-group".to_string(), "xbp".to_string()))
8080        );
8081        assert_eq!(
8082            parse_github_repo_from_remote_url(
8083                "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
8084            ),
8085            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8086        );
8087        assert_eq!(
8088            parse_github_repo_from_remote_url(
8089                "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
8090            ),
8091            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8092        );
8093        assert_eq!(
8094            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
8095            None
8096        );
8097    }
8098
8099    #[test]
8100    fn redacts_credentials_in_remote_urls() {
8101        let redacted = redact_remote_url_credentials(
8102            "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
8103        );
8104        assert!(redacted.contains("REDACTED"));
8105        assert!(!redacted.contains("ghp_secretvalue"));
8106
8107        let username_only = redact_remote_url_credentials(
8108            "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
8109        );
8110        assert!(username_only.contains("REDACTED@github.com"));
8111        assert!(!username_only.contains("floris-xlx@github.com"));
8112
8113        let ssh_remote =
8114            redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
8115        assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
8116    }
8117
8118    #[test]
8119    fn builds_github_release_urls_with_encoded_tag_segments() {
8120        let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
8121        assert_eq!(
8122            create_url.as_str(),
8123            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
8124        );
8125
8126        let lookup_url =
8127            github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
8128                .expect("url");
8129        assert_eq!(
8130            lookup_url.as_str(),
8131            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
8132        );
8133
8134        let update_url =
8135            github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8136        assert_eq!(
8137            update_url.as_str(),
8138            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
8139        );
8140
8141        let lookup_with_special_tag = github_release_by_tag_endpoint(
8142            "SuitsBooks",
8143            "suits-invoicing",
8144            "release candidate/v0.0.1+build",
8145        )
8146        .expect("url");
8147        assert_eq!(
8148            lookup_with_special_tag.as_str(),
8149            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
8150        );
8151    }
8152
8153    #[test]
8154    fn builds_github_release_asset_urls() {
8155        let list_url =
8156            github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8157        assert_eq!(
8158            list_url.as_str(),
8159            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
8160        );
8161
8162        let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
8163            .expect("url");
8164        assert_eq!(
8165            delete_url.as_str(),
8166            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
8167        );
8168
8169        let upload_url = github_release_asset_upload_endpoint(
8170            "SuitsBooks",
8171            "suits-invoicing",
8172            42,
8173            "openapi spec.json",
8174        )
8175        .expect("url");
8176        assert_eq!(
8177            upload_url.as_str(),
8178            "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
8179        );
8180    }
8181
8182    #[test]
8183    fn maps_release_latest_policy_to_github_api_values() {
8184        assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
8185        assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
8186        assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
8187    }
8188
8189    #[test]
8190    fn release_channel_from_semver_prerelease_labels() {
8191        let stable = Version::parse("3.6.2").expect("version");
8192        let nightly = Version::parse("3.6.2-nightly.1").expect("version");
8193        let experimental = Version::parse("0.1.1-alpha.1").expect("version");
8194        assert_eq!(release_channel(&stable), "stable");
8195        assert_eq!(release_channel(&nightly), "nightly");
8196        assert_eq!(release_channel(&experimental), "experimental");
8197    }
8198
8199    #[test]
8200    fn renders_release_docs_from_entries() {
8201        let entries = vec![
8202            ReleaseDocEntry {
8203                tag: "v3.6.2".to_string(),
8204                version: Version::parse("3.6.2").expect("version"),
8205                date: "2026-04-27".to_string(),
8206            },
8207            ReleaseDocEntry {
8208                tag: "docs-0.1.1-alpha.1".to_string(),
8209                version: Version::parse("0.1.1-alpha.1").expect("version"),
8210                date: "2026-04-20".to_string(),
8211            },
8212        ];
8213        let changelog = render_changelog("xylex-group", "athena", &entries);
8214        assert!(changelog.contains("## [3.6.2]"));
8215        assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
8216        assert!(changelog.contains("Release channel: stable"));
8217        assert!(changelog.contains("Release channel: experimental"));
8218
8219        let security = render_security_policy(&entries);
8220        assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
8221        assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
8222    }
8223
8224    #[test]
8225    fn formats_release_commit_lines_with_sha_and_pr_links() {
8226        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
8227        let formatted =
8228            format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
8229                .expect("formatted line");
8230
8231        assert_eq!(
8232            formatted,
8233            "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
8234        );
8235    }
8236
8237    #[test]
8238    fn formats_release_commit_lines_with_linear_links_when_available() {
8239        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
8240        let issue_infos = BTreeMap::from([(
8241            "SUI-1336".to_string(),
8242            LinearIssueInfo {
8243                title: "Release flow".to_string(),
8244                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8245            },
8246        )]);
8247        let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
8248            .expect("formatted line");
8249
8250        assert_eq!(
8251            formatted,
8252            "[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)"
8253        );
8254    }
8255
8256    #[test]
8257    fn renders_release_notes_in_requested_layout() {
8258        let commits = vec![
8259            ReleaseCommitEntry {
8260                full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
8261                short_sha: "abcdef1".to_string(),
8262                subject: "Improve release docs (#42)".to_string(),
8263                date: "2026-05-24".to_string(),
8264            },
8265            ReleaseCommitEntry {
8266                full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
8267                short_sha: "fedcba9".to_string(),
8268                subject: "Fix release flow for SUI-1336".to_string(),
8269                date: "2026-05-25".to_string(),
8270            },
8271        ];
8272        let pull_request_infos = BTreeMap::from([(
8273            "42".to_string(),
8274            super::release_notes::GithubPullRequestInfo {
8275                title: "Improve release docs".to_string(),
8276                url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
8277            },
8278        )]);
8279        let issue_infos = BTreeMap::from([(
8280            "SUI-1336".to_string(),
8281            LinearIssueInfo {
8282                title: "Release flow".to_string(),
8283                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8284            },
8285        )]);
8286        let sections = build_fallback_sections(&commits);
8287        let rendered = render_release_notes(&ReleaseNotesRenderInput {
8288            release_title: "1.7.0 - athena-auth",
8289            current_tag_name: "v1.7.0",
8290            owner: "xylex-group",
8291            repo: "athena-auth",
8292            previous_tag: Some("v1.6.0"),
8293            sections: &sections,
8294            commit_entries: &commits,
8295            pull_request_infos: &pull_request_infos,
8296            linear_issue_infos: &issue_infos,
8297        });
8298
8299        assert_eq!(
8300            rendered,
8301            "# [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)"
8302        );
8303    }
8304
8305    #[test]
8306    fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
8307        let commits = vec![
8308            ReleaseCommitEntry {
8309                full_sha: "a".repeat(40),
8310                short_sha: "aaaaaaa".to_string(),
8311                subject: "Fix SUI-1336 and SUI-1440".to_string(),
8312                date: "2026-05-24".to_string(),
8313            },
8314            ReleaseCommitEntry {
8315                full_sha: "b".repeat(40),
8316                short_sha: "bbbbbbb".to_string(),
8317                subject: "Touch SUI-1336 again".to_string(),
8318                date: "2026-05-25".to_string(),
8319            },
8320        ];
8321
8322        assert_eq!(
8323            collect_linear_issue_identifiers(&commits),
8324            vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
8325        );
8326    }
8327
8328    #[test]
8329    fn release_title_defaults_to_version_and_repo() {
8330        assert_eq!(
8331            default_release_title(&Version::new(1, 7, 0), "athena-auth"),
8332            "1.7.0 - athena-auth"
8333        );
8334    }
8335
8336    #[test]
8337    fn deduplicates_release_commit_entries_by_exact_subject() {
8338        let commits = vec![
8339            ReleaseCommitEntry {
8340                full_sha: "a".repeat(40),
8341                short_sha: "aaaaaaa".to_string(),
8342                subject: "Improve release docs".to_string(),
8343                date: "2026-05-24".to_string(),
8344            },
8345            ReleaseCommitEntry {
8346                full_sha: "b".repeat(40),
8347                short_sha: "bbbbbbb".to_string(),
8348                subject: "Improve release docs".to_string(),
8349                date: "2026-05-25".to_string(),
8350            },
8351        ];
8352
8353        let deduplicated = deduplicate_release_commit_entries(&commits);
8354        assert_eq!(deduplicated.len(), 1);
8355        assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
8356    }
8357
8358    #[test]
8359    fn fallback_sections_collapse_related_commit_themes() {
8360        let commits = vec![
8361            ReleaseCommitEntry {
8362                full_sha: "a".repeat(40),
8363                short_sha: "chat001".to_string(),
8364                subject: "Add optimistic chat retries".to_string(),
8365                date: "2026-06-01".to_string(),
8366            },
8367            ReleaseCommitEntry {
8368                full_sha: "b".repeat(40),
8369                short_sha: "chat002".to_string(),
8370                subject: "Persist deleted-message state in chat".to_string(),
8371                date: "2026-06-01".to_string(),
8372            },
8373            ReleaseCommitEntry {
8374                full_sha: "c".repeat(40),
8375                short_sha: "file001".to_string(),
8376                subject: "Fix upload UTF-8 audit retry handling".to_string(),
8377                date: "2026-06-01".to_string(),
8378            },
8379            ReleaseCommitEntry {
8380                full_sha: "d".repeat(40),
8381                short_sha: "ath001".to_string(),
8382                subject: "Migrate form progress routes to Athena".to_string(),
8383                date: "2026-06-01".to_string(),
8384            },
8385            ReleaseCommitEntry {
8386                full_sha: "e".repeat(40),
8387                short_sha: "ath002".to_string(),
8388                subject: "Update Athena models and package wiring".to_string(),
8389                date: "2026-06-01".to_string(),
8390            },
8391        ];
8392
8393        let sections = build_fallback_sections(&commits);
8394        assert_eq!(sections.len(), 3);
8395        assert_eq!(sections[0].title, "Cases & Communication");
8396        assert!(!sections[0].summary.is_empty());
8397        assert_eq!(sections[0].bullets.len(), 2);
8398        assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
8399        assert!(sections[0].bullets[0].summary.contains("chat"));
8400        assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
8401        assert!(sections[0].bullets[1].summary.contains("deleted-message"));
8402        assert_eq!(sections[1].title, "Reliability");
8403        assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
8404        assert_eq!(sections[2].title, "Athena Migration");
8405        assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
8406    }
8407
8408    #[test]
8409    fn appends_release_label_footer_for_pre_release() {
8410        let with_label = append_release_label_footer("# Release", true);
8411        assert_eq!(
8412            with_label,
8413            format!(
8414                "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
8415                env!("CARGO_PKG_VERSION")
8416            )
8417        );
8418    }
8419}