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