Skip to main content

xbp_cli/commands/
version.rs

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