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