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 !target.absolute.exists() {
1868            continue;
1869        }
1870
1871        match read_version_from_resolved_path(target) {
1872            Ok(Some(version)) => {
1873                let parsed = parse_version(&version)?;
1874                if highest
1875                    .as_ref()
1876                    .is_none_or(|current: &Version| parsed > *current)
1877                {
1878                    highest = Some(parsed);
1879                }
1880            }
1881            Ok(None) => {}
1882            Err(err) => return Err(err),
1883        }
1884    }
1885    Ok(highest)
1886}
1887
1888fn write_version_to_selected_paths(
1889    targets: &[ResolvedRegistryPath],
1890    version: &Version,
1891    allow_noop_when_targets_exist: bool,
1892) -> Result<Vec<PathBuf>, String> {
1893    let mut updated = 0usize;
1894    let mut matched_targets = 0usize;
1895    let mut updated_paths = Vec::new();
1896    let mut errors = Vec::new();
1897
1898    for entry in targets {
1899        let path = &entry.absolute;
1900        if !path.exists() {
1901            continue;
1902        }
1903        matched_targets += 1;
1904        match write_version_to_resolved_path(entry, version) {
1905            Ok(true) => {
1906                updated += 1;
1907                updated_paths.push(path.clone());
1908            }
1909            Ok(false) => {}
1910            Err(error) => errors.push(format!("{}: {}", path.display(), error)),
1911        }
1912    }
1913
1914    if matched_targets == 0 && errors.is_empty() {
1915        return Err("No selected version files were found to update.".to_string());
1916    }
1917    if !errors.is_empty() {
1918        return Err(format!(
1919            "Updated {} file(s), but some selected version targets failed:\n{}",
1920            updated,
1921            errors.join("\n")
1922        ));
1923    }
1924    if updated == 0 && allow_noop_when_targets_exist {
1925        return Ok(updated_paths);
1926    }
1927    Ok(updated_paths)
1928}
1929
1930fn initialize_release_ledger(
1931    version_scope: &VersionScope,
1932    repo: &str,
1933    release_version: &Version,
1934    tag_name: &str,
1935    branch_name: Option<String>,
1936    selection: &ChangedTargetSelection,
1937) -> ReleaseLedger {
1938    ReleaseLedger {
1939        scope_kind: version_scope_kind(version_scope).to_string(),
1940        scope_label: version_scope_label(version_scope, repo),
1941        scope_slug: current_scope_slug(version_scope, repo),
1942        version: release_version.to_string(),
1943        tag_name: tag_name.to_string(),
1944        branch_name,
1945        baseline: ReleaseBaselineRecord {
1946            source: selection.baseline_source.clone(),
1947            reference: selection.baseline_reference.clone(),
1948            tag: selection.baseline_tag.clone(),
1949        },
1950        selection: ReleaseSelectionRecord {
1951            changed_files: selection.changed_files.clone(),
1952            version_targets: selection
1953                .version_targets
1954                .iter()
1955                .map(|target| ReleaseVersionTarget {
1956                    path: target.relative.clone(),
1957                })
1958                .collect(),
1959            publish_targets: selection.publish_targets.clone(),
1960        },
1961        status: ReleaseLedgerStatus::InProgress,
1962        steps: BTreeMap::new(),
1963        created_at: Some(chrono::Utc::now()),
1964        updated_at: Some(chrono::Utc::now()),
1965        completed_at: None,
1966    }
1967}
1968
1969fn update_ledger_step(
1970    ledger: &mut ReleaseLedger,
1971    step_name: &str,
1972    status: ReleaseLedgerStepStatus,
1973    details: Option<serde_json::Value>,
1974    last_error: Option<String>,
1975) {
1976    let now = chrono::Utc::now();
1977    let step = ledger.steps.entry(step_name.to_string()).or_default();
1978    if step.started_at.is_none() {
1979        step.started_at = Some(now);
1980    }
1981    if matches!(
1982        status,
1983        ReleaseLedgerStepStatus::Completed
1984            | ReleaseLedgerStepStatus::Failed
1985            | ReleaseLedgerStepStatus::Skipped
1986            | ReleaseLedgerStepStatus::VerifiedRemote
1987    ) {
1988        step.completed_at = Some(now);
1989    }
1990    step.status = status;
1991    if let Some(details) = details {
1992        step.details = details;
1993    }
1994    step.last_error = last_error;
1995    ledger.updated_at = Some(now);
1996    ledger.status = match step.status {
1997        ReleaseLedgerStepStatus::Failed => ReleaseLedgerStatus::Failed,
1998        _ => ReleaseLedgerStatus::InProgress,
1999    };
2000}
2001
2002fn complete_release_ledger(ledger: &mut ReleaseLedger) {
2003    let now = chrono::Utc::now();
2004    ledger.status = ReleaseLedgerStatus::Completed;
2005    ledger.updated_at = Some(now);
2006    ledger.completed_at = Some(now);
2007}
2008
2009fn ledger_version_targets_to_resolved(
2010    project_root: &Path,
2011    ledger: &ReleaseLedger,
2012) -> Vec<ResolvedRegistryPath> {
2013    ledger
2014        .selection
2015        .version_targets
2016        .iter()
2017        .map(|target| ResolvedRegistryPath {
2018            relative: target.path.clone(),
2019            absolute: project_root.join(&target.path),
2020            cargo_package_override: None,
2021        })
2022        .collect()
2023}
2024
2025fn print_selected_release_targets(selection: &ChangedTargetSelection) {
2026    println!("{}", "Selected release targets".bright_cyan().bold());
2027    if selection.version_targets.is_empty() {
2028        println!("  {}", "No version targets selected".dimmed());
2029    } else {
2030        println!("  {}", "Version targets".bright_white());
2031        for target in &selection.version_targets {
2032            println!("    {} {}", "•".bright_cyan(), target.relative);
2033        }
2034    }
2035    if selection.publish_targets.is_empty() {
2036        println!("  {}", "Publish targets: none".dimmed());
2037    } else {
2038        println!("  {}", "Publish targets".bright_white());
2039        for target in &selection.publish_targets {
2040            println!(
2041                "    {} {} {}",
2042                "•".bright_cyan(),
2043                target.kind,
2044                target.manifest_path
2045            );
2046        }
2047    }
2048}
2049
2050fn begin_release_step(
2051    ledger: &mut ReleaseLedger,
2052    ledger_path: &Path,
2053    step_name: &str,
2054    details: Option<serde_json::Value>,
2055) -> Result<(), String> {
2056    update_ledger_step(
2057        ledger,
2058        step_name,
2059        ReleaseLedgerStepStatus::Running,
2060        details,
2061        None,
2062    );
2063    save_release_ledger(ledger_path, ledger)
2064}
2065
2066fn complete_release_step(
2067    ledger: &mut ReleaseLedger,
2068    ledger_path: &Path,
2069    step_name: &str,
2070    status: ReleaseLedgerStepStatus,
2071    details: Option<serde_json::Value>,
2072) -> Result<(), String> {
2073    update_ledger_step(ledger, step_name, status, details, None);
2074    save_release_ledger(ledger_path, ledger)
2075}
2076
2077fn fail_release_step(
2078    ledger: &mut ReleaseLedger,
2079    ledger_path: &Path,
2080    step_name: &str,
2081    error: &str,
2082) -> Result<(), String> {
2083    update_ledger_step(
2084        ledger,
2085        step_name,
2086        ReleaseLedgerStepStatus::Failed,
2087        None,
2088        Some(error.to_string()),
2089    );
2090    save_release_ledger(ledger_path, ledger)
2091}
2092
2093async fn verify_selected_publish_targets(
2094    project_root: &Path,
2095    selected_publish_targets: &[ReleasePublishTarget],
2096    expected_version: &Version,
2097) -> Result<bool, String> {
2098    if selected_publish_targets.is_empty() {
2099        return Ok(false);
2100    }
2101    let client = reqwest::Client::new();
2102    for target in selected_publish_targets {
2103        let manifest_path = project_root.join(&target.manifest_path);
2104        let package_name = target
2105            .package_name
2106            .clone()
2107            .or_else(|| detect_package_name_from_manifest(&target.kind, &manifest_path))
2108            .ok_or_else(|| {
2109                format!(
2110                    "Could not resolve package name for selected publish target `{}`.",
2111                    target.manifest_path
2112                )
2113            })?;
2114        let details = fetch_registry_latest_version(&client, &target.kind, &package_name).await?;
2115        if details.version.trim() != expected_version.to_string() {
2116            return Ok(false);
2117        }
2118    }
2119    Ok(true)
2120}
2121
2122fn detect_package_name_from_manifest(kind: &str, manifest_path: &Path) -> Option<String> {
2123    match kind {
2124        "npm" => {
2125            let content = fs::read_to_string(manifest_path).ok()?;
2126            let json: JsonValue = serde_json::from_str(&content).ok()?;
2127            json.get("name")
2128                .and_then(JsonValue::as_str)
2129                .map(str::trim)
2130                .filter(|value| !value.is_empty())
2131                .map(str::to_string)
2132        }
2133        "crates" => cargo_package_name(manifest_path).ok().flatten(),
2134        _ => None,
2135    }
2136}
2137
2138fn publish_target_filter_from_selection(
2139    selected_publish_targets: &[ReleasePublishTarget],
2140) -> Result<Option<String>, String> {
2141    let kinds = selected_publish_targets
2142        .iter()
2143        .map(|target| target.kind.as_str())
2144        .collect::<BTreeSet<_>>();
2145    if kinds.is_empty() || kinds.len() > 1 {
2146        return Ok(None);
2147    }
2148    let kind = kinds.iter().next().copied().unwrap_or_default();
2149    if kind.is_empty() {
2150        return Ok(None);
2151    }
2152    match kind {
2153        "npm" | "crates" => Ok(Some(kind.to_string())),
2154        other => Err(format!(
2155            "Unsupported selected publish target kind `{}` in release ledger.",
2156            other
2157        )),
2158    }
2159}
2160
2161fn collect_version_report(
2162    project_root: &Path,
2163    invocation_dir: &Path,
2164    registry: &[String],
2165    version_scope: &VersionScope,
2166) -> VersionReport {
2167    let mut report: VersionReport = VersionReport::default();
2168    let repo_label = project_root
2169        .file_name()
2170        .and_then(|value| value.to_str())
2171        .unwrap_or("repository");
2172    let scope_slug = current_scope_slug(version_scope, repo_label);
2173    report.worktree = collect_local_versions(
2174        project_root,
2175        invocation_dir,
2176        registry,
2177        version_scope,
2178        &mut report.warnings,
2179    );
2180    match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
2181        Ok(entries) => report.head = entries,
2182        Err(err) => report.warnings.push(err),
2183    }
2184    match collect_git_versions(project_root, version_scope) {
2185        Ok(tags) => report.local_tags = tags,
2186        Err(err) => report.warnings.push(err),
2187    }
2188    match collect_remote_git_versions(project_root, "origin", version_scope) {
2189        Ok(tags) => report.remote_tags = tags,
2190        Err(err) => report.warnings.push(err),
2191    }
2192    match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
2193        Ok(files) => report.dirty_files = files,
2194        Err(err) => report.warnings.push(err),
2195    }
2196    match find_latest_unfinished_release_ledger(project_root, &scope_slug) {
2197        Ok(ledger) => report.release_ledger = ledger,
2198        Err(err) => report.warnings.push(err),
2199    }
2200    report
2201}
2202
2203fn resolve_current_version_for_bump(
2204    project_root: &Path,
2205    invocation_dir: &Path,
2206    registry: &[String],
2207    version_scope: &VersionScope,
2208) -> Version {
2209    let mut _warnings = Vec::new();
2210    let local_versions = collect_local_versions(
2211        project_root,
2212        invocation_dir,
2213        registry,
2214        version_scope,
2215        &mut _warnings,
2216    );
2217    let head_versions =
2218        collect_head_versions(project_root, invocation_dir, registry, version_scope)
2219            .unwrap_or_default();
2220    let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
2221
2222    local_versions
2223        .iter()
2224        .map(|entry| entry.version.clone())
2225        .chain(head_versions.iter().map(|entry| entry.version.clone()))
2226        .chain(local_tags.iter().map(|entry| entry.version.clone()))
2227        .max()
2228        .unwrap_or_else(default_version)
2229}
2230
2231async fn auto_commit_command_paths(
2232    project_root: &Path,
2233    paths: Vec<PathBuf>,
2234    message: String,
2235    action_label: &'static str,
2236) {
2237    match commit_paths(AutoCommitRequest {
2238        project_root,
2239        paths,
2240        message,
2241        action_label,
2242    })
2243    .await
2244    {
2245        Ok(AutoCommitResult::Committed(_)) => {}
2246        Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
2247        Err(e) => print_skip(action_label, &e),
2248    }
2249}
2250
2251async fn collect_registry_versions(
2252    project_root: &Path,
2253    invocation_dir: &Path,
2254    lookups: &[PackageNameLookup],
2255    version_scope: &VersionScope,
2256    warnings: &mut Vec<String>,
2257) -> Vec<RegistryVersionObservation> {
2258    let mut entries: Vec<RegistryVersionObservation> = Vec::new();
2259    let mut seen: BTreeSet<String> = BTreeSet::new();
2260    let client: reqwest::Client = reqwest::Client::new();
2261
2262    for lookup in lookups {
2263        let dedupe_key: String = format!(
2264            "{}|{}|{}|{}",
2265            lookup.file, lookup.format, lookup.key, lookup.registry
2266        );
2267        if !seen.insert(dedupe_key) {
2268            continue;
2269        }
2270
2271        let source_file = resolve_registry_relative_path(
2272            project_root,
2273            invocation_dir,
2274            version_scope,
2275            &lookup.file,
2276        );
2277        let path = project_root.join(&source_file);
2278        if !path.exists() {
2279            continue;
2280        }
2281
2282        let content: String = match fs::read_to_string(&path) {
2283            Ok(content) => content,
2284            Err(err) => {
2285                warnings.push(format!("Failed to read {}: {}", path.display(), err));
2286                continue;
2287            }
2288        };
2289
2290        let package_name: String = match read_package_name_from_lookup(lookup, &content) {
2291            Ok(Some(value)) => value,
2292            Ok(None) => continue,
2293            Err(err) => {
2294                warnings.push(format!("{}: {}", source_file, err));
2295                continue;
2296            }
2297        };
2298
2299        let (latest, raw_version, published_at, note) =
2300            match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
2301                Ok(details) => {
2302                    let parsed: Option<Version> = parse_version(&details.version).ok();
2303                    let note: Option<String> = if parsed.is_none() {
2304                        Some(format!("Non-semver registry version: {}", details.version))
2305                    } else {
2306                        None
2307                    };
2308                    (parsed, Some(details.version), details.published_at, note)
2309                }
2310                Err(err) => (None, None, None, Some(err)),
2311            };
2312
2313        entries.push(RegistryVersionObservation {
2314            registry: lookup.registry.clone(),
2315            package_name,
2316            source_file,
2317            latest,
2318            raw_version,
2319            published_at,
2320            note,
2321        });
2322    }
2323
2324    entries.sort_by(|a, b| {
2325        a.registry
2326            .cmp(&b.registry)
2327            .then_with(|| a.package_name.cmp(&b.package_name))
2328    });
2329    entries
2330}
2331
2332fn read_package_name_from_lookup(
2333    lookup: &PackageNameLookup,
2334    content: &str,
2335) -> Result<Option<String>, String> {
2336    let key_parts: Vec<&str> = lookup
2337        .key
2338        .split('.')
2339        .map(|part| part.trim())
2340        .filter(|part| !part.is_empty())
2341        .collect();
2342    if key_parts.is_empty() {
2343        return Err("Lookup key cannot be empty".to_string());
2344    }
2345
2346    let format: String = lookup.format.trim().to_ascii_lowercase();
2347    match format.as_str() {
2348        "json" => {
2349            let value: JsonValue = serde_json::from_str(content)
2350                .map_err(|e| format!("Failed to parse JSON: {}", e))?;
2351            Ok(json_lookup_string(&value, &key_parts))
2352        }
2353        "yaml" | "yml" => {
2354            let value: YamlValue = serde_yaml::from_str(content)
2355                .map_err(|e| format!("Failed to parse YAML: {}", e))?;
2356            Ok(yaml_lookup_string(&value, &key_parts))
2357        }
2358        "toml" => {
2359            let value: TomlValue =
2360                toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2361            Ok(toml_lookup_string(&value, &key_parts))
2362        }
2363        other => Err(format!("Unsupported lookup format `{}`", other)),
2364    }
2365}
2366
2367async fn fetch_registry_latest_version(
2368    client: &reqwest::Client,
2369    registry: &str,
2370    package_name: &str,
2371) -> Result<RegistryVersionDetails, String> {
2372    let normalized_registry: String = registry.trim().to_ascii_lowercase();
2373    match normalized_registry.as_str() {
2374        "npm" => fetch_npm_latest_version(client, package_name).await,
2375        "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
2376        _ => Err(format!("Unsupported registry `{}`", registry)),
2377    }
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct NpmLatestResponse {
2382    #[serde(rename = "dist-tags")]
2383    dist_tags: NpmDistTags,
2384    #[serde(default)]
2385    time: BTreeMap<String, String>,
2386}
2387
2388#[derive(Debug, Deserialize)]
2389struct NpmDistTags {
2390    latest: String,
2391}
2392
2393async fn fetch_npm_latest_version(
2394    client: &reqwest::Client,
2395    package_name: &str,
2396) -> Result<RegistryVersionDetails, String> {
2397    let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
2398        .map_err(|e| format!("Failed to build npm URL: {}", e))?;
2399    {
2400        let mut segments = url
2401            .path_segments_mut()
2402            .map_err(|_| "Failed to compose npm URL segments".to_string())?;
2403        segments.push(package_name);
2404    }
2405
2406    let response: reqwest::Response = client
2407        .get(url)
2408        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2409        .send()
2410        .await
2411        .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
2412
2413    if !response.status().is_success() {
2414        return Err(format!(
2415            "npm lookup for {} returned status {}",
2416            package_name,
2417            response.status()
2418        ));
2419    }
2420
2421    let payload: NpmLatestResponse = response
2422        .json()
2423        .await
2424        .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
2425    Ok(RegistryVersionDetails {
2426        published_at: payload.time.get(&payload.dist_tags.latest).cloned(),
2427        version: payload.dist_tags.latest,
2428    })
2429}
2430
2431#[derive(Debug, Deserialize)]
2432struct CratesIoMeta {
2433    newest_version: String,
2434}
2435
2436#[derive(Debug, Deserialize)]
2437struct CratesIoVersionMeta {
2438    num: String,
2439    created_at: Option<String>,
2440}
2441
2442#[derive(Debug, Deserialize)]
2443struct CratesIoLatestResponse {
2444    #[serde(rename = "crate")]
2445    crate_meta: CratesIoMeta,
2446    #[serde(default)]
2447    versions: Vec<CratesIoVersionMeta>,
2448}
2449
2450async fn fetch_crates_latest_version(
2451    client: &reqwest::Client,
2452    package_name: &str,
2453) -> Result<RegistryVersionDetails, String> {
2454    let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
2455        .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
2456    {
2457        let mut segments = url
2458            .path_segments_mut()
2459            .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
2460        segments.push(package_name);
2461    }
2462
2463    let response: reqwest::Response = client
2464        .get(url)
2465        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
2466        .send()
2467        .await
2468        .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
2469
2470    if !response.status().is_success() {
2471        return Err(format!(
2472            "crates.io lookup for {} returned status {}",
2473            package_name,
2474            response.status()
2475        ));
2476    }
2477
2478    let payload: CratesIoLatestResponse = response.json().await.map_err(|e| {
2479        format!(
2480            "Failed to parse crates.io response for {}: {}",
2481            package_name, e
2482        )
2483    })?;
2484    let published_at = payload
2485        .versions
2486        .iter()
2487        .find(|version| version.num == payload.crate_meta.newest_version)
2488        .and_then(|version| version.created_at.clone());
2489    Ok(RegistryVersionDetails {
2490        version: payload.crate_meta.newest_version,
2491        published_at,
2492    })
2493}
2494
2495fn collect_local_versions(
2496    project_root: &Path,
2497    invocation_dir: &Path,
2498    registry: &[String],
2499    version_scope: &VersionScope,
2500    warnings: &mut Vec<String>,
2501) -> Vec<VersionObservation> {
2502    let mut observed = Vec::new();
2503
2504    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2505        let path: &PathBuf = &entry.absolute;
2506        if !path.exists() {
2507            continue;
2508        }
2509
2510        match read_version_from_resolved_path(&entry) {
2511            Ok(Some(version)) => {
2512                if let Ok(parsed) = parse_version(&version) {
2513                    observed.push(VersionObservation {
2514                        location: entry.relative.clone(),
2515                        version: parsed,
2516                    });
2517                } else {
2518                    warnings.push(format!("Ignoring non-semver version in {}", path.display()));
2519                }
2520            }
2521            Ok(None) => {}
2522            Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
2523        }
2524    }
2525
2526    observed.sort_by(|a, b| a.location.cmp(&b.location));
2527    observed
2528}
2529
2530fn collect_git_versions(
2531    project_root: &Path,
2532    version_scope: &VersionScope,
2533) -> Result<Vec<GitTagObservation>, String> {
2534    if !command_exists("git") {
2535        return Err("Git is not installed; skipping git tag inspection.".to_string());
2536    }
2537
2538    let output: std::process::Output = Command::new("git")
2539        .current_dir(project_root)
2540        .args(["tag", "--list"])
2541        .output()
2542        .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
2543
2544    if !output.status.success() {
2545        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2546        if stderr.is_empty() {
2547            return Err("`git tag --list` failed in the current directory.".to_string());
2548        }
2549        return Err(format!("`git tag --list` failed: {}", stderr));
2550    }
2551
2552    Ok(parse_local_git_tag_output_for_scope(
2553        &String::from_utf8_lossy(&output.stdout),
2554        version_scope,
2555    ))
2556}
2557
2558fn collect_remote_git_versions(
2559    project_root: &Path,
2560    remote: &str,
2561    version_scope: &VersionScope,
2562) -> Result<Vec<GitTagObservation>, String> {
2563    if !command_exists("git") {
2564        return Err("Git is not installed; skipping remote tag inspection.".to_string());
2565    }
2566
2567    let output: std::process::Output = Command::new("git")
2568        .current_dir(project_root)
2569        .args(["ls-remote", "--tags", remote])
2570        .output()
2571        .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
2572
2573    if !output.status.success() {
2574        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2575        if stderr.is_empty() {
2576            return Err(format!("`git ls-remote --tags {}` failed.", remote));
2577        }
2578        return Err(format!(
2579            "`git ls-remote --tags {}` failed: {}",
2580            remote, stderr
2581        ));
2582    }
2583
2584    Ok(parse_remote_git_tag_output_for_scope(
2585        &String::from_utf8_lossy(&output.stdout),
2586        version_scope,
2587    ))
2588}
2589
2590fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
2591    let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
2592
2593    if tags.is_empty() {
2594        println!("No semantic git tags found in {}.", project_root.display());
2595        return Ok(());
2596    }
2597
2598    println!("Git versions from `git tag --list`:");
2599    for tag in tags {
2600        if tag.raw_tags.len() > 1 {
2601            println!("  {}  ({})", tag.version, tag.raw_tags.join(", "));
2602        } else {
2603            println!("  {}", tag.version);
2604        }
2605    }
2606
2607    Ok(())
2608}
2609
2610fn print_version_observations(
2611    title: &str,
2612    entries: &[VersionObservation],
2613    dirty_files: Option<&[String]>,
2614) {
2615    println!();
2616    println!("{}", title.bright_cyan().bold());
2617    println!("{}", "─".repeat(72).bright_black());
2618
2619    if entries.is_empty() {
2620        println!("  {}", "none found".dimmed());
2621        return;
2622    }
2623
2624    let Some(highest) = highest_version_observation(entries) else {
2625        println!("  {}", "none found".dimmed());
2626        return;
2627    };
2628
2629    let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
2630    let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
2631    println!(
2632        "  {:<28} {} ({}/{})",
2633        "latest".bright_white(),
2634        highest.to_string().bright_green().bold(),
2635        latest_count,
2636        entries.len()
2637    );
2638
2639    if stale_entries.is_empty() {
2640        return;
2641    }
2642
2643    println!("  {}", "stale entries".bright_yellow().bold());
2644    for entry in stale_entries {
2645        let dirty: bool = dirty_files
2646            .map(|files| files.iter().any(|file| file == &entry.location))
2647            .unwrap_or(false);
2648        let dirty_suffix: String = if dirty {
2649            format!(" {}", "modified".bright_magenta())
2650        } else {
2651            String::new()
2652        };
2653
2654        println!(
2655            "  {:<28} {}{}",
2656            entry.location.bright_white(),
2657            entry.version.to_string().bright_green(),
2658            dirty_suffix
2659        );
2660    }
2661}
2662
2663fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
2664    println!();
2665    println!("{}", title.bright_cyan().bold());
2666    println!("{}", "─".repeat(72).bright_black());
2667
2668    if tags.is_empty() {
2669        println!("  {}", "none found".dimmed());
2670        return;
2671    }
2672
2673    let latest = &tags[0];
2674    if latest.raw_tags.len() > 1 {
2675        println!(
2676            "  {:<20} {}",
2677            latest.version.to_string().bright_green().bold(),
2678            latest.raw_tags.join(", ").dimmed()
2679        );
2680    } else {
2681        println!("  {}", latest.version.to_string().bright_green().bold());
2682    }
2683
2684    if tags.len() > 1 {
2685        let older = tags
2686            .iter()
2687            .skip(1)
2688            .take(5)
2689            .map(|tag| tag.version.to_string())
2690            .collect::<Vec<_>>();
2691        println!(
2692            "  {:<20} {}",
2693            "older tags".bright_white(),
2694            older.join(", ").dimmed()
2695        );
2696        if tags.len() > 6 {
2697            println!(
2698                "  {:<20} {}",
2699                "older hidden".bright_white(),
2700                format!("{} more", tags.len() - 6).dimmed()
2701            );
2702        }
2703    }
2704}
2705
2706fn collect_head_versions(
2707    project_root: &Path,
2708    invocation_dir: &Path,
2709    registry: &[String],
2710    version_scope: &VersionScope,
2711) -> Result<Vec<VersionObservation>, String> {
2712    if !command_exists("git") {
2713        return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
2714    }
2715
2716    let head_check = Command::new("git")
2717        .current_dir(project_root)
2718        .args(["rev-parse", "--verify", "HEAD"])
2719        .output()
2720        .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
2721
2722    if !head_check.status.success() {
2723        return Ok(Vec::new());
2724    }
2725
2726    let mut observed = Vec::new();
2727    let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
2728
2729    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2730        let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
2731            continue;
2732        };
2733
2734        match read_version_from_blob_with_override(
2735            &entry.relative,
2736            &content,
2737            cargo_toml_content.as_deref(),
2738            entry.cargo_package_override.as_deref(),
2739        ) {
2740            Ok(Some(version)) => {
2741                if let Ok(parsed) = parse_version(&version) {
2742                    observed.push(VersionObservation {
2743                        location: entry.relative.clone(),
2744                        version: parsed,
2745                    });
2746                }
2747            }
2748            Ok(None) => {}
2749            Err(_) => {}
2750        }
2751    }
2752
2753    observed.sort_by(|a, b| a.location.cmp(&b.location));
2754    Ok(observed)
2755}
2756
2757fn collect_dirty_version_files(
2758    project_root: &Path,
2759    invocation_dir: &Path,
2760    registry: &[String],
2761    version_scope: &VersionScope,
2762) -> Result<Vec<String>, String> {
2763    if !command_exists("git") {
2764        return Err("Git is not installed; skipping worktree status inspection.".to_string());
2765    }
2766
2767    let mut args = vec!["status", "--porcelain", "--"];
2768    let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
2769    for entry in &resolved {
2770        args.push(entry.relative.as_str());
2771    }
2772
2773    let output = Command::new("git")
2774        .current_dir(project_root)
2775        .args(&args)
2776        .output()
2777        .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
2778
2779    if !output.status.success() {
2780        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2781        if stderr.is_empty() {
2782            return Err("`git status --porcelain` failed.".to_string());
2783        }
2784        return Err(format!("`git status --porcelain` failed: {}", stderr));
2785    }
2786
2787    let mut dirty = Vec::new();
2788    for line in String::from_utf8_lossy(&output.stdout).lines() {
2789        if line.len() < 4 {
2790            continue;
2791        }
2792        let path = line[3..].trim();
2793        if !path.is_empty() {
2794            dirty.push(path.replace('\\', "/"));
2795        }
2796    }
2797
2798    dirty.sort();
2799    dirty.dedup();
2800    Ok(dirty)
2801}
2802
2803fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
2804    let output = Command::new("git")
2805        .current_dir(project_root)
2806        .args(["show", &format!("HEAD:{}", relative)])
2807        .output()
2808        .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
2809
2810    if !output.status.success() {
2811        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2812        if stderr.is_empty() {
2813            return Err(format!("{} is not present in HEAD", relative));
2814        }
2815        return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
2816    }
2817
2818    String::from_utf8(output.stdout)
2819        .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
2820}
2821
2822fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2823    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2824    for line in output.lines() {
2825        let tag = line.trim();
2826        if tag.is_empty() {
2827            continue;
2828        }
2829        if let Ok(version) = parse_version(tag) {
2830            by_version.entry(version).or_default().push(tag.to_string());
2831        }
2832    }
2833    git_tag_map_to_vec(by_version)
2834}
2835
2836fn parse_local_git_tag_output_for_scope(
2837    output: &str,
2838    version_scope: &VersionScope,
2839) -> Vec<GitTagObservation> {
2840    match version_scope {
2841        VersionScope::Repository => parse_local_git_tag_output(output),
2842        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2843            parse_scoped_git_tag_output(output, tag_prefix)
2844        }
2845    }
2846}
2847
2848fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
2849    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2850    for line in output.lines() {
2851        let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2852        let tag = reference
2853            .strip_prefix("refs/tags/")
2854            .unwrap_or(reference)
2855            .trim_end_matches("^{}")
2856            .trim();
2857
2858        if tag.is_empty() {
2859            continue;
2860        }
2861        if let Ok(version) = parse_version(tag) {
2862            by_version.entry(version).or_default().push(tag.to_string());
2863        }
2864    }
2865    git_tag_map_to_vec(by_version)
2866}
2867
2868fn parse_remote_git_tag_output_for_scope(
2869    output: &str,
2870    version_scope: &VersionScope,
2871) -> Vec<GitTagObservation> {
2872    match version_scope {
2873        VersionScope::Repository => parse_remote_git_tag_output(output),
2874        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
2875            let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2876            for line in output.lines() {
2877                let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
2878                let tag = reference
2879                    .strip_prefix("refs/tags/")
2880                    .unwrap_or(reference)
2881                    .trim_end_matches("^{}")
2882                    .trim();
2883
2884                if tag.is_empty() {
2885                    continue;
2886                }
2887                if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2888                    by_version.entry(version).or_default().push(tag.to_string());
2889                }
2890            }
2891            git_tag_map_to_vec(by_version)
2892        }
2893    }
2894}
2895
2896fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
2897    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
2898    for line in output.lines() {
2899        let tag = line.trim();
2900        if tag.is_empty() {
2901            continue;
2902        }
2903        if let Some(version) = parse_release_family_version(tag, tag_prefix) {
2904            by_version.entry(version).or_default().push(tag.to_string());
2905        }
2906    }
2907    git_tag_map_to_vec(by_version)
2908}
2909
2910fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
2911    let mut versions: Vec<GitTagObservation> = by_version
2912        .into_iter()
2913        .map(|(version, mut raw_tags)| {
2914            raw_tags.sort();
2915            raw_tags.dedup();
2916            GitTagObservation { version, raw_tags }
2917        })
2918        .collect();
2919    versions.sort_by(|a, b| b.version.cmp(&a.version));
2920    versions
2921}
2922
2923#[cfg(test)]
2924fn read_version_from_blob(
2925    relative: &str,
2926    content: &str,
2927    cargo_toml_content: Option<&str>,
2928) -> Result<Option<String>, String> {
2929    read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
2930}
2931
2932fn read_version_from_blob_with_override(
2933    relative: &str,
2934    content: &str,
2935    cargo_toml_content: Option<&str>,
2936    cargo_package_override: Option<&str>,
2937) -> Result<Option<String>, String> {
2938    let file_name = Path::new(relative)
2939        .file_name()
2940        .and_then(|n| n.to_str())
2941        .unwrap_or_default();
2942
2943    match file_name {
2944        "README.md" => read_readme_version_from_content(content),
2945        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2946            read_openapi_version_from_content(content)
2947        }
2948        "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
2949        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2950        | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
2951        "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
2952        "Cargo.toml" => read_cargo_toml_version_from_content(content),
2953        "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
2954            content,
2955            cargo_toml_content,
2956            cargo_package_override,
2957        ),
2958        "pyproject.toml" => read_pyproject_version_from_content(content),
2959        "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
2960        "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
2961        "pom.xml" => {
2962            read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
2963        }
2964        "build.gradle" | "build.gradle.kts" => {
2965            read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
2966        }
2967        "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
2968        _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
2969            Some("json") => read_json_root_version_from_content(content),
2970            Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
2971            Some("toml") => read_toml_root_version_from_content(content),
2972            Some("md") => read_readme_version_from_content(content),
2973            _ => Ok(None),
2974        },
2975    }
2976}
2977
2978fn print_version_report(project_root: &Path, report: &VersionReport) {
2979    let dirty_suffix = if report.dirty_files.is_empty() {
2980        "clean".green().to_string()
2981    } else {
2982        format!("dirty ({})", report.dirty_files.len())
2983            .bright_magenta()
2984            .to_string()
2985    };
2986
2987    println!(
2988        "\n{} {}",
2989        "Version Summary".bright_cyan().bold(),
2990        project_root.display().to_string().bright_white()
2991    );
2992    println!("{}", "─".repeat(72).bright_black());
2993    println!(
2994        "{:<20} {}",
2995        "Highest available".bright_white(),
2996        report.highest_available().to_string().bright_green().bold()
2997    );
2998    println!(
2999        "{:<20} {}",
3000        "Worktree".bright_white(),
3001        report
3002            .highest_worktree()
3003            .unwrap_or_else(default_version)
3004            .to_string()
3005            .bright_yellow()
3006    );
3007    println!(
3008        "{:<20} {}",
3009        "Committed HEAD".bright_white(),
3010        report
3011            .highest_head()
3012            .map(|v| v.to_string())
3013            .unwrap_or_else(|| "none".dimmed().to_string())
3014    );
3015    println!(
3016        "{:<20} {}",
3017        "GitHub tags".bright_white(),
3018        report
3019            .highest_remote_tag()
3020            .map(|v| v.to_string())
3021            .unwrap_or_else(|| "none".dimmed().to_string())
3022    );
3023    println!(
3024        "{:<20} {}",
3025        "Registry latest".bright_white(),
3026        report
3027            .highest_registry()
3028            .map(|v| v.to_string())
3029            .unwrap_or_else(|| "none".dimmed().to_string())
3030    );
3031    println!(
3032        "{:<20} {}",
3033        "Local tags".bright_white(),
3034        report
3035            .highest_local_tag()
3036            .map(|v| v.to_string())
3037            .unwrap_or_else(|| "none".dimmed().to_string())
3038    );
3039    if let Some(ledger) = &report.release_ledger {
3040        println!(
3041            "{:<20} {} {}",
3042            "Release ledger".bright_white(),
3043            ledger.version.bright_yellow(),
3044            format!("{:?}", ledger.status).bright_cyan()
3045        );
3046    }
3047    println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
3048
3049    if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
3050        println!(
3051            "{:<20} {}",
3052            "Suggested scope".bright_white(),
3053            review.scope_label.bright_cyan()
3054        );
3055        if !review.groups.is_empty() {
3056            println!("{:<20} {}", "Why".bright_white(), review.summary);
3057            for group in review.groups {
3058                println!("{:<20} {}", "", group);
3059            }
3060        }
3061    }
3062
3063    print_version_observations(
3064        "Worktree version files",
3065        &report.worktree,
3066        Some(&report.dirty_files),
3067    );
3068    print_version_observations("Committed HEAD version files", &report.head, None);
3069    print_registry_observations("Published package versions", &report.registry_versions);
3070    print_git_tag_observations("GitHub tags", &report.remote_tags);
3071    print_git_tag_observations("Local git tags", &report.local_tags);
3072
3073    let divergent = report.divergent_versions();
3074    let highest = report.highest_available();
3075    let outdated: Vec<_> = divergent
3076        .into_iter()
3077        .filter(|version| version != &highest)
3078        .collect();
3079    println!();
3080    println!("{}", "Divergence".bright_cyan().bold());
3081    println!("{}", "─".repeat(72).bright_black());
3082    println!(
3083        "  {:<20} {}",
3084        "latest target".bright_white(),
3085        highest.to_string().bright_green().bold()
3086    );
3087    if !outdated.is_empty() {
3088        for version in outdated {
3089            println!(
3090                "  {} {}",
3091                "•".bright_yellow(),
3092                version.to_string().bright_yellow()
3093            );
3094        }
3095        println!();
3096        println!(
3097            "{} {}",
3098            "Fix local files with".bright_white(),
3099            format!("xbp version {}", highest).black().on_bright_green()
3100        );
3101    } else {
3102        println!("  {}", "all relevant sources are aligned".green());
3103    }
3104
3105    if !report.warnings.is_empty() {
3106        println!();
3107        println!("{}", "Warnings".bright_yellow().bold());
3108        println!("{}", "─".repeat(72).bright_black());
3109        for warning in &report.warnings {
3110            println!("  {} {}", "!".bright_yellow(), warning);
3111        }
3112    }
3113}
3114
3115struct VersionDirtyFileReview {
3116    scope_label: String,
3117    summary: String,
3118    groups: Vec<String>,
3119}
3120
3121fn summarize_version_dirty_files(
3122    project_root: &Path,
3123    dirty_files: &[String],
3124) -> Option<VersionDirtyFileReview> {
3125    if dirty_files.is_empty() {
3126        return None;
3127    }
3128
3129    let mut crate_names = BTreeSet::new();
3130    let mut service_names = BTreeSet::new();
3131    let mut touches_workspace_files = false;
3132    let mut workspace_files = Vec::new();
3133    let mut crate_files = Vec::new();
3134    let mut service_files = Vec::new();
3135    let mut other_files = Vec::new();
3136
3137    for file in dirty_files {
3138        let path = Path::new(file);
3139        if path == Path::new("Cargo.toml")
3140            || path == Path::new("Cargo.lock")
3141            || path == Path::new("README.md")
3142            || path == Path::new("CHANGELOG.md")
3143            || path == Path::new("SECURITY.md")
3144        {
3145            touches_workspace_files = true;
3146            workspace_files.push(file.clone());
3147            continue;
3148        }
3149
3150        if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
3151            if let Some(crate_name) = crate_name {
3152                crate_names.insert(crate_name);
3153                crate_files.push(file.clone());
3154            }
3155            if let Some(service_name) = service_name {
3156                service_names.insert(service_name);
3157                service_files.push(file.clone());
3158            }
3159        } else {
3160            other_files.push(file.clone());
3161        }
3162    }
3163
3164    let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
3165        if crate_names.len() == 1 {
3166            format!("crate `{}`", crate_names.iter().next().unwrap())
3167        } else {
3168            format!("{} crates", crate_names.len())
3169        }
3170    } else if !service_names.is_empty() && crate_names.is_empty() {
3171        if service_names.len() == 1 {
3172            format!("service `{}`", service_names.iter().next().unwrap())
3173        } else {
3174            format!("{} services", service_names.len())
3175        }
3176    } else if touches_workspace_files {
3177        "repository / main crates".to_string()
3178    } else {
3179        "repository".to_string()
3180    };
3181
3182    let summary = if dirty_files.len() <= 8 {
3183        format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
3184    } else {
3185        format!(
3186            "{} file(s): {} ...",
3187            dirty_files.len(),
3188            dirty_files
3189                .iter()
3190                .take(8)
3191                .cloned()
3192                .collect::<Vec<_>>()
3193                .join(", ")
3194        )
3195    };
3196
3197    let mut groups = Vec::new();
3198    if !workspace_files.is_empty() {
3199        groups.push(format!(
3200            "Workspace files: {}",
3201            summarize_file_group(&workspace_files)
3202        ));
3203    }
3204    if !crate_files.is_empty() {
3205        groups.push(format!(
3206            "Crate files: {}",
3207            summarize_file_group(&crate_files)
3208        ));
3209    }
3210    if !service_files.is_empty() {
3211        groups.push(format!(
3212            "Service files: {}",
3213            summarize_file_group(&service_files)
3214        ));
3215    }
3216    if !other_files.is_empty() {
3217        groups.push(format!(
3218            "Other files: {}",
3219            summarize_file_group(&other_files)
3220        ));
3221    }
3222
3223    Some(VersionDirtyFileReview {
3224        scope_label,
3225        summary,
3226        groups,
3227    })
3228}
3229
3230fn summarize_file_group(files: &[String]) -> String {
3231    if files.is_empty() {
3232        return String::new();
3233    }
3234
3235    if files.len() <= 4 {
3236        return files.join(", ");
3237    }
3238
3239    format!(
3240        "{} (and {} more)",
3241        files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
3242        files.len() - 4
3243    )
3244}
3245
3246fn infer_version_scope_name(
3247    project_root: &Path,
3248    path: &Path,
3249) -> Option<(Option<String>, Option<String>)> {
3250    let absolute = if path.is_absolute() {
3251        path.to_path_buf()
3252    } else {
3253        project_root.join(path)
3254    };
3255
3256    let relative = absolute.strip_prefix(project_root).ok()?;
3257    let mut components = relative.components();
3258    let first = components.next()?.as_os_str().to_string_lossy().to_string();
3259    let second = components
3260        .next()
3261        .map(|value| value.as_os_str().to_string_lossy().to_string());
3262
3263    if first == "crates" {
3264        return second.map(|crate_name| (Some(crate_name), None));
3265    }
3266
3267    if first == "apps" {
3268        return second.map(|service_name| (None, Some(service_name)));
3269    }
3270
3271    None
3272}
3273
3274fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
3275    entries.iter().map(|entry| entry.version.clone()).max()
3276}
3277
3278fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
3279    let Some(highest) = highest_version_observation(entries) else {
3280        return Vec::new();
3281    };
3282
3283    entries
3284        .iter()
3285        .filter(|entry| entry.version < highest)
3286        .collect()
3287}
3288
3289fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
3290    println!();
3291    println!("{}", title.bright_cyan().bold());
3292    println!("{}", "─".repeat(72).bright_black());
3293
3294    if entries.is_empty() {
3295        println!("  {}", "none found".dimmed());
3296        return;
3297    }
3298
3299    for entry in entries {
3300        let latest_display = match (&entry.latest, &entry.raw_version) {
3301            (Some(version), _) => version.to_string().bright_green().to_string(),
3302            (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
3303            (None, None) => "unavailable".dimmed().to_string(),
3304        };
3305
3306        let note = entry
3307            .note
3308            .as_ref()
3309            .map(|value| format!(" {}", value.bright_yellow()))
3310            .unwrap_or_default();
3311        let published_at = entry
3312            .published_at
3313            .as_deref()
3314            .map(|value| format!(" {}", format!("published {value}").dimmed()))
3315            .unwrap_or_default();
3316
3317        println!(
3318            "  {:<9} {:<28} {:<16} {}{}{}",
3319            entry.registry.bright_white(),
3320            entry.package_name.bright_white(),
3321            latest_display,
3322            entry.source_file.dimmed(),
3323            published_at,
3324            note
3325        );
3326    }
3327}
3328
3329#[cfg(test)]
3330fn write_version_to_configured_files(
3331    project_root: &Path,
3332    invocation_dir: &Path,
3333    registry: &[String],
3334    version_scope: &VersionScope,
3335    version: &Version,
3336) -> Result<usize, String> {
3337    write_version_to_configured_files_with_paths(
3338        project_root,
3339        invocation_dir,
3340        registry,
3341        version_scope,
3342        version,
3343    )
3344    .map(|paths| paths.len())
3345}
3346
3347fn write_version_to_configured_files_with_paths(
3348    project_root: &Path,
3349    invocation_dir: &Path,
3350    registry: &[String],
3351    version_scope: &VersionScope,
3352    version: &Version,
3353) -> Result<Vec<PathBuf>, String> {
3354    write_version_to_configured_files_with_paths_internal(
3355        project_root,
3356        invocation_dir,
3357        registry,
3358        version_scope,
3359        version,
3360        false,
3361    )
3362}
3363
3364#[cfg(test)]
3365fn sync_version_to_configured_files_with_paths(
3366    project_root: &Path,
3367    invocation_dir: &Path,
3368    registry: &[String],
3369    version_scope: &VersionScope,
3370    version: &Version,
3371) -> Result<Vec<PathBuf>, String> {
3372    write_version_to_configured_files_with_paths_internal(
3373        project_root,
3374        invocation_dir,
3375        registry,
3376        version_scope,
3377        version,
3378        true,
3379    )
3380}
3381
3382fn write_version_to_configured_files_with_paths_internal(
3383    project_root: &Path,
3384    invocation_dir: &Path,
3385    registry: &[String],
3386    version_scope: &VersionScope,
3387    version: &Version,
3388    allow_noop_when_targets_exist: bool,
3389) -> Result<Vec<PathBuf>, String> {
3390    let mut updated = 0usize;
3391    let mut matched_targets = 0usize;
3392    let mut updated_paths = Vec::new();
3393    let mut errors = Vec::new();
3394
3395    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
3396        let path = &entry.absolute;
3397        if !path.exists() {
3398            continue;
3399        }
3400        matched_targets += 1;
3401
3402        match write_version_to_resolved_path(&entry, version) {
3403            Ok(true) => {
3404                updated += 1;
3405                updated_paths.push(path.clone());
3406            }
3407            Ok(false) => {}
3408            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
3409        }
3410    }
3411
3412    if matched_targets == 0 && errors.is_empty() {
3413        return Err("No configured version files were found to update.".to_string());
3414    }
3415
3416    if !errors.is_empty() {
3417        return Err(format!(
3418            "Updated {} file(s), but some version targets failed:\n{}",
3419            updated,
3420            errors.join("\n")
3421        ));
3422    }
3423
3424    if updated == 0 && allow_noop_when_targets_exist {
3425        return Ok(updated_paths);
3426    }
3427
3428    Ok(updated_paths)
3429}
3430
3431fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
3432    let file_name = path
3433        .file_name()
3434        .and_then(|n| n.to_str())
3435        .unwrap_or_default();
3436
3437    match file_name {
3438        "README.md" => read_readme_version(path),
3439        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3440            read_openapi_version(path)
3441        }
3442        "openapi.json" | "swagger.json" => read_json_openapi_version(path),
3443        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3444        | "xbp.json" => read_json_root_version(path),
3445        "deno.json" => read_json_root_version(path),
3446        "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
3447        "Cargo.toml" => read_cargo_toml_version(path),
3448        "Cargo.lock" => read_cargo_lock_version(path),
3449        "pyproject.toml" => read_pyproject_version(path),
3450        "Chart.yaml" => read_yaml_root_version(path, "version"),
3451        "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
3452        "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
3453        "build.gradle" | "build.gradle.kts" => {
3454            read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
3455        }
3456        "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
3457        _ => match path.extension().and_then(|ext| ext.to_str()) {
3458            Some("json") => read_json_root_version(path),
3459            Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
3460            Some("toml") => read_toml_root_version(path),
3461            Some("md") => read_readme_version(path),
3462            _ => Ok(None),
3463        },
3464    }
3465}
3466
3467fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
3468    let path = &entry.absolute;
3469    let file_name = path
3470        .file_name()
3471        .and_then(|n| n.to_str())
3472        .unwrap_or_default();
3473
3474    if file_name == "Cargo.lock" {
3475        if let Some(package_name) = entry.cargo_package_override.as_deref() {
3476            return read_cargo_lock_version_for_package(path, package_name);
3477        }
3478    }
3479
3480    read_version_from_path(path)
3481}
3482
3483fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
3484    let file_name = path
3485        .file_name()
3486        .and_then(|n| n.to_str())
3487        .unwrap_or_default();
3488
3489    match file_name {
3490        "README.md" => write_readme_version(path, version).map(|_| true),
3491        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
3492            write_openapi_version(path, version).map(|_| true)
3493        }
3494        "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
3495        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
3496        | "xbp.json" => write_json_root_version(path, version).map(|_| true),
3497        "deno.json" => write_json_root_version(path, version).map(|_| true),
3498        "deno.jsonc" => {
3499            write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
3500        }
3501        "Cargo.toml" => write_cargo_toml_version(path, version),
3502        "Cargo.lock" => write_cargo_lock_version(path, version),
3503        "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
3504        "Chart.yaml" => write_chart_version(path, version).map(|_| true),
3505        "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
3506        "pom.xml" => {
3507            write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
3508        }
3509        "build.gradle" | "build.gradle.kts" => {
3510            write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
3511                .map(|_| true)
3512        }
3513        "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
3514        _ => match path.extension().and_then(|ext| ext.to_str()) {
3515            Some("json") => write_json_root_version(path, version).map(|_| true),
3516            Some("yaml") | Some("yml") => {
3517                write_yaml_root_version(path, "version", version).map(|_| true)
3518            }
3519            Some("toml") => write_toml_root_version(path, version).map(|_| true),
3520            Some("md") => write_readme_version(path, version).map(|_| true),
3521            _ => Err("Unsupported version file type".to_string()),
3522        },
3523    }
3524}
3525
3526fn write_version_to_resolved_path(
3527    entry: &ResolvedRegistryPath,
3528    version: &Version,
3529) -> Result<bool, String> {
3530    let path = &entry.absolute;
3531    let file_name = path
3532        .file_name()
3533        .and_then(|n| n.to_str())
3534        .unwrap_or_default();
3535
3536    if file_name == "Cargo.lock" {
3537        if let Some(package_name) = entry.cargo_package_override.as_deref() {
3538            return write_cargo_lock_version_for_package(path, Some(package_name), version);
3539        }
3540    }
3541
3542    write_version_to_path(path, version)
3543}
3544
3545fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3546    let value: JsonValue =
3547        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3548    Ok(value
3549        .get("version")
3550        .and_then(JsonValue::as_str)
3551        .map(|value| value.to_string()))
3552}
3553
3554fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
3555    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3556    read_json_root_version_from_content(&content)
3557}
3558
3559fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
3560    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3561    let mut value: JsonValue =
3562        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3563
3564    let object = value
3565        .as_object_mut()
3566        .ok_or_else(|| "Expected a JSON object".to_string())?;
3567    object.insert(
3568        "version".to_string(),
3569        JsonValue::String(version.to_string()),
3570    );
3571
3572    fs::write(
3573        path,
3574        serde_json::to_string_pretty(&value)
3575            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3576    )
3577    .map_err(|e| format!("Failed to write file: {}", e))
3578}
3579
3580fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
3581    let value: YamlValue =
3582        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3583    Ok(yaml_get_string(&value, key))
3584}
3585
3586fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
3587    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3588    read_yaml_root_version_from_content(&content, key)
3589}
3590
3591fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
3592    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3593    let mut value: YamlValue =
3594        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3595
3596    let mapping = yaml_root_mapping_mut(&mut value)?;
3597    mapping.insert(
3598        YamlValue::String(key.to_string()),
3599        YamlValue::String(version.to_string()),
3600    );
3601
3602    fs::write(
3603        path,
3604        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3605    )
3606    .map_err(|e| format!("Failed to write file: {}", e))
3607}
3608
3609fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3610    let value: YamlValue =
3611        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3612
3613    let info = yaml_get_mapping(&value, "info");
3614    Ok(info.and_then(|mapping| {
3615        mapping
3616            .get(YamlValue::String("version".to_string()))
3617            .and_then(YamlValue::as_str)
3618            .map(|value| value.to_string())
3619    }))
3620}
3621
3622fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
3623    let content: String =
3624        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3625    read_openapi_version_from_content(&content)
3626}
3627
3628fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
3629    let value: JsonValue =
3630        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3631    Ok(value
3632        .get("info")
3633        .and_then(JsonValue::as_object)
3634        .and_then(|info| info.get("version"))
3635        .and_then(JsonValue::as_str)
3636        .map(|value| value.to_string()))
3637}
3638
3639fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
3640    let content: String =
3641        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3642    read_json_openapi_version_from_content(&content)
3643}
3644
3645fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3646    let content: String =
3647        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3648    let mut value: YamlValue =
3649        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3650
3651    let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
3652    let info_key: YamlValue = YamlValue::String("info".to_string());
3653    if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
3654        root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
3655    }
3656
3657    let info: &mut YamlMapping = root
3658        .get_mut(&info_key)
3659        .and_then(YamlValue::as_mapping_mut)
3660        .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
3661    info.insert(
3662        YamlValue::String("version".to_string()),
3663        YamlValue::String(version.to_string()),
3664    );
3665
3666    fs::write(
3667        path,
3668        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3669    )
3670    .map_err(|e| format!("Failed to write file: {}", e))
3671}
3672
3673fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
3674    let content: String =
3675        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3676    let mut value: JsonValue =
3677        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
3678
3679    let root = value
3680        .as_object_mut()
3681        .ok_or_else(|| "Expected a JSON object".to_string())?;
3682    let info = root
3683        .entry("info".to_string())
3684        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
3685    let info_object = info
3686        .as_object_mut()
3687        .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
3688    info_object.insert(
3689        "version".to_string(),
3690        JsonValue::String(version.to_string()),
3691    );
3692
3693    fs::write(
3694        path,
3695        serde_json::to_string_pretty(&value)
3696            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
3697    )
3698    .map_err(|e| format!("Failed to write file: {}", e))
3699}
3700
3701fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
3702    let value: TomlValue =
3703        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3704    Ok(value
3705        .get("version")
3706        .and_then(TomlValue::as_str)
3707        .map(|value| value.to_string()))
3708}
3709
3710fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
3711    let content: String =
3712        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3713    read_toml_root_version_from_content(&content)
3714}
3715
3716fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
3717    let content: String =
3718        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3719    let mut value: TomlValue =
3720        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3721    let table = value
3722        .as_table_mut()
3723        .ok_or_else(|| "Expected a TOML table".to_string())?;
3724    table.insert(
3725        "version".to_string(),
3726        TomlValue::String(version.to_string()),
3727    );
3728    fs::write(
3729        path,
3730        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3731    )
3732    .map_err(|e| format!("Failed to write file: {}", e))
3733}
3734
3735fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
3736    crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
3737}
3738
3739fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
3740    resolve_cargo_package_version(path)
3741}
3742
3743fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
3744    write_cargo_package_version(path, version)
3745}
3746
3747fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
3748    let value: TomlValue =
3749        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3750
3751    let project_version = value
3752        .get("project")
3753        .and_then(TomlValue::as_table)
3754        .and_then(|project| project.get("version"))
3755        .and_then(TomlValue::as_str);
3756
3757    let poetry_version = value
3758        .get("tool")
3759        .and_then(TomlValue::as_table)
3760        .and_then(|tool| tool.get("poetry"))
3761        .and_then(TomlValue::as_table)
3762        .and_then(|poetry| poetry.get("version"))
3763        .and_then(TomlValue::as_str);
3764
3765    Ok(project_version
3766        .or(poetry_version)
3767        .map(|value| value.to_string()))
3768}
3769
3770fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
3771    let content: String =
3772        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3773    read_pyproject_version_from_content(&content)
3774}
3775
3776fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
3777    let content: String =
3778        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3779    let mut value: TomlValue =
3780        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3781
3782    if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
3783        project.insert(
3784            "version".to_string(),
3785            TomlValue::String(version.to_string()),
3786        );
3787    } else if let Some(poetry) = value
3788        .get_mut("tool")
3789        .and_then(TomlValue::as_table_mut)
3790        .and_then(|tool| tool.get_mut("poetry"))
3791        .and_then(TomlValue::as_table_mut)
3792    {
3793        poetry.insert(
3794            "version".to_string(),
3795            TomlValue::String(version.to_string()),
3796        );
3797    } else {
3798        let table: &mut toml::map::Map<String, TomlValue> = value
3799            .as_table_mut()
3800            .ok_or_else(|| "Expected a TOML table".to_string())?;
3801        table.insert(
3802            "version".to_string(),
3803            TomlValue::String(version.to_string()),
3804        );
3805    }
3806
3807    fs::write(
3808        path,
3809        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3810    )
3811    .map_err(|e| format!("Failed to write file: {}", e))
3812}
3813
3814fn read_cargo_lock_version_from_content(
3815    content: &str,
3816    cargo_toml_content: Option<&str>,
3817) -> Result<Option<String>, String> {
3818    read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
3819}
3820
3821fn read_cargo_lock_version_from_content_with_package(
3822    content: &str,
3823    cargo_toml_content: Option<&str>,
3824    package_name_override: Option<&str>,
3825) -> Result<Option<String>, String> {
3826    let value: TomlValue =
3827        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3828    let package_name = if let Some(package_name_override) = package_name_override {
3829        package_name_override.trim().to_string()
3830    } else {
3831        let cargo_toml_content = cargo_toml_content
3832            .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
3833        cargo_package_name_from_content(cargo_toml_content)?
3834    };
3835
3836    Ok(value
3837        .get("package")
3838        .and_then(TomlValue::as_array)
3839        .and_then(|packages| {
3840            packages.iter().find_map(|package| {
3841                let table = package.as_table()?;
3842                if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3843                    table
3844                        .get("version")
3845                        .and_then(TomlValue::as_str)
3846                        .map(|value| value.to_string())
3847                } else {
3848                    None
3849                }
3850            })
3851        }))
3852}
3853
3854fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
3855    let content: String =
3856        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3857    let cargo_toml: String = fs::read_to_string(
3858        path.parent()
3859            .unwrap_or_else(|| Path::new("."))
3860            .join("Cargo.toml"),
3861    )
3862    .map_err(|e| format!("Failed to read file: {}", e))?;
3863    read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
3864}
3865
3866fn read_cargo_lock_version_for_package(
3867    path: &Path,
3868    package_name: &str,
3869) -> Result<Option<String>, String> {
3870    let content: String =
3871        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3872    read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
3873}
3874
3875fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
3876    write_cargo_lock_version_for_package(path, None, version)
3877}
3878
3879fn write_cargo_lock_version_for_package(
3880    path: &Path,
3881    package_name_override: Option<&str>,
3882    version: &Version,
3883) -> Result<bool, String> {
3884    let content: String =
3885        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3886    let mut value: TomlValue =
3887        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
3888    let package_name = if let Some(package_name_override) = package_name_override {
3889        package_name_override.trim().to_string()
3890    } else {
3891        let Some(package_name) = cargo_package_name(path)? else {
3892            return Ok(false);
3893        };
3894        package_name
3895    };
3896
3897    let packages: &mut Vec<TomlValue> = value
3898        .get_mut("package")
3899        .and_then(TomlValue::as_array_mut)
3900        .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
3901
3902    let mut updated = false;
3903    for package in packages {
3904        if let Some(table) = package.as_table_mut() {
3905            if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
3906                table.insert(
3907                    "version".to_string(),
3908                    TomlValue::String(version.to_string()),
3909                );
3910                updated = true;
3911            }
3912        }
3913    }
3914
3915    if !updated {
3916        return Err(format!(
3917            "Could not find package `{}` in Cargo.lock",
3918            package_name
3919        ));
3920    }
3921
3922    fs::write(
3923        path,
3924        toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
3925    )
3926    .map_err(|e| format!("Failed to write file: {}", e))?;
3927
3928    Ok(true)
3929}
3930
3931fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
3932    let cargo_toml: PathBuf = path
3933        .parent()
3934        .unwrap_or_else(|| Path::new("."))
3935        .join("Cargo.toml");
3936    let content: String = fs::read_to_string(&cargo_toml)
3937        .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
3938    cargo_package_name_from_content_optional(&content)
3939}
3940
3941fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
3942    cargo_package_name_from_content_optional(content)?
3943        .ok_or_else(|| "Could not determine Cargo package name".to_string())
3944}
3945
3946fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
3947    let value: TomlValue =
3948        toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
3949    Ok(value
3950        .get("package")
3951        .and_then(TomlValue::as_table)
3952        .and_then(|package| package.get("name"))
3953        .and_then(TomlValue::as_str)
3954        .map(|value| value.to_string()))
3955}
3956
3957fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
3958    read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3959}
3960
3961fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
3962    let content: String =
3963        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3964    read_readme_version_from_content(&content)
3965}
3966
3967fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
3968    let content: String =
3969        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3970    let marker: String = format!("current version: `{}`", version);
3971    let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
3972        .map_err(|e| format!("Failed to build README regex: {}", e))?;
3973
3974    let updated: String = if regex.is_match(&content) {
3975        regex.replace(&content, marker.as_str()).to_string()
3976    } else if let Some(first_break) = content.find('\n') {
3977        let mut next = String::new();
3978        next.push_str(&content[..=first_break]);
3979        next.push('\n');
3980        next.push_str(&marker);
3981        next.push('\n');
3982        next.push_str(&content[first_break + 1..]);
3983        next
3984    } else {
3985        format!("{}\n\n{}\n", content, marker)
3986    };
3987
3988    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
3989}
3990
3991fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
3992    let content: String =
3993        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3994    read_regex_version_from_content(&content, pattern)
3995}
3996
3997fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
3998    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
3999    Ok(regex
4000        .captures(content)
4001        .and_then(|captures| captures.get(1))
4002        .map(|matched| matched.as_str().trim().to_string()))
4003}
4004
4005fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
4006    let content: String =
4007        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4008    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
4009
4010    if !regex.is_match(&content) {
4011        return Err("No version pattern found".to_string());
4012    }
4013
4014    let updated: String = regex
4015        .replace(&content, |caps: &regex::Captures<'_>| {
4016            caps[0].replace(&caps[1], &version.to_string())
4017        })
4018        .to_string();
4019    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
4020}
4021
4022#[cfg(test)]
4023fn write_package_version_to_configured_files(
4024    project_root: &Path,
4025    invocation_dir: &Path,
4026    registry: &[String],
4027    version_scope: &VersionScope,
4028    package_name: &str,
4029    version: &Version,
4030) -> Result<usize, String> {
4031    write_package_version_to_configured_files_with_paths(
4032        project_root,
4033        invocation_dir,
4034        registry,
4035        version_scope,
4036        package_name,
4037        version,
4038    )
4039    .map(|paths| paths.len())
4040}
4041
4042fn write_package_version_to_configured_files_with_paths(
4043    project_root: &Path,
4044    invocation_dir: &Path,
4045    registry: &[String],
4046    version_scope: &VersionScope,
4047    package_name: &str,
4048    version: &Version,
4049) -> Result<Vec<PathBuf>, String> {
4050    let mut updated: usize = 0usize;
4051    let mut updated_paths = Vec::new();
4052    let mut errors: Vec<String> = Vec::new();
4053
4054    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
4055        let path: &PathBuf = &entry.absolute;
4056        if !path.exists() {
4057            continue;
4058        }
4059
4060        match write_package_version_to_path(path, package_name, version) {
4061            Ok(true) => {
4062                updated += 1;
4063                updated_paths.push(path.clone());
4064            }
4065            Ok(false) => {}
4066            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
4067        }
4068    }
4069
4070    if updated == 0 && errors.is_empty() {
4071        return Err(format!(
4072            "No configured TOML files contained package assignment `{}`.",
4073            package_name
4074        ));
4075    }
4076
4077    if !errors.is_empty() {
4078        return Err(format!(
4079            "Updated {} file(s), but some package version targets failed:\n{}",
4080            updated,
4081            errors.join("\n")
4082        ));
4083    }
4084
4085    Ok(updated_paths)
4086}
4087
4088fn write_package_version_to_path(
4089    path: &Path,
4090    package_name: &str,
4091    version: &Version,
4092) -> Result<bool, String> {
4093    let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
4094    if !is_toml {
4095        return Ok(false);
4096    }
4097
4098    let content: String =
4099        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4100    let (updated, changed) =
4101        rewrite_toml_package_assignment_versions(&content, package_name, version)?;
4102    if changed {
4103        fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
4104    }
4105    Ok(changed)
4106}
4107
4108fn rewrite_toml_package_assignment_versions(
4109    content: &str,
4110    package_name: &str,
4111    version: &Version,
4112) -> Result<(String, bool), String> {
4113    let escaped_name: String = regex::escape(package_name);
4114    let inline_pattern: String = format!(
4115        r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
4116        escaped_name
4117    );
4118    let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
4119
4120    let inline_regex: Regex =
4121        Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
4122    let string_regex: Regex =
4123        Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
4124
4125    let replacement: String = version.to_string();
4126
4127    let after_inline: String = inline_regex
4128        .replace_all(content, |caps: &regex::Captures<'_>| {
4129            format!("{}{}{}", &caps[1], replacement, &caps[3])
4130        })
4131        .to_string();
4132    let after_string: String = string_regex
4133        .replace_all(&after_inline, |caps: &regex::Captures<'_>| {
4134            format!("{}{}{}", &caps[1], replacement, &caps[3])
4135        })
4136        .to_string();
4137
4138    Ok((after_string.clone(), after_string != content))
4139}
4140
4141fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
4142    let content: String =
4143        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
4144    let mut value: YamlValue =
4145        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
4146    let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
4147    mapping.insert(
4148        YamlValue::String("version".to_string()),
4149        YamlValue::String(version.to_string()),
4150    );
4151    if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
4152        mapping.insert(
4153            YamlValue::String("appVersion".to_string()),
4154            YamlValue::String(version.to_string()),
4155        );
4156    }
4157    fs::write(
4158        path,
4159        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
4160    )
4161    .map_err(|e| format!("Failed to write file: {}", e))
4162}
4163
4164fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
4165    value
4166        .as_mapping_mut()
4167        .ok_or_else(|| "Expected a YAML mapping".to_string())
4168}
4169
4170fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
4171    value
4172        .as_mapping()
4173        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4174        .and_then(YamlValue::as_mapping)
4175}
4176
4177fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
4178    value
4179        .as_mapping()
4180        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
4181        .and_then(YamlValue::as_str)
4182        .map(|value| value.to_string())
4183}
4184
4185fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
4186    let mut current: &JsonValue = value;
4187    for part in key_parts {
4188        current = current.get(*part)?;
4189    }
4190    current.as_str().map(|value| value.to_string())
4191}
4192
4193fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
4194    let mut current = value;
4195    for part in key_parts {
4196        let mapping = current.as_mapping()?;
4197        current = mapping.get(YamlValue::String((*part).to_string()))?;
4198    }
4199    current.as_str().map(|value| value.to_string())
4200}
4201
4202fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
4203    let mut current = value;
4204    for part in key_parts {
4205        current = current.get(*part)?;
4206    }
4207    current.as_str().map(|value| value.to_string())
4208}
4209
4210fn parse_version(input: &str) -> Result<Version, String> {
4211    let trimmed: &str = input.trim();
4212    let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
4213    Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
4214}
4215
4216fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
4217    let trimmed = input.trim();
4218    if trimmed.is_empty() {
4219        return Err("Release version cannot be empty.".to_string());
4220    }
4221
4222    if let Ok(version) = parse_version(trimmed) {
4223        return Ok((version.clone(), format!("v{}", version)));
4224    }
4225
4226    let prefixed = Regex::new(
4227        r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
4228    )
4229    .map_err(|e| format!("Failed to build release target parser: {}", e))?;
4230
4231    if let Some(captures) = prefixed.captures(trimmed) {
4232        let semver = captures
4233            .name("semver")
4234            .map(|m| m.as_str())
4235            .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
4236        let version = Version::parse(semver)
4237            .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
4238        return Ok((version, trimmed.to_string()));
4239    }
4240
4241    Err(format!(
4242        "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
4243        input
4244    ))
4245}
4246
4247fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
4248    let Some((raw_package, raw_version)) = input.split_once('=') else {
4249        return Ok(None);
4250    };
4251
4252    let package_name = raw_package.trim();
4253    if package_name.is_empty() {
4254        return Ok(None);
4255    }
4256
4257    let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
4258        .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
4259    if !package_name_regex.is_match(package_name) {
4260        return Err(format!(
4261            "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
4262            input
4263        ));
4264    }
4265
4266    let version = parse_version(raw_version.trim())?;
4267    Ok(Some((package_name.to_string(), version)))
4268}
4269
4270fn bump_version(current: &Version, kind: &str) -> Version {
4271    let mut next = current.clone();
4272    match kind {
4273        "major" => {
4274            next.major += 1;
4275            next.minor = 0;
4276            next.patch = 0;
4277            next.pre = semver::Prerelease::EMPTY;
4278            next.build = semver::BuildMetadata::EMPTY;
4279        }
4280        "minor" => {
4281            next.minor += 1;
4282            next.patch = 0;
4283            next.pre = semver::Prerelease::EMPTY;
4284            next.build = semver::BuildMetadata::EMPTY;
4285        }
4286        _ => {
4287            next.patch += 1;
4288            next.pre = semver::Prerelease::EMPTY;
4289            next.build = semver::BuildMetadata::EMPTY;
4290        }
4291    }
4292    next
4293}
4294
4295fn default_version() -> Version {
4296    Version::new(0, 1, 0)
4297}
4298
4299fn resolve_registry_paths(
4300    project_root: &Path,
4301    invocation_dir: &Path,
4302    registry: &[String],
4303    version_scope: &VersionScope,
4304) -> Vec<ResolvedRegistryPath> {
4305    let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
4306    let mut seen: BTreeSet<String> = BTreeSet::new();
4307    let workspace_primary_target = match version_scope {
4308        VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
4309        VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
4310    };
4311
4312    for relative in registry {
4313        match version_scope {
4314            VersionScope::Repository => {
4315                if *relative == *"Cargo.lock" {
4316                    let resolved_relative = resolve_registry_relative_path(
4317                        project_root,
4318                        invocation_dir,
4319                        version_scope,
4320                        relative,
4321                    );
4322                    if !seen.insert(resolved_relative.clone()) {
4323                        continue;
4324                    }
4325
4326                    resolved.push(ResolvedRegistryPath {
4327                        absolute: project_root.join(&resolved_relative),
4328                        relative: resolved_relative,
4329                        cargo_package_override: workspace_primary_target
4330                            .as_ref()
4331                            .map(|target| target.package_name.clone()),
4332                    });
4333                    continue;
4334                }
4335
4336                if *relative == *"Cargo.toml" {
4337                    if let Some(target) = workspace_primary_target.as_ref() {
4338                        if !seen.insert(target.manifest_relative.clone()) {
4339                            continue;
4340                        }
4341
4342                        resolved.push(ResolvedRegistryPath {
4343                            absolute: target.manifest_absolute.clone(),
4344                            relative: target.manifest_relative.clone(),
4345                            cargo_package_override: None,
4346                        });
4347                        continue;
4348                    }
4349                }
4350
4351                let resolved_relative = resolve_registry_relative_path(
4352                    project_root,
4353                    invocation_dir,
4354                    version_scope,
4355                    relative,
4356                );
4357                if !seen.insert(resolved_relative.clone()) {
4358                    continue;
4359                }
4360
4361                resolved.push(ResolvedRegistryPath {
4362                    absolute: project_root.join(&resolved_relative),
4363                    relative: resolved_relative,
4364                    cargo_package_override: None,
4365                });
4366            }
4367            VersionScope::Crate {
4368                crate_root,
4369                package_name,
4370                ..
4371            } => {
4372                if *relative == *"Cargo.lock" {
4373                    let cargo_lock = project_root.join("Cargo.lock");
4374                    if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4375                        resolved.push(ResolvedRegistryPath {
4376                            absolute: cargo_lock,
4377                            relative: "Cargo.lock".to_string(),
4378                            cargo_package_override: Some(package_name.clone()),
4379                        });
4380                    }
4381                    continue;
4382                }
4383
4384                let preferred = crate_root.join(relative);
4385                if !preferred.exists() {
4386                    continue;
4387                }
4388                let Ok(stripped) = preferred.strip_prefix(project_root) else {
4389                    continue;
4390                };
4391                let resolved_relative = normalized_relative_path(stripped);
4392                if !seen.insert(resolved_relative.clone()) {
4393                    continue;
4394                }
4395
4396                resolved.push(ResolvedRegistryPath {
4397                    absolute: preferred,
4398                    relative: resolved_relative,
4399                    cargo_package_override: None,
4400                });
4401            }
4402            VersionScope::Service {
4403                service_root,
4404                cargo_package_name,
4405                ..
4406            } => {
4407                if *relative == *"Cargo.lock" {
4408                    let local_cargo_lock = service_root.join("Cargo.lock");
4409                    if local_cargo_lock.exists() {
4410                        let relative_path = local_cargo_lock
4411                            .strip_prefix(project_root)
4412                            .ok()
4413                            .map(normalized_relative_path)
4414                            .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
4415                        if seen.insert(relative_path.clone()) {
4416                            resolved.push(ResolvedRegistryPath {
4417                                absolute: local_cargo_lock,
4418                                relative: relative_path,
4419                                cargo_package_override: cargo_package_name.clone(),
4420                            });
4421                        }
4422                    } else if let Some(package_name) = cargo_package_name.as_ref() {
4423                        let workspace_cargo_lock = project_root.join("Cargo.lock");
4424                        if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
4425                            resolved.push(ResolvedRegistryPath {
4426                                absolute: workspace_cargo_lock,
4427                                relative: "Cargo.lock".to_string(),
4428                                cargo_package_override: Some(package_name.clone()),
4429                            });
4430                        }
4431                    }
4432                    continue;
4433                }
4434
4435                let preferred = service_root.join(relative);
4436                if !preferred.exists() {
4437                    continue;
4438                }
4439                let Ok(stripped) = preferred.strip_prefix(project_root) else {
4440                    continue;
4441                };
4442                let resolved_relative = normalized_relative_path(stripped);
4443                if !seen.insert(resolved_relative.clone()) {
4444                    continue;
4445                }
4446
4447                resolved.push(ResolvedRegistryPath {
4448                    absolute: preferred,
4449                    relative: resolved_relative,
4450                    cargo_package_override: None,
4451                });
4452            }
4453        }
4454    }
4455
4456    for configured_path in
4457        resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
4458    {
4459        if seen.insert(configured_path.relative.clone()) {
4460            resolved.push(configured_path);
4461        }
4462    }
4463
4464    resolved
4465}
4466
4467fn resolve_configured_version_target_paths(
4468    project_root: &Path,
4469    invocation_dir: &Path,
4470    version_scope: &VersionScope,
4471) -> Vec<ResolvedRegistryPath> {
4472    let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
4473        return Vec::new();
4474    };
4475
4476    let service_targets = match version_scope {
4477        VersionScope::Service {
4478            version_targets, ..
4479        } if !version_targets.is_empty() => Some(version_targets.clone()),
4480        _ => None,
4481    };
4482
4483    let manifest_paths = if let Some(service_targets) = &service_targets {
4484        service_targets
4485            .iter()
4486            .cloned()
4487            .map(PathBuf::from)
4488            .collect::<Vec<_>>()
4489    } else {
4490        let mut targets = config
4491            .version_targets
4492            .into_iter()
4493            .map(PathBuf::from)
4494            .collect::<Vec<_>>();
4495        targets.extend(
4496            config
4497                .publish
4498                .into_iter()
4499                .flat_map(|publish| [publish.npm, publish.crates])
4500                .flatten()
4501                .filter_map(|target| target.manifest_path)
4502                .map(PathBuf::from),
4503        );
4504        targets
4505    };
4506
4507    let scope_root = match version_scope {
4508        VersionScope::Repository => None,
4509        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4510        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4511    };
4512
4513    manifest_paths
4514        .into_iter()
4515        .filter_map(|manifest_path| {
4516            let absolute = if manifest_path.is_absolute() {
4517                manifest_path
4518            } else {
4519                config_root.join(manifest_path)
4520            };
4521            if !absolute.exists() {
4522                return None;
4523            }
4524            if let Some(scope_root) = scope_root {
4525                if !absolute.starts_with(scope_root) {
4526                    return None;
4527                }
4528            }
4529            let relative = absolute
4530                .strip_prefix(project_root)
4531                .ok()
4532                .map(normalized_relative_path)
4533                .unwrap_or_else(|| normalized_relative_path(&absolute));
4534            Some(ResolvedRegistryPath {
4535                relative,
4536                absolute,
4537                cargo_package_override: None,
4538            })
4539        })
4540        .collect()
4541}
4542
4543fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
4544    let found = find_xbp_config_upwards(invocation_dir)?;
4545    let config_path = if found.kind == "json" {
4546        maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
4547            .ok()
4548            .flatten()
4549            .unwrap_or_else(|| found.config_path.clone())
4550    } else {
4551        found.config_path.clone()
4552    };
4553
4554    let kind = if config_path
4555        .extension()
4556        .and_then(|ext| ext.to_str())
4557        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
4558        .unwrap_or(false)
4559    {
4560        "yaml"
4561    } else {
4562        "json"
4563    };
4564
4565    let content = fs::read_to_string(&config_path).ok()?;
4566    let (mut config, healed): (XbpConfig, Option<String>) =
4567        parse_config_with_auto_heal(&content, kind).ok()?;
4568    if let Some(healed_content) = healed {
4569        let _ = fs::write(&config_path, healed_content);
4570    }
4571    resolve_config_paths_for_runtime(&mut config, &found.project_root);
4572    Some((found.project_root, config))
4573}
4574
4575fn resolve_registry_relative_path(
4576    project_root: &Path,
4577    invocation_dir: &Path,
4578    version_scope: &VersionScope,
4579    relative: &str,
4580) -> String {
4581    if let Some(scope_root) = version_scope_root(version_scope) {
4582        let preferred = scope_root.join(relative);
4583        if preferred.exists() {
4584            if let Ok(stripped) = preferred.strip_prefix(project_root) {
4585                return normalized_relative_path(stripped);
4586            }
4587        }
4588        return relative.replace('\\', "/");
4589    }
4590
4591    if relative == "Cargo.toml" {
4592        if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
4593            return target.manifest_relative;
4594        }
4595    }
4596
4597    let preferred: PathBuf = invocation_dir.join(relative);
4598    if preferred.exists() {
4599        if let Ok(stripped) = preferred.strip_prefix(project_root) {
4600            return normalized_relative_path(stripped);
4601        }
4602    }
4603
4604    relative.replace('\\', "/")
4605}
4606
4607fn resolve_version_scope_with_prompt(
4608    project_root: &Path,
4609    invocation_dir: &Path,
4610) -> Result<VersionScope, String> {
4611    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4612    if let Some(scope) =
4613        select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4614    {
4615        return Ok(scope);
4616    }
4617
4618    if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
4619    {
4620        return prompt_for_version_scope_selection(&service_scopes);
4621    }
4622
4623    Ok(resolve_version_scope(project_root, invocation_dir))
4624}
4625
4626fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
4627    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
4628    if let Some(service_scope) =
4629        select_matching_service_scope(&service_scopes, project_root, invocation_dir)
4630    {
4631        return service_scope;
4632    }
4633
4634    if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
4635        return crate_scope;
4636    }
4637
4638    VersionScope::Repository
4639}
4640
4641fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
4642    let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
4643        return Vec::new();
4644    };
4645    let Some(services) = config.services else {
4646        return Vec::new();
4647    };
4648
4649    let mut scopes = services
4650        .into_iter()
4651        .filter_map(|service| build_service_scope(project_root, service))
4652        .collect::<Vec<_>>();
4653    scopes.sort_by(|left, right| {
4654        version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
4655    });
4656    scopes
4657}
4658
4659fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
4660    let version_targets = service
4661        .version_targets
4662        .clone()
4663        .unwrap_or_default()
4664        .into_iter()
4665        .map(normalized_path_string)
4666        .collect::<Vec<_>>();
4667    let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
4668    let service_relative_root = service_root
4669        .strip_prefix(project_root)
4670        .ok()
4671        .map(normalized_relative_path)
4672        .filter(|value| !value.is_empty())
4673        .unwrap_or_else(|| ".".to_string());
4674    let cargo_package_name =
4675        resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
4676    let tag_identity = cargo_package_name
4677        .clone()
4678        .unwrap_or_else(|| service.name.clone());
4679
4680    Some(VersionScope::Service {
4681        service_root,
4682        service_relative_root,
4683        service_name: service.name,
4684        tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
4685        cargo_package_name,
4686        version_targets,
4687    })
4688}
4689
4690fn resolve_service_scope_root(
4691    project_root: &Path,
4692    service: &ServiceConfig,
4693    version_targets: &[String],
4694) -> Option<PathBuf> {
4695    if !version_targets.is_empty() {
4696        return common_parent_from_targets(project_root, version_targets);
4697    }
4698
4699    service.root_directory.as_ref().map(|root| {
4700        let path = PathBuf::from(root);
4701        if path.is_absolute() {
4702            path
4703        } else {
4704            project_root.join(path)
4705        }
4706    })
4707}
4708
4709fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
4710    let mut parents = version_targets.iter().filter_map(|target| {
4711        let path = PathBuf::from(target);
4712        let absolute = if path.is_absolute() {
4713            path
4714        } else {
4715            project_root.join(path)
4716        };
4717        absolute.parent().map(Path::to_path_buf)
4718    });
4719
4720    let mut common = parents.next()?;
4721    for parent in parents {
4722        while !parent.starts_with(&common) {
4723            common = common.parent()?.to_path_buf();
4724        }
4725    }
4726    Some(common)
4727}
4728
4729fn resolve_service_scope_cargo_package_name(
4730    project_root: &Path,
4731    service_root: &Path,
4732    version_targets: &[String],
4733) -> Option<String> {
4734    let mut cargo_paths = version_targets
4735        .iter()
4736        .filter(|target| {
4737            Path::new(target)
4738                .file_name()
4739                .and_then(|value| value.to_str())
4740                == Some("Cargo.toml")
4741        })
4742        .map(|target| {
4743            let path = PathBuf::from(target);
4744            if path.is_absolute() {
4745                path
4746            } else {
4747                project_root.join(path)
4748            }
4749        })
4750        .collect::<Vec<_>>();
4751    if cargo_paths.is_empty() {
4752        cargo_paths.push(service_root.join("Cargo.toml"));
4753    }
4754
4755    cargo_paths.into_iter().find_map(|path| {
4756        let content = fs::read_to_string(path).ok()?;
4757        cargo_package_name_from_content_optional(&content)
4758            .ok()
4759            .flatten()
4760    })
4761}
4762
4763fn select_matching_service_scope(
4764    scopes: &[VersionScope],
4765    project_root: &Path,
4766    invocation_dir: &Path,
4767) -> Option<VersionScope> {
4768    scopes
4769        .iter()
4770        .filter_map(|scope| {
4771            let root = version_scope_root(scope)?;
4772            if invocation_dir == project_root && root == project_root {
4773                return None;
4774            }
4775            if invocation_dir.starts_with(root) {
4776                Some((root.components().count(), scope.clone()))
4777            } else {
4778                None
4779            }
4780        })
4781        .max_by_key(|(depth, _)| *depth)
4782        .map(|(_, scope)| scope)
4783}
4784
4785fn prompt_for_version_scope_selection(
4786    service_scopes: &[VersionScope],
4787) -> Result<VersionScope, String> {
4788    let mut items = service_scopes
4789        .iter()
4790        .map(version_scope_prompt_label)
4791        .collect::<Vec<_>>();
4792    items.push("Repository (all configured version targets)".to_string());
4793
4794    let selection = Select::with_theme(&ColorfulTheme::default())
4795        .with_prompt("Choose the project/service to version or release")
4796        .items(&items)
4797        .default(items.len().saturating_sub(1))
4798        .interact_opt()
4799        .map_err(|e| format!("Prompt failed: {}", e))?;
4800
4801    match selection {
4802        Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
4803        _ => Ok(VersionScope::Repository),
4804    }
4805}
4806
4807fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
4808    let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
4809    let cargo_toml = crate_root.join("Cargo.toml");
4810    let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
4811    let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
4812    let crate_relative_root = crate_root
4813        .strip_prefix(project_root)
4814        .ok()
4815        .map(normalized_relative_path)?;
4816
4817    Some(VersionScope::Crate {
4818        crate_root,
4819        crate_relative_root,
4820        tag_prefix: format!("{}-", package_name),
4821        package_name,
4822    })
4823}
4824
4825fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
4826    let crates_root = project_root.join("crates");
4827    let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
4828    let mut components = relative.components();
4829    let crate_name = components.next()?;
4830    Some(crates_root.join(crate_name.as_os_str()))
4831}
4832
4833fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
4834    match version_scope {
4835        VersionScope::Repository => None,
4836        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
4837        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
4838    }
4839}
4840
4841fn prepare_release_openapi_assets(
4842    project_root: &Path,
4843    invocation_dir: &Path,
4844    version_scope: &VersionScope,
4845    release_version: &Version,
4846    tag_name: &str,
4847) -> Result<Vec<ReleaseOpenApiAsset>, String> {
4848    let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
4849    validate_release_openapi_assets(&spec_paths, release_version)?;
4850
4851    spec_paths
4852        .into_iter()
4853        .map(|source_path| {
4854            let file_name = source_path
4855                .file_name()
4856                .and_then(|value| value.to_str())
4857                .ok_or_else(|| {
4858                    format!(
4859                        "Invalid OpenAPI asset path for release upload: {}",
4860                        source_path.display()
4861                    )
4862                })?;
4863            let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
4864            let source_label = source_path
4865                .strip_prefix(project_root)
4866                .map(normalized_relative_path)
4867                .unwrap_or_else(|_| normalized_relative_path(&source_path));
4868            Ok(ReleaseOpenApiAsset {
4869                source_path,
4870                source_label,
4871                asset_name,
4872            })
4873        })
4874        .collect()
4875}
4876
4877fn resolve_release_openapi_specs(
4878    project_root: &Path,
4879    invocation_dir: &Path,
4880    version_scope: &VersionScope,
4881) -> Result<Vec<PathBuf>, String> {
4882    const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
4883        "openapi.yaml",
4884        "openapi.yml",
4885        "openapi.json",
4886        "swagger.yaml",
4887        "swagger.yml",
4888        "swagger.json",
4889    ];
4890
4891    let mut roots: Vec<PathBuf> = Vec::new();
4892    if let Some(scope_root) = version_scope_root(version_scope) {
4893        roots.push(scope_root.to_path_buf());
4894    } else {
4895        if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
4896            roots.push(crate_root);
4897        }
4898        roots.push(project_root.to_path_buf());
4899    }
4900
4901    let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
4902    let mut seen_names: BTreeSet<String> = BTreeSet::new();
4903    let mut resolved_paths: Vec<PathBuf> = Vec::new();
4904
4905    for root in roots {
4906        if !seen_roots.insert(root.clone()) {
4907            continue;
4908        }
4909
4910        let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
4911        for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
4912            let path = root.join(file_name);
4913            if path.is_file() {
4914                root_paths.push(((*file_name).to_string(), path));
4915            }
4916        }
4917
4918        let entries = match fs::read_dir(&root) {
4919            Ok(entries) => entries,
4920            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
4921            Err(error) => {
4922                return Err(format!(
4923                    "Failed to inspect OpenAPI release assets in {}: {}",
4924                    root.display(),
4925                    error
4926                ));
4927            }
4928        };
4929
4930        let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
4931        for entry in entries {
4932            let entry = entry.map_err(|error| {
4933                format!(
4934                    "Failed to inspect OpenAPI release assets in {}: {}",
4935                    root.display(),
4936                    error
4937                )
4938            })?;
4939            let path = entry.path();
4940            if !path.is_file() {
4941                continue;
4942            }
4943            let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
4944                continue;
4945            };
4946            if !is_release_openapi_variant_file_name(file_name) {
4947                continue;
4948            }
4949            additional_specs.push((file_name.to_string(), path));
4950        }
4951        additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
4952
4953        root_paths.extend(additional_specs);
4954
4955        for (file_name, path) in root_paths {
4956            if seen_names.insert(file_name) {
4957                resolved_paths.push(path);
4958            }
4959        }
4960    }
4961
4962    Ok(resolved_paths)
4963}
4964
4965fn validate_release_openapi_assets(
4966    spec_paths: &[PathBuf],
4967    release_version: &Version,
4968) -> Result<(), String> {
4969    let Some(primary_spec_path) = spec_paths.iter().find(|path| {
4970        path.file_name()
4971            .and_then(|value| value.to_str())
4972            .map(is_primary_release_openapi_file_name)
4973            .unwrap_or(false)
4974    }) else {
4975        return Ok(());
4976    };
4977
4978    let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
4979        format!(
4980            "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
4981            primary_spec_path.display(),
4982            release_version
4983        )
4984    })?;
4985    let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
4986        format!(
4987            "Release OpenAPI spec `{}` has invalid version `{}`: {}",
4988            primary_spec_path.display(),
4989            declared_version,
4990            error
4991        )
4992    })?;
4993
4994    if &parsed_declared_version != release_version {
4995        return Err(format!(
4996            "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
4997            primary_spec_path.display(),
4998            declared_version,
4999            release_version
5000        ));
5001    }
5002
5003    Ok(())
5004}
5005
5006fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
5007    let path = Path::new(file_name);
5008    let stem = path
5009        .file_stem()
5010        .and_then(|value| value.to_str())
5011        .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
5012    let extension = path
5013        .extension()
5014        .and_then(|value| value.to_str())
5015        .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
5016    Ok(format!("{}-{}.{}", stem, tag_name, extension))
5017}
5018
5019fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
5020    matches!(
5021        file_name,
5022        "openapi.yaml"
5023            | "openapi.yml"
5024            | "openapi.json"
5025            | "swagger.yaml"
5026            | "swagger.yml"
5027            | "swagger.json"
5028    )
5029}
5030
5031fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
5032    if is_primary_release_openapi_file_name(file_name) {
5033        return false;
5034    }
5035
5036    let Some((stem, extension)) = file_name.rsplit_once('.') else {
5037        return false;
5038    };
5039    if !matches!(extension, "yaml" | "yml" | "json") {
5040        return false;
5041    }
5042
5043    stem.starts_with("openapi-") || stem.starts_with("swagger-")
5044}
5045
5046fn normalized_path_string(path: String) -> String {
5047    path.replace('\\', "/")
5048}
5049
5050fn slugify_scope_name(value: &str) -> String {
5051    let mut slug = value
5052        .chars()
5053        .map(|ch| {
5054            if ch.is_ascii_alphanumeric() {
5055                ch.to_ascii_lowercase()
5056            } else {
5057                '-'
5058            }
5059        })
5060        .collect::<String>();
5061    while slug.contains("--") {
5062        slug = slug.replace("--", "-");
5063    }
5064    slug.trim_matches('-').to_string()
5065}
5066
5067fn resolve_workspace_primary_cargo_target(
5068    project_root: &Path,
5069) -> Option<WorkspacePrimaryCargoTarget> {
5070    let workspace_manifest = project_root.join("Cargo.toml");
5071    let content = fs::read_to_string(&workspace_manifest).ok()?;
5072    let value: TomlValue = toml::from_str(&content).ok()?;
5073    if value.get("package").and_then(TomlValue::as_table).is_some() {
5074        return None;
5075    }
5076
5077    let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
5078    let mut candidate_roots = workspace
5079        .get("default-members")
5080        .and_then(TomlValue::as_array)
5081        .map(|members| {
5082            members
5083                .iter()
5084                .filter_map(TomlValue::as_str)
5085                .map(str::trim)
5086                .filter(|member| !member.is_empty() && !member.contains('*'))
5087                .map(str::to_string)
5088                .collect::<Vec<_>>()
5089        })
5090        .unwrap_or_default();
5091
5092    if candidate_roots.is_empty() {
5093        let members = workspace
5094            .get("members")
5095            .and_then(TomlValue::as_array)
5096            .map(|members| {
5097                members
5098                    .iter()
5099                    .filter_map(TomlValue::as_str)
5100                    .map(str::trim)
5101                    .filter(|member| !member.is_empty() && !member.contains('*'))
5102                    .map(str::to_string)
5103                    .collect::<Vec<_>>()
5104            })
5105            .unwrap_or_default();
5106        if members.len() == 1 {
5107            candidate_roots = members;
5108        }
5109    }
5110
5111    candidate_roots.sort();
5112    candidate_roots.dedup();
5113    if candidate_roots.len() != 1 {
5114        return None;
5115    }
5116
5117    let crate_relative_root = candidate_roots.into_iter().next()?;
5118    let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
5119    let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
5120    let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
5121
5122    Some(WorkspacePrimaryCargoTarget {
5123        manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
5124        manifest_absolute,
5125        package_name,
5126    })
5127}
5128
5129#[cfg(test)]
5130fn resolve_release_publish_target_filter(
5131    invocation_dir: &Path,
5132    version_scope: &VersionScope,
5133) -> Result<Option<String>, String> {
5134    let VersionScope::Service {
5135        service_name,
5136        service_root,
5137        version_targets,
5138        ..
5139    } = version_scope
5140    else {
5141        return Ok(None);
5142    };
5143
5144    let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
5145        return Ok(None);
5146    };
5147    let Some(publish) = config.publish else {
5148        return Ok(None);
5149    };
5150
5151    let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
5152    let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
5153        .into_iter()
5154        .filter_map(|(kind, target)| {
5155            let target = target?;
5156            if target.enabled.unwrap_or(true) {
5157                Some((kind, target))
5158            } else {
5159                None
5160            }
5161        })
5162        .collect::<Vec<_>>();
5163
5164    if enabled_targets.is_empty() {
5165        return Ok(None);
5166    }
5167
5168    let matched = enabled_targets
5169        .iter()
5170        .filter_map(|(kind, target)| {
5171            let manifest_path = target.manifest_path.as_ref()?;
5172            let absolute = if Path::new(manifest_path).is_absolute() {
5173                PathBuf::from(manifest_path)
5174            } else {
5175                project_root.join(manifest_path)
5176            };
5177            let relative = absolute
5178                .strip_prefix(&project_root)
5179                .ok()
5180                .map(normalized_relative_path)
5181                .unwrap_or_else(|| normalized_relative_path(&absolute));
5182
5183            if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
5184            {
5185                Some((*kind).to_string())
5186            } else {
5187                None
5188            }
5189        })
5190        .collect::<Vec<_>>();
5191
5192    if matched.is_empty() {
5193        return Err(format!(
5194            "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
5195            service_name
5196        ));
5197    }
5198
5199    if matched.len() == enabled_targets.len() {
5200        if matched.len() == 1 {
5201            return Ok(matched.into_iter().next());
5202        }
5203        return Ok(None);
5204    }
5205
5206    if matched.len() == 1 {
5207        return Ok(matched.into_iter().next());
5208    }
5209
5210    Err(format!(
5211        "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.",
5212        service_name
5213    ))
5214}
5215
5216async fn resolve_effective_linear_release_config(
5217    project_root: &Path,
5218    invocation_dir: &Path,
5219) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
5220    let global_config = resolve_global_linear_release_config();
5221    let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5222        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5223        config.linear.and_then(|linear| linear.release)
5224    } else {
5225        None
5226    };
5227
5228    Ok(resolve_linear_release_config(global_config, project_config)
5229        .map(|config| resolve_linear_release_placeholders(project_root, config)))
5230}
5231
5232fn resolve_linear_release_placeholders(
5233    project_root: &Path,
5234    mut config: ResolvedLinearReleaseConfig,
5235) -> ResolvedLinearReleaseConfig {
5236    let mut env_map = HashMap::new();
5237    for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
5238        env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
5239    }
5240    if let Some(organization_name) = config.organization_name.clone() {
5241        env_map.insert("organization_name".to_string(), organization_name);
5242    }
5243
5244    let resolved = resolve_env_placeholders(project_root, &env_map);
5245    config.initiative_ids = config
5246        .initiative_ids
5247        .iter()
5248        .enumerate()
5249        .map(|(index, initiative_id)| {
5250            resolved
5251                .get(&format!("initiative_id_{}", index))
5252                .cloned()
5253                .unwrap_or_else(|| initiative_id.clone())
5254        })
5255        .map(|value| value.trim().to_string())
5256        .filter(|value| !value.is_empty())
5257        .collect();
5258    config.organization_name = resolved
5259        .get("organization_name")
5260        .cloned()
5261        .or(config.organization_name)
5262        .map(|value| value.trim().to_string())
5263        .filter(|value| !value.is_empty());
5264    config
5265}
5266
5267async fn resolve_project_github_release_branch_config(
5268    _project_root: &Path,
5269    invocation_dir: &Path,
5270) -> Result<Option<GitHubReleaseBranchSettings>, String> {
5271    if let Some(found) = find_xbp_config_upwards(invocation_dir) {
5272        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
5273        Ok(config.github_release_branch_settings())
5274    } else {
5275        Ok(None)
5276    }
5277}
5278
5279fn normalized_relative_path(path: &Path) -> String {
5280    path.to_string_lossy().replace('\\', "/")
5281}
5282
5283fn git_repository_root(dir: &Path) -> Option<PathBuf> {
5284    if !command_exists("git") {
5285        return None;
5286    }
5287
5288    let output: std::process::Output = Command::new("git")
5289        .current_dir(dir)
5290        .args(["rev-parse", "--show-toplevel"])
5291        .output()
5292        .ok()?;
5293
5294    if !output.status.success() {
5295        return None;
5296    }
5297
5298    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
5299    if root.is_empty() {
5300        None
5301    } else {
5302        Some(PathBuf::from(root))
5303    }
5304}
5305
5306fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
5307    let output: std::process::Output = Command::new("git")
5308        .current_dir(project_root)
5309        .args(args)
5310        .output()
5311        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
5312
5313    if !output.status.success() {
5314        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
5315        if stderr.is_empty() {
5316            return Err(format!(
5317                "`git {}` failed with status {}",
5318                args.join(" "),
5319                output.status
5320            ));
5321        }
5322        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
5323    }
5324
5325    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
5326}
5327
5328fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
5329    let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
5330    Ok(output
5331        .lines()
5332        .map(|line| line.trim())
5333        .filter(|line| !line.is_empty())
5334        .map(|line| line.to_string())
5335        .collect())
5336}
5337
5338fn version_change_guard_state_path() -> Result<PathBuf, String> {
5339    let paths = global_xbp_paths()?;
5340    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
5341}
5342
5343fn version_change_guard_repo_key(project_root: &Path) -> String {
5344    fs::canonicalize(project_root)
5345        .unwrap_or_else(|_| project_root.to_path_buf())
5346        .to_string_lossy()
5347        .replace('\\', "/")
5348}
5349
5350fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
5351    if !path.exists() {
5352        return Ok(VersionChangeGuardRegistry::default());
5353    }
5354
5355    let content = fs::read_to_string(path).map_err(|e| {
5356        format!(
5357            "Failed to read version-change guard state {}: {}",
5358            path.display(),
5359            e
5360        )
5361    })?;
5362
5363    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
5364}
5365
5366fn save_version_change_guard_registry(
5367    path: &Path,
5368    registry: &VersionChangeGuardRegistry,
5369) -> Result<(), String> {
5370    if let Some(parent) = path.parent() {
5371        fs::create_dir_all(parent).map_err(|e| {
5372            format!(
5373                "Failed to create guard state directory {}: {}",
5374                parent.display(),
5375                e
5376            )
5377        })?;
5378    }
5379
5380    let content = serde_yaml::to_string(registry)
5381        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
5382    fs::write(path, content).map_err(|e| {
5383        format!(
5384            "Failed to write version-change guard state {}: {}",
5385            path.display(),
5386            e
5387        )
5388    })
5389}
5390
5391fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
5392    if !command_exists("git") {
5393        return Ok(None);
5394    }
5395
5396    let status_output: std::process::Output = Command::new("git")
5397        .current_dir(project_root)
5398        .args(["status", "--porcelain"])
5399        .output()
5400        .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
5401    if !status_output.status.success() {
5402        return Ok(None);
5403    }
5404
5405    let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
5406        .lines()
5407        .any(|line| !line.trim().is_empty());
5408
5409    let head_output: std::process::Output = Command::new("git")
5410        .current_dir(project_root)
5411        .args(["rev-parse", "HEAD"])
5412        .output()
5413        .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
5414    let head_commit: Option<String> = if head_output.status.success() {
5415        let value: String = String::from_utf8_lossy(&head_output.stdout)
5416            .trim()
5417            .to_string();
5418        if value.is_empty() {
5419            None
5420        } else {
5421            Some(value)
5422        }
5423    } else {
5424        None
5425    };
5426
5427    Ok(Some(GitWorktreeState {
5428        is_dirty,
5429        head_commit,
5430    }))
5431}
5432
5433fn should_clear_version_change_guard(
5434    entry: &VersionChangeGuardEntry,
5435    state: &GitWorktreeState,
5436) -> bool {
5437    if entry.pending_version_change_count == 0 {
5438        return true;
5439    }
5440    if !state.is_dirty {
5441        return true;
5442    }
5443
5444    match (&entry.head_commit, &state.head_commit) {
5445        (Some(previous), Some(current)) => previous != current,
5446        (Some(_), None) => true,
5447        _ => false,
5448    }
5449}
5450
5451fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
5452    let Some(state) = git_worktree_state(project_root)? else {
5453        return Ok(());
5454    };
5455
5456    let state_path: PathBuf = version_change_guard_state_path()?;
5457    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5458    let repo_key: String = version_change_guard_repo_key(project_root);
5459    let mut changed = false;
5460
5461    if let Some(entry) = registry.entries.get(&repo_key).cloned() {
5462        if should_clear_version_change_guard(&entry, &state) {
5463            registry.entries.remove(&repo_key);
5464            changed = true;
5465        }
5466    }
5467
5468    if changed {
5469        save_version_change_guard_registry(&state_path, &registry)?;
5470    }
5471
5472    if state.is_dirty {
5473        if let Some(entry) = registry.entries.get(&repo_key) {
5474            if entry.pending_version_change_count >= 1 {
5475                return Err(format!(
5476                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
5477                    entry.pending_version_change_count,
5478                    state_path.display()
5479                ));
5480            }
5481        }
5482    }
5483
5484    Ok(())
5485}
5486
5487fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
5488    let Some(state) = git_worktree_state(project_root)? else {
5489        return Ok(());
5490    };
5491
5492    let state_path: PathBuf = version_change_guard_state_path()?;
5493    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
5494    let repo_key: String = version_change_guard_repo_key(project_root);
5495
5496    if state.is_dirty {
5497        registry.entries.insert(
5498            repo_key,
5499            VersionChangeGuardEntry {
5500                pending_version_change_count: 1,
5501                head_commit: state.head_commit,
5502            },
5503        );
5504    } else {
5505        registry.entries.remove(&repo_key);
5506    }
5507
5508    save_version_change_guard_registry(&state_path, &registry)
5509}
5510
5511fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
5512    let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
5513    Ok(!output.trim().is_empty())
5514}
5515
5516fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
5517    let remotes: String = run_git_command(project_root, &["remote"])?;
5518    let exists: bool = remotes.lines().any(|line| line.trim() == remote);
5519    if exists {
5520        Ok(())
5521    } else {
5522        Err(format!(
5523            "Git remote `{}` is not configured for this repository.",
5524            remote
5525        ))
5526    }
5527}
5528
5529fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
5530    run_git_command(project_root, &["remote", "get-url", remote])
5531}
5532
5533fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
5534    let query: String = format!("refs/tags/{}", tag);
5535    let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
5536    Ok(!output.trim().is_empty())
5537}
5538
5539fn git_head_commitish(project_root: &Path) -> Result<String, String> {
5540    let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
5541    if commitish.is_empty() {
5542        Err("Unable to resolve HEAD commit for release target.".to_string())
5543    } else {
5544        Ok(commitish)
5545    }
5546}
5547
5548fn render_release_branch_name(
5549    naming_template: &str,
5550    release_version: &Version,
5551    tag_name: &str,
5552    service_name: &str,
5553) -> Result<String, String> {
5554    let service_name = sanitize_release_branch_segment(service_name);
5555    let branch_name = naming_template
5556        .replace("{VERSION}", &release_version.to_string())
5557        .replace("{TAG}", tag_name)
5558        .replace("{SERVICE_NAME}", &service_name)
5559        .replace("${GITHUB_VERSION}", &release_version.to_string())
5560        .replace("${GITHUB_TAG}", tag_name)
5561        .replace("${SERVICE_NAME}", &service_name);
5562    let branch_name = branch_name.trim();
5563    if branch_name.is_empty() {
5564        return Err(
5565            "GitHub release branch naming template resolved to an empty branch name.".to_string(),
5566        );
5567    }
5568    Ok(branch_name.to_string())
5569}
5570
5571fn sanitize_release_branch_segment(value: &str) -> String {
5572    value
5573        .trim()
5574        .chars()
5575        .map(|character| match character {
5576            'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => character,
5577            _ => '-',
5578        })
5579        .collect::<String>()
5580        .trim_matches('-')
5581        .to_string()
5582}
5583
5584fn git_local_branch_commit(
5585    project_root: &Path,
5586    branch_name: &str,
5587) -> Result<Option<String>, String> {
5588    let output = Command::new("git")
5589        .current_dir(project_root)
5590        .args([
5591            "rev-parse",
5592            "--verify",
5593            &format!("refs/heads/{}", branch_name),
5594        ])
5595        .output()
5596        .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
5597    if !output.status.success() {
5598        return Ok(None);
5599    }
5600    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
5601    if commit.is_empty() {
5602        Ok(None)
5603    } else {
5604        Ok(Some(commit))
5605    }
5606}
5607
5608fn git_remote_branch_commit(
5609    project_root: &Path,
5610    remote: &str,
5611    branch_name: &str,
5612) -> Result<Option<String>, String> {
5613    let output = run_git_command(
5614        project_root,
5615        &[
5616            "ls-remote",
5617            "--heads",
5618            remote,
5619            &format!("refs/heads/{}", branch_name),
5620        ],
5621    )?;
5622    let commit = output
5623        .split_whitespace()
5624        .next()
5625        .map(str::trim)
5626        .filter(|value| !value.is_empty())
5627        .map(str::to_string);
5628    Ok(commit)
5629}
5630
5631fn ensure_release_branch(
5632    project_root: &Path,
5633    branch_config: &GitHubReleaseBranchSettings,
5634    release_version: &Version,
5635    tag_name: &str,
5636    service_name: &str,
5637    target_commitish: &str,
5638) -> Result<String, String> {
5639    let branch_name = render_release_branch_name(
5640        &branch_config.naming_template,
5641        release_version,
5642        tag_name,
5643        service_name,
5644    )?;
5645
5646    if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
5647        if existing_local_commit != target_commitish {
5648            return Err(format!(
5649                "Configured release branch `{}` already exists locally at {}, expected {}.",
5650                branch_name, existing_local_commit, target_commitish
5651            ));
5652        }
5653    } else {
5654        run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
5655    }
5656
5657    if let Some(existing_remote_commit) =
5658        git_remote_branch_commit(project_root, "origin", &branch_name)?
5659    {
5660        if existing_remote_commit != target_commitish {
5661            return Err(format!(
5662                "Configured release branch `{}` already exists on origin at {}, expected {}.",
5663                branch_name, existing_remote_commit, target_commitish
5664            ));
5665        }
5666    } else {
5667        run_git_command(
5668            project_root,
5669            &[
5670                "push",
5671                "origin",
5672                &format!("{}:refs/heads/{}", target_commitish, branch_name),
5673            ],
5674        )?;
5675    }
5676
5677    Ok(branch_name)
5678}
5679
5680fn release_tag_family(tag_name: &str) -> String {
5681    if tag_name.starts_with('v')
5682        && tag_name
5683            .chars()
5684            .nth(1)
5685            .map(|ch| ch.is_ascii_digit())
5686            .unwrap_or(false)
5687    {
5688        return "v".to_string();
5689    }
5690
5691    let mut family: String = String::new();
5692    for ch in tag_name.chars() {
5693        if ch.is_ascii_digit() {
5694            break;
5695        }
5696        family.push(ch);
5697    }
5698    family
5699}
5700
5701fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
5702    if family == "v" {
5703        return parse_version(tag).ok();
5704    }
5705
5706    tag.strip_prefix(family)
5707        .and_then(|rest| parse_version(rest).ok())
5708}
5709
5710fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
5711    let range: String = format!("{}..HEAD", tag);
5712    run_git_command(project_root, &["rev-list", "--count", &range])
5713        .ok()
5714        .and_then(|raw| raw.trim().parse::<usize>().ok())
5715}
5716
5717fn previous_release_tag(
5718    project_root: &Path,
5719    current_tag_name: &str,
5720) -> Result<Option<String>, String> {
5721    let family: String = release_tag_family(current_tag_name);
5722    let tag_pattern: String = format!("{}*", family);
5723    let merged_tags: String = run_git_command(
5724        project_root,
5725        &["tag", "--merged", "HEAD", "--list", &tag_pattern],
5726    )?;
5727
5728    let mut best: Option<(usize, String)> = None;
5729    for raw in merged_tags.lines() {
5730        let tag = raw.trim();
5731        if tag.is_empty() || tag == current_tag_name {
5732            continue;
5733        }
5734        if parse_release_family_version(tag, &family).is_none() {
5735            continue;
5736        }
5737
5738        let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
5739            continue;
5740        };
5741        if distance == 0 {
5742            continue;
5743        }
5744
5745        match &best {
5746            None => best = Some((distance, tag.to_string())),
5747            Some((best_distance, _)) if distance < *best_distance => {
5748                best = Some((distance, tag.to_string()))
5749            }
5750            _ => {}
5751        }
5752    }
5753
5754    Ok(best.map(|(_, tag)| tag))
5755}
5756
5757fn default_release_title(version: &Version, repo: &str) -> String {
5758    format!("{} - {}", version, repo)
5759}
5760
5761fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
5762    match version_scope {
5763        VersionScope::Repository => repo,
5764        VersionScope::Crate { package_name, .. } => package_name.as_str(),
5765        VersionScope::Service { service_name, .. } => service_name.as_str(),
5766    }
5767}
5768
5769fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
5770    match version_scope {
5771        VersionScope::Repository => "repository",
5772        VersionScope::Crate { .. } => "crate",
5773        VersionScope::Service { .. } => "service",
5774    }
5775}
5776
5777fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
5778    match version_scope {
5779        VersionScope::Repository => repo.to_string(),
5780        VersionScope::Crate { package_name, .. } => package_name.clone(),
5781        VersionScope::Service { service_name, .. } => service_name.clone(),
5782    }
5783}
5784
5785fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
5786    match version_scope {
5787        VersionScope::Repository => "Repository".to_string(),
5788        VersionScope::Crate {
5789            package_name,
5790            crate_relative_root,
5791            ..
5792        } => format!("{} ({})", package_name, crate_relative_root),
5793        VersionScope::Service {
5794            service_name,
5795            service_relative_root,
5796            ..
5797        } => format!("{} ({})", service_name, service_relative_root),
5798    }
5799}
5800
5801fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
5802    match version_scope {
5803        VersionScope::Repository => format!("v{}", version),
5804        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
5805            format!("{}{}", tag_prefix, version)
5806        }
5807    }
5808}
5809
5810fn scoped_release_tag_name(
5811    version_scope: &VersionScope,
5812    version: &Version,
5813    parsed_tag_name: &str,
5814) -> String {
5815    match version_scope {
5816        VersionScope::Repository => parsed_tag_name.to_string(),
5817        VersionScope::Crate { .. } | VersionScope::Service { .. } => {
5818            if release_tag_family(parsed_tag_name) == "v" {
5819                default_release_tag_name(version_scope, version)
5820            } else {
5821                parsed_tag_name.to_string()
5822            }
5823        }
5824    }
5825}
5826
5827fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
5828    match version_scope {
5829        VersionScope::Repository => None,
5830        VersionScope::Crate {
5831            crate_relative_root,
5832            ..
5833        } => Some(crate_relative_root.clone()),
5834        VersionScope::Service {
5835            service_relative_root,
5836            ..
5837        } if service_relative_root == "." => None,
5838        VersionScope::Service {
5839            service_relative_root,
5840            ..
5841        } => Some(service_relative_root.clone()),
5842    }
5843}
5844
5845fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
5846    let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
5847        return (None, None);
5848    };
5849    let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
5850        return (None, None);
5851    };
5852
5853    (Some(owner), Some(repo))
5854}
5855
5856fn published_initiatives_to_activity(
5857    initiatives: &[PublishedLinearInitiative],
5858) -> Vec<VersionActivityLinearInitiative> {
5859    initiatives
5860        .iter()
5861        .map(|initiative| VersionActivityLinearInitiative {
5862            id: initiative.id.clone(),
5863            name: initiative.name.clone(),
5864            url: initiative.url.clone(),
5865        })
5866        .collect()
5867}
5868
5869async fn sync_cli_version_write_activity(
5870    project_root: &Path,
5871    version_scope: &VersionScope,
5872    version: &Version,
5873    message: String,
5874) {
5875    let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
5876    let scope_label = version_scope_label(
5877        version_scope,
5878        repository_name.as_deref().unwrap_or_else(|| {
5879            project_root
5880                .file_name()
5881                .and_then(|value| value.to_str())
5882                .unwrap_or("repository")
5883        }),
5884    );
5885
5886    let payload = CliVersionActivityPayload {
5887        command_kind: "version".to_string(),
5888        repository_owner,
5889        repository_name,
5890        scope_kind: version_scope_kind(version_scope).to_string(),
5891        scope_label,
5892        version: version.to_string(),
5893        tag_name: None,
5894        title: None,
5895        release_url: None,
5896        message_markdown: Some(message),
5897        published_initiatives: Vec::new(),
5898    };
5899
5900    if let Err(error) = post_version_activity(&payload).await {
5901        eprintln!("Warning: {}", error);
5902    }
5903}
5904
5905async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
5906    let payload = CliVersionActivityPayload {
5907        command_kind: "version_release".to_string(),
5908        repository_owner: Some(summary.repository_owner.clone()),
5909        repository_name: Some(summary.repository_name.clone()),
5910        scope_kind: summary.scope_kind.clone(),
5911        scope_label: summary.scope_label.clone(),
5912        version: summary.version.to_string(),
5913        tag_name: Some(summary.tag_name.clone()),
5914        title: Some(summary.release_title.clone()),
5915        release_url: Some(summary.release_url.clone()),
5916        message_markdown: Some(summary.release_notes.clone()),
5917        published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
5918    };
5919
5920    if let Err(error) = post_version_activity(&payload).await {
5921        eprintln!("Warning: {}", error);
5922    }
5923}
5924
5925fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
5926    let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
5927    let mut rendered_notes: String = notes.trim_end().to_string();
5928    if !rendered_notes.is_empty() {
5929        rendered_notes.push('\n');
5930    }
5931    rendered_notes.push_str("Release label: ");
5932    rendered_notes.push_str(release_label);
5933    rendered_notes.push('\n');
5934    rendered_notes.push_str("Generated by XBP ");
5935    rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
5936    rendered_notes
5937}
5938
5939#[cfg(test)]
5940mod tests {
5941    use super::github_release::{
5942        github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
5943        github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
5944        github_release_update_endpoint,
5945    };
5946    use super::release_docs::{
5947        release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
5948    };
5949    use super::release_notes::{
5950        build_fallback_sections, collect_linear_issue_identifiers,
5951        deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
5952        LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
5953    };
5954    use super::{
5955        analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
5956        current_scope_ledger_path, default_release_tag_name, default_release_title,
5957        highest_version_observation, is_safe_dirty_path, normalized_relative_path,
5958        parse_git_status_path, parse_github_repo_from_remote_url, parse_local_git_tag_output,
5959        parse_local_git_tag_output_for_scope, parse_package_version_target,
5960        parse_release_version_target, parse_remote_git_tag_output, parse_version,
5961        prepare_release_openapi_assets, read_cargo_lock_version,
5962        read_cargo_lock_version_for_package, read_cargo_toml_version,
5963        read_highest_version_from_targets, read_json_openapi_version, read_json_root_version,
5964        read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
5965        read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
5966        read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
5967        render_release_branch_name, resolve_configured_version_target_paths,
5968        resolve_linear_release_placeholders, resolve_project_root_from, resolve_registry_paths,
5969        resolve_release_openapi_specs, resolve_release_publish_target_filter,
5970        resolve_version_scope, rewrite_toml_package_assignment_versions,
5971        select_changed_version_targets, should_clear_version_change_guard,
5972        stale_version_observations, sync_version_to_configured_files_with_paths,
5973        write_cargo_lock_version, write_cargo_toml_version, write_chart_version,
5974        write_json_openapi_version, write_json_root_version, write_openapi_version,
5975        write_package_version_to_configured_files, write_pyproject_version, write_readme_version,
5976        write_regex_version, write_toml_root_version, write_version_to_configured_files,
5977        write_version_to_configured_files_with_paths, write_version_to_selected_paths,
5978        write_yaml_root_version, GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry,
5979        VersionObservation, VersionScope,
5980    };
5981
5982    use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
5983    use crate::config::PackageNameLookup;
5984    use semver::Version;
5985    use std::collections::BTreeMap;
5986    use std::fs;
5987    use std::path::{Path, PathBuf};
5988    use std::time::{SystemTime, UNIX_EPOCH};
5989
5990    fn temp_dir(label: &str) -> PathBuf {
5991        let nanos: u128 = SystemTime::now()
5992            .duration_since(UNIX_EPOCH)
5993            .expect("time")
5994            .as_nanos();
5995        let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
5996        fs::create_dir_all(&dir).expect("create temp dir");
5997        dir
5998    }
5999
6000    #[test]
6001    fn parse_git_status_path_handles_renames() {
6002        assert_eq!(
6003            parse_git_status_path("R  old-name -> new-name/Cargo.toml"),
6004            Some("new-name/Cargo.toml".to_string())
6005        );
6006        assert_eq!(
6007            parse_git_status_path(" M .xbp/xbp.yaml"),
6008            Some(".xbp/xbp.yaml".to_string())
6009        );
6010    }
6011
6012    #[test]
6013    fn is_safe_dirty_path_recognizes_generated_paths() {
6014        assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
6015        assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
6016        assert!(is_safe_dirty_path(
6017            "scripts/__pycache__/publish.cpython-312.pyc"
6018        ));
6019        assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
6020        assert!(!is_safe_dirty_path("Cargo.toml"));
6021    }
6022
6023    #[test]
6024    fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
6025        let analysis = analyze_dirty_worktree(&[
6026            " M .xbp/xbp.yaml".to_string(),
6027            " M crates/cli/src/main.rs".to_string(),
6028        ]);
6029        assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
6030        assert_eq!(
6031            analysis.risky_entries,
6032            vec!["crates/cli/src/main.rs".to_string()]
6033        );
6034    }
6035
6036    fn write_multi_service_config(dir: &Path) {
6037        let xbp_dir = dir.join(".xbp");
6038        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6039        fs::write(
6040            xbp_dir.join("xbp.yaml"),
6041            r#"project_name: xbp
6042version: 10.30.0
6043port: 3000
6044build_dir: ./
6045services:
6046  - name: cli
6047    target: rust
6048    branch: main
6049    port: 8080
6050    root_directory: ./
6051    version_targets:
6052      - crates/cli/Cargo.toml
6053  - name: web
6054    target: nextjs
6055    branch: main
6056    port: 3001
6057    root_directory: apps/web
6058    version_targets:
6059      - apps/web/package.json
6060publish:
6061  npm:
6062    enabled: true
6063    manifest_path: apps/web/package.json
6064  crates:
6065    enabled: true
6066    manifest_path: crates/cli/Cargo.toml
6067version_targets:
6068  - crates/cli/Cargo.toml
6069  - apps/web/package.json
6070"#,
6071        )
6072        .expect("write xbp config");
6073    }
6074
6075    #[test]
6076    fn parses_prefixed_semver() {
6077        assert_eq!(
6078            parse_version("v1.2.3").expect("version"),
6079            Version::new(1, 2, 3)
6080        );
6081    }
6082
6083    #[test]
6084    fn rejects_invalid_semver() {
6085        let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
6086        assert!(error.contains("Invalid semantic version"));
6087    }
6088
6089    #[test]
6090    fn release_target_parser_supports_plain_semver() {
6091        let (version, tag_name) =
6092            parse_release_version_target("1.2.3-alpha.1").expect("release target");
6093        assert_eq!(version.major, 1);
6094        assert_eq!(version.minor, 2);
6095        assert_eq!(version.patch, 3);
6096        assert_eq!(version.pre.as_str(), "alpha.1");
6097        assert_eq!(tag_name, "v1.2.3-alpha.1");
6098    }
6099
6100    #[test]
6101    fn release_target_parser_supports_prefixed_semver() {
6102        let (version, tag_name) =
6103            parse_release_version_target("studio-0.3.2-alpha").expect("release target");
6104        assert_eq!(version.major, 0);
6105        assert_eq!(version.minor, 3);
6106        assert_eq!(version.patch, 2);
6107        assert_eq!(version.pre.as_str(), "alpha");
6108        assert_eq!(tag_name, "studio-0.3.2-alpha");
6109    }
6110
6111    #[test]
6112    fn bumps_versions_correctly() {
6113        let base: Version = Version::new(0, 1, 0);
6114        assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
6115        assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
6116        assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
6117    }
6118
6119    #[test]
6120    fn version_change_guard_clears_when_worktree_is_clean() {
6121        let entry = VersionChangeGuardEntry {
6122            pending_version_change_count: 1,
6123            head_commit: Some("abc123".to_string()),
6124        };
6125        let state = GitWorktreeState {
6126            is_dirty: false,
6127            head_commit: Some("abc123".to_string()),
6128        };
6129        assert!(should_clear_version_change_guard(&entry, &state));
6130    }
6131
6132    #[test]
6133    fn version_change_guard_clears_when_head_changes() {
6134        let entry = VersionChangeGuardEntry {
6135            pending_version_change_count: 1,
6136            head_commit: Some("abc123".to_string()),
6137        };
6138        let state = GitWorktreeState {
6139            is_dirty: true,
6140            head_commit: Some("def456".to_string()),
6141        };
6142        assert!(should_clear_version_change_guard(&entry, &state));
6143    }
6144
6145    #[test]
6146    fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
6147        let entry = VersionChangeGuardEntry {
6148            pending_version_change_count: 1,
6149            head_commit: Some("abc123".to_string()),
6150        };
6151        let state = GitWorktreeState {
6152            is_dirty: true,
6153            head_commit: Some("abc123".to_string()),
6154        };
6155        assert!(!should_clear_version_change_guard(&entry, &state));
6156    }
6157
6158    #[test]
6159    fn render_release_branch_name_replaces_supported_tokens() {
6160        let branch = render_release_branch_name(
6161            "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
6162            &Version::new(10, 27, 0),
6163            "v10.27.0",
6164            "xbp",
6165        )
6166        .expect("branch name");
6167
6168        assert_eq!(branch, "releases/10.27.0/v10.27.0");
6169    }
6170
6171    #[test]
6172    fn render_release_branch_name_supports_service_name_template() {
6173        let branch = render_release_branch_name(
6174            "releases/{VERSION}-{SERVICE_NAME}",
6175            &Version::new(10, 27, 0),
6176            "v10.27.0",
6177            "athena/api",
6178        )
6179        .expect("branch name");
6180
6181        assert_eq!(branch, "releases/10.27.0-athena-api");
6182    }
6183
6184    #[test]
6185    fn resolve_linear_release_placeholders_reads_env_files() {
6186        let temp_dir = std::env::temp_dir().join(format!(
6187            "xbp-linear-release-placeholders-{}",
6188            std::time::SystemTime::now()
6189                .duration_since(std::time::UNIX_EPOCH)
6190                .expect("time")
6191                .as_nanos()
6192        ));
6193        fs::create_dir_all(&temp_dir).expect("temp dir");
6194        fs::write(
6195            temp_dir.join(".env.local"),
6196            "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
6197        )
6198        .expect("env file");
6199
6200        let resolved = resolve_linear_release_placeholders(
6201            &temp_dir,
6202            ResolvedLinearReleaseConfig {
6203                initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
6204                organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
6205                health: "on_track".to_string(),
6206            },
6207        );
6208
6209        assert_eq!(
6210            resolved.initiative_ids,
6211            vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
6212        );
6213        assert_eq!(
6214            resolved.organization_name.as_deref(),
6215            Some("suits-formations")
6216        );
6217
6218        let _ = fs::remove_dir_all(temp_dir);
6219    }
6220
6221    #[test]
6222    fn version_change_guard_clears_when_pending_count_is_zero() {
6223        let entry = VersionChangeGuardEntry {
6224            pending_version_change_count: 0,
6225            head_commit: Some("abc123".to_string()),
6226        };
6227        let state = GitWorktreeState {
6228            is_dirty: true,
6229            head_commit: Some("abc123".to_string()),
6230        };
6231        assert!(should_clear_version_change_guard(&entry, &state));
6232    }
6233
6234    #[test]
6235    fn parse_package_version_target_supports_assignment_syntax() {
6236        let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
6237            .expect("parse")
6238            .expect("target");
6239        assert_eq!(parsed.0, "demo_pkg".to_string());
6240        assert_eq!(parsed.1, Version::new(1, 2, 3));
6241    }
6242
6243    #[test]
6244    fn parse_package_version_target_rejects_invalid_package_names() {
6245        let error: String = parse_package_version_target("bad package=1.2.3")
6246            .expect_err("invalid package target should fail");
6247        assert!(error.contains("Invalid package target"));
6248    }
6249
6250    #[test]
6251    fn parse_package_version_target_returns_none_without_assignment() {
6252        assert!(parse_package_version_target("1.2.3")
6253            .expect("parse")
6254            .is_none());
6255    }
6256
6257    #[test]
6258    fn parse_package_version_target_returns_none_for_empty_package_name() {
6259        assert!(parse_package_version_target(" =1.2.3")
6260            .expect("parse")
6261            .is_none());
6262    }
6263
6264    #[test]
6265    fn bumping_clears_prerelease_and_build_metadata() {
6266        let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
6267        assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
6268        assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
6269        assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
6270    }
6271
6272    #[test]
6273    fn cargo_toml_adapter_reads_and_writes() {
6274        let dir: PathBuf = temp_dir("cargo");
6275        let path: PathBuf = dir.join("Cargo.toml");
6276        fs::write(
6277            &path,
6278            r#"[package]
6279            name = "xbp"
6280            version = "1.0.0"
6281            "#,
6282        )
6283        .expect("write Cargo.toml");
6284
6285        assert_eq!(
6286            read_cargo_toml_version(&path).expect("read"),
6287            Some("1.0.0".to_string())
6288        );
6289
6290        write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
6291        assert_eq!(
6292            read_version_from_path(&path).expect("read"),
6293            Some("1.1.0".to_string())
6294        );
6295
6296        let _ = fs::remove_dir_all(dir);
6297    }
6298
6299    #[test]
6300    fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
6301        let dir: PathBuf = temp_dir("workspace-root-version");
6302        let crate_dir = dir.join("crates").join("demo");
6303        fs::create_dir_all(&crate_dir).expect("create crate dir");
6304        fs::write(
6305            dir.join("Cargo.toml"),
6306            r#"[workspace]
6307members = ["crates/demo"]
6308resolver = "2"
6309
6310[workspace.package]
6311version = "0.1.0"
6312"#,
6313        )
6314        .expect("write workspace root");
6315        fs::write(
6316            crate_dir.join("Cargo.toml"),
6317            r#"[package]
6318name = "demo"
6319version = { workspace = true }
6320"#,
6321        )
6322        .expect("write crate manifest");
6323
6324        assert_eq!(
6325            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
6326            Some("0.1.0".to_string())
6327        );
6328        assert_eq!(
6329            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6330            Some("0.1.0".to_string())
6331        );
6332
6333        write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
6334        assert_eq!(
6335            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
6336            Some("0.2.0".to_string())
6337        );
6338
6339        let _ = fs::remove_dir_all(dir);
6340    }
6341
6342    #[test]
6343    fn json_root_adapter_reads_and_writes() {
6344        let dir: PathBuf = temp_dir("json");
6345        let path: PathBuf = dir.join("package.json");
6346        fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
6347
6348        assert_eq!(
6349            read_json_root_version(&path).expect("read"),
6350            Some("1.4.0".to_string())
6351        );
6352
6353        write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
6354        assert_eq!(
6355            read_version_from_path(&path).expect("read"),
6356            Some("1.5.0".to_string())
6357        );
6358
6359        let _ = fs::remove_dir_all(dir);
6360    }
6361
6362    #[test]
6363    fn yaml_root_adapter_reads_and_writes() {
6364        let dir: PathBuf = temp_dir("yaml");
6365        let path: PathBuf = dir.join("xbp.yaml");
6366        fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
6367
6368        assert_eq!(
6369            read_yaml_root_version(&path, "version").expect("read"),
6370            Some("0.2.0".to_string())
6371        );
6372
6373        write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
6374        assert_eq!(
6375            read_version_from_path(&path).expect("read"),
6376            Some("0.3.0".to_string())
6377        );
6378
6379        let _ = fs::remove_dir_all(dir);
6380    }
6381
6382    #[test]
6383    fn toml_root_adapter_reads_and_writes() {
6384        let dir: PathBuf = temp_dir("toml");
6385        let path: PathBuf = dir.join("config.toml");
6386        fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
6387
6388        assert_eq!(
6389            read_toml_root_version(&path).expect("read"),
6390            Some("3.1.4".to_string())
6391        );
6392
6393        write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
6394        assert_eq!(
6395            read_toml_root_version(&path).expect("read"),
6396            Some("3.2.0".to_string())
6397        );
6398
6399        let _ = fs::remove_dir_all(dir);
6400    }
6401
6402    #[test]
6403    fn openapi_adapter_reads_and_writes_nested_version() {
6404        let dir: PathBuf = temp_dir("openapi");
6405        let path: PathBuf = dir.join("openapi.yaml");
6406        fs::write(
6407            &path,
6408            "openapi: 3.0.3\ninfo:\n  title: Test\n  version: 1.2.3\n",
6409        )
6410        .expect("write openapi");
6411
6412        assert_eq!(
6413            read_openapi_version(&path).expect("read"),
6414            Some("1.2.3".to_string())
6415        );
6416
6417        write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
6418        assert_eq!(
6419            read_openapi_version(&path).expect("read"),
6420            Some("2.0.0".to_string())
6421        );
6422
6423        let _ = fs::remove_dir_all(dir);
6424    }
6425
6426    #[test]
6427    fn openapi_writer_creates_missing_info_mapping() {
6428        let dir: PathBuf = temp_dir("openapi-missing-info");
6429        let path: PathBuf = dir.join("openapi.yaml");
6430        fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
6431
6432        write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6433        assert_eq!(
6434            read_openapi_version(&path).expect("read"),
6435            Some("4.0.0".to_string())
6436        );
6437
6438        let _ = fs::remove_dir_all(dir);
6439    }
6440
6441    #[test]
6442    fn json_openapi_adapter_reads_and_writes_nested_version() {
6443        let dir: PathBuf = temp_dir("openapi-json");
6444        let path: PathBuf = dir.join("openapi.json");
6445        fs::write(
6446            &path,
6447            r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
6448        )
6449        .expect("write openapi json");
6450
6451        assert_eq!(
6452            read_json_openapi_version(&path).expect("read"),
6453            Some("1.2.3".to_string())
6454        );
6455
6456        write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
6457        assert_eq!(
6458            read_json_openapi_version(&path).expect("read"),
6459            Some("2.1.0".to_string())
6460        );
6461
6462        let _ = fs::remove_dir_all(dir);
6463    }
6464
6465    #[test]
6466    fn json_openapi_writer_creates_missing_info_object() {
6467        let dir: PathBuf = temp_dir("openapi-json-missing-info");
6468        let path: PathBuf = dir.join("openapi.json");
6469        fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
6470
6471        write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
6472        assert_eq!(
6473            read_json_openapi_version(&path).expect("read"),
6474            Some("4.0.0".to_string())
6475        );
6476
6477        let _ = fs::remove_dir_all(dir);
6478    }
6479
6480    #[test]
6481    fn pyproject_reader_prefers_project_version() {
6482        let dir: PathBuf = temp_dir("pyproject-project");
6483        let path: PathBuf = dir.join("pyproject.toml");
6484        fs::write(
6485            &path,
6486            "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
6487        )
6488        .expect("write pyproject");
6489
6490        assert_eq!(
6491            read_pyproject_version(&path).expect("read"),
6492            Some("0.8.0".to_string())
6493        );
6494
6495        let _ = fs::remove_dir_all(dir);
6496    }
6497
6498    #[test]
6499    fn pyproject_reader_falls_back_to_poetry_version() {
6500        let dir: PathBuf = temp_dir("pyproject-poetry");
6501        let path: PathBuf = dir.join("pyproject.toml");
6502        fs::write(
6503            &path,
6504            "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
6505        )
6506        .expect("write pyproject");
6507
6508        assert_eq!(
6509            read_pyproject_version(&path).expect("read"),
6510            Some("1.9.0".to_string())
6511        );
6512
6513        let _ = fs::remove_dir_all(dir);
6514    }
6515
6516    #[test]
6517    fn pyproject_writer_updates_project_table() {
6518        let dir: PathBuf = temp_dir("pyproject-write-project");
6519        let path: PathBuf = dir.join("pyproject.toml");
6520        fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
6521            .expect("write pyproject");
6522
6523        write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
6524        assert_eq!(
6525            read_pyproject_version(&path).expect("read"),
6526            Some("1.1.0".to_string())
6527        );
6528
6529        let _ = fs::remove_dir_all(dir);
6530    }
6531
6532    #[test]
6533    fn pyproject_writer_updates_poetry_table() {
6534        let dir: PathBuf = temp_dir("pyproject-write-poetry");
6535        let path: PathBuf = dir.join("pyproject.toml");
6536        fs::write(
6537            &path,
6538            "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
6539        )
6540        .expect("write pyproject");
6541
6542        write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
6543        assert_eq!(
6544            read_pyproject_version(&path).expect("read"),
6545            Some("2.1.0".to_string())
6546        );
6547
6548        let _ = fs::remove_dir_all(dir);
6549    }
6550
6551    #[test]
6552    fn cargo_lock_reader_and_writer_follow_package_name() {
6553        let dir: PathBuf = temp_dir("cargo-lock");
6554        let cargo_toml: PathBuf = dir.join("Cargo.toml");
6555        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6556        fs::write(
6557            &cargo_toml,
6558            r#"[package]
6559            name = "xbp"
6560            version = "1.0.0"
6561            "#,
6562        )
6563        .expect("write Cargo.toml");
6564        fs::write(
6565            &cargo_lock,
6566            r#"version = 4
6567
6568            [[package]]
6569            name = "xbp"
6570            version = "1.0.0"
6571
6572            [[package]]
6573            name = "other"
6574            version = "9.9.9"
6575            "#,
6576        )
6577        .expect("write Cargo.lock");
6578
6579        assert_eq!(
6580            read_cargo_lock_version(&cargo_lock).expect("read"),
6581            Some("1.0.0".to_string())
6582        );
6583
6584        write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
6585        assert_eq!(
6586            read_cargo_lock_version(&cargo_lock).expect("read"),
6587            Some("1.0.1".to_string())
6588        );
6589
6590        let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
6591        assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
6592
6593        let _ = fs::remove_dir_all(dir);
6594    }
6595
6596    #[test]
6597    fn cargo_lock_writer_errors_when_package_missing() {
6598        let dir: PathBuf = temp_dir("cargo-lock-missing");
6599        fs::write(
6600            dir.join("Cargo.toml"),
6601            "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
6602        )
6603        .expect("write Cargo.toml");
6604        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6605        fs::write(
6606            &cargo_lock,
6607            "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
6608        )
6609        .expect("write Cargo.lock");
6610
6611        let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
6612            .expect_err("missing package should fail");
6613        assert!(error.contains("Could not find package `xbp`"));
6614
6615        let _ = fs::remove_dir_all(dir);
6616    }
6617
6618    #[test]
6619    fn cargo_package_name_reads_package_section() {
6620        let dir: PathBuf = temp_dir("cargo-package-name");
6621        let cargo_lock: PathBuf = dir.join("Cargo.lock");
6622        fs::write(
6623            dir.join("Cargo.toml"),
6624            "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
6625        )
6626        .expect("write Cargo.toml");
6627        fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
6628
6629        assert_eq!(
6630            cargo_package_name(&cargo_lock).expect("name"),
6631            Some("xbp-cli".to_string())
6632        );
6633
6634        let _ = fs::remove_dir_all(dir);
6635    }
6636
6637    #[test]
6638    fn cargo_toml_writer_skips_workspace_manifest_without_package() {
6639        let dir: PathBuf = temp_dir("cargo-workspace-manifest");
6640        let path: PathBuf = dir.join("Cargo.toml");
6641        fs::write(
6642            &path,
6643            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6644        )
6645        .expect("write Cargo.toml");
6646
6647        let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
6648        assert!(changed);
6649        let content = fs::read_to_string(&path).expect("read Cargo.toml");
6650        assert!(content.contains("[workspace.package]"));
6651        assert!(content.contains("version = \"2.0.0\""));
6652
6653        let _ = fs::remove_dir_all(dir);
6654    }
6655
6656    #[test]
6657    fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
6658        let dir: PathBuf = temp_dir("workspace-cargo-skip");
6659        fs::write(
6660            dir.join("Cargo.toml"),
6661            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6662        )
6663        .expect("write Cargo.toml");
6664        fs::write(
6665            dir.join("Cargo.lock"),
6666            "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
6667        )
6668        .expect("write Cargo.lock");
6669        fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
6670            .expect("write README");
6671
6672        let updated = write_version_to_configured_files(
6673            &dir,
6674            &dir,
6675            &[
6676                "Cargo.toml".to_string(),
6677                "Cargo.lock".to_string(),
6678                "README.md".to_string(),
6679            ],
6680            &VersionScope::Repository,
6681            &Version::new(1, 1, 0),
6682        )
6683        .expect("write versions");
6684
6685        assert_eq!(updated, 2);
6686        assert_eq!(
6687            read_readme_version(&dir.join("README.md")).expect("read"),
6688            Some("1.1.0".to_string())
6689        );
6690        let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
6691        assert!(cargo_content.contains("version = \"1.1.0\""));
6692
6693        let _ = fs::remove_dir_all(dir);
6694    }
6695
6696    #[test]
6697    fn repository_scope_prefers_workspace_default_member_manifest() {
6698        let dir: PathBuf = temp_dir("workspace-default-member-path");
6699        let crate_dir: PathBuf = dir.join("crates").join("cli");
6700        fs::create_dir_all(&crate_dir).expect("create crate dir");
6701        fs::write(
6702            dir.join("Cargo.toml"),
6703            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6704        )
6705        .expect("write workspace cargo");
6706        fs::write(
6707            crate_dir.join("Cargo.toml"),
6708            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6709        )
6710        .expect("write crate cargo");
6711
6712        let resolved = super::resolve_registry_relative_path(
6713            &dir,
6714            &dir,
6715            &VersionScope::Repository,
6716            "Cargo.toml",
6717        );
6718
6719        assert_eq!(resolved, "crates/cli/Cargo.toml");
6720
6721        let _ = fs::remove_dir_all(dir);
6722    }
6723
6724    #[test]
6725    fn root_discovered_service_does_not_hijack_repository_scope() {
6726        let dir: PathBuf = temp_dir("root-discovered-service-scope");
6727        let crate_dir: PathBuf = dir.join("crates").join("cli");
6728        let xbp_dir: PathBuf = dir.join(".xbp");
6729        fs::create_dir_all(&crate_dir).expect("create crate dir");
6730        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6731        fs::write(
6732            dir.join("Cargo.toml"),
6733            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
6734        )
6735        .expect("write workspace cargo");
6736        fs::write(
6737            crate_dir.join("Cargo.toml"),
6738            "[package]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6739        )
6740        .expect("write crate cargo");
6741        fs::write(
6742            dir.join("Cargo.lock"),
6743            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.35.0\"\n",
6744        )
6745        .expect("write cargo lock");
6746        fs::write(
6747            xbp_dir.join("xbp.yaml"),
6748            r#"project_name: xbp
6749version: 10.35.0
6750port: 3000
6751build_dir: ./
6752services:
6753  - name: xbp-2
6754    target: python
6755    branch: main
6756    port: 8001
6757    root_directory: ./
6758    version_targets:
6759      - Cargo.toml
6760  - name: crates-cli
6761    target: rust
6762    branch: main
6763    port: 8002
6764    root_directory: crates/cli
6765    version_targets:
6766      - crates/cli/Cargo.toml
6767publish:
6768  crates:
6769    enabled: true
6770    package_name: xbp
6771    working_directory: crates/cli
6772    manifest_path: crates/cli/Cargo.toml
6773version_targets:
6774  - Cargo.toml
6775  - crates/cli/Cargo.toml
6776"#,
6777        )
6778        .expect("write xbp config");
6779
6780        assert!(matches!(
6781            resolve_version_scope(&dir, &dir),
6782            VersionScope::Repository
6783        ));
6784
6785        let resolved = resolve_registry_paths(
6786            &dir,
6787            &dir,
6788            &["Cargo.toml".to_string(), "Cargo.lock".to_string()],
6789            &VersionScope::Repository,
6790        );
6791        assert!(resolved
6792            .iter()
6793            .any(|path| path.relative == "crates/cli/Cargo.toml"));
6794        assert_eq!(
6795            read_highest_version_from_targets(&resolved).expect("read selected versions"),
6796            Some(Version::new(10, 35, 0))
6797        );
6798
6799        let _ = fs::remove_dir_all(dir);
6800    }
6801
6802    #[test]
6803    fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
6804        let dir: PathBuf = temp_dir("workspace-default-member-writer");
6805        let crate_dir: PathBuf = dir.join("crates").join("cli");
6806        fs::create_dir_all(&crate_dir).expect("create crate dir");
6807        fs::write(
6808            dir.join("Cargo.toml"),
6809            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
6810        )
6811        .expect("write workspace cargo");
6812        fs::write(
6813            crate_dir.join("Cargo.toml"),
6814            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
6815        )
6816        .expect("write crate cargo");
6817        fs::write(
6818            dir.join("Cargo.lock"),
6819            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
6820        )
6821        .expect("write cargo lock");
6822        fs::write(
6823            dir.join("README.md"),
6824            "# XBP\n\ncurrent version: `10.21.0`\n",
6825        )
6826        .expect("write readme");
6827
6828        let updated = write_version_to_configured_files(
6829            &dir,
6830            &dir,
6831            &[
6832                "Cargo.toml".to_string(),
6833                "Cargo.lock".to_string(),
6834                "README.md".to_string(),
6835            ],
6836            &VersionScope::Repository,
6837            &Version::new(10, 22, 0),
6838        )
6839        .expect("write versions");
6840
6841        assert_eq!(updated, 3);
6842        assert_eq!(
6843            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
6844            Some("10.22.0".to_string())
6845        );
6846        assert_eq!(
6847            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
6848            Some("10.22.0".to_string())
6849        );
6850        assert_eq!(
6851            read_readme_version(&dir.join("README.md")).expect("read readme"),
6852            Some("10.22.0".to_string())
6853        );
6854
6855        let _ = fs::remove_dir_all(dir);
6856    }
6857
6858    #[test]
6859    fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
6860        let dir: PathBuf = temp_dir("publish-manifest-version-target");
6861        let package_dir = dir.join("packages").join("heroui");
6862        let xbp_dir = dir.join(".xbp");
6863        fs::create_dir_all(&package_dir).expect("create package dir");
6864        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6865
6866        fs::write(
6867            xbp_dir.join("xbp.yaml"),
6868            r#"project_name: athena-auth-ui
6869version: 0.3.1
6870port: 4000
6871build_dir: ./
6872publish:
6873  npm:
6874    enabled: true
6875    working_directory: packages/heroui
6876    manifest_path: packages/heroui/package.json
6877"#,
6878        )
6879        .expect("write xbp config");
6880        fs::write(
6881            dir.join("package.json"),
6882            r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
6883        )
6884        .expect("write root package");
6885        fs::write(
6886            package_dir.join("package.json"),
6887            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
6888        )
6889        .expect("write package manifest");
6890
6891        let updated = write_version_to_configured_files(
6892            &dir,
6893            &dir,
6894            &["package.json".to_string()],
6895            &VersionScope::Repository,
6896            &Version::new(0, 3, 1),
6897        )
6898        .expect("write versions");
6899
6900        assert_eq!(updated, 2);
6901        assert_eq!(
6902            read_json_root_version(&dir.join("package.json")).expect("read root package"),
6903            Some("0.3.1".to_string())
6904        );
6905        assert_eq!(
6906            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
6907            Some("0.3.1".to_string())
6908        );
6909
6910        let _ = fs::remove_dir_all(dir);
6911    }
6912
6913    #[test]
6914    fn configured_writer_updates_version_targets_from_xbp_config() {
6915        let dir: PathBuf = temp_dir("explicit-version-targets");
6916        let app_dir = dir.join("apps").join("web");
6917        let cli_dir = dir.join("crates").join("cli");
6918        let xbp_dir = dir.join(".xbp");
6919        fs::create_dir_all(&app_dir).expect("create app dir");
6920        fs::create_dir_all(&cli_dir).expect("create cli dir");
6921        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6922
6923        fs::write(
6924            xbp_dir.join("xbp.yaml"),
6925            r#"project_name: xbp
6926version: 10.30.0
6927port: 3000
6928build_dir: ./
6929version_targets:
6930  - crates/cli/Cargo.toml
6931  - apps/web/package.json
6932"#,
6933        )
6934        .expect("write xbp config");
6935        fs::write(
6936            cli_dir.join("Cargo.toml"),
6937            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6938        )
6939        .expect("write cli Cargo.toml");
6940        fs::write(
6941            app_dir.join("package.json"),
6942            r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
6943        )
6944        .expect("write app package");
6945
6946        let updated = write_version_to_configured_files(
6947            &dir,
6948            &dir,
6949            &[".xbp/xbp.yaml".to_string()],
6950            &VersionScope::Repository,
6951            &Version::new(10, 30, 0),
6952        )
6953        .expect("write versions");
6954
6955        assert_eq!(updated, 3);
6956        assert_eq!(
6957            read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
6958            Some("10.30.0".to_string())
6959        );
6960        assert_eq!(
6961            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
6962            Some("10.30.0".to_string())
6963        );
6964        assert_eq!(
6965            read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
6966            Some("10.30.0".to_string())
6967        );
6968
6969        let _ = fs::remove_dir_all(dir);
6970    }
6971
6972    #[test]
6973    fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
6974        let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
6975        let package_dir = dir.join("packages").join("heroui");
6976        let xbp_dir = dir.join(".xbp");
6977        fs::create_dir_all(&package_dir).expect("create package dir");
6978        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
6979
6980        fs::write(
6981            xbp_dir.join("xbp.yaml"),
6982            r#"project_name: athena-auth-ui
6983version: 0.3.0
6984port: 4000
6985build_dir: ./
6986publish:
6987  npm:
6988    enabled: true
6989    working_directory: packages/heroui
6990    manifest_path: packages/heroui/package.json
6991"#,
6992        )
6993        .expect("write xbp config");
6994        fs::write(
6995            dir.join("package.json"),
6996            r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
6997        )
6998        .expect("write root package");
6999        fs::write(
7000            package_dir.join("package.json"),
7001            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
7002        )
7003        .expect("write package manifest");
7004
7005        let _updated_paths = sync_version_to_configured_files_with_paths(
7006            &dir,
7007            &dir,
7008            &["package.json".to_string()],
7009            &VersionScope::Repository,
7010            &Version::new(0, 3, 0),
7011        )
7012        .expect("sync versions");
7013
7014        assert_eq!(
7015            read_json_root_version(&dir.join("package.json")).expect("read root package"),
7016            Some("0.3.0".to_string())
7017        );
7018        assert_eq!(
7019            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
7020            Some("0.3.0".to_string())
7021        );
7022
7023        let _ = fs::remove_dir_all(dir);
7024    }
7025
7026    #[test]
7027    fn readme_adapter_updates_current_version_marker() {
7028        let dir: PathBuf = temp_dir("readme");
7029        let path: PathBuf = dir.join("README.md");
7030        fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7031
7032        write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
7033        assert_eq!(
7034            read_readme_version(&path).expect("read"),
7035            Some("1.2.0".to_string())
7036        );
7037
7038        let _ = fs::remove_dir_all(dir);
7039    }
7040
7041    #[test]
7042    fn readme_writer_inserts_marker_when_missing() {
7043        let dir: PathBuf = temp_dir("readme-insert");
7044        let path: PathBuf = dir.join("README.md");
7045        fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
7046
7047        write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
7048        let content: String = fs::read_to_string(&path).expect("read readme");
7049        assert!(content.contains("current version: `3.0.0`"));
7050
7051        let _ = fs::remove_dir_all(dir);
7052    }
7053
7054    #[test]
7055    fn regex_adapter_reads_and_writes_versions() {
7056        let dir: PathBuf = temp_dir("regex");
7057        let path: PathBuf = dir.join("build.gradle");
7058        fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
7059
7060        assert_eq!(
7061            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7062            Some("5.4.3".to_string())
7063        );
7064
7065        write_regex_version(
7066            &path,
7067            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7068            &Version::new(5, 5, 0),
7069        )
7070        .expect("write");
7071
7072        assert_eq!(
7073            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
7074            Some("5.5.0".to_string())
7075        );
7076
7077        let _ = fs::remove_dir_all(dir);
7078    }
7079
7080    #[test]
7081    fn regex_writer_errors_without_matching_pattern() {
7082        let dir: PathBuf = temp_dir("regex-miss");
7083        let path: PathBuf = dir.join("build.gradle");
7084        fs::write(&path, "group = 'demo'\n").expect("write gradle");
7085
7086        let error: String = write_regex_version(
7087            &path,
7088            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
7089            &Version::new(1, 0, 0),
7090        )
7091        .expect_err("missing version should fail");
7092        assert!(error.contains("No version pattern found"));
7093
7094        let _ = fs::remove_dir_all(dir);
7095    }
7096
7097    #[test]
7098    fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
7099        let original: &str = r#"[dependencies]
7100            serde = "1.0.219"
7101            tokio = { version = "1.44.1", features = ["full"] }
7102            "#;
7103
7104        let (updated, changed) =
7105            rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
7106                .expect("rewrite");
7107        assert!(changed);
7108        assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
7109
7110        let (updated, changed) =
7111            rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
7112                .expect("rewrite");
7113        assert!(changed);
7114        assert!(updated.contains(r#"serde = "1.1.0""#));
7115    }
7116
7117    #[test]
7118    fn package_version_writer_updates_registry_toml_targets() {
7119        let dir: PathBuf = temp_dir("package-version-registry");
7120        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7121        fs::write(
7122            &cargo_toml,
7123            r#"[package]
7124            name = "demo"
7125            version = "0.1.0"
7126
7127            [dependencies]
7128            serde = "1.0.219"
7129            tokio = { version = "1.44.1", features = ["full"] }
7130            "#,
7131        )
7132        .expect("write Cargo.toml");
7133
7134        let updated: usize = write_package_version_to_configured_files(
7135            &dir,
7136            &dir,
7137            &["Cargo.toml".to_string()],
7138            &VersionScope::Repository,
7139            "tokio",
7140            &Version::new(1, 45, 1),
7141        )
7142        .expect("update package assignment");
7143        assert_eq!(updated, 1);
7144
7145        let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
7146        assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
7147
7148        let _ = fs::remove_dir_all(dir);
7149    }
7150
7151    #[test]
7152    fn package_version_writer_errors_when_package_assignment_not_found() {
7153        let dir: PathBuf = temp_dir("package-version-missing");
7154        let cargo_toml: PathBuf = dir.join("Cargo.toml");
7155        fs::write(
7156            &cargo_toml,
7157            r#"[package]
7158        name = "demo"
7159        version = "0.1.0"
7160
7161        [dependencies]
7162        serde = "1.0.219"
7163        "#,
7164        )
7165        .expect("write Cargo.toml");
7166
7167        let error: String = write_package_version_to_configured_files(
7168            &dir,
7169            &dir,
7170            &["Cargo.toml".to_string()],
7171            &VersionScope::Repository,
7172            "tokio",
7173            &Version::new(1, 45, 1),
7174        )
7175        .expect_err("missing package assignment should fail");
7176        assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
7177
7178        let _ = fs::remove_dir_all(dir);
7179    }
7180
7181    #[test]
7182    fn chart_writer_updates_app_version_when_present() {
7183        let dir: PathBuf = temp_dir("chart");
7184        let path: PathBuf = dir.join("Chart.yaml");
7185        fs::write(
7186            &path,
7187            "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
7188        )
7189        .expect("write chart");
7190
7191        write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
7192        let content: String = fs::read_to_string(&path).expect("read chart");
7193        assert!(content.contains("version: 0.2.0"));
7194        assert!(content.contains("appVersion: 0.2.0"));
7195
7196        let _ = fs::remove_dir_all(dir);
7197    }
7198
7199    #[test]
7200    fn configured_file_writer_deduplicates_registry_entries() {
7201        let dir: PathBuf = temp_dir("dedupe");
7202        let readme: PathBuf = dir.join("README.md");
7203        fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
7204
7205        let updated: usize = write_version_to_configured_files(
7206            &dir,
7207            &dir,
7208            &[
7209                "README.md".to_string(),
7210                "README.md".to_string(),
7211                "missing.md".to_string(),
7212            ],
7213            &VersionScope::Repository,
7214            &Version::new(1, 1, 0),
7215        )
7216        .expect("write versions");
7217
7218        assert_eq!(updated, 1);
7219        assert_eq!(
7220            read_readme_version(&readme).expect("read"),
7221            Some("1.1.0".to_string())
7222        );
7223
7224        let _ = fs::remove_dir_all(dir);
7225    }
7226
7227    #[test]
7228    fn configured_file_writer_prefers_invocation_directory_targets() {
7229        let dir: PathBuf = temp_dir("invocation-precedence");
7230        let app_dir: PathBuf = dir.join("apps").join("web");
7231        fs::create_dir_all(&app_dir).expect("create app dir");
7232
7233        let root_package: PathBuf = dir.join("package.json");
7234        let app_package: PathBuf = app_dir.join("package.json");
7235        fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
7236            .expect("write root package");
7237        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7238            .expect("write app package");
7239
7240        let updated: usize = write_version_to_configured_files(
7241            &dir,
7242            &app_dir,
7243            &["package.json".to_string()],
7244            &VersionScope::Repository,
7245            &Version::new(2, 14, 0),
7246        )
7247        .expect("write versions");
7248        assert_eq!(updated, 1);
7249
7250        assert_eq!(
7251            read_json_root_version(&root_package).expect("read root"),
7252            Some("9.9.9".to_string())
7253        );
7254        assert_eq!(
7255            read_json_root_version(&app_package).expect("read app"),
7256            Some("2.14.0".to_string())
7257        );
7258
7259        let _ = fs::remove_dir_all(dir);
7260    }
7261
7262    #[test]
7263    fn resolve_project_root_prefers_nearest_xbp_config() {
7264        let dir: PathBuf = temp_dir("nested-xbp-project-root");
7265        let service_root: PathBuf = dir.join("services").join("athena-auth");
7266        let nested_dir: PathBuf = service_root.join("src");
7267        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7268        fs::create_dir_all(&nested_dir).expect("create nested dir");
7269        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7270        fs::write(
7271            service_xbp_dir.join("xbp.yaml"),
7272            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7273        )
7274        .expect("write service xbp config");
7275
7276        assert_eq!(resolve_project_root_from(&nested_dir), service_root);
7277
7278        let _ = fs::remove_dir_all(dir);
7279    }
7280
7281    #[test]
7282    fn nested_xbp_repository_scope_does_not_touch_outer_root_version_files() {
7283        let dir: PathBuf = temp_dir("nested-xbp-version-scope");
7284        let outer_xbp_dir = dir.join(".xbp");
7285        let service_root: PathBuf = dir.join("services").join("athena-auth");
7286        let nested_dir: PathBuf = service_root.join("src");
7287        let service_xbp_dir: PathBuf = service_root.join(".xbp");
7288        fs::create_dir_all(&outer_xbp_dir).expect("create outer xbp dir");
7289        fs::create_dir_all(&nested_dir).expect("create nested dir");
7290        fs::create_dir_all(&service_xbp_dir).expect("create service xbp dir");
7291
7292        fs::write(
7293            outer_xbp_dir.join("xbp.yaml"),
7294            "project_name: root\nversion: 9.9.9\nport: 3000\nbuild_dir: ./\n",
7295        )
7296        .expect("write outer xbp config");
7297        fs::write(
7298            dir.join("Cargo.toml"),
7299            "[package]\nname = \"root\"\nversion = \"9.9.9\"\n",
7300        )
7301        .expect("write outer cargo");
7302        fs::write(
7303            dir.join("Cargo.lock"),
7304            "version = 4\n\n[[package]]\nname = \"root\"\nversion = \"9.9.9\"\n",
7305        )
7306        .expect("write outer cargo lock");
7307        fs::write(
7308            dir.join("README.md"),
7309            "# root\n\ncurrent version: `9.9.9`\n",
7310        )
7311        .expect("write outer readme");
7312        fs::write(
7313            dir.join("openapi.yaml"),
7314            "openapi: 3.0.3\ninfo:\n  title: Root\n  version: 9.9.9\n",
7315        )
7316        .expect("write outer openapi");
7317        fs::write(
7318            dir.join("package.json"),
7319            r#"{ "name": "root", "version": "9.9.9" }"#,
7320        )
7321        .expect("write outer package");
7322
7323        fs::write(
7324            service_xbp_dir.join("xbp.yaml"),
7325            "project_name: athena-auth\nversion: 3.22.0\nport: 3000\nbuild_dir: ./\n",
7326        )
7327        .expect("write service xbp config");
7328        fs::write(
7329            service_root.join("Cargo.toml"),
7330            "[package]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7331        )
7332        .expect("write service cargo");
7333        fs::write(
7334            service_root.join("Cargo.lock"),
7335            "version = 4\n\n[[package]]\nname = \"athena-auth\"\nversion = \"3.22.0\"\n",
7336        )
7337        .expect("write service cargo lock");
7338        fs::write(
7339            service_root.join("README.md"),
7340            "# athena-auth\n\ncurrent version: `3.22.0`\n",
7341        )
7342        .expect("write service readme");
7343        fs::write(
7344            service_root.join("openapi.yaml"),
7345            "openapi: 3.0.3\ninfo:\n  title: Athena Auth\n  version: 3.22.0\n",
7346        )
7347        .expect("write service openapi");
7348        fs::write(
7349            service_root.join("package.json"),
7350            r#"{ "name": "athena-auth", "version": "3.22.0" }"#,
7351        )
7352        .expect("write service package");
7353
7354        let project_root = resolve_project_root_from(&nested_dir);
7355        let updated_paths = write_version_to_configured_files_with_paths(
7356            &project_root,
7357            &nested_dir,
7358            &[
7359                ".xbp/xbp.yaml".to_string(),
7360                "Cargo.toml".to_string(),
7361                "Cargo.lock".to_string(),
7362                "README.md".to_string(),
7363                "openapi.yaml".to_string(),
7364                "package.json".to_string(),
7365            ],
7366            &VersionScope::Repository,
7367            &Version::new(3, 22, 1),
7368        )
7369        .expect("write nested project versions");
7370
7371        let updated_relative = updated_paths
7372            .iter()
7373            .map(|path| path.strip_prefix(&project_root).expect("strip prefix"))
7374            .map(normalized_relative_path)
7375            .collect::<Vec<_>>();
7376
7377        assert_eq!(
7378            updated_relative,
7379            vec![
7380                ".xbp/xbp.yaml".to_string(),
7381                "Cargo.toml".to_string(),
7382                "Cargo.lock".to_string(),
7383                "README.md".to_string(),
7384                "openapi.yaml".to_string(),
7385                "package.json".to_string(),
7386            ]
7387        );
7388
7389        assert_eq!(
7390            read_yaml_root_version(&outer_xbp_dir.join("xbp.yaml"), "version")
7391                .expect("read outer xbp"),
7392            Some("9.9.9".to_string())
7393        );
7394        assert_eq!(
7395            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read outer cargo"),
7396            Some("9.9.9".to_string())
7397        );
7398        assert_eq!(
7399            read_cargo_lock_version(&dir.join("Cargo.lock")).expect("read outer cargo lock"),
7400            Some("9.9.9".to_string())
7401        );
7402        assert_eq!(
7403            read_readme_version(&dir.join("README.md")).expect("read outer readme"),
7404            Some("9.9.9".to_string())
7405        );
7406        assert_eq!(
7407            read_openapi_version(&dir.join("openapi.yaml")).expect("read outer openapi"),
7408            Some("9.9.9".to_string())
7409        );
7410        assert_eq!(
7411            read_json_root_version(&dir.join("package.json")).expect("read outer package"),
7412            Some("9.9.9".to_string())
7413        );
7414
7415        assert_eq!(
7416            read_yaml_root_version(&service_xbp_dir.join("xbp.yaml"), "version")
7417                .expect("read service xbp"),
7418            Some("3.22.1".to_string())
7419        );
7420        assert_eq!(
7421            read_cargo_toml_version(&service_root.join("Cargo.toml")).expect("read service cargo"),
7422            Some("3.22.1".to_string())
7423        );
7424        assert_eq!(
7425            read_cargo_lock_version(&service_root.join("Cargo.lock"))
7426                .expect("read service cargo lock"),
7427            Some("3.22.1".to_string())
7428        );
7429        assert_eq!(
7430            read_readme_version(&service_root.join("README.md")).expect("read service readme"),
7431            Some("3.22.1".to_string())
7432        );
7433        assert_eq!(
7434            read_openapi_version(&service_root.join("openapi.yaml")).expect("read service openapi"),
7435            Some("3.22.1".to_string())
7436        );
7437        assert_eq!(
7438            read_json_root_version(&service_root.join("package.json"))
7439                .expect("read service package"),
7440            Some("3.22.1".to_string())
7441        );
7442
7443        let _ = fs::remove_dir_all(dir);
7444    }
7445
7446    #[test]
7447    fn resolve_version_scope_detects_crate_scoped_invocation() {
7448        let dir: PathBuf = temp_dir("crate-scope");
7449        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7450        let nested_dir: PathBuf = crate_dir.join("src");
7451        fs::create_dir_all(&nested_dir).expect("create nested dir");
7452        fs::write(
7453            crate_dir.join("Cargo.toml"),
7454            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7455        )
7456        .expect("write Cargo.toml");
7457
7458        let scope = resolve_version_scope(&dir, &nested_dir);
7459        match scope {
7460            VersionScope::Crate {
7461                package_name,
7462                crate_relative_root,
7463                tag_prefix,
7464                ..
7465            } => {
7466                assert_eq!(package_name, "alpha-crate");
7467                assert_eq!(crate_relative_root, "crates/alpha");
7468                assert_eq!(tag_prefix, "alpha-crate-");
7469            }
7470            _ => panic!("expected crate scope"),
7471        }
7472
7473        let _ = fs::remove_dir_all(dir);
7474    }
7475
7476    #[test]
7477    fn resolve_version_scope_detects_service_scoped_invocation() {
7478        let dir: PathBuf = temp_dir("service-scope");
7479        let service_dir: PathBuf = dir.join("apps").join("web");
7480        let nested_dir: PathBuf = service_dir.join("src");
7481        let cli_dir: PathBuf = dir.join("crates").join("cli");
7482        fs::create_dir_all(&nested_dir).expect("create nested dir");
7483        fs::create_dir_all(&cli_dir).expect("create cli dir");
7484        write_multi_service_config(&dir);
7485        fs::write(
7486            service_dir.join("package.json"),
7487            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7488        )
7489        .expect("write package");
7490        fs::write(
7491            cli_dir.join("Cargo.toml"),
7492            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7493        )
7494        .expect("write cargo");
7495
7496        let scope = resolve_version_scope(&dir, &nested_dir);
7497        match scope {
7498            VersionScope::Service {
7499                service_name,
7500                service_relative_root,
7501                tag_prefix,
7502                ..
7503            } => {
7504                assert_eq!(service_name, "web");
7505                assert_eq!(service_relative_root, "apps/web");
7506                assert_eq!(tag_prefix, "web-");
7507            }
7508            _ => panic!("expected service scope"),
7509        }
7510
7511        let _ = fs::remove_dir_all(dir);
7512    }
7513
7514    #[test]
7515    fn service_scoped_configured_targets_only_include_selected_service() {
7516        let dir: PathBuf = temp_dir("service-targets");
7517        let service_dir: PathBuf = dir.join("apps").join("web");
7518        let nested_dir: PathBuf = service_dir.join("src");
7519        let cli_dir: PathBuf = dir.join("crates").join("cli");
7520        fs::create_dir_all(&nested_dir).expect("create nested dir");
7521        fs::create_dir_all(&cli_dir).expect("create cli dir");
7522        write_multi_service_config(&dir);
7523        fs::write(
7524            service_dir.join("package.json"),
7525            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7526        )
7527        .expect("write package");
7528        fs::write(
7529            cli_dir.join("Cargo.toml"),
7530            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7531        )
7532        .expect("write cargo");
7533
7534        let scope = resolve_version_scope(&dir, &nested_dir);
7535        let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
7536        assert_eq!(targets.len(), 1);
7537        assert_eq!(targets[0].relative, "apps/web/package.json");
7538
7539        let _ = fs::remove_dir_all(dir);
7540    }
7541
7542    #[test]
7543    fn service_scoped_release_publish_filter_selects_matching_target() {
7544        let dir: PathBuf = temp_dir("service-publish-filter");
7545        let service_dir: PathBuf = dir.join("apps").join("web");
7546        let nested_dir: PathBuf = service_dir.join("src");
7547        let cli_dir: PathBuf = dir.join("crates").join("cli");
7548        fs::create_dir_all(&nested_dir).expect("create nested dir");
7549        fs::create_dir_all(&cli_dir).expect("create cli dir");
7550        write_multi_service_config(&dir);
7551        fs::write(
7552            service_dir.join("package.json"),
7553            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7554        )
7555        .expect("write package");
7556        fs::write(
7557            cli_dir.join("Cargo.toml"),
7558            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7559        )
7560        .expect("write cargo");
7561
7562        let scope = resolve_version_scope(&dir, &nested_dir);
7563        let publish_target =
7564            resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
7565        assert_eq!(publish_target.as_deref(), Some("npm"));
7566
7567        let _ = fs::remove_dir_all(dir);
7568    }
7569
7570    #[test]
7571    fn current_scope_ledger_path_uses_repo_local_scope_directory() {
7572        let dir: PathBuf = temp_dir("ledger-path");
7573        let scope = resolve_version_scope(&dir, &dir);
7574        let path = current_scope_ledger_path(&dir, &scope, "xbp", &Version::new(10, 30, 0));
7575
7576        assert_eq!(
7577            path,
7578            dir.join(".xbp")
7579                .join("releases")
7580                .join("repository-xbp")
7581                .join("10.30.0.yaml")
7582        );
7583
7584        let _ = fs::remove_dir_all(dir);
7585    }
7586
7587    #[test]
7588    fn repository_change_selection_matches_only_changed_target_manifest_root() {
7589        let dir: PathBuf = temp_dir("selection-direct");
7590        let service_dir: PathBuf = dir.join("apps").join("web");
7591        let cli_dir: PathBuf = dir.join("crates").join("cli");
7592        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7593        fs::create_dir_all(&cli_dir).expect("create cli dir");
7594        write_multi_service_config(&dir);
7595        fs::write(
7596            service_dir.join("package.json"),
7597            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7598        )
7599        .expect("write package");
7600        fs::write(
7601            cli_dir.join("Cargo.toml"),
7602            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7603        )
7604        .expect("write cargo");
7605
7606        let scope = resolve_version_scope(&dir, &dir);
7607        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7608        let selected = select_changed_version_targets(
7609            &dir,
7610            &scope,
7611            &targets,
7612            &["apps/web/src/app.tsx".into()],
7613        );
7614
7615        assert_eq!(selected.len(), 1);
7616        assert_eq!(selected[0].relative, "apps/web/package.json");
7617
7618        let _ = fs::remove_dir_all(dir);
7619    }
7620
7621    #[test]
7622    fn repository_change_selection_fans_out_on_shared_root_change() {
7623        let dir: PathBuf = temp_dir("selection-shared");
7624        let service_dir: PathBuf = dir.join("apps").join("web");
7625        let cli_dir: PathBuf = dir.join("crates").join("cli");
7626        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7627        fs::create_dir_all(&cli_dir).expect("create cli dir");
7628        write_multi_service_config(&dir);
7629        fs::write(
7630            service_dir.join("package.json"),
7631            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7632        )
7633        .expect("write package");
7634        fs::write(
7635            cli_dir.join("Cargo.toml"),
7636            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7637        )
7638        .expect("write cargo");
7639
7640        let scope = resolve_version_scope(&dir, &dir);
7641        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7642        let selected =
7643            select_changed_version_targets(&dir, &scope, &targets, &["README.md".into()]);
7644
7645        assert_eq!(selected.len(), 2);
7646        assert_eq!(
7647            selected
7648                .iter()
7649                .map(|target| target.relative.as_str())
7650                .collect::<Vec<_>>(),
7651            vec!["crates/cli/Cargo.toml", "apps/web/package.json"]
7652        );
7653
7654        let _ = fs::remove_dir_all(dir);
7655    }
7656
7657    #[test]
7658    fn selected_version_writer_updates_only_selected_targets() {
7659        let dir: PathBuf = temp_dir("selection-write");
7660        let service_dir: PathBuf = dir.join("apps").join("web");
7661        let cli_dir: PathBuf = dir.join("crates").join("cli");
7662        fs::create_dir_all(service_dir.join("src")).expect("create service dir");
7663        fs::create_dir_all(&cli_dir).expect("create cli dir");
7664        write_multi_service_config(&dir);
7665        fs::write(
7666            service_dir.join("package.json"),
7667            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
7668        )
7669        .expect("write package");
7670        fs::write(
7671            cli_dir.join("Cargo.toml"),
7672            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
7673        )
7674        .expect("write cargo");
7675
7676        let scope = resolve_version_scope(&dir, &dir);
7677        let targets = resolve_configured_version_target_paths(&dir, &dir, &scope);
7678        let selected = select_changed_version_targets(
7679            &dir,
7680            &scope,
7681            &targets,
7682            &["apps/web/src/app.tsx".into()],
7683        );
7684
7685        let updated = write_version_to_selected_paths(&selected, &Version::new(10, 30, 0), false)
7686            .expect("write selected targets");
7687        assert_eq!(updated.len(), 1);
7688        assert_eq!(
7689            read_json_root_version(&service_dir.join("package.json")).expect("read web package"),
7690            Some("10.30.0".to_string())
7691        );
7692        assert_eq!(
7693            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
7694            Some("10.29.0".to_string())
7695        );
7696
7697        let _ = fs::remove_dir_all(dir);
7698    }
7699
7700    #[test]
7701    fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
7702        let dir: PathBuf = temp_dir("crate-writer");
7703        let crate_dir: PathBuf = dir.join("crates").join("alpha");
7704        fs::create_dir_all(&crate_dir).expect("create crate dir");
7705        fs::write(
7706            crate_dir.join("Cargo.toml"),
7707            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
7708        )
7709        .expect("write crate Cargo.toml");
7710        fs::write(
7711            dir.join("Cargo.lock"),
7712            "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
7713        )
7714        .expect("write Cargo.lock");
7715        fs::write(
7716            dir.join("README.md"),
7717            "# root\n\ncurrent version: `9.9.9`\n",
7718        )
7719        .expect("write root readme");
7720
7721        let scope = resolve_version_scope(&dir, &crate_dir);
7722        let updated = write_version_to_configured_files(
7723            &dir,
7724            &crate_dir,
7725            &[
7726                "Cargo.toml".to_string(),
7727                "Cargo.lock".to_string(),
7728                "README.md".to_string(),
7729            ],
7730            &scope,
7731            &Version::new(1, 3, 0),
7732        )
7733        .expect("write versions");
7734
7735        assert_eq!(updated, 2);
7736        assert_eq!(
7737            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
7738            Some("1.3.0".to_string())
7739        );
7740        assert_eq!(
7741            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
7742                .expect("read cargo lock"),
7743            Some("1.3.0".to_string())
7744        );
7745        assert_eq!(
7746            read_readme_version(&dir.join("README.md")).expect("read readme"),
7747            Some("9.9.9".to_string())
7748        );
7749
7750        let _ = fs::remove_dir_all(dir);
7751    }
7752
7753    #[test]
7754    fn release_openapi_resolution_prefers_crate_scope() {
7755        let dir: PathBuf = temp_dir("release-openapi-crate");
7756        let crate_dir: PathBuf = dir.join("crates").join("monitor");
7757        let nested_dir: PathBuf = crate_dir.join("src");
7758        fs::create_dir_all(&nested_dir).expect("create nested dir");
7759        fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
7760        let crate_openapi: PathBuf = crate_dir.join("openapi.json");
7761        fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
7762
7763        let scope = resolve_version_scope(&dir, &nested_dir);
7764        let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
7765            .expect("crate-scoped openapi")
7766            .into_iter()
7767            .next()
7768            .expect("crate-scoped openapi");
7769        assert_eq!(resolved, crate_openapi);
7770
7771        let _ = fs::remove_dir_all(dir);
7772    }
7773
7774    #[test]
7775    fn release_openapi_resolution_falls_back_to_repo_root() {
7776        let dir: PathBuf = temp_dir("release-openapi-root");
7777        let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
7778        fs::create_dir_all(&crate_dir).expect("create crate dir");
7779        let root_openapi: PathBuf = dir.join("openapi.json");
7780        fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
7781
7782        let scope = resolve_version_scope(&dir, &dir);
7783        let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
7784            .expect("repo root openapi")
7785            .into_iter()
7786            .next()
7787            .expect("repo root openapi");
7788        assert_eq!(resolved, root_openapi);
7789
7790        let _ = fs::remove_dir_all(dir);
7791    }
7792
7793    #[test]
7794    fn release_openapi_assets_use_versioned_release_tag_names() {
7795        let dir: PathBuf = temp_dir("release-openapi-assets");
7796        fs::write(
7797            dir.join("openapi.yaml"),
7798            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.2\n",
7799        )
7800        .expect("write http openapi");
7801        fs::write(
7802            dir.join("openapi-wss.yaml"),
7803            "openapi: 3.0.3\ninfo:\n  title: Athena WSS\n  version: 1.3.0\n",
7804        )
7805        .expect("write wss openapi");
7806
7807        let assets = prepare_release_openapi_assets(
7808            &dir,
7809            &dir,
7810            &VersionScope::Repository,
7811            &Version::new(3, 15, 2),
7812            "v3.15.2",
7813        )
7814        .expect("prepare release assets");
7815
7816        assert_eq!(
7817            assets
7818                .iter()
7819                .map(|asset| asset.asset_name.as_str())
7820                .collect::<Vec<_>>(),
7821            vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
7822        );
7823        assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
7824        assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
7825
7826        let _ = fs::remove_dir_all(dir);
7827    }
7828
7829    #[test]
7830    fn release_openapi_assets_reject_http_version_mismatch() {
7831        let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
7832        fs::write(
7833            dir.join("openapi.yaml"),
7834            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.1\n",
7835        )
7836        .expect("write http openapi");
7837
7838        let error = prepare_release_openapi_assets(
7839            &dir,
7840            &dir,
7841            &VersionScope::Repository,
7842            &Version::new(3, 15, 2),
7843            "v3.15.2",
7844        )
7845        .expect_err("mismatched OpenAPI version should fail");
7846
7847        assert!(error.contains("does not match release version"));
7848
7849        let _ = fs::remove_dir_all(dir);
7850    }
7851
7852    #[test]
7853    fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
7854        let dir: PathBuf = temp_dir("invocation-dedupe");
7855        let app_dir: PathBuf = dir.join("apps").join("web");
7856        fs::create_dir_all(&app_dir).expect("create app dir");
7857
7858        let app_package: PathBuf = app_dir.join("package.json");
7859        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
7860            .expect("write app package");
7861
7862        let updated: usize = write_version_to_configured_files(
7863            &dir,
7864            &app_dir,
7865            &[
7866                "package.json".to_string(),
7867                "apps/web/package.json".to_string(),
7868            ],
7869            &VersionScope::Repository,
7870            &Version::new(2, 14, 0),
7871        )
7872        .expect("write versions");
7873        assert_eq!(updated, 1);
7874
7875        assert_eq!(
7876            read_json_root_version(&app_package).expect("read app"),
7877            Some("2.14.0".to_string())
7878        );
7879
7880        let _ = fs::remove_dir_all(dir);
7881    }
7882
7883    #[test]
7884    fn configured_file_writer_errors_when_no_targets_exist() {
7885        let dir: PathBuf = temp_dir("no-targets");
7886        let error: String = write_version_to_configured_files(
7887            &dir,
7888            &dir,
7889            &["missing.toml".to_string()],
7890            &VersionScope::Repository,
7891            &Version::new(1, 0, 0),
7892        )
7893        .expect_err("missing targets should fail");
7894
7895        assert!(error.contains("No configured version files were found"));
7896
7897        let _ = fs::remove_dir_all(dir);
7898    }
7899
7900    #[test]
7901    fn remote_git_tag_parser_deduplicates_peeled_refs() {
7902        let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
7903            "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
7904        );
7905
7906        assert_eq!(parsed.len(), 2);
7907        assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
7908        assert_eq!(
7909            parsed[1].version,
7910            Version::parse("0.1.7-exp").expect("version")
7911        );
7912        assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
7913    }
7914
7915    #[test]
7916    fn local_git_tag_parser_normalizes_prefixed_versions() {
7917        let parsed: Vec<crate::commands::version::GitTagObservation> =
7918            parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
7919
7920        assert_eq!(parsed.len(), 2);
7921        assert_eq!(parsed[0].version, Version::new(1, 0, 0));
7922        assert_eq!(
7923            parsed[0].raw_tags,
7924            vec!["1.0.0".to_string(), "v1.0.0".to_string()]
7925        );
7926    }
7927
7928    #[test]
7929    fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
7930        let scope = VersionScope::Crate {
7931            crate_root: PathBuf::from("/tmp/crates/alpha"),
7932            crate_relative_root: "crates/alpha".to_string(),
7933            package_name: "alpha-crate".to_string(),
7934            tag_prefix: "alpha-crate-".to_string(),
7935        };
7936
7937        let parsed = parse_local_git_tag_output_for_scope(
7938            "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
7939            &scope,
7940        );
7941
7942        assert_eq!(parsed.len(), 2);
7943        assert_eq!(parsed[0].version, Version::new(1, 2, 0));
7944        assert_eq!(parsed[1].version, Version::new(1, 0, 0));
7945    }
7946
7947    #[test]
7948    fn crate_scoped_release_tags_default_to_package_prefix() {
7949        let scope = VersionScope::Crate {
7950            crate_root: PathBuf::from("/tmp/crates/alpha"),
7951            crate_relative_root: "crates/alpha".to_string(),
7952            package_name: "alpha-crate".to_string(),
7953            tag_prefix: "alpha-crate-".to_string(),
7954        };
7955
7956        assert_eq!(
7957            default_release_tag_name(&scope, &Version::new(1, 2, 3)),
7958            "alpha-crate-1.2.3"
7959        );
7960    }
7961
7962    #[test]
7963    fn blob_reader_handles_head_readme_versions() {
7964        assert_eq!(
7965            read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
7966                .expect("read"),
7967            Some("0.4.0".to_string())
7968        );
7969    }
7970
7971    #[test]
7972    fn blob_reader_handles_head_cargo_lock_versions() {
7973        let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
7974        let cargo_lock: &str =
7975            "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
7976
7977        assert_eq!(
7978            read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
7979            Some("0.2.0".to_string())
7980        );
7981    }
7982
7983    #[test]
7984    fn package_name_lookup_reads_json_name_for_npm() {
7985        let lookup: PackageNameLookup = PackageNameLookup {
7986            file: "package.json".to_string(),
7987            format: "json".to_string(),
7988            key: "name".to_string(),
7989            registry: "npm".to_string(),
7990        };
7991
7992        assert_eq!(
7993            read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
7994                .expect("read"),
7995            Some("@xylex/athena-mcp".to_string())
7996        );
7997    }
7998
7999    #[test]
8000    fn package_name_lookup_reads_toml_nested_package_name() {
8001        let lookup: PackageNameLookup = PackageNameLookup {
8002            file: "Cargo.toml".to_string(),
8003            format: "toml".to_string(),
8004            key: "package.name".to_string(),
8005            registry: "crates.io".to_string(),
8006        };
8007
8008        assert_eq!(
8009            read_package_name_from_lookup(
8010                &lookup,
8011                "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
8012            )
8013            .expect("read"),
8014            Some("athena-mcp".to_string())
8015        );
8016    }
8017
8018    #[test]
8019    fn package_name_lookup_errors_on_unknown_format() {
8020        let lookup: PackageNameLookup = PackageNameLookup {
8021            file: "meta.txt".to_string(),
8022            format: "ini".to_string(),
8023            key: "name".to_string(),
8024            registry: "npm".to_string(),
8025        };
8026
8027        let error = read_package_name_from_lookup(&lookup, "name=demo")
8028            .expect_err("unsupported format should fail");
8029        assert!(error.contains("Unsupported lookup format"));
8030    }
8031
8032    #[test]
8033    fn highest_version_observation_returns_max_version() {
8034        let entries: Vec<VersionObservation> = vec![
8035            VersionObservation {
8036                location: "README.md".to_string(),
8037                version: Version::new(1, 0, 0),
8038            },
8039            VersionObservation {
8040                location: "Cargo.toml".to_string(),
8041                version: Version::new(1, 2, 0),
8042            },
8043        ];
8044
8045        assert_eq!(
8046            highest_version_observation(&entries).expect("max version"),
8047            Version::new(1, 2, 0)
8048        );
8049    }
8050
8051    #[test]
8052    fn stale_version_observations_only_returns_outdated_entries() {
8053        let entries: Vec<VersionObservation> = vec![
8054            VersionObservation {
8055                location: "README.md".to_string(),
8056                version: Version::new(1, 1, 0),
8057            },
8058            VersionObservation {
8059                location: "Cargo.toml".to_string(),
8060                version: Version::new(1, 2, 0),
8061            },
8062            VersionObservation {
8063                location: "openapi.yaml".to_string(),
8064                version: Version::new(1, 0, 5),
8065            },
8066        ];
8067
8068        let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
8069        assert_eq!(stale.len(), 2);
8070        assert!(stale.iter().any(|entry| entry.location == "README.md"));
8071        assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
8072        assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
8073    }
8074
8075    #[test]
8076    fn parses_github_remote_urls() {
8077        assert_eq!(
8078            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
8079            Some(("xylex-group".to_string(), "xbp".to_string()))
8080        );
8081        assert_eq!(
8082            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
8083            Some(("xylex-group".to_string(), "xbp".to_string()))
8084        );
8085        assert_eq!(
8086            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
8087            Some(("xylex-group".to_string(), "xbp".to_string()))
8088        );
8089        assert_eq!(
8090            parse_github_repo_from_remote_url(
8091                "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
8092            ),
8093            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8094        );
8095        assert_eq!(
8096            parse_github_repo_from_remote_url(
8097                "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
8098            ),
8099            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
8100        );
8101        assert_eq!(
8102            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
8103            None
8104        );
8105    }
8106
8107    #[test]
8108    fn redacts_credentials_in_remote_urls() {
8109        let redacted = redact_remote_url_credentials(
8110            "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
8111        );
8112        assert!(redacted.contains("REDACTED"));
8113        assert!(!redacted.contains("ghp_secretvalue"));
8114
8115        let username_only = redact_remote_url_credentials(
8116            "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
8117        );
8118        assert!(username_only.contains("REDACTED@github.com"));
8119        assert!(!username_only.contains("floris-xlx@github.com"));
8120
8121        let ssh_remote =
8122            redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
8123        assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
8124    }
8125
8126    #[test]
8127    fn builds_github_release_urls_with_encoded_tag_segments() {
8128        let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
8129        assert_eq!(
8130            create_url.as_str(),
8131            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
8132        );
8133
8134        let lookup_url =
8135            github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
8136                .expect("url");
8137        assert_eq!(
8138            lookup_url.as_str(),
8139            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
8140        );
8141
8142        let update_url =
8143            github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8144        assert_eq!(
8145            update_url.as_str(),
8146            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
8147        );
8148
8149        let lookup_with_special_tag = github_release_by_tag_endpoint(
8150            "SuitsBooks",
8151            "suits-invoicing",
8152            "release candidate/v0.0.1+build",
8153        )
8154        .expect("url");
8155        assert_eq!(
8156            lookup_with_special_tag.as_str(),
8157            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
8158        );
8159    }
8160
8161    #[test]
8162    fn builds_github_release_asset_urls() {
8163        let list_url =
8164            github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
8165        assert_eq!(
8166            list_url.as_str(),
8167            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
8168        );
8169
8170        let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
8171            .expect("url");
8172        assert_eq!(
8173            delete_url.as_str(),
8174            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
8175        );
8176
8177        let upload_url = github_release_asset_upload_endpoint(
8178            "SuitsBooks",
8179            "suits-invoicing",
8180            42,
8181            "openapi spec.json",
8182        )
8183        .expect("url");
8184        assert_eq!(
8185            upload_url.as_str(),
8186            "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
8187        );
8188    }
8189
8190    #[test]
8191    fn maps_release_latest_policy_to_github_api_values() {
8192        assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
8193        assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
8194        assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
8195    }
8196
8197    #[test]
8198    fn release_channel_from_semver_prerelease_labels() {
8199        let stable = Version::parse("3.6.2").expect("version");
8200        let nightly = Version::parse("3.6.2-nightly.1").expect("version");
8201        let experimental = Version::parse("0.1.1-alpha.1").expect("version");
8202        assert_eq!(release_channel(&stable), "stable");
8203        assert_eq!(release_channel(&nightly), "nightly");
8204        assert_eq!(release_channel(&experimental), "experimental");
8205    }
8206
8207    #[test]
8208    fn renders_release_docs_from_entries() {
8209        let entries = vec![
8210            ReleaseDocEntry {
8211                tag: "v3.6.2".to_string(),
8212                version: Version::parse("3.6.2").expect("version"),
8213                date: "2026-04-27".to_string(),
8214            },
8215            ReleaseDocEntry {
8216                tag: "docs-0.1.1-alpha.1".to_string(),
8217                version: Version::parse("0.1.1-alpha.1").expect("version"),
8218                date: "2026-04-20".to_string(),
8219            },
8220        ];
8221        let changelog = render_changelog("xylex-group", "athena", &entries);
8222        assert!(changelog.contains("## [3.6.2]"));
8223        assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
8224        assert!(changelog.contains("Release channel: stable"));
8225        assert!(changelog.contains("Release channel: experimental"));
8226
8227        let security = render_security_policy(&entries);
8228        assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
8229        assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
8230    }
8231
8232    #[test]
8233    fn formats_release_commit_lines_with_sha_and_pr_links() {
8234        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
8235        let formatted =
8236            format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
8237                .expect("formatted line");
8238
8239        assert_eq!(
8240            formatted,
8241            "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
8242        );
8243    }
8244
8245    #[test]
8246    fn formats_release_commit_lines_with_linear_links_when_available() {
8247        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
8248        let issue_infos = BTreeMap::from([(
8249            "SUI-1336".to_string(),
8250            LinearIssueInfo {
8251                title: "Release flow".to_string(),
8252                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8253            },
8254        )]);
8255        let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
8256            .expect("formatted line");
8257
8258        assert_eq!(
8259            formatted,
8260            "[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)"
8261        );
8262    }
8263
8264    #[test]
8265    fn renders_release_notes_in_requested_layout() {
8266        let commits = vec![
8267            ReleaseCommitEntry {
8268                full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
8269                short_sha: "abcdef1".to_string(),
8270                subject: "Improve release docs (#42)".to_string(),
8271                date: "2026-05-24".to_string(),
8272            },
8273            ReleaseCommitEntry {
8274                full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
8275                short_sha: "fedcba9".to_string(),
8276                subject: "Fix release flow for SUI-1336".to_string(),
8277                date: "2026-05-25".to_string(),
8278            },
8279        ];
8280        let pull_request_infos = BTreeMap::from([(
8281            "42".to_string(),
8282            super::release_notes::GithubPullRequestInfo {
8283                title: "Improve release docs".to_string(),
8284                url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
8285            },
8286        )]);
8287        let issue_infos = BTreeMap::from([(
8288            "SUI-1336".to_string(),
8289            LinearIssueInfo {
8290                title: "Release flow".to_string(),
8291                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
8292            },
8293        )]);
8294        let sections = build_fallback_sections(&commits);
8295        let rendered = render_release_notes(&ReleaseNotesRenderInput {
8296            release_title: "1.7.0 - athena-auth",
8297            current_tag_name: "v1.7.0",
8298            owner: "xylex-group",
8299            repo: "athena-auth",
8300            previous_tag: Some("v1.6.0"),
8301            sections: &sections,
8302            commit_entries: &commits,
8303            pull_request_infos: &pull_request_infos,
8304            linear_issue_infos: &issue_infos,
8305        });
8306
8307        assert_eq!(
8308            rendered,
8309            "# [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)"
8310        );
8311    }
8312
8313    #[test]
8314    fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
8315        let commits = vec![
8316            ReleaseCommitEntry {
8317                full_sha: "a".repeat(40),
8318                short_sha: "aaaaaaa".to_string(),
8319                subject: "Fix SUI-1336 and SUI-1440".to_string(),
8320                date: "2026-05-24".to_string(),
8321            },
8322            ReleaseCommitEntry {
8323                full_sha: "b".repeat(40),
8324                short_sha: "bbbbbbb".to_string(),
8325                subject: "Touch SUI-1336 again".to_string(),
8326                date: "2026-05-25".to_string(),
8327            },
8328        ];
8329
8330        assert_eq!(
8331            collect_linear_issue_identifiers(&commits),
8332            vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
8333        );
8334    }
8335
8336    #[test]
8337    fn release_title_defaults_to_version_and_repo() {
8338        assert_eq!(
8339            default_release_title(&Version::new(1, 7, 0), "athena-auth"),
8340            "1.7.0 - athena-auth"
8341        );
8342    }
8343
8344    #[test]
8345    fn deduplicates_release_commit_entries_by_exact_subject() {
8346        let commits = vec![
8347            ReleaseCommitEntry {
8348                full_sha: "a".repeat(40),
8349                short_sha: "aaaaaaa".to_string(),
8350                subject: "Improve release docs".to_string(),
8351                date: "2026-05-24".to_string(),
8352            },
8353            ReleaseCommitEntry {
8354                full_sha: "b".repeat(40),
8355                short_sha: "bbbbbbb".to_string(),
8356                subject: "Improve release docs".to_string(),
8357                date: "2026-05-25".to_string(),
8358            },
8359        ];
8360
8361        let deduplicated = deduplicate_release_commit_entries(&commits);
8362        assert_eq!(deduplicated.len(), 1);
8363        assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
8364    }
8365
8366    #[test]
8367    fn fallback_sections_collapse_related_commit_themes() {
8368        let commits = vec![
8369            ReleaseCommitEntry {
8370                full_sha: "a".repeat(40),
8371                short_sha: "chat001".to_string(),
8372                subject: "Add optimistic chat retries".to_string(),
8373                date: "2026-06-01".to_string(),
8374            },
8375            ReleaseCommitEntry {
8376                full_sha: "b".repeat(40),
8377                short_sha: "chat002".to_string(),
8378                subject: "Persist deleted-message state in chat".to_string(),
8379                date: "2026-06-01".to_string(),
8380            },
8381            ReleaseCommitEntry {
8382                full_sha: "c".repeat(40),
8383                short_sha: "file001".to_string(),
8384                subject: "Fix upload UTF-8 audit retry handling".to_string(),
8385                date: "2026-06-01".to_string(),
8386            },
8387            ReleaseCommitEntry {
8388                full_sha: "d".repeat(40),
8389                short_sha: "ath001".to_string(),
8390                subject: "Migrate form progress routes to Athena".to_string(),
8391                date: "2026-06-01".to_string(),
8392            },
8393            ReleaseCommitEntry {
8394                full_sha: "e".repeat(40),
8395                short_sha: "ath002".to_string(),
8396                subject: "Update Athena models and package wiring".to_string(),
8397                date: "2026-06-01".to_string(),
8398            },
8399        ];
8400
8401        let sections = build_fallback_sections(&commits);
8402        assert_eq!(sections.len(), 3);
8403        assert_eq!(sections[0].title, "Cases & Communication");
8404        assert!(!sections[0].summary.is_empty());
8405        assert_eq!(sections[0].bullets.len(), 2);
8406        assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
8407        assert!(sections[0].bullets[0].summary.contains("chat"));
8408        assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
8409        assert!(sections[0].bullets[1].summary.contains("deleted-message"));
8410        assert_eq!(sections[1].title, "Reliability");
8411        assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
8412        assert_eq!(sections[2].title, "Athena Migration");
8413        assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
8414    }
8415
8416    #[test]
8417    fn appends_release_label_footer_for_pre_release() {
8418        let with_label = append_release_label_footer("# Release", true);
8419        assert_eq!(
8420            with_label,
8421            format!(
8422                "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
8423                env!("CARGO_PKG_VERSION")
8424            )
8425        );
8426    }
8427}