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