Skip to main content

xbp_cli/commands/
version.rs

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