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, resolve_cargo_package_version,
24    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/github_release.rs"]
43mod github_release;
44#[path = "version/release_docs.rs"]
45mod release_docs;
46#[path = "version/release_linear.rs"]
47mod release_linear;
48#[path = "version/release_notes.rs"]
49mod release_notes;
50#[path = "version/bump.rs"]
51mod bump;
52#[path = "version/discover_services.rs"]
53mod discover_services;
54#[path = "version/workspace_release.rs"]
55mod workspace_release;
56
57use github_release::{
58    create_github_release, get_github_release_by_tag, update_github_release, GithubReleaseInput,
59    GithubReleaseResult, GithubReleaseTagResponse,
60};
61use release_docs::sync_release_docs;
62use release_linear::{
63    publish_release_to_linear_initiatives, resolve_linear_release_config,
64    LinearReleasePublishInput, PublishedLinearInitiative, ResolvedLinearReleaseConfig,
65};
66use release_notes::{generate_release_notes, ReleaseNotesRequest};
67pub(crate) use workspace_release::{
68    inspect_workspace_version_drift, resolve_manifest_workspace_publish,
69    sync_workspace_to_version, ManifestWorkspacePublishResolution,
70};
71pub use bump::run_version_bump_command;
72pub use discover_services::run_version_discover_services;
73pub use workspace_release::{
74    run_version_workspace_command, WorkspacePublishPlanOptions, WorkspacePublishRunOptions,
75    WorkspaceVersionCheckOptions, WorkspaceVersionCommand, WorkspaceVersionCommandOptions,
76    WorkspaceVersionSyncOptions, WorkspaceVersionValidateOptions,
77};
78
79#[derive(Clone, Debug)]
80struct VersionObservation {
81    location: String,
82    version: Version,
83}
84
85#[derive(Clone, Debug)]
86struct GitTagObservation {
87    version: Version,
88    raw_tags: Vec<String>,
89}
90
91#[derive(Clone, Debug)]
92struct RegistryVersionObservation {
93    registry: String,
94    package_name: String,
95    source_file: String,
96    latest: Option<Version>,
97    raw_version: Option<String>,
98    note: Option<String>,
99}
100
101#[derive(Clone, Debug)]
102struct ResolvedRegistryPath {
103    relative: String,
104    absolute: PathBuf,
105    cargo_package_override: Option<String>,
106}
107
108#[derive(Clone, Debug)]
109struct WorkspacePrimaryCargoTarget {
110    manifest_relative: String,
111    manifest_absolute: PathBuf,
112    package_name: String,
113}
114
115#[derive(Clone, Debug)]
116enum VersionScope {
117    Repository,
118    Crate {
119        crate_root: PathBuf,
120        crate_relative_root: String,
121        package_name: String,
122        tag_prefix: String,
123    },
124    Service {
125        service_root: PathBuf,
126        service_relative_root: String,
127        service_name: String,
128        tag_prefix: String,
129        cargo_package_name: Option<String>,
130        version_targets: Vec<String>,
131    },
132}
133
134#[derive(Default, Debug)]
135struct VersionReport {
136    worktree: Vec<VersionObservation>,
137    head: Vec<VersionObservation>,
138    local_tags: Vec<GitTagObservation>,
139    remote_tags: Vec<GitTagObservation>,
140    registry_versions: Vec<RegistryVersionObservation>,
141    dirty_files: Vec<String>,
142    warnings: Vec<String>,
143}
144
145const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
146
147#[derive(Clone, Debug, Default, Deserialize, Serialize)]
148struct VersionChangeGuardRegistry {
149    #[serde(default)]
150    entries: BTreeMap<String, VersionChangeGuardEntry>,
151}
152
153#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
154struct VersionChangeGuardEntry {
155    #[serde(default)]
156    pending_version_change_count: usize,
157    #[serde(default)]
158    head_commit: Option<String>,
159}
160
161#[derive(Clone, Debug, Default, PartialEq, Eq)]
162struct GitWorktreeState {
163    is_dirty: bool,
164    head_commit: Option<String>,
165}
166
167impl VersionReport {
168    fn highest_worktree(&self) -> Option<Version> {
169        self.worktree
170            .iter()
171            .map(|entry| entry.version.clone())
172            .max()
173    }
174
175    fn highest_head(&self) -> Option<Version> {
176        self.head.iter().map(|entry| entry.version.clone()).max()
177    }
178
179    fn highest_local_tag(&self) -> Option<Version> {
180        self.local_tags
181            .iter()
182            .map(|entry| entry.version.clone())
183            .max()
184    }
185
186    fn highest_remote_tag(&self) -> Option<Version> {
187        self.remote_tags
188            .iter()
189            .map(|entry| entry.version.clone())
190            .max()
191    }
192
193    fn highest_git(&self) -> Option<Version> {
194        self.highest_remote_tag()
195            .or_else(|| self.highest_local_tag())
196    }
197
198    fn highest_registry(&self) -> Option<Version> {
199        self.registry_versions
200            .iter()
201            .filter_map(|entry| entry.latest.clone())
202            .max()
203    }
204
205    fn highest_available(&self) -> Version {
206        self.highest_worktree()
207            .into_iter()
208            .chain(self.highest_head())
209            .chain(self.highest_git())
210            .chain(self.highest_registry())
211            .max()
212            .unwrap_or_else(default_version)
213    }
214
215    fn divergent_versions(&self) -> Vec<Version> {
216        let mut versions = BTreeSet::new();
217        for entry in &self.worktree {
218            versions.insert(entry.version.clone());
219        }
220        for entry in &self.head {
221            versions.insert(entry.version.clone());
222        }
223        for entry in &self.local_tags {
224            versions.insert(entry.version.clone());
225        }
226        for entry in &self.remote_tags {
227            versions.insert(entry.version.clone());
228        }
229        for entry in &self.registry_versions {
230            if let Some(version) = &entry.latest {
231                versions.insert(version.clone());
232            }
233        }
234        versions.into_iter().collect()
235    }
236}
237
238pub async fn run_version_command(
239    target: Option<String>,
240    git_only: bool,
241    _debug: bool,
242) -> Result<(), String> {
243    if git_only && target.is_some() {
244        return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
245    }
246
247    let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
248    let project_root: PathBuf = resolve_project_root();
249    let version_scope: VersionScope =
250        resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
251    let registry: Vec<String> = load_versioning_files_registry()?;
252
253    if git_only {
254        print_git_versions(&project_root, &version_scope)?;
255        return Ok(());
256    }
257
258    match target.as_deref() {
259        None => {
260            let mut report: VersionReport =
261                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
262            match load_package_name_files_registry() {
263                Ok(lookups) => {
264                    report.registry_versions = collect_registry_versions(
265                        &project_root,
266                        &invocation_dir,
267                        &lookups,
268                        &version_scope,
269                        &mut report.warnings,
270                    )
271                    .await;
272                }
273                Err(err) => report.warnings.push(err),
274            }
275            print_version_report(&project_root, &report);
276            Ok(())
277        }
278        Some(bump_target @ ("major" | "minor" | "patch")) => {
279            enforce_version_change_guard(&project_root)?;
280            let current: Version = resolve_current_version_for_bump(
281                &project_root,
282                &invocation_dir,
283                &registry,
284                &version_scope,
285            );
286            let next: Version = bump_version(&current, bump_target);
287            let updated_paths = write_version_to_configured_files_with_paths(
288                &project_root,
289                &invocation_dir,
290                &registry,
291                &version_scope,
292                &next,
293            )?;
294            let updated = updated_paths.len();
295            println!(
296                "Updated {} version file(s) from {} to {}.",
297                updated, current, next
298            );
299            auto_commit_command_paths(
300                &project_root,
301                updated_paths,
302                format!("chore(version): update version to {}", next),
303                "xbp version",
304            )
305            .await;
306            record_version_change_guard(&project_root)?;
307            sync_cli_version_write_activity(
308                &project_root,
309                &version_scope,
310                &next,
311                format!(
312                    "Updated {} version file(s) from {} to {}.",
313                    updated, current, next
314                ),
315            )
316            .await;
317            Ok(())
318        }
319        Some(explicit) => {
320            enforce_version_change_guard(&project_root)?;
321            if let Some((package_name, version)) = parse_package_version_target(explicit)? {
322                let updated_paths = write_package_version_to_configured_files_with_paths(
323                    &project_root,
324                    &invocation_dir,
325                    &registry,
326                    &version_scope,
327                    &package_name,
328                    &version,
329                )?;
330                let updated = updated_paths.len();
331                println!(
332                    "Updated {} file(s) for package `{}` to {}.",
333                    updated, package_name, version
334                );
335                auto_commit_command_paths(
336                    &project_root,
337                    updated_paths,
338                    format!("chore(version): set {} to {}", package_name, version),
339                    "xbp version",
340                )
341                .await;
342                record_version_change_guard(&project_root)?;
343                sync_cli_version_write_activity(
344                    &project_root,
345                    &version_scope,
346                    &version,
347                    format!(
348                        "Updated {} file(s) for package `{}` to {}.",
349                        updated, package_name, version
350                    ),
351                )
352                .await;
353            } else {
354                let version: Version = parse_version(explicit)?;
355                let updated_paths = write_version_to_configured_files_with_paths(
356                    &project_root,
357                    &invocation_dir,
358                    &registry,
359                    &version_scope,
360                    &version,
361                )?;
362                let updated = updated_paths.len();
363                println!("Updated {} version file(s) to {}.", updated, version);
364                auto_commit_command_paths(
365                    &project_root,
366                    updated_paths,
367                    format!("chore(version): update version to {}", version),
368                    "xbp version",
369                )
370                .await;
371                record_version_change_guard(&project_root)?;
372                sync_cli_version_write_activity(
373                    &project_root,
374                    &version_scope,
375                    &version,
376                    format!("Updated {} version file(s) to {}.", updated, version),
377                )
378                .await;
379            }
380            Ok(())
381        }
382    }
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub enum ReleaseLatestPolicy {
387    True,
388    False,
389    Legacy,
390}
391
392impl ReleaseLatestPolicy {
393    pub(crate) fn as_github_api_value(self) -> &'static str {
394        match self {
395            Self::True => "true",
396            Self::False => "false",
397            Self::Legacy => "legacy",
398        }
399    }
400}
401
402#[derive(Debug, Clone)]
403pub struct VersionReleaseOptions {
404    pub explicit_version: Option<String>,
405    pub allow_dirty: bool,
406    pub title: Option<String>,
407    pub notes: Option<String>,
408    pub notes_file: Option<PathBuf>,
409    pub draft: bool,
410    pub prerelease: bool,
411    pub publish: bool,
412    pub force: bool,
413    pub latest_policy: ReleaseLatestPolicy,
414}
415
416struct ReleaseWorkflowSummary {
417    version: Version,
418    tag_name: String,
419    release_url: String,
420    uploaded_openapi_assets: Vec<String>,
421    release_title: String,
422    release_notes: String,
423    repository_owner: String,
424    repository_name: String,
425    scope_kind: String,
426    scope_label: String,
427    published_initiatives: Vec<PublishedLinearInitiative>,
428    release_branch: Option<String>,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
432struct ReleaseOpenApiAsset {
433    source_path: PathBuf,
434    source_label: String,
435    asset_name: String,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439struct DirtyWorktreeAnalysis {
440    all_entries: Vec<String>,
441    safe_entries: Vec<String>,
442    risky_entries: Vec<String>,
443}
444
445fn parse_git_status_path(line: &str) -> Option<String> {
446    let line = line.trim_end();
447    if line.len() < 3 {
448        return None;
449    }
450    let path_part = line.get(2..)?.trim_start();
451    if path_part.is_empty() {
452        return None;
453    }
454    if let Some((_old, new)) = path_part.split_once(" -> ") {
455        return Some(new.trim().replace('\\', "/"));
456    }
457    Some(path_part.replace('\\', "/"))
458}
459
460fn is_safe_dirty_path(path: &str) -> bool {
461    let normalized = path.replace('\\', "/");
462    let segments: Vec<&str> = normalized.split('/').collect();
463
464    if segments.first() == Some(&".xbp") {
465        return true;
466    }
467    if segments.contains(&"target") {
468        return true;
469    }
470    if segments.contains(&"__pycache__") {
471        return true;
472    }
473    if segments.contains(&"node_modules") || segments.contains(&".next") {
474        return true;
475    }
476    if normalized.ends_with(".pyc") || normalized.ends_with(".pyo") {
477        return true;
478    }
479
480    false
481}
482
483fn analyze_dirty_worktree(entries: &[String]) -> DirtyWorktreeAnalysis {
484    let mut paths = Vec::new();
485    for entry in entries {
486        if let Some(path) = parse_git_status_path(entry) {
487            paths.push(path);
488        }
489    }
490    paths.sort();
491    paths.dedup();
492
493    let mut safe_entries = Vec::new();
494    let mut risky_entries = Vec::new();
495    for path in paths {
496        if is_safe_dirty_path(&path) {
497            safe_entries.push(path);
498        } else {
499            risky_entries.push(path);
500        }
501    }
502
503    DirtyWorktreeAnalysis {
504        all_entries: entries.to_vec(),
505        safe_entries,
506        risky_entries,
507    }
508}
509
510fn run_release_config_preflight(invocation_dir: &Path) -> Result<(), String> {
511    let Some(heal_result) = heal_project_xbp_config(invocation_dir)? else {
512        return Ok(());
513    };
514
515    println!(
516        "  {} Auto-healed {}",
517        "✓".bright_green(),
518        heal_result.config_path.display()
519    );
520    for fix in &heal_result.fixes {
521        println!("    {} {}", "•".bright_cyan(), fix);
522    }
523    Ok(())
524}
525
526fn resolve_dirty_worktree_for_release(
527    project_root: &Path,
528    force: bool,
529) -> Result<bool, String> {
530    let dirty = git_dirty_entries(project_root)?;
531    if dirty.is_empty() {
532        return Ok(false);
533    }
534
535    let analysis = analyze_dirty_worktree(&dirty);
536    if analysis.risky_entries.is_empty() {
537        println!(
538            "  {} Allowing release with {} generated/auto-healed change(s)",
539            "i".bright_blue(),
540            analysis.safe_entries.len()
541        );
542        for path in analysis.safe_entries.iter().take(4) {
543            println!("    {} {}", "•".bright_black(), path);
544        }
545        if analysis.safe_entries.len() > 4 {
546            println!(
547                "    {} … and {} more",
548                "•".bright_black(),
549                analysis.safe_entries.len() - 4
550            );
551        }
552        return Ok(true);
553    }
554
555    if force {
556        return Ok(true);
557    }
558
559    println!(
560        "  {} Working tree has {} uncommitted change(s)",
561        "!".bright_yellow(),
562        analysis.risky_entries.len()
563    );
564    for path in analysis.risky_entries.iter().take(8) {
565        println!("    {} {}", "!".bright_yellow(), path);
566    }
567    if analysis.risky_entries.len() > 8 {
568        println!(
569            "    {} … and {} more",
570            "!".bright_yellow(),
571            analysis.risky_entries.len() - 8
572        );
573    }
574    if !analysis.safe_entries.is_empty() {
575        println!(
576            "    {} Ignoring {} generated/auto-healed path(s)",
577            "i".bright_blue(),
578            analysis.safe_entries.len()
579        );
580    }
581
582    if std::io::stdin().is_terminal() {
583        let proceed = Confirm::with_theme(&ColorfulTheme::default())
584            .with_prompt("Continue release with uncommitted changes?")
585            .default(false)
586            .interact()
587            .map_err(|e| format!("Failed to read confirmation: {}", e))?;
588        if proceed {
589            return Ok(true);
590        }
591        return Err(
592            "Release cancelled. Commit/stash changes first or pass `--allow-dirty`.".to_string(),
593        );
594    }
595
596    let preview = analysis
597        .risky_entries
598        .into_iter()
599        .take(8)
600        .collect::<Vec<_>>()
601        .join(", ");
602    Err(format!(
603        "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
604        preview
605    ))
606}
607
608async fn maybe_heal_workspace_versions_for_release(
609    project_root: &Path,
610    release_version: &Version,
611    force: bool,
612) -> Result<(), String> {
613    let Some(drift) = inspect_workspace_version_drift(project_root, release_version)? else {
614        return Ok(());
615    };
616
617    println!(
618        "  {} Workspace version drift detected ({} mismatch(es) for {})",
619        "!".bright_yellow(),
620        drift.drift_count,
621        drift.expected_version
622    );
623    for line in &drift.preview {
624        println!("    {} {}", "•".bright_yellow(), line);
625    }
626    if drift.drift_count > drift.preview.len() {
627        println!(
628            "    {} … and {} more",
629            "•".bright_yellow(),
630            drift.drift_count - drift.preview.len()
631        );
632    }
633
634    let should_sync = if force {
635        true
636    } else if std::io::stdin().is_terminal() {
637        Confirm::with_theme(&ColorfulTheme::default())
638            .with_prompt(format!(
639                "Sync workspace versions to {} before publishing?",
640                release_version
641            ))
642            .default(true)
643            .interact()
644            .map_err(|e| format!("Failed to read confirmation: {}", e))?
645    } else {
646        return Err(format!(
647            "Workspace version drift detected for {}. Run `xbp version workspace sync --write --version {}` or pass `--force` to auto-sync.",
648            release_version, release_version
649        ));
650    };
651
652    if !should_sync {
653        return Err(
654            "Release cancelled. Align workspace versions before publishing.".to_string(),
655        );
656    }
657
658    let changed_files = sync_workspace_to_version(project_root, release_version)?;
659    if changed_files.is_empty() {
660        println!(
661            "  {} Workspace versions already aligned to {}",
662            "✓".bright_green(),
663            release_version
664        );
665        return Ok(());
666    }
667
668    println!(
669        "  {} Synced workspace versions to {} ({} file(s))",
670        "✓".bright_green(),
671        release_version,
672        changed_files.len()
673    );
674    for path in changed_files.iter().take(6) {
675        println!("    {} {}", "•".bright_cyan(), path);
676    }
677    if changed_files.len() > 6 {
678        println!(
679            "    {} … and {} more",
680            "•".bright_cyan(),
681            changed_files.len() - 6
682        );
683    }
684
685    let changed_paths = changed_files
686        .into_iter()
687        .map(|relative| project_root.join(relative))
688        .collect::<Vec<_>>();
689    auto_commit_command_paths(
690        project_root,
691        changed_paths,
692        format!("chore(version): sync workspace to {}", release_version),
693        "xbp version release",
694    )
695    .await;
696
697    Ok(())
698}
699
700pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
701    let loader = Loader::start("Publishing release");
702    let result: Result<ReleaseWorkflowSummary, String> = async {
703        let VersionReleaseOptions {
704            explicit_version,
705            allow_dirty,
706            title,
707            notes,
708            notes_file,
709            draft,
710            prerelease,
711            publish,
712            force,
713            latest_policy,
714        } = options;
715        let mut effective_allow_dirty = allow_dirty || force;
716
717        if notes.is_some() && notes_file.is_some() {
718            return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
719        }
720
721        if !command_exists("git") {
722            return Err(
723                "Git is required for `xbp version release`, but it is not installed.".to_string(),
724            );
725        }
726
727        let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
728        let project_root: PathBuf = resolve_project_root();
729        let version_scope: VersionScope =
730            resolve_version_scope_with_prompt(&project_root, &invocation_dir)?;
731        let sync_explicit_version = explicit_version.is_some();
732        let total_steps = 10usize + usize::from(sync_explicit_version) + usize::from(publish) * 2;
733        let mut step = 1usize;
734
735        loader.update(&format!(
736            "[{}/{}] Checking project configuration",
737            step, total_steps
738        ));
739        run_release_config_preflight(&invocation_dir)?;
740
741        step += 1;
742        loader.update(&format!(
743            "[{}/{}] Validating git state and resolving release target",
744            step, total_steps
745        ));
746        if !effective_allow_dirty {
747            effective_allow_dirty = resolve_dirty_worktree_for_release(&project_root, force)?;
748        }
749
750        let (release_version, tag_name) = if let Some(raw) = explicit_version.as_deref() {
751            let (version, parsed_tag_name) = parse_release_version_target(raw)?;
752            (
753                version.clone(),
754                scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
755            )
756        } else {
757            let registry: Vec<String> = load_versioning_files_registry()?;
758            let report: VersionReport =
759                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
760            let release_version: Version = report.highest_available();
761            let tag_name: String = default_release_tag_name(&version_scope, &release_version);
762            (release_version, tag_name)
763        };
764
765        if sync_explicit_version {
766            step += 1;
767            loader.update(&format!(
768                "[{}/{}] Syncing configured version files",
769                step, total_steps
770            ));
771            let registry: Vec<String> = load_versioning_files_registry()?;
772            let updated_paths = sync_version_to_configured_files_with_paths(
773                &project_root,
774                &invocation_dir,
775                &registry,
776                &version_scope,
777                &release_version,
778            )?;
779            if !updated_paths.is_empty() {
780                auto_commit_command_paths(
781                    &project_root,
782                    updated_paths,
783                    format!("chore(version): update version to {}", release_version),
784                    "xbp version release",
785                )
786                .await;
787            }
788        }
789
790        ensure_remote_exists(&project_root, "origin")?;
791        let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
792        let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
793
794        if publish {
795            step += 1;
796            loader.update(&format!(
797                "[{}/{}] Checking workspace version alignment",
798                step, total_steps
799            ));
800            maybe_heal_workspace_versions_for_release(&project_root, &release_version, force)
801                .await?;
802
803            step += 1;
804            loader.update(&format!(
805                "[{}/{}] Publishing configured packages",
806                step, total_steps
807            ));
808            let publish_target_filter =
809                resolve_release_publish_target_filter(&invocation_dir, &version_scope)?;
810            run_publish_command_with_progress_prefix(
811                PublishCommandOptions {
812                    dry_run: false,
813                    allow_dirty: effective_allow_dirty,
814                    force,
815                    include_prereqs: true,
816                    target: publish_target_filter,
817                    manifest_path: None,
818                    expected_version: Some(release_version.to_string()),
819                },
820                &loader,
821                format!("[{}/{}]", step, total_steps),
822            )
823            .await?;
824        }
825
826        step += 1;
827        loader.update(&format!(
828            "[{}/{}] Resolving GitHub repository and auth",
829            step, total_steps
830        ));
831        let origin_url: String = git_remote_url(&project_root, "origin")?;
832        let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
833            format!(
834                "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.",
835                redact_remote_url_credentials(&origin_url)
836            )
837        })?;
838
839        let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
840            "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
841                .to_string()
842        })?;
843        let linear_api_key: Option<String> = if let Some(key) = resolve_linear_api_key() {
844            Some(key)
845        } else {
846            fetch_linear_api_key_from_dashboard().await?
847        };
848        let release_title: String = title.unwrap_or_else(|| {
849            default_release_title(
850                &release_version,
851                release_title_subject(&version_scope, &repo),
852            )
853        });
854        let release_branch_config =
855            resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
856
857        step += 1;
858        loader.update(&format!(
859            "[{}/{}] Generating release notes",
860            step, total_steps
861        ));
862        let release_notes_body: String = if let Some(path) = notes_file {
863            fs::read_to_string(&path).map_err(|e| {
864                format!(
865                    "Failed to read release notes file {}: {}",
866                    path.display(),
867                    e
868                )
869            })?
870        } else if let Some(body) = notes {
871            body
872        } else {
873            generate_release_notes(&ReleaseNotesRequest {
874                project_root: &project_root,
875                release_title: &release_title,
876                current_tag_name: &tag_name,
877                owner: &owner,
878                repo: &repo,
879                github_token: &github_token,
880                linear_api_key: linear_api_key.as_deref(),
881                openrouter_api_key: resolve_openrouter_api_key().as_deref(),
882                path_filter: release_notes_scope_path(&version_scope).as_deref(),
883            })
884            .await?
885        };
886        let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
887
888        step += 1;
889        loader.update(&format!(
890            "[{}/{}] Preparing release OpenAPI assets",
891            step, total_steps
892        ));
893        let release_openapi_assets = prepare_release_openapi_assets(
894            &project_root,
895            &invocation_dir,
896            &version_scope,
897            &release_version,
898            &tag_name,
899        )?;
900
901        step += 1;
902        loader.update(&format!(
903            "[{}/{}] Creating and pushing release tag",
904            step, total_steps
905        ));
906        let tag_message: String = format!("Release {}", tag_name);
907        let target_commitish: String = git_head_commitish(&project_root)?;
908        let created_release_branch = if let Some(branch_config) = &release_branch_config {
909            Some(ensure_release_branch(
910                &project_root,
911                branch_config,
912                &release_version,
913                &tag_name,
914                &target_commitish,
915            )?)
916        } else {
917            None
918        };
919        if !tag_exists_local {
920            run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])?;
921        }
922        if !tag_exists_remote {
923            run_git_command(&project_root, &["push", "origin", &tag_name])?;
924        }
925
926        let release_input: GithubReleaseInput = GithubReleaseInput {
927            owner: owner.clone(),
928            repo: repo.clone(),
929            token: github_token,
930            tag_name: tag_name.clone(),
931            target_commitish,
932            title: release_title,
933            notes: release_notes,
934            draft,
935            prerelease,
936            latest_policy,
937        };
938
939        step += 1;
940        loader.update(&format!(
941            "[{}/{}] Publishing GitHub release",
942            step, total_steps
943        ));
944        let release_result: GithubReleaseResult = match create_github_release(&release_input).await {
945            Ok(result) => result,
946            Err(create_error) => {
947                let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
948                    format!(
949                        "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
950                        create_error, tag_name, e
951                    )
952                })?;
953
954                let Some(existing_release) = existing_release else {
955                    return Err(format!(
956                        "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
957                        create_error, tag_name
958                    ));
959                };
960
961                let needs_update: bool = existing_release.prerelease.unwrap_or(false)
962                    != release_input.prerelease
963                    || existing_release.draft.unwrap_or(false) != release_input.draft
964                    || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
965
966                if needs_update {
967                    update_github_release(&release_input, existing_release.id)
968                        .await
969                        .map_err(|e| {
970                            format!(
971                                "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
972                                create_error, tag_name, e
973                            )
974                        })?
975                } else {
976                    GithubReleaseResult {
977                        id: existing_release.id,
978                        html_url: existing_release.html_url.unwrap_or_else(|| {
979                            format!(
980                                "https://github.com/{}/{}/releases/tag/{}",
981                                release_input.owner, release_input.repo, release_input.tag_name
982                            )
983                        }),
984                    }
985                }
986            }
987        };
988        let release_url = release_result.html_url.clone();
989
990        step += 1;
991        loader.update(&format!(
992            "[{}/{}] Publishing release integrations",
993            step, total_steps
994        ));
995        let linear_release_config =
996            resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
997        let openapi_release_input = release_input.clone();
998        let linear_release_input = release_input.clone();
999        let openapi_tag_name = tag_name.clone();
1000        let linear_tag_name = tag_name.clone();
1001        let linear_release_url = release_url.clone();
1002        let linear_api_key_for_release = linear_api_key.clone();
1003
1004        let openapi_future = async move {
1005            let mut uploaded_assets: Vec<String> = Vec::new();
1006            for asset in release_openapi_assets {
1007                github_release::upload_github_release_asset(
1008                    &openapi_release_input,
1009                    release_result.id,
1010                    &asset.source_path,
1011                    &asset.asset_name,
1012                )
1013                .await
1014                .map_err(|e| {
1015                    format!(
1016                        "Release `{}` was published, but uploading OpenAPI asset `{}` from `{}` failed: {}",
1017                        openapi_tag_name,
1018                        asset.asset_name,
1019                        asset.source_label,
1020                        e
1021                    )
1022                })?;
1023                uploaded_assets.push(asset.asset_name);
1024            }
1025            Ok(uploaded_assets)
1026        };
1027
1028        let linear_future = async move {
1029            if let Some(linear_release_config) = linear_release_config {
1030                let linear_api_key: String = linear_api_key_for_release.ok_or_else(|| {
1031                    "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."
1032                        .to_string()
1033                })?;
1034                publish_release_to_linear_initiatives(&LinearReleasePublishInput {
1035                    api_key: linear_api_key,
1036                    initiative_ids: linear_release_config.initiative_ids,
1037                    organization_name: linear_release_config.organization_name,
1038                    health: linear_release_config.health,
1039                    release_title: linear_release_input.title.clone(),
1040                    release_tag: linear_release_input.tag_name.clone(),
1041                    release_url: linear_release_url,
1042                    release_notes: linear_release_input.notes.clone(),
1043                })
1044                .await
1045                .map_err(|e| {
1046                    format!(
1047                        "Release `{}` was published, but publishing to configured Linear initiatives failed: {}",
1048                        linear_tag_name, e
1049                    )
1050                })
1051            } else {
1052                Ok(Vec::new())
1053            }
1054        };
1055
1056        let (uploaded_openapi_assets, published_initiatives) =
1057            tokio::try_join!(openapi_future, linear_future)?;
1058
1059        step += 1;
1060        loader.update(&format!(
1061            "[{}/{}] Syncing release docs",
1062            step, total_steps
1063        ));
1064        let release_doc_paths = sync_release_docs(&project_root, &owner, &repo)?;
1065        step += 1;
1066        loader.update(&format!(
1067            "[{}/{}] Auto-committing release docs",
1068            step, total_steps
1069        ));
1070        auto_commit_command_paths(
1071            &project_root,
1072            release_doc_paths,
1073            format!("docs(release): sync release docs for {}", tag_name),
1074            "xbp version release",
1075        )
1076        .await;
1077        let summary = ReleaseWorkflowSummary {
1078            version: release_version.clone(),
1079            tag_name,
1080            release_url,
1081            uploaded_openapi_assets,
1082            release_title: release_input.title.clone(),
1083            release_notes: release_input.notes.clone(),
1084            repository_owner: owner.clone(),
1085            repository_name: repo.clone(),
1086            scope_kind: version_scope_kind(&version_scope).to_string(),
1087            scope_label: version_scope_label(&version_scope, &repo),
1088            published_initiatives,
1089            release_branch: created_release_branch,
1090        };
1091        sync_cli_release_activity(&summary).await;
1092
1093        Ok(summary)
1094    }
1095    .await;
1096
1097    match result {
1098        Ok(summary) => {
1099            loader.success_with(&format!("Published {}", summary.tag_name));
1100            println!("Released {} successfully.", summary.tag_name);
1101            println!("GitHub release: {}", summary.release_url);
1102            if !summary.uploaded_openapi_assets.is_empty() {
1103                println!(
1104                    "Uploaded OpenAPI assets: {}",
1105                    summary.uploaded_openapi_assets.join(", ")
1106                );
1107            }
1108            if !summary.published_initiatives.is_empty() {
1109                println!(
1110                    "Published release update to Linear initiative(s): {}",
1111                    summary
1112                        .published_initiatives
1113                        .iter()
1114                        .map(|initiative| initiative.name.as_str())
1115                        .collect::<Vec<_>>()
1116                        .join(", ")
1117                );
1118            }
1119            if let Some(release_branch) = summary.release_branch {
1120                println!("Release branch: {}", release_branch);
1121            }
1122            println!("Updated release docs: CHANGELOG.md and SECURITY.md");
1123            Ok(())
1124        }
1125        Err(error) => {
1126            loader.fail(&error);
1127            Err(error)
1128        }
1129    }
1130}
1131
1132/// Print program version from Cargo metadata.
1133pub async fn print_version() {
1134    println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
1135}
1136
1137fn resolve_project_root() -> PathBuf {
1138    let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1139
1140    if let Some(root) = git_repository_root(&cwd) {
1141        return root;
1142    }
1143
1144    if let Some(found) = find_xbp_config_upwards(&cwd) {
1145        return found.project_root;
1146    }
1147
1148    cwd
1149}
1150
1151fn collect_version_report(
1152    project_root: &Path,
1153    invocation_dir: &Path,
1154    registry: &[String],
1155    version_scope: &VersionScope,
1156) -> VersionReport {
1157    let mut report: VersionReport = VersionReport::default();
1158    report.worktree = collect_local_versions(
1159        project_root,
1160        invocation_dir,
1161        registry,
1162        version_scope,
1163        &mut report.warnings,
1164    );
1165    match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
1166        Ok(entries) => report.head = entries,
1167        Err(err) => report.warnings.push(err),
1168    }
1169    match collect_git_versions(project_root, version_scope) {
1170        Ok(tags) => report.local_tags = tags,
1171        Err(err) => report.warnings.push(err),
1172    }
1173    match collect_remote_git_versions(project_root, "origin", version_scope) {
1174        Ok(tags) => report.remote_tags = tags,
1175        Err(err) => report.warnings.push(err),
1176    }
1177    match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
1178        Ok(files) => report.dirty_files = files,
1179        Err(err) => report.warnings.push(err),
1180    }
1181    report
1182}
1183
1184fn resolve_current_version_for_bump(
1185    project_root: &Path,
1186    invocation_dir: &Path,
1187    registry: &[String],
1188    version_scope: &VersionScope,
1189) -> Version {
1190    let mut _warnings = Vec::new();
1191    let local_versions = collect_local_versions(
1192        project_root,
1193        invocation_dir,
1194        registry,
1195        version_scope,
1196        &mut _warnings,
1197    );
1198    let head_versions =
1199        collect_head_versions(project_root, invocation_dir, registry, version_scope)
1200            .unwrap_or_default();
1201    let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
1202
1203    local_versions
1204        .iter()
1205        .map(|entry| entry.version.clone())
1206        .chain(head_versions.iter().map(|entry| entry.version.clone()))
1207        .chain(local_tags.iter().map(|entry| entry.version.clone()))
1208        .max()
1209        .unwrap_or_else(default_version)
1210}
1211
1212async fn auto_commit_command_paths(
1213    project_root: &Path,
1214    paths: Vec<PathBuf>,
1215    message: String,
1216    action_label: &'static str,
1217) {
1218    match commit_paths(AutoCommitRequest {
1219        project_root,
1220        paths,
1221        message,
1222        action_label,
1223    })
1224    .await
1225    {
1226        Ok(AutoCommitResult::Committed(_)) => {}
1227        Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
1228        Err(e) => print_skip(action_label, &e),
1229    }
1230}
1231
1232async fn collect_registry_versions(
1233    project_root: &Path,
1234    invocation_dir: &Path,
1235    lookups: &[PackageNameLookup],
1236    version_scope: &VersionScope,
1237    warnings: &mut Vec<String>,
1238) -> Vec<RegistryVersionObservation> {
1239    let mut entries: Vec<RegistryVersionObservation> = Vec::new();
1240    let mut seen: BTreeSet<String> = BTreeSet::new();
1241    let client: reqwest::Client = reqwest::Client::new();
1242
1243    for lookup in lookups {
1244        let dedupe_key: String = format!(
1245            "{}|{}|{}|{}",
1246            lookup.file, lookup.format, lookup.key, lookup.registry
1247        );
1248        if !seen.insert(dedupe_key) {
1249            continue;
1250        }
1251
1252        let source_file = resolve_registry_relative_path(
1253            project_root,
1254            invocation_dir,
1255            version_scope,
1256            &lookup.file,
1257        );
1258        let path = project_root.join(&source_file);
1259        if !path.exists() {
1260            continue;
1261        }
1262
1263        let content: String = match fs::read_to_string(&path) {
1264            Ok(content) => content,
1265            Err(err) => {
1266                warnings.push(format!("Failed to read {}: {}", path.display(), err));
1267                continue;
1268            }
1269        };
1270
1271        let package_name: String = match read_package_name_from_lookup(lookup, &content) {
1272            Ok(Some(value)) => value,
1273            Ok(None) => continue,
1274            Err(err) => {
1275                warnings.push(format!("{}: {}", source_file, err));
1276                continue;
1277            }
1278        };
1279
1280        let (latest, raw_version, note) =
1281            match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
1282                Ok(version) => {
1283                    let parsed: Option<Version> = parse_version(&version).ok();
1284                    let note: Option<String> = if parsed.is_none() {
1285                        Some(format!("Non-semver registry version: {}", version))
1286                    } else {
1287                        None
1288                    };
1289                    (parsed, Some(version), note)
1290                }
1291                Err(err) => (None, None, Some(err)),
1292            };
1293
1294        entries.push(RegistryVersionObservation {
1295            registry: lookup.registry.clone(),
1296            package_name,
1297            source_file,
1298            latest,
1299            raw_version,
1300            note,
1301        });
1302    }
1303
1304    entries.sort_by(|a, b| {
1305        a.registry
1306            .cmp(&b.registry)
1307            .then_with(|| a.package_name.cmp(&b.package_name))
1308    });
1309    entries
1310}
1311
1312fn read_package_name_from_lookup(
1313    lookup: &PackageNameLookup,
1314    content: &str,
1315) -> Result<Option<String>, String> {
1316    let key_parts: Vec<&str> = lookup
1317        .key
1318        .split('.')
1319        .map(|part| part.trim())
1320        .filter(|part| !part.is_empty())
1321        .collect();
1322    if key_parts.is_empty() {
1323        return Err("Lookup key cannot be empty".to_string());
1324    }
1325
1326    let format: String = lookup.format.trim().to_ascii_lowercase();
1327    match format.as_str() {
1328        "json" => {
1329            let value: JsonValue = serde_json::from_str(content)
1330                .map_err(|e| format!("Failed to parse JSON: {}", e))?;
1331            Ok(json_lookup_string(&value, &key_parts))
1332        }
1333        "yaml" | "yml" => {
1334            let value: YamlValue = serde_yaml::from_str(content)
1335                .map_err(|e| format!("Failed to parse YAML: {}", e))?;
1336            Ok(yaml_lookup_string(&value, &key_parts))
1337        }
1338        "toml" => {
1339            let value: TomlValue =
1340                toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1341            Ok(toml_lookup_string(&value, &key_parts))
1342        }
1343        other => Err(format!("Unsupported lookup format `{}`", other)),
1344    }
1345}
1346
1347async fn fetch_registry_latest_version(
1348    client: &reqwest::Client,
1349    registry: &str,
1350    package_name: &str,
1351) -> Result<String, String> {
1352    let normalized_registry: String = registry.trim().to_ascii_lowercase();
1353    match normalized_registry.as_str() {
1354        "npm" => fetch_npm_latest_version(client, package_name).await,
1355        "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
1356        _ => Err(format!("Unsupported registry `{}`", registry)),
1357    }
1358}
1359
1360#[derive(Debug, Deserialize)]
1361struct NpmLatestResponse {
1362    version: String,
1363}
1364
1365async fn fetch_npm_latest_version(
1366    client: &reqwest::Client,
1367    package_name: &str,
1368) -> Result<String, String> {
1369    let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
1370        .map_err(|e| format!("Failed to build npm URL: {}", e))?;
1371    {
1372        let mut segments = url
1373            .path_segments_mut()
1374            .map_err(|_| "Failed to compose npm URL segments".to_string())?;
1375        segments.push(package_name);
1376        segments.push("latest");
1377    }
1378
1379    let response: reqwest::Response = client
1380        .get(url)
1381        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1382        .send()
1383        .await
1384        .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
1385
1386    if !response.status().is_success() {
1387        return Err(format!(
1388            "npm lookup for {} returned status {}",
1389            package_name,
1390            response.status()
1391        ));
1392    }
1393
1394    let payload: NpmLatestResponse = response
1395        .json()
1396        .await
1397        .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
1398    Ok(payload.version)
1399}
1400
1401#[derive(Debug, Deserialize)]
1402struct CratesIoResponse {
1403    #[serde(rename = "crate")]
1404    crate_meta: CratesIoMeta,
1405}
1406
1407#[derive(Debug, Deserialize)]
1408struct CratesIoMeta {
1409    newest_version: String,
1410}
1411
1412async fn fetch_crates_latest_version(
1413    client: &reqwest::Client,
1414    package_name: &str,
1415) -> Result<String, String> {
1416    let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
1417        .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
1418    {
1419        let mut segments = url
1420            .path_segments_mut()
1421            .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
1422        segments.push(package_name);
1423    }
1424
1425    let response: reqwest::Response = client
1426        .get(url)
1427        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
1428        .send()
1429        .await
1430        .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
1431
1432    if !response.status().is_success() {
1433        return Err(format!(
1434            "crates.io lookup for {} returned status {}",
1435            package_name,
1436            response.status()
1437        ));
1438    }
1439
1440    let payload: CratesIoResponse = response.json().await.map_err(|e| {
1441        format!(
1442            "Failed to parse crates.io response for {}: {}",
1443            package_name, e
1444        )
1445    })?;
1446    Ok(payload.crate_meta.newest_version)
1447}
1448
1449fn collect_local_versions(
1450    project_root: &Path,
1451    invocation_dir: &Path,
1452    registry: &[String],
1453    version_scope: &VersionScope,
1454    warnings: &mut Vec<String>,
1455) -> Vec<VersionObservation> {
1456    let mut observed = Vec::new();
1457
1458    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1459        let path: &PathBuf = &entry.absolute;
1460        if !path.exists() {
1461            continue;
1462        }
1463
1464        match read_version_from_resolved_path(&entry) {
1465            Ok(Some(version)) => {
1466                if let Ok(parsed) = parse_version(&version) {
1467                    observed.push(VersionObservation {
1468                        location: entry.relative.clone(),
1469                        version: parsed,
1470                    });
1471                } else {
1472                    warnings.push(format!("Ignoring non-semver version in {}", path.display()));
1473                }
1474            }
1475            Ok(None) => {}
1476            Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
1477        }
1478    }
1479
1480    observed.sort_by(|a, b| a.location.cmp(&b.location));
1481    observed
1482}
1483
1484fn collect_git_versions(
1485    project_root: &Path,
1486    version_scope: &VersionScope,
1487) -> Result<Vec<GitTagObservation>, String> {
1488    if !command_exists("git") {
1489        return Err("Git is not installed; skipping git tag inspection.".to_string());
1490    }
1491
1492    let output: std::process::Output = Command::new("git")
1493        .current_dir(project_root)
1494        .args(["tag", "--list"])
1495        .output()
1496        .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
1497
1498    if !output.status.success() {
1499        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1500        if stderr.is_empty() {
1501            return Err("`git tag --list` failed in the current directory.".to_string());
1502        }
1503        return Err(format!("`git tag --list` failed: {}", stderr));
1504    }
1505
1506    Ok(parse_local_git_tag_output_for_scope(
1507        &String::from_utf8_lossy(&output.stdout),
1508        version_scope,
1509    ))
1510}
1511
1512fn collect_remote_git_versions(
1513    project_root: &Path,
1514    remote: &str,
1515    version_scope: &VersionScope,
1516) -> Result<Vec<GitTagObservation>, String> {
1517    if !command_exists("git") {
1518        return Err("Git is not installed; skipping remote tag inspection.".to_string());
1519    }
1520
1521    let output: std::process::Output = Command::new("git")
1522        .current_dir(project_root)
1523        .args(["ls-remote", "--tags", remote])
1524        .output()
1525        .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
1526
1527    if !output.status.success() {
1528        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1529        if stderr.is_empty() {
1530            return Err(format!("`git ls-remote --tags {}` failed.", remote));
1531        }
1532        return Err(format!(
1533            "`git ls-remote --tags {}` failed: {}",
1534            remote, stderr
1535        ));
1536    }
1537
1538    Ok(parse_remote_git_tag_output_for_scope(
1539        &String::from_utf8_lossy(&output.stdout),
1540        version_scope,
1541    ))
1542}
1543
1544fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
1545    let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
1546
1547    if tags.is_empty() {
1548        println!("No semantic git tags found in {}.", project_root.display());
1549        return Ok(());
1550    }
1551
1552    println!("Git versions from `git tag --list`:");
1553    for tag in tags {
1554        if tag.raw_tags.len() > 1 {
1555            println!("  {}  ({})", tag.version, tag.raw_tags.join(", "));
1556        } else {
1557            println!("  {}", tag.version);
1558        }
1559    }
1560
1561    Ok(())
1562}
1563
1564fn print_version_observations(
1565    title: &str,
1566    entries: &[VersionObservation],
1567    dirty_files: Option<&[String]>,
1568) {
1569    println!();
1570    println!("{}", title.bright_cyan().bold());
1571    println!("{}", "─".repeat(72).bright_black());
1572
1573    if entries.is_empty() {
1574        println!("  {}", "none found".dimmed());
1575        return;
1576    }
1577
1578    let Some(highest) = highest_version_observation(entries) else {
1579        println!("  {}", "none found".dimmed());
1580        return;
1581    };
1582
1583    let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
1584    let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
1585    println!(
1586        "  {:<28} {} ({}/{})",
1587        "latest".bright_white(),
1588        highest.to_string().bright_green().bold(),
1589        latest_count,
1590        entries.len()
1591    );
1592
1593    if stale_entries.is_empty() {
1594        return;
1595    }
1596
1597    println!("  {}", "stale entries".bright_yellow().bold());
1598    for entry in stale_entries {
1599        let dirty: bool = dirty_files
1600            .map(|files| files.iter().any(|file| file == &entry.location))
1601            .unwrap_or(false);
1602        let dirty_suffix: String = if dirty {
1603            format!(" {}", "modified".bright_magenta())
1604        } else {
1605            String::new()
1606        };
1607
1608        println!(
1609            "  {:<28} {}{}",
1610            entry.location.bright_white(),
1611            entry.version.to_string().bright_green(),
1612            dirty_suffix
1613        );
1614    }
1615}
1616
1617fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
1618    println!();
1619    println!("{}", title.bright_cyan().bold());
1620    println!("{}", "─".repeat(72).bright_black());
1621
1622    if tags.is_empty() {
1623        println!("  {}", "none found".dimmed());
1624        return;
1625    }
1626
1627    let latest = &tags[0];
1628    if latest.raw_tags.len() > 1 {
1629        println!(
1630            "  {:<20} {}",
1631            latest.version.to_string().bright_green().bold(),
1632            latest.raw_tags.join(", ").dimmed()
1633        );
1634    } else {
1635        println!("  {}", latest.version.to_string().bright_green().bold());
1636    }
1637
1638    if tags.len() > 1 {
1639        println!(
1640            "  {:<20} {}",
1641            "older tags".bright_white(),
1642            format!("{} hidden", tags.len() - 1).dimmed()
1643        );
1644    }
1645}
1646
1647fn collect_head_versions(
1648    project_root: &Path,
1649    invocation_dir: &Path,
1650    registry: &[String],
1651    version_scope: &VersionScope,
1652) -> Result<Vec<VersionObservation>, String> {
1653    if !command_exists("git") {
1654        return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
1655    }
1656
1657    let head_check = Command::new("git")
1658        .current_dir(project_root)
1659        .args(["rev-parse", "--verify", "HEAD"])
1660        .output()
1661        .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
1662
1663    if !head_check.status.success() {
1664        return Ok(Vec::new());
1665    }
1666
1667    let mut observed = Vec::new();
1668    let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
1669
1670    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1671        let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
1672            continue;
1673        };
1674
1675        match read_version_from_blob_with_override(
1676            &entry.relative,
1677            &content,
1678            cargo_toml_content.as_deref(),
1679            entry.cargo_package_override.as_deref(),
1680        ) {
1681            Ok(Some(version)) => {
1682                if let Ok(parsed) = parse_version(&version) {
1683                    observed.push(VersionObservation {
1684                        location: entry.relative.clone(),
1685                        version: parsed,
1686                    });
1687                }
1688            }
1689            Ok(None) => {}
1690            Err(_) => {}
1691        }
1692    }
1693
1694    observed.sort_by(|a, b| a.location.cmp(&b.location));
1695    Ok(observed)
1696}
1697
1698fn collect_dirty_version_files(
1699    project_root: &Path,
1700    invocation_dir: &Path,
1701    registry: &[String],
1702    version_scope: &VersionScope,
1703) -> Result<Vec<String>, String> {
1704    if !command_exists("git") {
1705        return Err("Git is not installed; skipping worktree status inspection.".to_string());
1706    }
1707
1708    let mut args = vec!["status", "--porcelain", "--"];
1709    let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1710    for entry in &resolved {
1711        args.push(entry.relative.as_str());
1712    }
1713
1714    let output = Command::new("git")
1715        .current_dir(project_root)
1716        .args(&args)
1717        .output()
1718        .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
1719
1720    if !output.status.success() {
1721        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1722        if stderr.is_empty() {
1723            return Err("`git status --porcelain` failed.".to_string());
1724        }
1725        return Err(format!("`git status --porcelain` failed: {}", stderr));
1726    }
1727
1728    let mut dirty = Vec::new();
1729    for line in String::from_utf8_lossy(&output.stdout).lines() {
1730        if line.len() < 4 {
1731            continue;
1732        }
1733        let path = line[3..].trim();
1734        if !path.is_empty() {
1735            dirty.push(path.replace('\\', "/"));
1736        }
1737    }
1738
1739    dirty.sort();
1740    dirty.dedup();
1741    Ok(dirty)
1742}
1743
1744fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
1745    let output = Command::new("git")
1746        .current_dir(project_root)
1747        .args(["show", &format!("HEAD:{}", relative)])
1748        .output()
1749        .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
1750
1751    if !output.status.success() {
1752        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1753        if stderr.is_empty() {
1754            return Err(format!("{} is not present in HEAD", relative));
1755        }
1756        return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
1757    }
1758
1759    String::from_utf8(output.stdout)
1760        .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
1761}
1762
1763fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1764    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1765    for line in output.lines() {
1766        let tag = line.trim();
1767        if tag.is_empty() {
1768            continue;
1769        }
1770        if let Ok(version) = parse_version(tag) {
1771            by_version.entry(version).or_default().push(tag.to_string());
1772        }
1773    }
1774    git_tag_map_to_vec(by_version)
1775}
1776
1777fn parse_local_git_tag_output_for_scope(
1778    output: &str,
1779    version_scope: &VersionScope,
1780) -> Vec<GitTagObservation> {
1781    match version_scope {
1782        VersionScope::Repository => parse_local_git_tag_output(output),
1783        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
1784            parse_scoped_git_tag_output(output, tag_prefix)
1785        }
1786    }
1787}
1788
1789fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1790    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1791    for line in output.lines() {
1792        let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1793        let tag = reference
1794            .strip_prefix("refs/tags/")
1795            .unwrap_or(reference)
1796            .trim_end_matches("^{}")
1797            .trim();
1798
1799        if tag.is_empty() {
1800            continue;
1801        }
1802        if let Ok(version) = parse_version(tag) {
1803            by_version.entry(version).or_default().push(tag.to_string());
1804        }
1805    }
1806    git_tag_map_to_vec(by_version)
1807}
1808
1809fn parse_remote_git_tag_output_for_scope(
1810    output: &str,
1811    version_scope: &VersionScope,
1812) -> Vec<GitTagObservation> {
1813    match version_scope {
1814        VersionScope::Repository => parse_remote_git_tag_output(output),
1815        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
1816            let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1817            for line in output.lines() {
1818                let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1819                let tag = reference
1820                    .strip_prefix("refs/tags/")
1821                    .unwrap_or(reference)
1822                    .trim_end_matches("^{}")
1823                    .trim();
1824
1825                if tag.is_empty() {
1826                    continue;
1827                }
1828                if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1829                    by_version.entry(version).or_default().push(tag.to_string());
1830                }
1831            }
1832            git_tag_map_to_vec(by_version)
1833        }
1834    }
1835}
1836
1837fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
1838    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1839    for line in output.lines() {
1840        let tag = line.trim();
1841        if tag.is_empty() {
1842            continue;
1843        }
1844        if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1845            by_version.entry(version).or_default().push(tag.to_string());
1846        }
1847    }
1848    git_tag_map_to_vec(by_version)
1849}
1850
1851fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
1852    let mut versions: Vec<GitTagObservation> = by_version
1853        .into_iter()
1854        .map(|(version, mut raw_tags)| {
1855            raw_tags.sort();
1856            raw_tags.dedup();
1857            GitTagObservation { version, raw_tags }
1858        })
1859        .collect();
1860    versions.sort_by(|a, b| b.version.cmp(&a.version));
1861    versions
1862}
1863
1864#[cfg(test)]
1865fn read_version_from_blob(
1866    relative: &str,
1867    content: &str,
1868    cargo_toml_content: Option<&str>,
1869) -> Result<Option<String>, String> {
1870    read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
1871}
1872
1873fn read_version_from_blob_with_override(
1874    relative: &str,
1875    content: &str,
1876    cargo_toml_content: Option<&str>,
1877    cargo_package_override: Option<&str>,
1878) -> Result<Option<String>, String> {
1879    let file_name = Path::new(relative)
1880        .file_name()
1881        .and_then(|n| n.to_str())
1882        .unwrap_or_default();
1883
1884    match file_name {
1885        "README.md" => read_readme_version_from_content(content),
1886        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1887            read_openapi_version_from_content(content)
1888        }
1889        "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
1890        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1891        | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
1892        "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
1893        "Cargo.toml" => read_cargo_toml_version_from_content(content),
1894        "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
1895            content,
1896            cargo_toml_content,
1897            cargo_package_override,
1898        ),
1899        "pyproject.toml" => read_pyproject_version_from_content(content),
1900        "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
1901        "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
1902        "pom.xml" => {
1903            read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
1904        }
1905        "build.gradle" | "build.gradle.kts" => {
1906            read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1907        }
1908        "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
1909        _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
1910            Some("json") => read_json_root_version_from_content(content),
1911            Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
1912            Some("toml") => read_toml_root_version_from_content(content),
1913            Some("md") => read_readme_version_from_content(content),
1914            _ => Ok(None),
1915        },
1916    }
1917}
1918
1919fn print_version_report(project_root: &Path, report: &VersionReport) {
1920    let dirty_suffix = if report.dirty_files.is_empty() {
1921        "clean".green().to_string()
1922    } else {
1923        format!("dirty ({})", report.dirty_files.len())
1924            .bright_magenta()
1925            .to_string()
1926    };
1927
1928    println!(
1929        "\n{} {}",
1930        "Version Summary".bright_cyan().bold(),
1931        project_root.display().to_string().bright_white()
1932    );
1933    println!("{}", "─".repeat(72).bright_black());
1934    println!(
1935        "{:<20} {}",
1936        "Highest available".bright_white(),
1937        report.highest_available().to_string().bright_green().bold()
1938    );
1939    println!(
1940        "{:<20} {}",
1941        "Worktree".bright_white(),
1942        report
1943            .highest_worktree()
1944            .unwrap_or_else(default_version)
1945            .to_string()
1946            .bright_yellow()
1947    );
1948    println!(
1949        "{:<20} {}",
1950        "Committed HEAD".bright_white(),
1951        report
1952            .highest_head()
1953            .map(|v| v.to_string())
1954            .unwrap_or_else(|| "none".dimmed().to_string())
1955    );
1956    println!(
1957        "{:<20} {}",
1958        "GitHub tags".bright_white(),
1959        report
1960            .highest_remote_tag()
1961            .map(|v| v.to_string())
1962            .unwrap_or_else(|| "none".dimmed().to_string())
1963    );
1964    println!(
1965        "{:<20} {}",
1966        "Registry latest".bright_white(),
1967        report
1968            .highest_registry()
1969            .map(|v| v.to_string())
1970            .unwrap_or_else(|| "none".dimmed().to_string())
1971    );
1972    println!(
1973        "{:<20} {}",
1974        "Local tags".bright_white(),
1975        report
1976            .highest_local_tag()
1977            .map(|v| v.to_string())
1978            .unwrap_or_else(|| "none".dimmed().to_string())
1979    );
1980    println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
1981
1982    if let Some(review) = summarize_version_dirty_files(project_root, &report.dirty_files) {
1983        println!(
1984            "{:<20} {}",
1985            "Suggested scope".bright_white(),
1986            review.scope_label.bright_cyan()
1987        );
1988        if !review.groups.is_empty() {
1989            println!("{:<20} {}", "Why".bright_white(), review.summary);
1990            for group in review.groups {
1991                println!("{:<20} {}", "", group);
1992            }
1993        }
1994    }
1995
1996    print_version_observations(
1997        "Worktree version files",
1998        &report.worktree,
1999        Some(&report.dirty_files),
2000    );
2001    print_version_observations("Committed HEAD version files", &report.head, None);
2002    print_registry_observations("Published package versions", &report.registry_versions);
2003    print_git_tag_observations("GitHub tags", &report.remote_tags);
2004    print_git_tag_observations("Local git tags", &report.local_tags);
2005
2006    let divergent = report.divergent_versions();
2007    let highest = report.highest_available();
2008    let outdated: Vec<_> = divergent
2009        .into_iter()
2010        .filter(|version| version != &highest)
2011        .collect();
2012    println!();
2013    println!("{}", "Divergence".bright_cyan().bold());
2014    println!("{}", "─".repeat(72).bright_black());
2015    println!(
2016        "  {:<20} {}",
2017        "latest target".bright_white(),
2018        highest.to_string().bright_green().bold()
2019    );
2020    if !outdated.is_empty() {
2021        for version in outdated {
2022            println!(
2023                "  {} {}",
2024                "•".bright_yellow(),
2025                version.to_string().bright_yellow()
2026            );
2027        }
2028        println!();
2029        println!(
2030            "{} {}",
2031            "Fix local files with".bright_white(),
2032            format!("xbp version {}", highest).black().on_bright_green()
2033        );
2034    } else {
2035        println!("  {}", "all relevant sources are aligned".green());
2036    }
2037
2038    if !report.warnings.is_empty() {
2039        println!();
2040        println!("{}", "Warnings".bright_yellow().bold());
2041        println!("{}", "─".repeat(72).bright_black());
2042        for warning in &report.warnings {
2043            println!("  {} {}", "!".bright_yellow(), warning);
2044        }
2045    }
2046}
2047
2048struct VersionDirtyFileReview {
2049    scope_label: String,
2050    summary: String,
2051    groups: Vec<String>,
2052}
2053
2054fn summarize_version_dirty_files(
2055    project_root: &Path,
2056    dirty_files: &[String],
2057) -> Option<VersionDirtyFileReview> {
2058    if dirty_files.is_empty() {
2059        return None;
2060    }
2061
2062    let mut crate_names = BTreeSet::new();
2063    let mut service_names = BTreeSet::new();
2064    let mut touches_workspace_files = false;
2065    let mut workspace_files = Vec::new();
2066    let mut crate_files = Vec::new();
2067    let mut service_files = Vec::new();
2068    let mut other_files = Vec::new();
2069
2070    for file in dirty_files {
2071        let path = Path::new(file);
2072        if path == Path::new("Cargo.toml")
2073            || path == Path::new("Cargo.lock")
2074            || path == Path::new("README.md")
2075            || path == Path::new("CHANGELOG.md")
2076            || path == Path::new("SECURITY.md")
2077        {
2078            touches_workspace_files = true;
2079            workspace_files.push(file.clone());
2080            continue;
2081        }
2082
2083        if let Some((crate_name, service_name)) = infer_version_scope_name(project_root, path) {
2084            if let Some(crate_name) = crate_name {
2085                crate_names.insert(crate_name);
2086                crate_files.push(file.clone());
2087            }
2088            if let Some(service_name) = service_name {
2089                service_names.insert(service_name);
2090                service_files.push(file.clone());
2091            }
2092        } else {
2093            other_files.push(file.clone());
2094        }
2095    }
2096
2097    let scope_label = if !crate_names.is_empty() && service_names.is_empty() {
2098        if crate_names.len() == 1 {
2099            format!("crate `{}`", crate_names.iter().next().unwrap())
2100        } else {
2101            format!("{} crates", crate_names.len())
2102        }
2103    } else if !service_names.is_empty() && crate_names.is_empty() {
2104        if service_names.len() == 1 {
2105            format!("service `{}`", service_names.iter().next().unwrap())
2106        } else {
2107            format!("{} services", service_names.len())
2108        }
2109    } else if touches_workspace_files {
2110        "repository / main crates".to_string()
2111    } else {
2112        "repository".to_string()
2113    };
2114
2115    let summary = if dirty_files.len() <= 8 {
2116        format!("{} file(s): {}", dirty_files.len(), dirty_files.join(", "))
2117    } else {
2118        format!(
2119            "{} file(s): {} ...",
2120            dirty_files.len(),
2121            dirty_files
2122                .iter()
2123                .take(8)
2124                .cloned()
2125                .collect::<Vec<_>>()
2126                .join(", ")
2127        )
2128    };
2129
2130    let mut groups = Vec::new();
2131    if !workspace_files.is_empty() {
2132        groups.push(format!(
2133            "Workspace files: {}",
2134            summarize_file_group(&workspace_files)
2135        ));
2136    }
2137    if !crate_files.is_empty() {
2138        groups.push(format!(
2139            "Crate files: {}",
2140            summarize_file_group(&crate_files)
2141        ));
2142    }
2143    if !service_files.is_empty() {
2144        groups.push(format!(
2145            "Service files: {}",
2146            summarize_file_group(&service_files)
2147        ));
2148    }
2149    if !other_files.is_empty() {
2150        groups.push(format!(
2151            "Other files: {}",
2152            summarize_file_group(&other_files)
2153        ));
2154    }
2155
2156    Some(VersionDirtyFileReview {
2157        scope_label,
2158        summary,
2159        groups,
2160    })
2161}
2162
2163fn summarize_file_group(files: &[String]) -> String {
2164    if files.is_empty() {
2165        return String::new();
2166    }
2167
2168    if files.len() <= 4 {
2169        return files.join(", ");
2170    }
2171
2172    format!(
2173        "{} (and {} more)",
2174        files.iter().take(4).cloned().collect::<Vec<_>>().join(", "),
2175        files.len() - 4
2176    )
2177}
2178
2179fn infer_version_scope_name(
2180    project_root: &Path,
2181    path: &Path,
2182) -> Option<(Option<String>, Option<String>)> {
2183    let absolute = if path.is_absolute() {
2184        path.to_path_buf()
2185    } else {
2186        project_root.join(path)
2187    };
2188
2189    let relative = absolute.strip_prefix(project_root).ok()?;
2190    let mut components = relative.components();
2191    let first = components.next()?.as_os_str().to_string_lossy().to_string();
2192    let second = components
2193        .next()
2194        .map(|value| value.as_os_str().to_string_lossy().to_string());
2195
2196    if first == "crates" {
2197        return second.map(|crate_name| (Some(crate_name), None));
2198    }
2199
2200    if first == "apps" {
2201        return second.map(|service_name| (None, Some(service_name)));
2202    }
2203
2204    None
2205}
2206
2207fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
2208    entries.iter().map(|entry| entry.version.clone()).max()
2209}
2210
2211fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
2212    let Some(highest) = highest_version_observation(entries) else {
2213        return Vec::new();
2214    };
2215
2216    entries
2217        .iter()
2218        .filter(|entry| entry.version < highest)
2219        .collect()
2220}
2221
2222fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
2223    println!();
2224    println!("{}", title.bright_cyan().bold());
2225    println!("{}", "─".repeat(72).bright_black());
2226
2227    if entries.is_empty() {
2228        println!("  {}", "none found".dimmed());
2229        return;
2230    }
2231
2232    for entry in entries {
2233        let latest_display = match (&entry.latest, &entry.raw_version) {
2234            (Some(version), _) => version.to_string().bright_green().to_string(),
2235            (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
2236            (None, None) => "unavailable".dimmed().to_string(),
2237        };
2238
2239        let note = entry
2240            .note
2241            .as_ref()
2242            .map(|value| format!(" {}", value.bright_yellow()))
2243            .unwrap_or_default();
2244
2245        println!(
2246            "  {:<9} {:<28} {:<16} {}{}",
2247            entry.registry.bright_white(),
2248            entry.package_name.bright_white(),
2249            latest_display,
2250            entry.source_file.dimmed(),
2251            note
2252        );
2253    }
2254}
2255
2256#[cfg(test)]
2257fn write_version_to_configured_files(
2258    project_root: &Path,
2259    invocation_dir: &Path,
2260    registry: &[String],
2261    version_scope: &VersionScope,
2262    version: &Version,
2263) -> Result<usize, String> {
2264    write_version_to_configured_files_with_paths(
2265        project_root,
2266        invocation_dir,
2267        registry,
2268        version_scope,
2269        version,
2270    )
2271    .map(|paths| paths.len())
2272}
2273
2274fn write_version_to_configured_files_with_paths(
2275    project_root: &Path,
2276    invocation_dir: &Path,
2277    registry: &[String],
2278    version_scope: &VersionScope,
2279    version: &Version,
2280) -> Result<Vec<PathBuf>, String> {
2281    write_version_to_configured_files_with_paths_internal(
2282        project_root,
2283        invocation_dir,
2284        registry,
2285        version_scope,
2286        version,
2287        false,
2288    )
2289}
2290
2291fn sync_version_to_configured_files_with_paths(
2292    project_root: &Path,
2293    invocation_dir: &Path,
2294    registry: &[String],
2295    version_scope: &VersionScope,
2296    version: &Version,
2297) -> Result<Vec<PathBuf>, String> {
2298    write_version_to_configured_files_with_paths_internal(
2299        project_root,
2300        invocation_dir,
2301        registry,
2302        version_scope,
2303        version,
2304        true,
2305    )
2306}
2307
2308fn write_version_to_configured_files_with_paths_internal(
2309    project_root: &Path,
2310    invocation_dir: &Path,
2311    registry: &[String],
2312    version_scope: &VersionScope,
2313    version: &Version,
2314    allow_noop_when_targets_exist: bool,
2315) -> Result<Vec<PathBuf>, String> {
2316    let mut updated = 0usize;
2317    let mut matched_targets = 0usize;
2318    let mut updated_paths = Vec::new();
2319    let mut errors = Vec::new();
2320
2321    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2322        let path = &entry.absolute;
2323        if !path.exists() {
2324            continue;
2325        }
2326        matched_targets += 1;
2327
2328        match write_version_to_resolved_path(&entry, version) {
2329            Ok(true) => {
2330                updated += 1;
2331                updated_paths.push(path.clone());
2332            }
2333            Ok(false) => {}
2334            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
2335        }
2336    }
2337
2338    if matched_targets == 0 && errors.is_empty() {
2339        return Err("No configured version files were found to update.".to_string());
2340    }
2341
2342    if !errors.is_empty() {
2343        return Err(format!(
2344            "Updated {} file(s), but some version targets failed:\n{}",
2345            updated,
2346            errors.join("\n")
2347        ));
2348    }
2349
2350    if updated == 0 && allow_noop_when_targets_exist {
2351        return Ok(updated_paths);
2352    }
2353
2354    Ok(updated_paths)
2355}
2356
2357fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
2358    let file_name = path
2359        .file_name()
2360        .and_then(|n| n.to_str())
2361        .unwrap_or_default();
2362
2363    match file_name {
2364        "README.md" => read_readme_version(path),
2365        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2366            read_openapi_version(path)
2367        }
2368        "openapi.json" | "swagger.json" => read_json_openapi_version(path),
2369        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2370        | "xbp.json" => read_json_root_version(path),
2371        "deno.json" => read_json_root_version(path),
2372        "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
2373        "Cargo.toml" => read_cargo_toml_version(path),
2374        "Cargo.lock" => read_cargo_lock_version(path),
2375        "pyproject.toml" => read_pyproject_version(path),
2376        "Chart.yaml" => read_yaml_root_version(path, "version"),
2377        "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
2378        "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
2379        "build.gradle" | "build.gradle.kts" => {
2380            read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
2381        }
2382        "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
2383        _ => match path.extension().and_then(|ext| ext.to_str()) {
2384            Some("json") => read_json_root_version(path),
2385            Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
2386            Some("toml") => read_toml_root_version(path),
2387            Some("md") => read_readme_version(path),
2388            _ => Ok(None),
2389        },
2390    }
2391}
2392
2393fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
2394    let path = &entry.absolute;
2395    let file_name = path
2396        .file_name()
2397        .and_then(|n| n.to_str())
2398        .unwrap_or_default();
2399
2400    if file_name == "Cargo.lock" {
2401        if let Some(package_name) = entry.cargo_package_override.as_deref() {
2402            return read_cargo_lock_version_for_package(path, package_name);
2403        }
2404    }
2405
2406    read_version_from_path(path)
2407}
2408
2409fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
2410    let file_name = path
2411        .file_name()
2412        .and_then(|n| n.to_str())
2413        .unwrap_or_default();
2414
2415    match file_name {
2416        "README.md" => write_readme_version(path, version).map(|_| true),
2417        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
2418            write_openapi_version(path, version).map(|_| true)
2419        }
2420        "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
2421        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
2422        | "xbp.json" => write_json_root_version(path, version).map(|_| true),
2423        "deno.json" => write_json_root_version(path, version).map(|_| true),
2424        "deno.jsonc" => {
2425            write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
2426        }
2427        "Cargo.toml" => write_cargo_toml_version(path, version),
2428        "Cargo.lock" => write_cargo_lock_version(path, version),
2429        "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
2430        "Chart.yaml" => write_chart_version(path, version).map(|_| true),
2431        "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
2432        "pom.xml" => {
2433            write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
2434        }
2435        "build.gradle" | "build.gradle.kts" => {
2436            write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
2437                .map(|_| true)
2438        }
2439        "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
2440        _ => match path.extension().and_then(|ext| ext.to_str()) {
2441            Some("json") => write_json_root_version(path, version).map(|_| true),
2442            Some("yaml") | Some("yml") => {
2443                write_yaml_root_version(path, "version", version).map(|_| true)
2444            }
2445            Some("toml") => write_toml_root_version(path, version).map(|_| true),
2446            Some("md") => write_readme_version(path, version).map(|_| true),
2447            _ => Err("Unsupported version file type".to_string()),
2448        },
2449    }
2450}
2451
2452fn write_version_to_resolved_path(
2453    entry: &ResolvedRegistryPath,
2454    version: &Version,
2455) -> Result<bool, String> {
2456    let path = &entry.absolute;
2457    let file_name = path
2458        .file_name()
2459        .and_then(|n| n.to_str())
2460        .unwrap_or_default();
2461
2462    if file_name == "Cargo.lock" {
2463        if let Some(package_name) = entry.cargo_package_override.as_deref() {
2464            return write_cargo_lock_version_for_package(path, Some(package_name), version);
2465        }
2466    }
2467
2468    write_version_to_path(path, version)
2469}
2470
2471fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
2472    let value: JsonValue =
2473        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2474    Ok(value
2475        .get("version")
2476        .and_then(JsonValue::as_str)
2477        .map(|value| value.to_string()))
2478}
2479
2480fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
2481    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2482    read_json_root_version_from_content(&content)
2483}
2484
2485fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
2486    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2487    let mut value: JsonValue =
2488        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2489
2490    let object = value
2491        .as_object_mut()
2492        .ok_or_else(|| "Expected a JSON object".to_string())?;
2493    object.insert(
2494        "version".to_string(),
2495        JsonValue::String(version.to_string()),
2496    );
2497
2498    fs::write(
2499        path,
2500        serde_json::to_string_pretty(&value)
2501            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
2502    )
2503    .map_err(|e| format!("Failed to write file: {}", e))
2504}
2505
2506fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
2507    let value: YamlValue =
2508        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2509    Ok(yaml_get_string(&value, key))
2510}
2511
2512fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
2513    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2514    read_yaml_root_version_from_content(&content, key)
2515}
2516
2517fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
2518    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2519    let mut value: YamlValue =
2520        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2521
2522    let mapping = yaml_root_mapping_mut(&mut value)?;
2523    mapping.insert(
2524        YamlValue::String(key.to_string()),
2525        YamlValue::String(version.to_string()),
2526    );
2527
2528    fs::write(
2529        path,
2530        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2531    )
2532    .map_err(|e| format!("Failed to write file: {}", e))
2533}
2534
2535fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
2536    let value: YamlValue =
2537        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2538
2539    let info = yaml_get_mapping(&value, "info");
2540    Ok(info.and_then(|mapping| {
2541        mapping
2542            .get(YamlValue::String("version".to_string()))
2543            .and_then(YamlValue::as_str)
2544            .map(|value| value.to_string())
2545    }))
2546}
2547
2548fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
2549    let content: String =
2550        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2551    read_openapi_version_from_content(&content)
2552}
2553
2554fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
2555    let value: JsonValue =
2556        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2557    Ok(value
2558        .get("info")
2559        .and_then(JsonValue::as_object)
2560        .and_then(|info| info.get("version"))
2561        .and_then(JsonValue::as_str)
2562        .map(|value| value.to_string()))
2563}
2564
2565fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
2566    let content: String =
2567        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2568    read_json_openapi_version_from_content(&content)
2569}
2570
2571fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2572    let content: String =
2573        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2574    let mut value: YamlValue =
2575        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2576
2577    let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2578    let info_key: YamlValue = YamlValue::String("info".to_string());
2579    if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
2580        root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
2581    }
2582
2583    let info: &mut YamlMapping = root
2584        .get_mut(&info_key)
2585        .and_then(YamlValue::as_mapping_mut)
2586        .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
2587    info.insert(
2588        YamlValue::String("version".to_string()),
2589        YamlValue::String(version.to_string()),
2590    );
2591
2592    fs::write(
2593        path,
2594        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2595    )
2596    .map_err(|e| format!("Failed to write file: {}", e))
2597}
2598
2599fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
2600    let content: String =
2601        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2602    let mut value: JsonValue =
2603        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
2604
2605    let root = value
2606        .as_object_mut()
2607        .ok_or_else(|| "Expected a JSON object".to_string())?;
2608    let info = root
2609        .entry("info".to_string())
2610        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
2611    let info_object = info
2612        .as_object_mut()
2613        .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
2614    info_object.insert(
2615        "version".to_string(),
2616        JsonValue::String(version.to_string()),
2617    );
2618
2619    fs::write(
2620        path,
2621        serde_json::to_string_pretty(&value)
2622            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
2623    )
2624    .map_err(|e| format!("Failed to write file: {}", e))
2625}
2626
2627fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
2628    let value: TomlValue =
2629        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2630    Ok(value
2631        .get("version")
2632        .and_then(TomlValue::as_str)
2633        .map(|value| value.to_string()))
2634}
2635
2636fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
2637    let content: String =
2638        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2639    read_toml_root_version_from_content(&content)
2640}
2641
2642fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
2643    let content: String =
2644        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2645    let mut value: TomlValue =
2646        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2647    let table = value
2648        .as_table_mut()
2649        .ok_or_else(|| "Expected a TOML table".to_string())?;
2650    table.insert(
2651        "version".to_string(),
2652        TomlValue::String(version.to_string()),
2653    );
2654    fs::write(
2655        path,
2656        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2657    )
2658    .map_err(|e| format!("Failed to write file: {}", e))
2659}
2660
2661fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
2662    crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
2663}
2664
2665fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
2666    resolve_cargo_package_version(path)
2667}
2668
2669fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
2670    write_cargo_package_version(path, version)
2671}
2672
2673fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
2674    let value: TomlValue =
2675        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2676
2677    let project_version = value
2678        .get("project")
2679        .and_then(TomlValue::as_table)
2680        .and_then(|project| project.get("version"))
2681        .and_then(TomlValue::as_str);
2682
2683    let poetry_version = value
2684        .get("tool")
2685        .and_then(TomlValue::as_table)
2686        .and_then(|tool| tool.get("poetry"))
2687        .and_then(TomlValue::as_table)
2688        .and_then(|poetry| poetry.get("version"))
2689        .and_then(TomlValue::as_str);
2690
2691    Ok(project_version
2692        .or(poetry_version)
2693        .map(|value| value.to_string()))
2694}
2695
2696fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
2697    let content: String =
2698        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2699    read_pyproject_version_from_content(&content)
2700}
2701
2702fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
2703    let content: String =
2704        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2705    let mut value: TomlValue =
2706        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2707
2708    if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
2709        project.insert(
2710            "version".to_string(),
2711            TomlValue::String(version.to_string()),
2712        );
2713    } else if let Some(poetry) = value
2714        .get_mut("tool")
2715        .and_then(TomlValue::as_table_mut)
2716        .and_then(|tool| tool.get_mut("poetry"))
2717        .and_then(TomlValue::as_table_mut)
2718    {
2719        poetry.insert(
2720            "version".to_string(),
2721            TomlValue::String(version.to_string()),
2722        );
2723    } else {
2724        let table: &mut toml::map::Map<String, TomlValue> = value
2725            .as_table_mut()
2726            .ok_or_else(|| "Expected a TOML table".to_string())?;
2727        table.insert(
2728            "version".to_string(),
2729            TomlValue::String(version.to_string()),
2730        );
2731    }
2732
2733    fs::write(
2734        path,
2735        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2736    )
2737    .map_err(|e| format!("Failed to write file: {}", e))
2738}
2739
2740fn read_cargo_lock_version_from_content(
2741    content: &str,
2742    cargo_toml_content: Option<&str>,
2743) -> Result<Option<String>, String> {
2744    read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
2745}
2746
2747fn read_cargo_lock_version_from_content_with_package(
2748    content: &str,
2749    cargo_toml_content: Option<&str>,
2750    package_name_override: Option<&str>,
2751) -> Result<Option<String>, String> {
2752    let value: TomlValue =
2753        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2754    let package_name = if let Some(package_name_override) = package_name_override {
2755        package_name_override.trim().to_string()
2756    } else {
2757        let cargo_toml_content = cargo_toml_content
2758            .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
2759        cargo_package_name_from_content(cargo_toml_content)?
2760    };
2761
2762    Ok(value
2763        .get("package")
2764        .and_then(TomlValue::as_array)
2765        .and_then(|packages| {
2766            packages.iter().find_map(|package| {
2767                let table = package.as_table()?;
2768                if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2769                    table
2770                        .get("version")
2771                        .and_then(TomlValue::as_str)
2772                        .map(|value| value.to_string())
2773                } else {
2774                    None
2775                }
2776            })
2777        }))
2778}
2779
2780fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
2781    let content: String =
2782        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2783    let cargo_toml: String = fs::read_to_string(
2784        path.parent()
2785            .unwrap_or_else(|| Path::new("."))
2786            .join("Cargo.toml"),
2787    )
2788    .map_err(|e| format!("Failed to read file: {}", e))?;
2789    read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
2790}
2791
2792fn read_cargo_lock_version_for_package(
2793    path: &Path,
2794    package_name: &str,
2795) -> Result<Option<String>, String> {
2796    let content: String =
2797        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2798    read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
2799}
2800
2801fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
2802    write_cargo_lock_version_for_package(path, None, version)
2803}
2804
2805fn write_cargo_lock_version_for_package(
2806    path: &Path,
2807    package_name_override: Option<&str>,
2808    version: &Version,
2809) -> Result<bool, String> {
2810    let content: String =
2811        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2812    let mut value: TomlValue =
2813        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2814    let package_name = if let Some(package_name_override) = package_name_override {
2815        package_name_override.trim().to_string()
2816    } else {
2817        let Some(package_name) = cargo_package_name(path)? else {
2818            return Ok(false);
2819        };
2820        package_name
2821    };
2822
2823    let packages: &mut Vec<TomlValue> = value
2824        .get_mut("package")
2825        .and_then(TomlValue::as_array_mut)
2826        .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
2827
2828    let mut updated = false;
2829    for package in packages {
2830        if let Some(table) = package.as_table_mut() {
2831            if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2832                table.insert(
2833                    "version".to_string(),
2834                    TomlValue::String(version.to_string()),
2835                );
2836                updated = true;
2837            }
2838        }
2839    }
2840
2841    if !updated {
2842        return Err(format!(
2843            "Could not find package `{}` in Cargo.lock",
2844            package_name
2845        ));
2846    }
2847
2848    fs::write(
2849        path,
2850        toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2851    )
2852    .map_err(|e| format!("Failed to write file: {}", e))?;
2853
2854    Ok(true)
2855}
2856
2857fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
2858    let cargo_toml: PathBuf = path
2859        .parent()
2860        .unwrap_or_else(|| Path::new("."))
2861        .join("Cargo.toml");
2862    let content: String = fs::read_to_string(&cargo_toml)
2863        .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
2864    cargo_package_name_from_content_optional(&content)
2865}
2866
2867fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
2868    cargo_package_name_from_content_optional(content)?
2869        .ok_or_else(|| "Could not determine Cargo package name".to_string())
2870}
2871
2872fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
2873    let value: TomlValue =
2874        toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
2875    Ok(value
2876        .get("package")
2877        .and_then(TomlValue::as_table)
2878        .and_then(|package| package.get("name"))
2879        .and_then(TomlValue::as_str)
2880        .map(|value| value.to_string()))
2881}
2882
2883fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
2884    read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2885}
2886
2887fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
2888    let content: String =
2889        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2890    read_readme_version_from_content(&content)
2891}
2892
2893fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
2894    let content: String =
2895        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2896    let marker: String = format!("current version: `{}`", version);
2897    let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2898        .map_err(|e| format!("Failed to build README regex: {}", e))?;
2899
2900    let updated: String = if regex.is_match(&content) {
2901        regex.replace(&content, marker.as_str()).to_string()
2902    } else if let Some(first_break) = content.find('\n') {
2903        let mut next = String::new();
2904        next.push_str(&content[..=first_break]);
2905        next.push('\n');
2906        next.push_str(&marker);
2907        next.push('\n');
2908        next.push_str(&content[first_break + 1..]);
2909        next
2910    } else {
2911        format!("{}\n\n{}\n", content, marker)
2912    };
2913
2914    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2915}
2916
2917fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
2918    let content: String =
2919        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2920    read_regex_version_from_content(&content, pattern)
2921}
2922
2923fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
2924    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2925    Ok(regex
2926        .captures(content)
2927        .and_then(|captures| captures.get(1))
2928        .map(|matched| matched.as_str().trim().to_string()))
2929}
2930
2931fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
2932    let content: String =
2933        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2934    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2935
2936    if !regex.is_match(&content) {
2937        return Err("No version pattern found".to_string());
2938    }
2939
2940    let updated: String = regex
2941        .replace(&content, |caps: &regex::Captures<'_>| {
2942            caps[0].replace(&caps[1], &version.to_string())
2943        })
2944        .to_string();
2945    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2946}
2947
2948#[cfg(test)]
2949fn write_package_version_to_configured_files(
2950    project_root: &Path,
2951    invocation_dir: &Path,
2952    registry: &[String],
2953    version_scope: &VersionScope,
2954    package_name: &str,
2955    version: &Version,
2956) -> Result<usize, String> {
2957    write_package_version_to_configured_files_with_paths(
2958        project_root,
2959        invocation_dir,
2960        registry,
2961        version_scope,
2962        package_name,
2963        version,
2964    )
2965    .map(|paths| paths.len())
2966}
2967
2968fn write_package_version_to_configured_files_with_paths(
2969    project_root: &Path,
2970    invocation_dir: &Path,
2971    registry: &[String],
2972    version_scope: &VersionScope,
2973    package_name: &str,
2974    version: &Version,
2975) -> Result<Vec<PathBuf>, String> {
2976    let mut updated: usize = 0usize;
2977    let mut updated_paths = Vec::new();
2978    let mut errors: Vec<String> = Vec::new();
2979
2980    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2981        let path: &PathBuf = &entry.absolute;
2982        if !path.exists() {
2983            continue;
2984        }
2985
2986        match write_package_version_to_path(path, package_name, version) {
2987            Ok(true) => {
2988                updated += 1;
2989                updated_paths.push(path.clone());
2990            }
2991            Ok(false) => {}
2992            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
2993        }
2994    }
2995
2996    if updated == 0 && errors.is_empty() {
2997        return Err(format!(
2998            "No configured TOML files contained package assignment `{}`.",
2999            package_name
3000        ));
3001    }
3002
3003    if !errors.is_empty() {
3004        return Err(format!(
3005            "Updated {} file(s), but some package version targets failed:\n{}",
3006            updated,
3007            errors.join("\n")
3008        ));
3009    }
3010
3011    Ok(updated_paths)
3012}
3013
3014fn write_package_version_to_path(
3015    path: &Path,
3016    package_name: &str,
3017    version: &Version,
3018) -> Result<bool, String> {
3019    let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
3020    if !is_toml {
3021        return Ok(false);
3022    }
3023
3024    let content: String =
3025        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3026    let (updated, changed) =
3027        rewrite_toml_package_assignment_versions(&content, package_name, version)?;
3028    if changed {
3029        fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
3030    }
3031    Ok(changed)
3032}
3033
3034fn rewrite_toml_package_assignment_versions(
3035    content: &str,
3036    package_name: &str,
3037    version: &Version,
3038) -> Result<(String, bool), String> {
3039    let escaped_name: String = regex::escape(package_name);
3040    let inline_pattern: String = format!(
3041        r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
3042        escaped_name
3043    );
3044    let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
3045
3046    let inline_regex: Regex =
3047        Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
3048    let string_regex: Regex =
3049        Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
3050
3051    let replacement: String = version.to_string();
3052
3053    let after_inline: String = inline_regex
3054        .replace_all(content, |caps: &regex::Captures<'_>| {
3055            format!("{}{}{}", &caps[1], replacement, &caps[3])
3056        })
3057        .to_string();
3058    let after_string: String = string_regex
3059        .replace_all(&after_inline, |caps: &regex::Captures<'_>| {
3060            format!("{}{}{}", &caps[1], replacement, &caps[3])
3061        })
3062        .to_string();
3063
3064    Ok((after_string.clone(), after_string != content))
3065}
3066
3067fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
3068    let content: String =
3069        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
3070    let mut value: YamlValue =
3071        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
3072    let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
3073    mapping.insert(
3074        YamlValue::String("version".to_string()),
3075        YamlValue::String(version.to_string()),
3076    );
3077    if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
3078        mapping.insert(
3079            YamlValue::String("appVersion".to_string()),
3080            YamlValue::String(version.to_string()),
3081        );
3082    }
3083    fs::write(
3084        path,
3085        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
3086    )
3087    .map_err(|e| format!("Failed to write file: {}", e))
3088}
3089
3090fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
3091    value
3092        .as_mapping_mut()
3093        .ok_or_else(|| "Expected a YAML mapping".to_string())
3094}
3095
3096fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
3097    value
3098        .as_mapping()
3099        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
3100        .and_then(YamlValue::as_mapping)
3101}
3102
3103fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
3104    value
3105        .as_mapping()
3106        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
3107        .and_then(YamlValue::as_str)
3108        .map(|value| value.to_string())
3109}
3110
3111fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
3112    let mut current: &JsonValue = value;
3113    for part in key_parts {
3114        current = current.get(*part)?;
3115    }
3116    current.as_str().map(|value| value.to_string())
3117}
3118
3119fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
3120    let mut current = value;
3121    for part in key_parts {
3122        let mapping = current.as_mapping()?;
3123        current = mapping.get(YamlValue::String((*part).to_string()))?;
3124    }
3125    current.as_str().map(|value| value.to_string())
3126}
3127
3128fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
3129    let mut current = value;
3130    for part in key_parts {
3131        current = current.get(*part)?;
3132    }
3133    current.as_str().map(|value| value.to_string())
3134}
3135
3136fn parse_version(input: &str) -> Result<Version, String> {
3137    let trimmed: &str = input.trim();
3138    let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
3139    Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
3140}
3141
3142fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
3143    let trimmed = input.trim();
3144    if trimmed.is_empty() {
3145        return Err("Release version cannot be empty.".to_string());
3146    }
3147
3148    if let Ok(version) = parse_version(trimmed) {
3149        return Ok((version.clone(), format!("v{}", version)));
3150    }
3151
3152    let prefixed = Regex::new(
3153        r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
3154    )
3155    .map_err(|e| format!("Failed to build release target parser: {}", e))?;
3156
3157    if let Some(captures) = prefixed.captures(trimmed) {
3158        let semver = captures
3159            .name("semver")
3160            .map(|m| m.as_str())
3161            .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
3162        let version = Version::parse(semver)
3163            .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
3164        return Ok((version, trimmed.to_string()));
3165    }
3166
3167    Err(format!(
3168        "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
3169        input
3170    ))
3171}
3172
3173fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
3174    let Some((raw_package, raw_version)) = input.split_once('=') else {
3175        return Ok(None);
3176    };
3177
3178    let package_name = raw_package.trim();
3179    if package_name.is_empty() {
3180        return Ok(None);
3181    }
3182
3183    let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
3184        .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
3185    if !package_name_regex.is_match(package_name) {
3186        return Err(format!(
3187            "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
3188            input
3189        ));
3190    }
3191
3192    let version = parse_version(raw_version.trim())?;
3193    Ok(Some((package_name.to_string(), version)))
3194}
3195
3196fn bump_version(current: &Version, kind: &str) -> Version {
3197    let mut next = current.clone();
3198    match kind {
3199        "major" => {
3200            next.major += 1;
3201            next.minor = 0;
3202            next.patch = 0;
3203            next.pre = semver::Prerelease::EMPTY;
3204            next.build = semver::BuildMetadata::EMPTY;
3205        }
3206        "minor" => {
3207            next.minor += 1;
3208            next.patch = 0;
3209            next.pre = semver::Prerelease::EMPTY;
3210            next.build = semver::BuildMetadata::EMPTY;
3211        }
3212        _ => {
3213            next.patch += 1;
3214            next.pre = semver::Prerelease::EMPTY;
3215            next.build = semver::BuildMetadata::EMPTY;
3216        }
3217    }
3218    next
3219}
3220
3221fn default_version() -> Version {
3222    Version::new(0, 1, 0)
3223}
3224
3225fn resolve_registry_paths(
3226    project_root: &Path,
3227    invocation_dir: &Path,
3228    registry: &[String],
3229    version_scope: &VersionScope,
3230) -> Vec<ResolvedRegistryPath> {
3231    let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
3232    let mut seen: BTreeSet<String> = BTreeSet::new();
3233    let workspace_primary_target = match version_scope {
3234        VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
3235        VersionScope::Crate { .. } | VersionScope::Service { .. } => None,
3236    };
3237
3238    for relative in registry {
3239        match version_scope {
3240            VersionScope::Repository => {
3241                if *relative == *"Cargo.lock" {
3242                    let resolved_relative = resolve_registry_relative_path(
3243                        project_root,
3244                        invocation_dir,
3245                        version_scope,
3246                        relative,
3247                    );
3248                    if !seen.insert(resolved_relative.clone()) {
3249                        continue;
3250                    }
3251
3252                    resolved.push(ResolvedRegistryPath {
3253                        absolute: project_root.join(&resolved_relative),
3254                        relative: resolved_relative,
3255                        cargo_package_override: workspace_primary_target
3256                            .as_ref()
3257                            .map(|target| target.package_name.clone()),
3258                    });
3259                    continue;
3260                }
3261
3262                if *relative == *"Cargo.toml" {
3263                    if let Some(target) = workspace_primary_target.as_ref() {
3264                        if !seen.insert(target.manifest_relative.clone()) {
3265                            continue;
3266                        }
3267
3268                        resolved.push(ResolvedRegistryPath {
3269                            absolute: target.manifest_absolute.clone(),
3270                            relative: target.manifest_relative.clone(),
3271                            cargo_package_override: None,
3272                        });
3273                        continue;
3274                    }
3275                }
3276
3277                let resolved_relative = resolve_registry_relative_path(
3278                    project_root,
3279                    invocation_dir,
3280                    version_scope,
3281                    relative,
3282                );
3283                if !seen.insert(resolved_relative.clone()) {
3284                    continue;
3285                }
3286
3287                resolved.push(ResolvedRegistryPath {
3288                    absolute: project_root.join(&resolved_relative),
3289                    relative: resolved_relative,
3290                    cargo_package_override: None,
3291                });
3292            }
3293            VersionScope::Crate {
3294                crate_root,
3295                package_name,
3296                ..
3297            } => {
3298                if *relative == *"Cargo.lock" {
3299                    let cargo_lock = project_root.join("Cargo.lock");
3300                    if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
3301                        resolved.push(ResolvedRegistryPath {
3302                            absolute: cargo_lock,
3303                            relative: "Cargo.lock".to_string(),
3304                            cargo_package_override: Some(package_name.clone()),
3305                        });
3306                    }
3307                    continue;
3308                }
3309
3310                let preferred = crate_root.join(relative);
3311                if !preferred.exists() {
3312                    continue;
3313                }
3314                let Ok(stripped) = preferred.strip_prefix(project_root) else {
3315                    continue;
3316                };
3317                let resolved_relative = normalized_relative_path(stripped);
3318                if !seen.insert(resolved_relative.clone()) {
3319                    continue;
3320                }
3321
3322                resolved.push(ResolvedRegistryPath {
3323                    absolute: preferred,
3324                    relative: resolved_relative,
3325                    cargo_package_override: None,
3326                });
3327            }
3328            VersionScope::Service {
3329                service_root,
3330                cargo_package_name,
3331                ..
3332            } => {
3333                if *relative == *"Cargo.lock" {
3334                    let local_cargo_lock = service_root.join("Cargo.lock");
3335                    if local_cargo_lock.exists() {
3336                        let relative_path = local_cargo_lock
3337                            .strip_prefix(project_root)
3338                            .ok()
3339                            .map(normalized_relative_path)
3340                            .unwrap_or_else(|| normalized_relative_path(&local_cargo_lock));
3341                        if seen.insert(relative_path.clone()) {
3342                            resolved.push(ResolvedRegistryPath {
3343                                absolute: local_cargo_lock,
3344                                relative: relative_path,
3345                                cargo_package_override: cargo_package_name.clone(),
3346                            });
3347                        }
3348                    } else if let Some(package_name) = cargo_package_name.as_ref() {
3349                        let workspace_cargo_lock = project_root.join("Cargo.lock");
3350                        if workspace_cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
3351                            resolved.push(ResolvedRegistryPath {
3352                                absolute: workspace_cargo_lock,
3353                                relative: "Cargo.lock".to_string(),
3354                                cargo_package_override: Some(package_name.clone()),
3355                            });
3356                        }
3357                    }
3358                    continue;
3359                }
3360
3361                let preferred = service_root.join(relative);
3362                if !preferred.exists() {
3363                    continue;
3364                }
3365                let Ok(stripped) = preferred.strip_prefix(project_root) else {
3366                    continue;
3367                };
3368                let resolved_relative = normalized_relative_path(stripped);
3369                if !seen.insert(resolved_relative.clone()) {
3370                    continue;
3371                }
3372
3373                resolved.push(ResolvedRegistryPath {
3374                    absolute: preferred,
3375                    relative: resolved_relative,
3376                    cargo_package_override: None,
3377                });
3378            }
3379        }
3380    }
3381
3382    for configured_path in
3383        resolve_configured_version_target_paths(project_root, invocation_dir, version_scope)
3384    {
3385        if seen.insert(configured_path.relative.clone()) {
3386            resolved.push(configured_path);
3387        }
3388    }
3389
3390    resolved
3391}
3392
3393fn resolve_configured_version_target_paths(
3394    project_root: &Path,
3395    invocation_dir: &Path,
3396    version_scope: &VersionScope,
3397) -> Vec<ResolvedRegistryPath> {
3398    let Some((config_root, config)) = load_version_target_config(invocation_dir) else {
3399        return Vec::new();
3400    };
3401
3402    let service_targets = match version_scope {
3403        VersionScope::Service {
3404            version_targets, ..
3405        } if !version_targets.is_empty() => Some(version_targets.clone()),
3406        _ => None,
3407    };
3408
3409    let manifest_paths = if let Some(service_targets) = &service_targets {
3410        service_targets
3411            .iter()
3412            .cloned()
3413            .map(PathBuf::from)
3414            .collect::<Vec<_>>()
3415    } else {
3416        let mut targets = config
3417            .version_targets
3418            .into_iter()
3419            .map(PathBuf::from)
3420            .collect::<Vec<_>>();
3421        targets.extend(
3422            config
3423                .publish
3424                .into_iter()
3425                .flat_map(|publish| [publish.npm, publish.crates])
3426                .flatten()
3427                .filter_map(|target| target.manifest_path)
3428                .map(PathBuf::from),
3429        );
3430        targets
3431    };
3432
3433    let scope_root = match version_scope {
3434        VersionScope::Repository => None,
3435        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
3436        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
3437    };
3438
3439    manifest_paths
3440        .into_iter()
3441        .filter_map(|manifest_path| {
3442            let absolute = if manifest_path.is_absolute() {
3443                manifest_path
3444            } else {
3445                config_root.join(manifest_path)
3446            };
3447            if !absolute.exists() {
3448                return None;
3449            }
3450            if let Some(scope_root) = scope_root {
3451                if !absolute.starts_with(scope_root) {
3452                    return None;
3453                }
3454            }
3455            let relative = absolute
3456                .strip_prefix(project_root)
3457                .ok()
3458                .map(normalized_relative_path)
3459                .unwrap_or_else(|| normalized_relative_path(&absolute));
3460            Some(ResolvedRegistryPath {
3461                relative,
3462                absolute,
3463                cargo_package_override: None,
3464            })
3465        })
3466        .collect()
3467}
3468
3469fn load_version_target_config(invocation_dir: &Path) -> Option<(PathBuf, XbpConfig)> {
3470    let found = find_xbp_config_upwards(invocation_dir)?;
3471    let config_path = if found.kind == "json" {
3472        maybe_auto_convert_legacy_xbp_json_to_yaml(&found.project_root, &found.config_path)
3473            .ok()
3474            .flatten()
3475            .unwrap_or_else(|| found.config_path.clone())
3476    } else {
3477        found.config_path.clone()
3478    };
3479
3480    let kind = if config_path
3481        .extension()
3482        .and_then(|ext| ext.to_str())
3483        .map(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
3484        .unwrap_or(false)
3485    {
3486        "yaml"
3487    } else {
3488        "json"
3489    };
3490
3491    let content = fs::read_to_string(&config_path).ok()?;
3492    let (mut config, healed): (XbpConfig, Option<String>) =
3493        parse_config_with_auto_heal(&content, kind).ok()?;
3494    if let Some(healed_content) = healed {
3495        let _ = fs::write(&config_path, healed_content);
3496    }
3497    resolve_config_paths_for_runtime(&mut config, &found.project_root);
3498    Some((found.project_root, config))
3499}
3500
3501fn resolve_registry_relative_path(
3502    project_root: &Path,
3503    invocation_dir: &Path,
3504    version_scope: &VersionScope,
3505    relative: &str,
3506) -> String {
3507    if let Some(scope_root) = version_scope_root(version_scope) {
3508        let preferred = scope_root.join(relative);
3509        if preferred.exists() {
3510            if let Ok(stripped) = preferred.strip_prefix(project_root) {
3511                return normalized_relative_path(stripped);
3512            }
3513        }
3514        return relative.replace('\\', "/");
3515    }
3516
3517    if relative == "Cargo.toml" {
3518        if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
3519            return target.manifest_relative;
3520        }
3521    }
3522
3523    let preferred: PathBuf = invocation_dir.join(relative);
3524    if preferred.exists() {
3525        if let Ok(stripped) = preferred.strip_prefix(project_root) {
3526            return normalized_relative_path(stripped);
3527        }
3528    }
3529
3530    relative.replace('\\', "/")
3531}
3532
3533fn resolve_version_scope_with_prompt(
3534    project_root: &Path,
3535    invocation_dir: &Path,
3536) -> Result<VersionScope, String> {
3537    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
3538    if let Some(scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
3539        return Ok(scope);
3540    }
3541
3542    if invocation_dir == project_root && service_scopes.len() > 1 && std::io::stdin().is_terminal()
3543    {
3544        return prompt_for_version_scope_selection(&service_scopes);
3545    }
3546
3547    Ok(resolve_version_scope(project_root, invocation_dir))
3548}
3549
3550fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
3551    let service_scopes = load_service_version_scopes(project_root, invocation_dir);
3552    if let Some(service_scope) = select_matching_service_scope(&service_scopes, invocation_dir) {
3553        return service_scope;
3554    }
3555
3556    if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
3557        return crate_scope;
3558    }
3559
3560    VersionScope::Repository
3561}
3562
3563fn load_service_version_scopes(project_root: &Path, invocation_dir: &Path) -> Vec<VersionScope> {
3564    let Some((_config_root, config)) = load_version_target_config(invocation_dir) else {
3565        return Vec::new();
3566    };
3567    let Some(services) = config.services else {
3568        return Vec::new();
3569    };
3570
3571    let mut scopes = services
3572        .into_iter()
3573        .filter_map(|service| build_service_scope(project_root, service))
3574        .collect::<Vec<_>>();
3575    scopes.sort_by(|left, right| {
3576        version_scope_label(left, "repository").cmp(&version_scope_label(right, "repository"))
3577    });
3578    scopes
3579}
3580
3581fn build_service_scope(project_root: &Path, service: ServiceConfig) -> Option<VersionScope> {
3582    let version_targets = service
3583        .version_targets
3584        .clone()
3585        .unwrap_or_default()
3586        .into_iter()
3587        .map(normalized_path_string)
3588        .collect::<Vec<_>>();
3589    let service_root = resolve_service_scope_root(project_root, &service, &version_targets)?;
3590    let service_relative_root = service_root
3591        .strip_prefix(project_root)
3592        .ok()
3593        .map(normalized_relative_path)
3594        .filter(|value| !value.is_empty())
3595        .unwrap_or_else(|| ".".to_string());
3596    let cargo_package_name =
3597        resolve_service_scope_cargo_package_name(project_root, &service_root, &version_targets);
3598    let tag_identity = cargo_package_name
3599        .clone()
3600        .unwrap_or_else(|| service.name.clone());
3601
3602    Some(VersionScope::Service {
3603        service_root,
3604        service_relative_root,
3605        service_name: service.name,
3606        tag_prefix: format!("{}-", slugify_scope_name(&tag_identity)),
3607        cargo_package_name,
3608        version_targets,
3609    })
3610}
3611
3612fn resolve_service_scope_root(
3613    project_root: &Path,
3614    service: &ServiceConfig,
3615    version_targets: &[String],
3616) -> Option<PathBuf> {
3617    if !version_targets.is_empty() {
3618        return common_parent_from_targets(project_root, version_targets);
3619    }
3620
3621    service.root_directory.as_ref().map(|root| {
3622        let path = PathBuf::from(root);
3623        if path.is_absolute() {
3624            path
3625        } else {
3626            project_root.join(path)
3627        }
3628    })
3629}
3630
3631fn common_parent_from_targets(project_root: &Path, version_targets: &[String]) -> Option<PathBuf> {
3632    let mut parents = version_targets.iter().filter_map(|target| {
3633        let path = PathBuf::from(target);
3634        let absolute = if path.is_absolute() {
3635            path
3636        } else {
3637            project_root.join(path)
3638        };
3639        absolute.parent().map(Path::to_path_buf)
3640    });
3641
3642    let mut common = parents.next()?;
3643    for parent in parents {
3644        while !parent.starts_with(&common) {
3645            common = common.parent()?.to_path_buf();
3646        }
3647    }
3648    Some(common)
3649}
3650
3651fn resolve_service_scope_cargo_package_name(
3652    project_root: &Path,
3653    service_root: &Path,
3654    version_targets: &[String],
3655) -> Option<String> {
3656    let mut cargo_paths = version_targets
3657        .iter()
3658        .filter(|target| {
3659            Path::new(target)
3660                .file_name()
3661                .and_then(|value| value.to_str())
3662                == Some("Cargo.toml")
3663        })
3664        .map(|target| {
3665            let path = PathBuf::from(target);
3666            if path.is_absolute() {
3667                path
3668            } else {
3669                project_root.join(path)
3670            }
3671        })
3672        .collect::<Vec<_>>();
3673    if cargo_paths.is_empty() {
3674        cargo_paths.push(service_root.join("Cargo.toml"));
3675    }
3676
3677    cargo_paths.into_iter().find_map(|path| {
3678        let content = fs::read_to_string(path).ok()?;
3679        cargo_package_name_from_content_optional(&content)
3680            .ok()
3681            .flatten()
3682    })
3683}
3684
3685fn select_matching_service_scope(
3686    scopes: &[VersionScope],
3687    invocation_dir: &Path,
3688) -> Option<VersionScope> {
3689    scopes
3690        .iter()
3691        .filter_map(|scope| {
3692            let root = version_scope_root(scope)?;
3693            if invocation_dir.starts_with(root) {
3694                Some((root.components().count(), scope.clone()))
3695            } else {
3696                None
3697            }
3698        })
3699        .max_by_key(|(depth, _)| *depth)
3700        .map(|(_, scope)| scope)
3701}
3702
3703fn prompt_for_version_scope_selection(
3704    service_scopes: &[VersionScope],
3705) -> Result<VersionScope, String> {
3706    let mut items = service_scopes
3707        .iter()
3708        .map(version_scope_prompt_label)
3709        .collect::<Vec<_>>();
3710    items.push("Repository (all configured version targets)".to_string());
3711
3712    let selection = Select::with_theme(&ColorfulTheme::default())
3713        .with_prompt("Choose the project/service to version or release")
3714        .items(&items)
3715        .default(0)
3716        .interact_opt()
3717        .map_err(|e| format!("Prompt failed: {}", e))?;
3718
3719    match selection {
3720        Some(index) if index < service_scopes.len() => Ok(service_scopes[index].clone()),
3721        _ => Ok(VersionScope::Repository),
3722    }
3723}
3724
3725fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
3726    let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
3727    let cargo_toml = crate_root.join("Cargo.toml");
3728    let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
3729    let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
3730    let crate_relative_root = crate_root
3731        .strip_prefix(project_root)
3732        .ok()
3733        .map(normalized_relative_path)?;
3734
3735    Some(VersionScope::Crate {
3736        crate_root,
3737        crate_relative_root,
3738        tag_prefix: format!("{}-", package_name),
3739        package_name,
3740    })
3741}
3742
3743fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
3744    let crates_root = project_root.join("crates");
3745    let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
3746    let mut components = relative.components();
3747    let crate_name = components.next()?;
3748    Some(crates_root.join(crate_name.as_os_str()))
3749}
3750
3751fn version_scope_root(version_scope: &VersionScope) -> Option<&Path> {
3752    match version_scope {
3753        VersionScope::Repository => None,
3754        VersionScope::Crate { crate_root, .. } => Some(crate_root.as_path()),
3755        VersionScope::Service { service_root, .. } => Some(service_root.as_path()),
3756    }
3757}
3758
3759fn prepare_release_openapi_assets(
3760    project_root: &Path,
3761    invocation_dir: &Path,
3762    version_scope: &VersionScope,
3763    release_version: &Version,
3764    tag_name: &str,
3765) -> Result<Vec<ReleaseOpenApiAsset>, String> {
3766    let spec_paths = resolve_release_openapi_specs(project_root, invocation_dir, version_scope)?;
3767    validate_release_openapi_assets(&spec_paths, release_version)?;
3768
3769    spec_paths
3770        .into_iter()
3771        .map(|source_path| {
3772            let file_name = source_path
3773                .file_name()
3774                .and_then(|value| value.to_str())
3775                .ok_or_else(|| {
3776                    format!(
3777                        "Invalid OpenAPI asset path for release upload: {}",
3778                        source_path.display()
3779                    )
3780                })?;
3781            let asset_name = versioned_release_openapi_asset_name(file_name, tag_name)?;
3782            let source_label = source_path
3783                .strip_prefix(project_root)
3784                .map(normalized_relative_path)
3785                .unwrap_or_else(|_| normalized_relative_path(&source_path));
3786            Ok(ReleaseOpenApiAsset {
3787                source_path,
3788                source_label,
3789                asset_name,
3790            })
3791        })
3792        .collect()
3793}
3794
3795fn resolve_release_openapi_specs(
3796    project_root: &Path,
3797    invocation_dir: &Path,
3798    version_scope: &VersionScope,
3799) -> Result<Vec<PathBuf>, String> {
3800    const PRIMARY_RELEASE_OPENAPI_FILES: &[&str] = &[
3801        "openapi.yaml",
3802        "openapi.yml",
3803        "openapi.json",
3804        "swagger.yaml",
3805        "swagger.yml",
3806        "swagger.json",
3807    ];
3808
3809    let mut roots: Vec<PathBuf> = Vec::new();
3810    if let Some(scope_root) = version_scope_root(version_scope) {
3811        roots.push(scope_root.to_path_buf());
3812    } else if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
3813        roots.push(crate_root);
3814    }
3815    roots.push(project_root.to_path_buf());
3816
3817    let mut seen_roots: BTreeSet<PathBuf> = BTreeSet::new();
3818    let mut seen_names: BTreeSet<String> = BTreeSet::new();
3819    let mut resolved_paths: Vec<PathBuf> = Vec::new();
3820
3821    for root in roots {
3822        if !seen_roots.insert(root.clone()) {
3823            continue;
3824        }
3825
3826        let mut root_paths: Vec<(String, PathBuf)> = Vec::new();
3827        for file_name in PRIMARY_RELEASE_OPENAPI_FILES {
3828            let path = root.join(file_name);
3829            if path.is_file() {
3830                root_paths.push(((*file_name).to_string(), path));
3831            }
3832        }
3833
3834        let entries = match fs::read_dir(&root) {
3835            Ok(entries) => entries,
3836            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
3837            Err(error) => {
3838                return Err(format!(
3839                    "Failed to inspect OpenAPI release assets in {}: {}",
3840                    root.display(),
3841                    error
3842                ));
3843            }
3844        };
3845
3846        let mut additional_specs: Vec<(String, PathBuf)> = Vec::new();
3847        for entry in entries {
3848            let entry = entry.map_err(|error| {
3849                format!(
3850                    "Failed to inspect OpenAPI release assets in {}: {}",
3851                    root.display(),
3852                    error
3853                )
3854            })?;
3855            let path = entry.path();
3856            if !path.is_file() {
3857                continue;
3858            }
3859            let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
3860                continue;
3861            };
3862            if !is_release_openapi_variant_file_name(file_name) {
3863                continue;
3864            }
3865            additional_specs.push((file_name.to_string(), path));
3866        }
3867        additional_specs.sort_by(|left, right| left.0.cmp(&right.0));
3868
3869        root_paths.extend(additional_specs);
3870
3871        for (file_name, path) in root_paths {
3872            if seen_names.insert(file_name) {
3873                resolved_paths.push(path);
3874            }
3875        }
3876    }
3877
3878    Ok(resolved_paths)
3879}
3880
3881fn validate_release_openapi_assets(
3882    spec_paths: &[PathBuf],
3883    release_version: &Version,
3884) -> Result<(), String> {
3885    let Some(primary_spec_path) = spec_paths.iter().find(|path| {
3886        path.file_name()
3887            .and_then(|value| value.to_str())
3888            .map(is_primary_release_openapi_file_name)
3889            .unwrap_or(false)
3890    }) else {
3891        return Ok(());
3892    };
3893
3894    let declared_version = read_version_from_path(primary_spec_path)?.ok_or_else(|| {
3895        format!(
3896            "Release OpenAPI spec `{}` does not declare a version, so it cannot be verified against release version `{}`.",
3897            primary_spec_path.display(),
3898            release_version
3899        )
3900    })?;
3901    let parsed_declared_version = parse_version(&declared_version).map_err(|error| {
3902        format!(
3903            "Release OpenAPI spec `{}` has invalid version `{}`: {}",
3904            primary_spec_path.display(),
3905            declared_version,
3906            error
3907        )
3908    })?;
3909
3910    if &parsed_declared_version != release_version {
3911        return Err(format!(
3912            "Release OpenAPI spec `{}` version `{}` does not match release version `{}`.",
3913            primary_spec_path.display(),
3914            declared_version,
3915            release_version
3916        ));
3917    }
3918
3919    Ok(())
3920}
3921
3922fn versioned_release_openapi_asset_name(file_name: &str, tag_name: &str) -> Result<String, String> {
3923    let path = Path::new(file_name);
3924    let stem = path
3925        .file_stem()
3926        .and_then(|value| value.to_str())
3927        .ok_or_else(|| format!("Invalid OpenAPI asset filename `{}`.", file_name))?;
3928    let extension = path
3929        .extension()
3930        .and_then(|value| value.to_str())
3931        .ok_or_else(|| format!("OpenAPI asset `{}` is missing a file extension.", file_name))?;
3932    Ok(format!("{}-{}.{}", stem, tag_name, extension))
3933}
3934
3935fn is_primary_release_openapi_file_name(file_name: &str) -> bool {
3936    matches!(
3937        file_name,
3938        "openapi.yaml"
3939            | "openapi.yml"
3940            | "openapi.json"
3941            | "swagger.yaml"
3942            | "swagger.yml"
3943            | "swagger.json"
3944    )
3945}
3946
3947fn is_release_openapi_variant_file_name(file_name: &str) -> bool {
3948    if is_primary_release_openapi_file_name(file_name) {
3949        return false;
3950    }
3951
3952    let Some((stem, extension)) = file_name.rsplit_once('.') else {
3953        return false;
3954    };
3955    if !matches!(extension, "yaml" | "yml" | "json") {
3956        return false;
3957    }
3958
3959    stem.starts_with("openapi-") || stem.starts_with("swagger-")
3960}
3961
3962fn normalized_path_string(path: String) -> String {
3963    path.replace('\\', "/")
3964}
3965
3966fn slugify_scope_name(value: &str) -> String {
3967    let mut slug = value
3968        .chars()
3969        .map(|ch| {
3970            if ch.is_ascii_alphanumeric() {
3971                ch.to_ascii_lowercase()
3972            } else {
3973                '-'
3974            }
3975        })
3976        .collect::<String>();
3977    while slug.contains("--") {
3978        slug = slug.replace("--", "-");
3979    }
3980    slug.trim_matches('-').to_string()
3981}
3982
3983fn resolve_workspace_primary_cargo_target(
3984    project_root: &Path,
3985) -> Option<WorkspacePrimaryCargoTarget> {
3986    let workspace_manifest = project_root.join("Cargo.toml");
3987    let content = fs::read_to_string(&workspace_manifest).ok()?;
3988    let value: TomlValue = toml::from_str(&content).ok()?;
3989    if value.get("package").and_then(TomlValue::as_table).is_some() {
3990        return None;
3991    }
3992
3993    let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
3994    let mut candidate_roots = workspace
3995        .get("default-members")
3996        .and_then(TomlValue::as_array)
3997        .map(|members| {
3998            members
3999                .iter()
4000                .filter_map(TomlValue::as_str)
4001                .map(str::trim)
4002                .filter(|member| !member.is_empty() && !member.contains('*'))
4003                .map(str::to_string)
4004                .collect::<Vec<_>>()
4005        })
4006        .unwrap_or_default();
4007
4008    if candidate_roots.is_empty() {
4009        let members = workspace
4010            .get("members")
4011            .and_then(TomlValue::as_array)
4012            .map(|members| {
4013                members
4014                    .iter()
4015                    .filter_map(TomlValue::as_str)
4016                    .map(str::trim)
4017                    .filter(|member| !member.is_empty() && !member.contains('*'))
4018                    .map(str::to_string)
4019                    .collect::<Vec<_>>()
4020            })
4021            .unwrap_or_default();
4022        if members.len() == 1 {
4023            candidate_roots = members;
4024        }
4025    }
4026
4027    candidate_roots.sort();
4028    candidate_roots.dedup();
4029    if candidate_roots.len() != 1 {
4030        return None;
4031    }
4032
4033    let crate_relative_root = candidate_roots.into_iter().next()?;
4034    let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
4035    let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
4036    let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
4037
4038    Some(WorkspacePrimaryCargoTarget {
4039        manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
4040        manifest_absolute,
4041        package_name,
4042    })
4043}
4044
4045fn resolve_release_publish_target_filter(
4046    invocation_dir: &Path,
4047    version_scope: &VersionScope,
4048) -> Result<Option<String>, String> {
4049    let VersionScope::Service {
4050        service_name,
4051        service_root,
4052        version_targets,
4053        ..
4054    } = version_scope
4055    else {
4056        return Ok(None);
4057    };
4058
4059    let Some((project_root, config)) = load_version_target_config(invocation_dir) else {
4060        return Ok(None);
4061    };
4062    let Some(publish) = config.publish else {
4063        return Ok(None);
4064    };
4065
4066    let service_target_set: BTreeSet<&str> = version_targets.iter().map(String::as_str).collect();
4067    let enabled_targets = [("npm", publish.npm), ("crates", publish.crates)]
4068        .into_iter()
4069        .filter_map(|(kind, target)| {
4070            let target = target?;
4071            if target.enabled.unwrap_or(true) {
4072                Some((kind, target))
4073            } else {
4074                None
4075            }
4076        })
4077        .collect::<Vec<_>>();
4078
4079    if enabled_targets.is_empty() {
4080        return Ok(None);
4081    }
4082
4083    let matched = enabled_targets
4084        .iter()
4085        .filter_map(|(kind, target)| {
4086            let manifest_path = target.manifest_path.as_ref()?;
4087            let absolute = if Path::new(manifest_path).is_absolute() {
4088                PathBuf::from(manifest_path)
4089            } else {
4090                project_root.join(manifest_path)
4091            };
4092            let relative = absolute
4093                .strip_prefix(&project_root)
4094                .ok()
4095                .map(normalized_relative_path)
4096                .unwrap_or_else(|| normalized_relative_path(&absolute));
4097
4098            if service_target_set.contains(relative.as_str()) || absolute.starts_with(service_root)
4099            {
4100                Some((*kind).to_string())
4101            } else {
4102                None
4103            }
4104        })
4105        .collect::<Vec<_>>();
4106
4107    if matched.is_empty() {
4108        return Err(format!(
4109            "`--publish` was requested for service `{}`, but no enabled publish target in `.xbp/xbp.yaml` points at this service.",
4110            service_name
4111        ));
4112    }
4113
4114    if matched.len() == enabled_targets.len() {
4115        if matched.len() == 1 {
4116            return Ok(matched.into_iter().next());
4117        }
4118        return Ok(None);
4119    }
4120
4121    if matched.len() == 1 {
4122        return Ok(matched.into_iter().next());
4123    }
4124
4125    Err(format!(
4126        "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.",
4127        service_name
4128    ))
4129}
4130
4131async fn resolve_effective_linear_release_config(
4132    project_root: &Path,
4133    invocation_dir: &Path,
4134) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
4135    let global_config = resolve_global_linear_release_config();
4136    let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
4137        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
4138        config.linear.and_then(|linear| linear.release)
4139    } else {
4140        None
4141    };
4142
4143    Ok(resolve_linear_release_config(global_config, project_config)
4144        .map(|config| resolve_linear_release_placeholders(project_root, config)))
4145}
4146
4147fn resolve_linear_release_placeholders(
4148    project_root: &Path,
4149    mut config: ResolvedLinearReleaseConfig,
4150) -> ResolvedLinearReleaseConfig {
4151    let mut env_map = HashMap::new();
4152    for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
4153        env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
4154    }
4155    if let Some(organization_name) = config.organization_name.clone() {
4156        env_map.insert("organization_name".to_string(), organization_name);
4157    }
4158
4159    let resolved = resolve_env_placeholders(project_root, &env_map);
4160    config.initiative_ids = config
4161        .initiative_ids
4162        .iter()
4163        .enumerate()
4164        .map(|(index, initiative_id)| {
4165            resolved
4166                .get(&format!("initiative_id_{}", index))
4167                .cloned()
4168                .unwrap_or_else(|| initiative_id.clone())
4169        })
4170        .map(|value| value.trim().to_string())
4171        .filter(|value| !value.is_empty())
4172        .collect();
4173    config.organization_name = resolved
4174        .get("organization_name")
4175        .cloned()
4176        .or(config.organization_name)
4177        .map(|value| value.trim().to_string())
4178        .filter(|value| !value.is_empty());
4179    config
4180}
4181
4182async fn resolve_project_github_release_branch_config(
4183    _project_root: &Path,
4184    invocation_dir: &Path,
4185) -> Result<Option<GitHubReleaseBranchSettings>, String> {
4186    if let Some(found) = find_xbp_config_upwards(invocation_dir) {
4187        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
4188        Ok(config.github_release_branch_settings())
4189    } else {
4190        Ok(None)
4191    }
4192}
4193
4194fn normalized_relative_path(path: &Path) -> String {
4195    path.to_string_lossy().replace('\\', "/")
4196}
4197
4198fn git_repository_root(dir: &Path) -> Option<PathBuf> {
4199    if !command_exists("git") {
4200        return None;
4201    }
4202
4203    let output: std::process::Output = Command::new("git")
4204        .current_dir(dir)
4205        .args(["rev-parse", "--show-toplevel"])
4206        .output()
4207        .ok()?;
4208
4209    if !output.status.success() {
4210        return None;
4211    }
4212
4213    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
4214    if root.is_empty() {
4215        None
4216    } else {
4217        Some(PathBuf::from(root))
4218    }
4219}
4220
4221fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
4222    let output: std::process::Output = Command::new("git")
4223        .current_dir(project_root)
4224        .args(args)
4225        .output()
4226        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
4227
4228    if !output.status.success() {
4229        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
4230        if stderr.is_empty() {
4231            return Err(format!(
4232                "`git {}` failed with status {}",
4233                args.join(" "),
4234                output.status
4235            ));
4236        }
4237        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
4238    }
4239
4240    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
4241}
4242
4243fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
4244    let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
4245    Ok(output
4246        .lines()
4247        .map(|line| line.trim())
4248        .filter(|line| !line.is_empty())
4249        .map(|line| line.to_string())
4250        .collect())
4251}
4252
4253fn version_change_guard_state_path() -> Result<PathBuf, String> {
4254    let paths = global_xbp_paths()?;
4255    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
4256}
4257
4258fn version_change_guard_repo_key(project_root: &Path) -> String {
4259    fs::canonicalize(project_root)
4260        .unwrap_or_else(|_| project_root.to_path_buf())
4261        .to_string_lossy()
4262        .replace('\\', "/")
4263}
4264
4265fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
4266    if !path.exists() {
4267        return Ok(VersionChangeGuardRegistry::default());
4268    }
4269
4270    let content = fs::read_to_string(path).map_err(|e| {
4271        format!(
4272            "Failed to read version-change guard state {}: {}",
4273            path.display(),
4274            e
4275        )
4276    })?;
4277
4278    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
4279}
4280
4281fn save_version_change_guard_registry(
4282    path: &Path,
4283    registry: &VersionChangeGuardRegistry,
4284) -> Result<(), String> {
4285    if let Some(parent) = path.parent() {
4286        fs::create_dir_all(parent).map_err(|e| {
4287            format!(
4288                "Failed to create guard state directory {}: {}",
4289                parent.display(),
4290                e
4291            )
4292        })?;
4293    }
4294
4295    let content = serde_yaml::to_string(registry)
4296        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
4297    fs::write(path, content).map_err(|e| {
4298        format!(
4299            "Failed to write version-change guard state {}: {}",
4300            path.display(),
4301            e
4302        )
4303    })
4304}
4305
4306fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
4307    if !command_exists("git") {
4308        return Ok(None);
4309    }
4310
4311    let status_output: std::process::Output = Command::new("git")
4312        .current_dir(project_root)
4313        .args(["status", "--porcelain"])
4314        .output()
4315        .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
4316    if !status_output.status.success() {
4317        return Ok(None);
4318    }
4319
4320    let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
4321        .lines()
4322        .any(|line| !line.trim().is_empty());
4323
4324    let head_output: std::process::Output = Command::new("git")
4325        .current_dir(project_root)
4326        .args(["rev-parse", "HEAD"])
4327        .output()
4328        .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
4329    let head_commit: Option<String> = if head_output.status.success() {
4330        let value: String = String::from_utf8_lossy(&head_output.stdout)
4331            .trim()
4332            .to_string();
4333        if value.is_empty() {
4334            None
4335        } else {
4336            Some(value)
4337        }
4338    } else {
4339        None
4340    };
4341
4342    Ok(Some(GitWorktreeState {
4343        is_dirty,
4344        head_commit,
4345    }))
4346}
4347
4348fn should_clear_version_change_guard(
4349    entry: &VersionChangeGuardEntry,
4350    state: &GitWorktreeState,
4351) -> bool {
4352    if entry.pending_version_change_count == 0 {
4353        return true;
4354    }
4355    if !state.is_dirty {
4356        return true;
4357    }
4358
4359    match (&entry.head_commit, &state.head_commit) {
4360        (Some(previous), Some(current)) => previous != current,
4361        (Some(_), None) => true,
4362        _ => false,
4363    }
4364}
4365
4366fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
4367    let Some(state) = git_worktree_state(project_root)? else {
4368        return Ok(());
4369    };
4370
4371    let state_path: PathBuf = version_change_guard_state_path()?;
4372    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
4373    let repo_key: String = version_change_guard_repo_key(project_root);
4374    let mut changed = false;
4375
4376    if let Some(entry) = registry.entries.get(&repo_key).cloned() {
4377        if should_clear_version_change_guard(&entry, &state) {
4378            registry.entries.remove(&repo_key);
4379            changed = true;
4380        }
4381    }
4382
4383    if changed {
4384        save_version_change_guard_registry(&state_path, &registry)?;
4385    }
4386
4387    if state.is_dirty {
4388        if let Some(entry) = registry.entries.get(&repo_key) {
4389            if entry.pending_version_change_count >= 1 {
4390                return Err(format!(
4391                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
4392                    entry.pending_version_change_count,
4393                    state_path.display()
4394                ));
4395            }
4396        }
4397    }
4398
4399    Ok(())
4400}
4401
4402fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
4403    let Some(state) = git_worktree_state(project_root)? else {
4404        return Ok(());
4405    };
4406
4407    let state_path: PathBuf = version_change_guard_state_path()?;
4408    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
4409    let repo_key: String = version_change_guard_repo_key(project_root);
4410
4411    if state.is_dirty {
4412        registry.entries.insert(
4413            repo_key,
4414            VersionChangeGuardEntry {
4415                pending_version_change_count: 1,
4416                head_commit: state.head_commit,
4417            },
4418        );
4419    } else {
4420        registry.entries.remove(&repo_key);
4421    }
4422
4423    save_version_change_guard_registry(&state_path, &registry)
4424}
4425
4426fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
4427    let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
4428    Ok(!output.trim().is_empty())
4429}
4430
4431fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
4432    let remotes: String = run_git_command(project_root, &["remote"])?;
4433    let exists: bool = remotes.lines().any(|line| line.trim() == remote);
4434    if exists {
4435        Ok(())
4436    } else {
4437        Err(format!(
4438            "Git remote `{}` is not configured for this repository.",
4439            remote
4440        ))
4441    }
4442}
4443
4444fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
4445    run_git_command(project_root, &["remote", "get-url", remote])
4446}
4447
4448fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
4449    let query: String = format!("refs/tags/{}", tag);
4450    let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
4451    Ok(!output.trim().is_empty())
4452}
4453
4454fn git_head_commitish(project_root: &Path) -> Result<String, String> {
4455    let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
4456    if commitish.is_empty() {
4457        Err("Unable to resolve HEAD commit for release target.".to_string())
4458    } else {
4459        Ok(commitish)
4460    }
4461}
4462
4463fn render_release_branch_name(
4464    naming_template: &str,
4465    release_version: &Version,
4466    tag_name: &str,
4467) -> Result<String, String> {
4468    let branch_name = naming_template
4469        .replace("${GITHUB_VERSION}", &release_version.to_string())
4470        .replace("${GITHUB_TAG}", tag_name);
4471    let branch_name = branch_name.trim();
4472    if branch_name.is_empty() {
4473        return Err(
4474            "GitHub release branch naming template resolved to an empty branch name.".to_string(),
4475        );
4476    }
4477    Ok(branch_name.to_string())
4478}
4479
4480fn git_local_branch_commit(
4481    project_root: &Path,
4482    branch_name: &str,
4483) -> Result<Option<String>, String> {
4484    let output = Command::new("git")
4485        .current_dir(project_root)
4486        .args([
4487            "rev-parse",
4488            "--verify",
4489            &format!("refs/heads/{}", branch_name),
4490        ])
4491        .output()
4492        .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
4493    if !output.status.success() {
4494        return Ok(None);
4495    }
4496    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
4497    if commit.is_empty() {
4498        Ok(None)
4499    } else {
4500        Ok(Some(commit))
4501    }
4502}
4503
4504fn git_remote_branch_commit(
4505    project_root: &Path,
4506    remote: &str,
4507    branch_name: &str,
4508) -> Result<Option<String>, String> {
4509    let output = run_git_command(
4510        project_root,
4511        &[
4512            "ls-remote",
4513            "--heads",
4514            remote,
4515            &format!("refs/heads/{}", branch_name),
4516        ],
4517    )?;
4518    let commit = output
4519        .split_whitespace()
4520        .next()
4521        .map(str::trim)
4522        .filter(|value| !value.is_empty())
4523        .map(str::to_string);
4524    Ok(commit)
4525}
4526
4527fn ensure_release_branch(
4528    project_root: &Path,
4529    branch_config: &GitHubReleaseBranchSettings,
4530    release_version: &Version,
4531    tag_name: &str,
4532    target_commitish: &str,
4533) -> Result<String, String> {
4534    let branch_name =
4535        render_release_branch_name(&branch_config.naming_template, release_version, tag_name)?;
4536
4537    if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
4538        if existing_local_commit != target_commitish {
4539            return Err(format!(
4540                "Configured release branch `{}` already exists locally at {}, expected {}.",
4541                branch_name, existing_local_commit, target_commitish
4542            ));
4543        }
4544    } else {
4545        run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
4546    }
4547
4548    if let Some(existing_remote_commit) =
4549        git_remote_branch_commit(project_root, "origin", &branch_name)?
4550    {
4551        if existing_remote_commit != target_commitish {
4552            return Err(format!(
4553                "Configured release branch `{}` already exists on origin at {}, expected {}.",
4554                branch_name, existing_remote_commit, target_commitish
4555            ));
4556        }
4557    } else {
4558        run_git_command(
4559            project_root,
4560            &[
4561                "push",
4562                "origin",
4563                &format!("{}:refs/heads/{}", target_commitish, branch_name),
4564            ],
4565        )?;
4566    }
4567
4568    Ok(branch_name)
4569}
4570
4571fn release_tag_family(tag_name: &str) -> String {
4572    if tag_name.starts_with('v')
4573        && tag_name
4574            .chars()
4575            .nth(1)
4576            .map(|ch| ch.is_ascii_digit())
4577            .unwrap_or(false)
4578    {
4579        return "v".to_string();
4580    }
4581
4582    let mut family: String = String::new();
4583    for ch in tag_name.chars() {
4584        if ch.is_ascii_digit() {
4585            break;
4586        }
4587        family.push(ch);
4588    }
4589    family
4590}
4591
4592fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
4593    if family == "v" {
4594        return parse_version(tag).ok();
4595    }
4596
4597    tag.strip_prefix(family)
4598        .and_then(|rest| parse_version(rest).ok())
4599}
4600
4601fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
4602    let range: String = format!("{}..HEAD", tag);
4603    run_git_command(project_root, &["rev-list", "--count", &range])
4604        .ok()
4605        .and_then(|raw| raw.trim().parse::<usize>().ok())
4606}
4607
4608fn previous_release_tag(
4609    project_root: &Path,
4610    current_tag_name: &str,
4611) -> Result<Option<String>, String> {
4612    let family: String = release_tag_family(current_tag_name);
4613    let tag_pattern: String = format!("{}*", family);
4614    let merged_tags: String = run_git_command(
4615        project_root,
4616        &["tag", "--merged", "HEAD", "--list", &tag_pattern],
4617    )?;
4618
4619    let mut best: Option<(usize, String)> = None;
4620    for raw in merged_tags.lines() {
4621        let tag = raw.trim();
4622        if tag.is_empty() || tag == current_tag_name {
4623            continue;
4624        }
4625        if parse_release_family_version(tag, &family).is_none() {
4626            continue;
4627        }
4628
4629        let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
4630            continue;
4631        };
4632        if distance == 0 {
4633            continue;
4634        }
4635
4636        match &best {
4637            None => best = Some((distance, tag.to_string())),
4638            Some((best_distance, _)) if distance < *best_distance => {
4639                best = Some((distance, tag.to_string()))
4640            }
4641            _ => {}
4642        }
4643    }
4644
4645    Ok(best.map(|(_, tag)| tag))
4646}
4647
4648fn default_release_title(version: &Version, repo: &str) -> String {
4649    format!("{} - {}", version, repo)
4650}
4651
4652fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
4653    match version_scope {
4654        VersionScope::Repository => repo,
4655        VersionScope::Crate { package_name, .. } => package_name.as_str(),
4656        VersionScope::Service { service_name, .. } => service_name.as_str(),
4657    }
4658}
4659
4660fn version_scope_kind(version_scope: &VersionScope) -> &'static str {
4661    match version_scope {
4662        VersionScope::Repository => "repository",
4663        VersionScope::Crate { .. } => "crate",
4664        VersionScope::Service { .. } => "service",
4665    }
4666}
4667
4668fn version_scope_label(version_scope: &VersionScope, repo: &str) -> String {
4669    match version_scope {
4670        VersionScope::Repository => repo.to_string(),
4671        VersionScope::Crate { package_name, .. } => package_name.clone(),
4672        VersionScope::Service { service_name, .. } => service_name.clone(),
4673    }
4674}
4675
4676fn version_scope_prompt_label(version_scope: &VersionScope) -> String {
4677    match version_scope {
4678        VersionScope::Repository => "Repository".to_string(),
4679        VersionScope::Crate {
4680            package_name,
4681            crate_relative_root,
4682            ..
4683        } => format!("{} ({})", package_name, crate_relative_root),
4684        VersionScope::Service {
4685            service_name,
4686            service_relative_root,
4687            ..
4688        } => format!("{} ({})", service_name, service_relative_root),
4689    }
4690}
4691
4692fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
4693    match version_scope {
4694        VersionScope::Repository => format!("v{}", version),
4695        VersionScope::Crate { tag_prefix, .. } | VersionScope::Service { tag_prefix, .. } => {
4696            format!("{}{}", tag_prefix, version)
4697        }
4698    }
4699}
4700
4701fn scoped_release_tag_name(
4702    version_scope: &VersionScope,
4703    version: &Version,
4704    parsed_tag_name: &str,
4705) -> String {
4706    match version_scope {
4707        VersionScope::Repository => parsed_tag_name.to_string(),
4708        VersionScope::Crate { .. } | VersionScope::Service { .. } => {
4709            if release_tag_family(parsed_tag_name) == "v" {
4710                default_release_tag_name(version_scope, version)
4711            } else {
4712                parsed_tag_name.to_string()
4713            }
4714        }
4715    }
4716}
4717
4718fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
4719    match version_scope {
4720        VersionScope::Repository => None,
4721        VersionScope::Crate {
4722            crate_relative_root,
4723            ..
4724        } => Some(crate_relative_root.clone()),
4725        VersionScope::Service {
4726            service_relative_root,
4727            ..
4728        } if service_relative_root == "." => None,
4729        VersionScope::Service {
4730            service_relative_root,
4731            ..
4732        } => Some(service_relative_root.clone()),
4733    }
4734}
4735
4736fn resolve_optional_github_repository(project_root: &Path) -> (Option<String>, Option<String>) {
4737    let Some(origin_url) = git_remote_url(project_root, "origin").ok() else {
4738        return (None, None);
4739    };
4740    let Some((owner, repo)) = parse_github_repo_from_remote_url(&origin_url) else {
4741        return (None, None);
4742    };
4743
4744    (Some(owner), Some(repo))
4745}
4746
4747fn published_initiatives_to_activity(
4748    initiatives: &[PublishedLinearInitiative],
4749) -> Vec<VersionActivityLinearInitiative> {
4750    initiatives
4751        .iter()
4752        .map(|initiative| VersionActivityLinearInitiative {
4753            id: initiative.id.clone(),
4754            name: initiative.name.clone(),
4755            url: initiative.url.clone(),
4756        })
4757        .collect()
4758}
4759
4760async fn sync_cli_version_write_activity(
4761    project_root: &Path,
4762    version_scope: &VersionScope,
4763    version: &Version,
4764    message: String,
4765) {
4766    let (repository_owner, repository_name) = resolve_optional_github_repository(project_root);
4767    let scope_label = version_scope_label(
4768        version_scope,
4769        repository_name.as_deref().unwrap_or_else(|| {
4770            project_root
4771                .file_name()
4772                .and_then(|value| value.to_str())
4773                .unwrap_or("repository")
4774        }),
4775    );
4776
4777    let payload = CliVersionActivityPayload {
4778        command_kind: "version".to_string(),
4779        repository_owner,
4780        repository_name,
4781        scope_kind: version_scope_kind(version_scope).to_string(),
4782        scope_label,
4783        version: version.to_string(),
4784        tag_name: None,
4785        title: None,
4786        release_url: None,
4787        message_markdown: Some(message),
4788        published_initiatives: Vec::new(),
4789    };
4790
4791    if let Err(error) = post_version_activity(&payload).await {
4792        eprintln!("Warning: {}", error);
4793    }
4794}
4795
4796async fn sync_cli_release_activity(summary: &ReleaseWorkflowSummary) {
4797    let payload = CliVersionActivityPayload {
4798        command_kind: "version_release".to_string(),
4799        repository_owner: Some(summary.repository_owner.clone()),
4800        repository_name: Some(summary.repository_name.clone()),
4801        scope_kind: summary.scope_kind.clone(),
4802        scope_label: summary.scope_label.clone(),
4803        version: summary.version.to_string(),
4804        tag_name: Some(summary.tag_name.clone()),
4805        title: Some(summary.release_title.clone()),
4806        release_url: Some(summary.release_url.clone()),
4807        message_markdown: Some(summary.release_notes.clone()),
4808        published_initiatives: published_initiatives_to_activity(&summary.published_initiatives),
4809    };
4810
4811    if let Err(error) = post_version_activity(&payload).await {
4812        eprintln!("Warning: {}", error);
4813    }
4814}
4815
4816fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
4817    let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
4818    let mut rendered_notes: String = notes.trim_end().to_string();
4819    if !rendered_notes.is_empty() {
4820        rendered_notes.push('\n');
4821    }
4822    rendered_notes.push_str("Release label: ");
4823    rendered_notes.push_str(release_label);
4824    rendered_notes.push('\n');
4825    rendered_notes.push_str("Generated by XBP ");
4826    rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
4827    rendered_notes
4828}
4829
4830#[cfg(test)]
4831mod tests {
4832    use super::github_release::{
4833        github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
4834        github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
4835        github_release_update_endpoint,
4836    };
4837    use super::release_docs::{
4838        release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
4839    };
4840    use super::release_notes::{
4841        build_fallback_sections, collect_linear_issue_identifiers,
4842        deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
4843        LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
4844    };
4845    use super::{
4846        analyze_dirty_worktree, append_release_label_footer, bump_version, cargo_package_name,
4847        default_release_tag_name, default_release_title, highest_version_observation,
4848        is_safe_dirty_path, parse_git_status_path, parse_github_repo_from_remote_url,
4849        parse_local_git_tag_output, parse_local_git_tag_output_for_scope,
4850        parse_package_version_target, parse_release_version_target, parse_remote_git_tag_output,
4851        parse_version, prepare_release_openapi_assets, read_cargo_lock_version,
4852        read_cargo_lock_version_for_package, read_cargo_toml_version, read_json_openapi_version,
4853        read_json_root_version, read_openapi_version, read_package_name_from_lookup,
4854        read_pyproject_version, read_readme_version, read_regex_version, read_toml_root_version,
4855        read_version_from_blob, read_version_from_path, read_yaml_root_version,
4856        redact_remote_url_credentials, render_release_branch_name,
4857        resolve_configured_version_target_paths, resolve_linear_release_placeholders,
4858        resolve_release_openapi_specs, resolve_release_publish_target_filter,
4859        resolve_version_scope, rewrite_toml_package_assignment_versions,
4860        should_clear_version_change_guard, stale_version_observations,
4861        sync_version_to_configured_files_with_paths, write_cargo_lock_version,
4862        write_cargo_toml_version, write_chart_version, write_json_openapi_version,
4863        write_json_root_version, write_openapi_version, write_package_version_to_configured_files,
4864        write_pyproject_version, write_readme_version, write_regex_version,
4865        write_toml_root_version, write_version_to_configured_files, write_yaml_root_version,
4866        GitWorktreeState, ReleaseLatestPolicy, VersionChangeGuardEntry, VersionObservation,
4867        VersionScope,
4868    };
4869
4870    use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
4871    use crate::config::PackageNameLookup;
4872    use semver::Version;
4873    use std::collections::BTreeMap;
4874    use std::fs;
4875    use std::path::{Path, PathBuf};
4876    use std::time::{SystemTime, UNIX_EPOCH};
4877
4878    fn temp_dir(label: &str) -> PathBuf {
4879        let nanos: u128 = SystemTime::now()
4880            .duration_since(UNIX_EPOCH)
4881            .expect("time")
4882            .as_nanos();
4883        let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
4884        fs::create_dir_all(&dir).expect("create temp dir");
4885        dir
4886    }
4887
4888    #[test]
4889    fn parse_git_status_path_handles_renames() {
4890        assert_eq!(
4891            parse_git_status_path("R  old-name -> new-name/Cargo.toml"),
4892            Some("new-name/Cargo.toml".to_string())
4893        );
4894        assert_eq!(
4895            parse_git_status_path(" M .xbp/xbp.yaml"),
4896            Some(".xbp/xbp.yaml".to_string())
4897        );
4898    }
4899
4900    #[test]
4901    fn is_safe_dirty_path_recognizes_generated_paths() {
4902        assert!(is_safe_dirty_path(".xbp/xbp.yaml"));
4903        assert!(is_safe_dirty_path("crates/cli/target/debug/xbp"));
4904        assert!(is_safe_dirty_path("scripts/__pycache__/publish.cpython-312.pyc"));
4905        assert!(!is_safe_dirty_path("crates/cli/src/main.rs"));
4906        assert!(!is_safe_dirty_path("Cargo.toml"));
4907    }
4908
4909    #[test]
4910    fn analyze_dirty_worktree_splits_safe_and_risky_entries() {
4911        let analysis = analyze_dirty_worktree(&[
4912            " M .xbp/xbp.yaml".to_string(),
4913            " M crates/cli/src/main.rs".to_string(),
4914        ]);
4915        assert_eq!(analysis.safe_entries, vec![".xbp/xbp.yaml".to_string()]);
4916        assert_eq!(
4917            analysis.risky_entries,
4918            vec!["crates/cli/src/main.rs".to_string()]
4919        );
4920    }
4921
4922    fn write_multi_service_config(dir: &Path) {
4923        let xbp_dir = dir.join(".xbp");
4924        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
4925        fs::write(
4926            xbp_dir.join("xbp.yaml"),
4927            r#"project_name: xbp
4928version: 10.30.0
4929port: 3000
4930build_dir: ./
4931services:
4932  - name: cli
4933    target: rust
4934    branch: main
4935    port: 8080
4936    root_directory: ./
4937    version_targets:
4938      - crates/cli/Cargo.toml
4939  - name: web
4940    target: nextjs
4941    branch: main
4942    port: 3001
4943    root_directory: apps/web
4944    version_targets:
4945      - apps/web/package.json
4946publish:
4947  npm:
4948    enabled: true
4949    manifest_path: apps/web/package.json
4950  crates:
4951    enabled: true
4952    manifest_path: crates/cli/Cargo.toml
4953version_targets:
4954  - crates/cli/Cargo.toml
4955  - apps/web/package.json
4956"#,
4957        )
4958        .expect("write xbp config");
4959    }
4960
4961    #[test]
4962    fn parses_prefixed_semver() {
4963        assert_eq!(
4964            parse_version("v1.2.3").expect("version"),
4965            Version::new(1, 2, 3)
4966        );
4967    }
4968
4969    #[test]
4970    fn rejects_invalid_semver() {
4971        let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
4972        assert!(error.contains("Invalid semantic version"));
4973    }
4974
4975    #[test]
4976    fn release_target_parser_supports_plain_semver() {
4977        let (version, tag_name) =
4978            parse_release_version_target("1.2.3-alpha.1").expect("release target");
4979        assert_eq!(version.major, 1);
4980        assert_eq!(version.minor, 2);
4981        assert_eq!(version.patch, 3);
4982        assert_eq!(version.pre.as_str(), "alpha.1");
4983        assert_eq!(tag_name, "v1.2.3-alpha.1");
4984    }
4985
4986    #[test]
4987    fn release_target_parser_supports_prefixed_semver() {
4988        let (version, tag_name) =
4989            parse_release_version_target("studio-0.3.2-alpha").expect("release target");
4990        assert_eq!(version.major, 0);
4991        assert_eq!(version.minor, 3);
4992        assert_eq!(version.patch, 2);
4993        assert_eq!(version.pre.as_str(), "alpha");
4994        assert_eq!(tag_name, "studio-0.3.2-alpha");
4995    }
4996
4997    #[test]
4998    fn bumps_versions_correctly() {
4999        let base: Version = Version::new(0, 1, 0);
5000        assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
5001        assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
5002        assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
5003    }
5004
5005    #[test]
5006    fn version_change_guard_clears_when_worktree_is_clean() {
5007        let entry = VersionChangeGuardEntry {
5008            pending_version_change_count: 1,
5009            head_commit: Some("abc123".to_string()),
5010        };
5011        let state = GitWorktreeState {
5012            is_dirty: false,
5013            head_commit: Some("abc123".to_string()),
5014        };
5015        assert!(should_clear_version_change_guard(&entry, &state));
5016    }
5017
5018    #[test]
5019    fn version_change_guard_clears_when_head_changes() {
5020        let entry = VersionChangeGuardEntry {
5021            pending_version_change_count: 1,
5022            head_commit: Some("abc123".to_string()),
5023        };
5024        let state = GitWorktreeState {
5025            is_dirty: true,
5026            head_commit: Some("def456".to_string()),
5027        };
5028        assert!(should_clear_version_change_guard(&entry, &state));
5029    }
5030
5031    #[test]
5032    fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
5033        let entry = VersionChangeGuardEntry {
5034            pending_version_change_count: 1,
5035            head_commit: Some("abc123".to_string()),
5036        };
5037        let state = GitWorktreeState {
5038            is_dirty: true,
5039            head_commit: Some("abc123".to_string()),
5040        };
5041        assert!(!should_clear_version_change_guard(&entry, &state));
5042    }
5043
5044    #[test]
5045    fn render_release_branch_name_replaces_supported_tokens() {
5046        let branch = render_release_branch_name(
5047            "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
5048            &Version::new(10, 27, 0),
5049            "v10.27.0",
5050        )
5051        .expect("branch name");
5052
5053        assert_eq!(branch, "releases/10.27.0/v10.27.0");
5054    }
5055
5056    #[test]
5057    fn resolve_linear_release_placeholders_reads_env_files() {
5058        let temp_dir = std::env::temp_dir().join(format!(
5059            "xbp-linear-release-placeholders-{}",
5060            std::time::SystemTime::now()
5061                .duration_since(std::time::UNIX_EPOCH)
5062                .expect("time")
5063                .as_nanos()
5064        ));
5065        fs::create_dir_all(&temp_dir).expect("temp dir");
5066        fs::write(
5067            temp_dir.join(".env.local"),
5068            "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
5069        )
5070        .expect("env file");
5071
5072        let resolved = resolve_linear_release_placeholders(
5073            &temp_dir,
5074            ResolvedLinearReleaseConfig {
5075                initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
5076                organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
5077                health: "on_track".to_string(),
5078            },
5079        );
5080
5081        assert_eq!(
5082            resolved.initiative_ids,
5083            vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
5084        );
5085        assert_eq!(
5086            resolved.organization_name.as_deref(),
5087            Some("suits-formations")
5088        );
5089
5090        let _ = fs::remove_dir_all(temp_dir);
5091    }
5092
5093    #[test]
5094    fn version_change_guard_clears_when_pending_count_is_zero() {
5095        let entry = VersionChangeGuardEntry {
5096            pending_version_change_count: 0,
5097            head_commit: Some("abc123".to_string()),
5098        };
5099        let state = GitWorktreeState {
5100            is_dirty: true,
5101            head_commit: Some("abc123".to_string()),
5102        };
5103        assert!(should_clear_version_change_guard(&entry, &state));
5104    }
5105
5106    #[test]
5107    fn parse_package_version_target_supports_assignment_syntax() {
5108        let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
5109            .expect("parse")
5110            .expect("target");
5111        assert_eq!(parsed.0, "demo_pkg".to_string());
5112        assert_eq!(parsed.1, Version::new(1, 2, 3));
5113    }
5114
5115    #[test]
5116    fn parse_package_version_target_rejects_invalid_package_names() {
5117        let error: String = parse_package_version_target("bad package=1.2.3")
5118            .expect_err("invalid package target should fail");
5119        assert!(error.contains("Invalid package target"));
5120    }
5121
5122    #[test]
5123    fn parse_package_version_target_returns_none_without_assignment() {
5124        assert!(parse_package_version_target("1.2.3")
5125            .expect("parse")
5126            .is_none());
5127    }
5128
5129    #[test]
5130    fn parse_package_version_target_returns_none_for_empty_package_name() {
5131        assert!(parse_package_version_target(" =1.2.3")
5132            .expect("parse")
5133            .is_none());
5134    }
5135
5136    #[test]
5137    fn bumping_clears_prerelease_and_build_metadata() {
5138        let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
5139        assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
5140        assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
5141        assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
5142    }
5143
5144    #[test]
5145    fn cargo_toml_adapter_reads_and_writes() {
5146        let dir: PathBuf = temp_dir("cargo");
5147        let path: PathBuf = dir.join("Cargo.toml");
5148        fs::write(
5149            &path,
5150            r#"[package]
5151            name = "xbp"
5152            version = "1.0.0"
5153            "#,
5154        )
5155        .expect("write Cargo.toml");
5156
5157        assert_eq!(
5158            read_cargo_toml_version(&path).expect("read"),
5159            Some("1.0.0".to_string())
5160        );
5161
5162        write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
5163        assert_eq!(
5164            read_version_from_path(&path).expect("read"),
5165            Some("1.1.0".to_string())
5166        );
5167
5168        let _ = fs::remove_dir_all(dir);
5169    }
5170
5171    #[test]
5172    fn cargo_workspace_package_version_reads_and_writes_from_virtual_root() {
5173        let dir: PathBuf = temp_dir("workspace-root-version");
5174        let crate_dir = dir.join("crates").join("demo");
5175        fs::create_dir_all(&crate_dir).expect("create crate dir");
5176        fs::write(
5177            dir.join("Cargo.toml"),
5178            r#"[workspace]
5179members = ["crates/demo"]
5180resolver = "2"
5181
5182[workspace.package]
5183version = "0.1.0"
5184"#,
5185        )
5186        .expect("write workspace root");
5187        fs::write(
5188            crate_dir.join("Cargo.toml"),
5189            r#"[package]
5190name = "demo"
5191version = { workspace = true }
5192"#,
5193        )
5194        .expect("write crate manifest");
5195
5196        assert_eq!(
5197            read_cargo_toml_version(&dir.join("Cargo.toml")).expect("read root"),
5198            Some("0.1.0".to_string())
5199        );
5200        assert_eq!(
5201            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
5202            Some("0.1.0".to_string())
5203        );
5204
5205        write_cargo_toml_version(&dir.join("Cargo.toml"), &Version::new(0, 2, 0)).expect("write");
5206        assert_eq!(
5207            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate"),
5208            Some("0.2.0".to_string())
5209        );
5210
5211        let _ = fs::remove_dir_all(dir);
5212    }
5213
5214    #[test]
5215    fn json_root_adapter_reads_and_writes() {
5216        let dir: PathBuf = temp_dir("json");
5217        let path: PathBuf = dir.join("package.json");
5218        fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
5219
5220        assert_eq!(
5221            read_json_root_version(&path).expect("read"),
5222            Some("1.4.0".to_string())
5223        );
5224
5225        write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
5226        assert_eq!(
5227            read_version_from_path(&path).expect("read"),
5228            Some("1.5.0".to_string())
5229        );
5230
5231        let _ = fs::remove_dir_all(dir);
5232    }
5233
5234    #[test]
5235    fn yaml_root_adapter_reads_and_writes() {
5236        let dir: PathBuf = temp_dir("yaml");
5237        let path: PathBuf = dir.join("xbp.yaml");
5238        fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
5239
5240        assert_eq!(
5241            read_yaml_root_version(&path, "version").expect("read"),
5242            Some("0.2.0".to_string())
5243        );
5244
5245        write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
5246        assert_eq!(
5247            read_version_from_path(&path).expect("read"),
5248            Some("0.3.0".to_string())
5249        );
5250
5251        let _ = fs::remove_dir_all(dir);
5252    }
5253
5254    #[test]
5255    fn toml_root_adapter_reads_and_writes() {
5256        let dir: PathBuf = temp_dir("toml");
5257        let path: PathBuf = dir.join("config.toml");
5258        fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
5259
5260        assert_eq!(
5261            read_toml_root_version(&path).expect("read"),
5262            Some("3.1.4".to_string())
5263        );
5264
5265        write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
5266        assert_eq!(
5267            read_toml_root_version(&path).expect("read"),
5268            Some("3.2.0".to_string())
5269        );
5270
5271        let _ = fs::remove_dir_all(dir);
5272    }
5273
5274    #[test]
5275    fn openapi_adapter_reads_and_writes_nested_version() {
5276        let dir: PathBuf = temp_dir("openapi");
5277        let path: PathBuf = dir.join("openapi.yaml");
5278        fs::write(
5279            &path,
5280            "openapi: 3.0.3\ninfo:\n  title: Test\n  version: 1.2.3\n",
5281        )
5282        .expect("write openapi");
5283
5284        assert_eq!(
5285            read_openapi_version(&path).expect("read"),
5286            Some("1.2.3".to_string())
5287        );
5288
5289        write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
5290        assert_eq!(
5291            read_openapi_version(&path).expect("read"),
5292            Some("2.0.0".to_string())
5293        );
5294
5295        let _ = fs::remove_dir_all(dir);
5296    }
5297
5298    #[test]
5299    fn openapi_writer_creates_missing_info_mapping() {
5300        let dir: PathBuf = temp_dir("openapi-missing-info");
5301        let path: PathBuf = dir.join("openapi.yaml");
5302        fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
5303
5304        write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
5305        assert_eq!(
5306            read_openapi_version(&path).expect("read"),
5307            Some("4.0.0".to_string())
5308        );
5309
5310        let _ = fs::remove_dir_all(dir);
5311    }
5312
5313    #[test]
5314    fn json_openapi_adapter_reads_and_writes_nested_version() {
5315        let dir: PathBuf = temp_dir("openapi-json");
5316        let path: PathBuf = dir.join("openapi.json");
5317        fs::write(
5318            &path,
5319            r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
5320        )
5321        .expect("write openapi json");
5322
5323        assert_eq!(
5324            read_json_openapi_version(&path).expect("read"),
5325            Some("1.2.3".to_string())
5326        );
5327
5328        write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
5329        assert_eq!(
5330            read_json_openapi_version(&path).expect("read"),
5331            Some("2.1.0".to_string())
5332        );
5333
5334        let _ = fs::remove_dir_all(dir);
5335    }
5336
5337    #[test]
5338    fn json_openapi_writer_creates_missing_info_object() {
5339        let dir: PathBuf = temp_dir("openapi-json-missing-info");
5340        let path: PathBuf = dir.join("openapi.json");
5341        fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
5342
5343        write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
5344        assert_eq!(
5345            read_json_openapi_version(&path).expect("read"),
5346            Some("4.0.0".to_string())
5347        );
5348
5349        let _ = fs::remove_dir_all(dir);
5350    }
5351
5352    #[test]
5353    fn pyproject_reader_prefers_project_version() {
5354        let dir: PathBuf = temp_dir("pyproject-project");
5355        let path: PathBuf = dir.join("pyproject.toml");
5356        fs::write(
5357            &path,
5358            "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
5359        )
5360        .expect("write pyproject");
5361
5362        assert_eq!(
5363            read_pyproject_version(&path).expect("read"),
5364            Some("0.8.0".to_string())
5365        );
5366
5367        let _ = fs::remove_dir_all(dir);
5368    }
5369
5370    #[test]
5371    fn pyproject_reader_falls_back_to_poetry_version() {
5372        let dir: PathBuf = temp_dir("pyproject-poetry");
5373        let path: PathBuf = dir.join("pyproject.toml");
5374        fs::write(
5375            &path,
5376            "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
5377        )
5378        .expect("write pyproject");
5379
5380        assert_eq!(
5381            read_pyproject_version(&path).expect("read"),
5382            Some("1.9.0".to_string())
5383        );
5384
5385        let _ = fs::remove_dir_all(dir);
5386    }
5387
5388    #[test]
5389    fn pyproject_writer_updates_project_table() {
5390        let dir: PathBuf = temp_dir("pyproject-write-project");
5391        let path: PathBuf = dir.join("pyproject.toml");
5392        fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
5393            .expect("write pyproject");
5394
5395        write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
5396        assert_eq!(
5397            read_pyproject_version(&path).expect("read"),
5398            Some("1.1.0".to_string())
5399        );
5400
5401        let _ = fs::remove_dir_all(dir);
5402    }
5403
5404    #[test]
5405    fn pyproject_writer_updates_poetry_table() {
5406        let dir: PathBuf = temp_dir("pyproject-write-poetry");
5407        let path: PathBuf = dir.join("pyproject.toml");
5408        fs::write(
5409            &path,
5410            "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
5411        )
5412        .expect("write pyproject");
5413
5414        write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
5415        assert_eq!(
5416            read_pyproject_version(&path).expect("read"),
5417            Some("2.1.0".to_string())
5418        );
5419
5420        let _ = fs::remove_dir_all(dir);
5421    }
5422
5423    #[test]
5424    fn cargo_lock_reader_and_writer_follow_package_name() {
5425        let dir: PathBuf = temp_dir("cargo-lock");
5426        let cargo_toml: PathBuf = dir.join("Cargo.toml");
5427        let cargo_lock: PathBuf = dir.join("Cargo.lock");
5428        fs::write(
5429            &cargo_toml,
5430            r#"[package]
5431            name = "xbp"
5432            version = "1.0.0"
5433            "#,
5434        )
5435        .expect("write Cargo.toml");
5436        fs::write(
5437            &cargo_lock,
5438            r#"version = 4
5439
5440            [[package]]
5441            name = "xbp"
5442            version = "1.0.0"
5443
5444            [[package]]
5445            name = "other"
5446            version = "9.9.9"
5447            "#,
5448        )
5449        .expect("write Cargo.lock");
5450
5451        assert_eq!(
5452            read_cargo_lock_version(&cargo_lock).expect("read"),
5453            Some("1.0.0".to_string())
5454        );
5455
5456        write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
5457        assert_eq!(
5458            read_cargo_lock_version(&cargo_lock).expect("read"),
5459            Some("1.0.1".to_string())
5460        );
5461
5462        let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
5463        assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
5464
5465        let _ = fs::remove_dir_all(dir);
5466    }
5467
5468    #[test]
5469    fn cargo_lock_writer_errors_when_package_missing() {
5470        let dir: PathBuf = temp_dir("cargo-lock-missing");
5471        fs::write(
5472            dir.join("Cargo.toml"),
5473            "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
5474        )
5475        .expect("write Cargo.toml");
5476        let cargo_lock: PathBuf = dir.join("Cargo.lock");
5477        fs::write(
5478            &cargo_lock,
5479            "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
5480        )
5481        .expect("write Cargo.lock");
5482
5483        let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
5484            .expect_err("missing package should fail");
5485        assert!(error.contains("Could not find package `xbp`"));
5486
5487        let _ = fs::remove_dir_all(dir);
5488    }
5489
5490    #[test]
5491    fn cargo_package_name_reads_package_section() {
5492        let dir: PathBuf = temp_dir("cargo-package-name");
5493        let cargo_lock: PathBuf = dir.join("Cargo.lock");
5494        fs::write(
5495            dir.join("Cargo.toml"),
5496            "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
5497        )
5498        .expect("write Cargo.toml");
5499        fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
5500
5501        assert_eq!(
5502            cargo_package_name(&cargo_lock).expect("name"),
5503            Some("xbp-cli".to_string())
5504        );
5505
5506        let _ = fs::remove_dir_all(dir);
5507    }
5508
5509    #[test]
5510    fn cargo_toml_writer_skips_workspace_manifest_without_package() {
5511        let dir: PathBuf = temp_dir("cargo-workspace-manifest");
5512        let path: PathBuf = dir.join("Cargo.toml");
5513        fs::write(
5514            &path,
5515            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
5516        )
5517        .expect("write Cargo.toml");
5518
5519        let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
5520        assert!(changed);
5521        let content = fs::read_to_string(&path).expect("read Cargo.toml");
5522        assert!(content.contains("[workspace.package]"));
5523        assert!(content.contains("version = \"2.0.0\""));
5524
5525        let _ = fs::remove_dir_all(dir);
5526    }
5527
5528    #[test]
5529    fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
5530        let dir: PathBuf = temp_dir("workspace-cargo-skip");
5531        fs::write(
5532            dir.join("Cargo.toml"),
5533            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
5534        )
5535        .expect("write Cargo.toml");
5536        fs::write(
5537            dir.join("Cargo.lock"),
5538            "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
5539        )
5540        .expect("write Cargo.lock");
5541        fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
5542            .expect("write README");
5543
5544        let updated = write_version_to_configured_files(
5545            &dir,
5546            &dir,
5547            &[
5548                "Cargo.toml".to_string(),
5549                "Cargo.lock".to_string(),
5550                "README.md".to_string(),
5551            ],
5552            &VersionScope::Repository,
5553            &Version::new(1, 1, 0),
5554        )
5555        .expect("write versions");
5556
5557        assert_eq!(updated, 2);
5558        assert_eq!(
5559            read_readme_version(&dir.join("README.md")).expect("read"),
5560            Some("1.1.0".to_string())
5561        );
5562        let cargo_content = fs::read_to_string(dir.join("Cargo.toml")).expect("read Cargo.toml");
5563        assert!(cargo_content.contains("version = \"1.1.0\""));
5564
5565        let _ = fs::remove_dir_all(dir);
5566    }
5567
5568    #[test]
5569    fn repository_scope_prefers_workspace_default_member_manifest() {
5570        let dir: PathBuf = temp_dir("workspace-default-member-path");
5571        let crate_dir: PathBuf = dir.join("crates").join("cli");
5572        fs::create_dir_all(&crate_dir).expect("create crate dir");
5573        fs::write(
5574            dir.join("Cargo.toml"),
5575            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
5576        )
5577        .expect("write workspace cargo");
5578        fs::write(
5579            crate_dir.join("Cargo.toml"),
5580            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
5581        )
5582        .expect("write crate cargo");
5583
5584        let resolved = super::resolve_registry_relative_path(
5585            &dir,
5586            &dir,
5587            &VersionScope::Repository,
5588            "Cargo.toml",
5589        );
5590
5591        assert_eq!(resolved, "crates/cli/Cargo.toml");
5592
5593        let _ = fs::remove_dir_all(dir);
5594    }
5595
5596    #[test]
5597    fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
5598        let dir: PathBuf = temp_dir("workspace-default-member-writer");
5599        let crate_dir: PathBuf = dir.join("crates").join("cli");
5600        fs::create_dir_all(&crate_dir).expect("create crate dir");
5601        fs::write(
5602            dir.join("Cargo.toml"),
5603            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
5604        )
5605        .expect("write workspace cargo");
5606        fs::write(
5607            crate_dir.join("Cargo.toml"),
5608            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
5609        )
5610        .expect("write crate cargo");
5611        fs::write(
5612            dir.join("Cargo.lock"),
5613            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
5614        )
5615        .expect("write cargo lock");
5616        fs::write(
5617            dir.join("README.md"),
5618            "# XBP\n\ncurrent version: `10.21.0`\n",
5619        )
5620        .expect("write readme");
5621
5622        let updated = write_version_to_configured_files(
5623            &dir,
5624            &dir,
5625            &[
5626                "Cargo.toml".to_string(),
5627                "Cargo.lock".to_string(),
5628                "README.md".to_string(),
5629            ],
5630            &VersionScope::Repository,
5631            &Version::new(10, 22, 0),
5632        )
5633        .expect("write versions");
5634
5635        assert_eq!(updated, 3);
5636        assert_eq!(
5637            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
5638            Some("10.22.0".to_string())
5639        );
5640        assert_eq!(
5641            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
5642            Some("10.22.0".to_string())
5643        );
5644        assert_eq!(
5645            read_readme_version(&dir.join("README.md")).expect("read readme"),
5646            Some("10.22.0".to_string())
5647        );
5648
5649        let _ = fs::remove_dir_all(dir);
5650    }
5651
5652    #[test]
5653    fn configured_writer_updates_publish_manifest_paths_from_xbp_config() {
5654        let dir: PathBuf = temp_dir("publish-manifest-version-target");
5655        let package_dir = dir.join("packages").join("heroui");
5656        let xbp_dir = dir.join(".xbp");
5657        fs::create_dir_all(&package_dir).expect("create package dir");
5658        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
5659
5660        fs::write(
5661            xbp_dir.join("xbp.yaml"),
5662            r#"project_name: athena-auth-ui
5663version: 0.3.1
5664port: 4000
5665build_dir: ./
5666publish:
5667  npm:
5668    enabled: true
5669    working_directory: packages/heroui
5670    manifest_path: packages/heroui/package.json
5671"#,
5672        )
5673        .expect("write xbp config");
5674        fs::write(
5675            dir.join("package.json"),
5676            r#"{"name":"athena-auth-ui","version":"0.3.1"}"#,
5677        )
5678        .expect("write root package");
5679        fs::write(
5680            package_dir.join("package.json"),
5681            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.1.1"}"#,
5682        )
5683        .expect("write package manifest");
5684
5685        let updated = write_version_to_configured_files(
5686            &dir,
5687            &dir,
5688            &["package.json".to_string()],
5689            &VersionScope::Repository,
5690            &Version::new(0, 3, 1),
5691        )
5692        .expect("write versions");
5693
5694        assert_eq!(updated, 2);
5695        assert_eq!(
5696            read_json_root_version(&dir.join("package.json")).expect("read root package"),
5697            Some("0.3.1".to_string())
5698        );
5699        assert_eq!(
5700            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
5701            Some("0.3.1".to_string())
5702        );
5703
5704        let _ = fs::remove_dir_all(dir);
5705    }
5706
5707    #[test]
5708    fn configured_writer_updates_version_targets_from_xbp_config() {
5709        let dir: PathBuf = temp_dir("explicit-version-targets");
5710        let app_dir = dir.join("apps").join("web");
5711        let cli_dir = dir.join("crates").join("cli");
5712        let xbp_dir = dir.join(".xbp");
5713        fs::create_dir_all(&app_dir).expect("create app dir");
5714        fs::create_dir_all(&cli_dir).expect("create cli dir");
5715        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
5716
5717        fs::write(
5718            xbp_dir.join("xbp.yaml"),
5719            r#"project_name: xbp
5720version: 10.30.0
5721port: 3000
5722build_dir: ./
5723version_targets:
5724  - crates/cli/Cargo.toml
5725  - apps/web/package.json
5726"#,
5727        )
5728        .expect("write xbp config");
5729        fs::write(
5730            cli_dir.join("Cargo.toml"),
5731            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
5732        )
5733        .expect("write cli Cargo.toml");
5734        fs::write(
5735            app_dir.join("package.json"),
5736            r#"{"name":"@xbp/dashboard","version":"10.29.0","private":true}"#,
5737        )
5738        .expect("write app package");
5739
5740        let updated = write_version_to_configured_files(
5741            &dir,
5742            &dir,
5743            &[".xbp/xbp.yaml".to_string()],
5744            &VersionScope::Repository,
5745            &Version::new(10, 30, 0),
5746        )
5747        .expect("write versions");
5748
5749        assert_eq!(updated, 3);
5750        assert_eq!(
5751            read_yaml_root_version(&xbp_dir.join("xbp.yaml"), "version").expect("read xbp config"),
5752            Some("10.30.0".to_string())
5753        );
5754        assert_eq!(
5755            read_cargo_toml_version(&cli_dir.join("Cargo.toml")).expect("read cli cargo"),
5756            Some("10.30.0".to_string())
5757        );
5758        assert_eq!(
5759            read_json_root_version(&app_dir.join("package.json")).expect("read app package"),
5760            Some("10.30.0".to_string())
5761        );
5762
5763        let _ = fs::remove_dir_all(dir);
5764    }
5765
5766    #[test]
5767    fn sync_writer_allows_already_aligned_publish_manifest_paths_from_xbp_config() {
5768        let dir: PathBuf = temp_dir("publish-manifest-version-sync-noop");
5769        let package_dir = dir.join("packages").join("heroui");
5770        let xbp_dir = dir.join(".xbp");
5771        fs::create_dir_all(&package_dir).expect("create package dir");
5772        fs::create_dir_all(&xbp_dir).expect("create xbp dir");
5773
5774        fs::write(
5775            xbp_dir.join("xbp.yaml"),
5776            r#"project_name: athena-auth-ui
5777version: 0.3.0
5778port: 4000
5779build_dir: ./
5780publish:
5781  npm:
5782    enabled: true
5783    working_directory: packages/heroui
5784    manifest_path: packages/heroui/package.json
5785"#,
5786        )
5787        .expect("write xbp config");
5788        fs::write(
5789            dir.join("package.json"),
5790            r#"{"name":"athena-auth-ui","version":"0.3.0"}"#,
5791        )
5792        .expect("write root package");
5793        fs::write(
5794            package_dir.join("package.json"),
5795            r#"{"name":"@xylex-group/athena-auth-ui","version":"0.3.0"}"#,
5796        )
5797        .expect("write package manifest");
5798
5799        let _updated_paths = sync_version_to_configured_files_with_paths(
5800            &dir,
5801            &dir,
5802            &["package.json".to_string()],
5803            &VersionScope::Repository,
5804            &Version::new(0, 3, 0),
5805        )
5806        .expect("sync versions");
5807
5808        assert_eq!(
5809            read_json_root_version(&dir.join("package.json")).expect("read root package"),
5810            Some("0.3.0".to_string())
5811        );
5812        assert_eq!(
5813            read_json_root_version(&package_dir.join("package.json")).expect("read package"),
5814            Some("0.3.0".to_string())
5815        );
5816
5817        let _ = fs::remove_dir_all(dir);
5818    }
5819
5820    #[test]
5821    fn readme_adapter_updates_current_version_marker() {
5822        let dir: PathBuf = temp_dir("readme");
5823        let path: PathBuf = dir.join("README.md");
5824        fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
5825
5826        write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
5827        assert_eq!(
5828            read_readme_version(&path).expect("read"),
5829            Some("1.2.0".to_string())
5830        );
5831
5832        let _ = fs::remove_dir_all(dir);
5833    }
5834
5835    #[test]
5836    fn readme_writer_inserts_marker_when_missing() {
5837        let dir: PathBuf = temp_dir("readme-insert");
5838        let path: PathBuf = dir.join("README.md");
5839        fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
5840
5841        write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
5842        let content: String = fs::read_to_string(&path).expect("read readme");
5843        assert!(content.contains("current version: `3.0.0`"));
5844
5845        let _ = fs::remove_dir_all(dir);
5846    }
5847
5848    #[test]
5849    fn regex_adapter_reads_and_writes_versions() {
5850        let dir: PathBuf = temp_dir("regex");
5851        let path: PathBuf = dir.join("build.gradle");
5852        fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
5853
5854        assert_eq!(
5855            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
5856            Some("5.4.3".to_string())
5857        );
5858
5859        write_regex_version(
5860            &path,
5861            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
5862            &Version::new(5, 5, 0),
5863        )
5864        .expect("write");
5865
5866        assert_eq!(
5867            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
5868            Some("5.5.0".to_string())
5869        );
5870
5871        let _ = fs::remove_dir_all(dir);
5872    }
5873
5874    #[test]
5875    fn regex_writer_errors_without_matching_pattern() {
5876        let dir: PathBuf = temp_dir("regex-miss");
5877        let path: PathBuf = dir.join("build.gradle");
5878        fs::write(&path, "group = 'demo'\n").expect("write gradle");
5879
5880        let error: String = write_regex_version(
5881            &path,
5882            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
5883            &Version::new(1, 0, 0),
5884        )
5885        .expect_err("missing version should fail");
5886        assert!(error.contains("No version pattern found"));
5887
5888        let _ = fs::remove_dir_all(dir);
5889    }
5890
5891    #[test]
5892    fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
5893        let original: &str = r#"[dependencies]
5894            serde = "1.0.219"
5895            tokio = { version = "1.44.1", features = ["full"] }
5896            "#;
5897
5898        let (updated, changed) =
5899            rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
5900                .expect("rewrite");
5901        assert!(changed);
5902        assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
5903
5904        let (updated, changed) =
5905            rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
5906                .expect("rewrite");
5907        assert!(changed);
5908        assert!(updated.contains(r#"serde = "1.1.0""#));
5909    }
5910
5911    #[test]
5912    fn package_version_writer_updates_registry_toml_targets() {
5913        let dir: PathBuf = temp_dir("package-version-registry");
5914        let cargo_toml: PathBuf = dir.join("Cargo.toml");
5915        fs::write(
5916            &cargo_toml,
5917            r#"[package]
5918            name = "demo"
5919            version = "0.1.0"
5920
5921            [dependencies]
5922            serde = "1.0.219"
5923            tokio = { version = "1.44.1", features = ["full"] }
5924            "#,
5925        )
5926        .expect("write Cargo.toml");
5927
5928        let updated: usize = write_package_version_to_configured_files(
5929            &dir,
5930            &dir,
5931            &["Cargo.toml".to_string()],
5932            &VersionScope::Repository,
5933            "tokio",
5934            &Version::new(1, 45, 1),
5935        )
5936        .expect("update package assignment");
5937        assert_eq!(updated, 1);
5938
5939        let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
5940        assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
5941
5942        let _ = fs::remove_dir_all(dir);
5943    }
5944
5945    #[test]
5946    fn package_version_writer_errors_when_package_assignment_not_found() {
5947        let dir: PathBuf = temp_dir("package-version-missing");
5948        let cargo_toml: PathBuf = dir.join("Cargo.toml");
5949        fs::write(
5950            &cargo_toml,
5951            r#"[package]
5952        name = "demo"
5953        version = "0.1.0"
5954
5955        [dependencies]
5956        serde = "1.0.219"
5957        "#,
5958        )
5959        .expect("write Cargo.toml");
5960
5961        let error: String = write_package_version_to_configured_files(
5962            &dir,
5963            &dir,
5964            &["Cargo.toml".to_string()],
5965            &VersionScope::Repository,
5966            "tokio",
5967            &Version::new(1, 45, 1),
5968        )
5969        .expect_err("missing package assignment should fail");
5970        assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
5971
5972        let _ = fs::remove_dir_all(dir);
5973    }
5974
5975    #[test]
5976    fn chart_writer_updates_app_version_when_present() {
5977        let dir: PathBuf = temp_dir("chart");
5978        let path: PathBuf = dir.join("Chart.yaml");
5979        fs::write(
5980            &path,
5981            "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
5982        )
5983        .expect("write chart");
5984
5985        write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
5986        let content: String = fs::read_to_string(&path).expect("read chart");
5987        assert!(content.contains("version: 0.2.0"));
5988        assert!(content.contains("appVersion: 0.2.0"));
5989
5990        let _ = fs::remove_dir_all(dir);
5991    }
5992
5993    #[test]
5994    fn configured_file_writer_deduplicates_registry_entries() {
5995        let dir: PathBuf = temp_dir("dedupe");
5996        let readme: PathBuf = dir.join("README.md");
5997        fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
5998
5999        let updated: usize = write_version_to_configured_files(
6000            &dir,
6001            &dir,
6002            &[
6003                "README.md".to_string(),
6004                "README.md".to_string(),
6005                "missing.md".to_string(),
6006            ],
6007            &VersionScope::Repository,
6008            &Version::new(1, 1, 0),
6009        )
6010        .expect("write versions");
6011
6012        assert_eq!(updated, 1);
6013        assert_eq!(
6014            read_readme_version(&readme).expect("read"),
6015            Some("1.1.0".to_string())
6016        );
6017
6018        let _ = fs::remove_dir_all(dir);
6019    }
6020
6021    #[test]
6022    fn configured_file_writer_prefers_invocation_directory_targets() {
6023        let dir: PathBuf = temp_dir("invocation-precedence");
6024        let app_dir: PathBuf = dir.join("apps").join("web");
6025        fs::create_dir_all(&app_dir).expect("create app dir");
6026
6027        let root_package: PathBuf = dir.join("package.json");
6028        let app_package: PathBuf = app_dir.join("package.json");
6029        fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
6030            .expect("write root package");
6031        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
6032            .expect("write app package");
6033
6034        let updated: usize = write_version_to_configured_files(
6035            &dir,
6036            &app_dir,
6037            &["package.json".to_string()],
6038            &VersionScope::Repository,
6039            &Version::new(2, 14, 0),
6040        )
6041        .expect("write versions");
6042        assert_eq!(updated, 1);
6043
6044        assert_eq!(
6045            read_json_root_version(&root_package).expect("read root"),
6046            Some("9.9.9".to_string())
6047        );
6048        assert_eq!(
6049            read_json_root_version(&app_package).expect("read app"),
6050            Some("2.14.0".to_string())
6051        );
6052
6053        let _ = fs::remove_dir_all(dir);
6054    }
6055
6056    #[test]
6057    fn resolve_version_scope_detects_crate_scoped_invocation() {
6058        let dir: PathBuf = temp_dir("crate-scope");
6059        let crate_dir: PathBuf = dir.join("crates").join("alpha");
6060        let nested_dir: PathBuf = crate_dir.join("src");
6061        fs::create_dir_all(&nested_dir).expect("create nested dir");
6062        fs::write(
6063            crate_dir.join("Cargo.toml"),
6064            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
6065        )
6066        .expect("write Cargo.toml");
6067
6068        let scope = resolve_version_scope(&dir, &nested_dir);
6069        match scope {
6070            VersionScope::Crate {
6071                package_name,
6072                crate_relative_root,
6073                tag_prefix,
6074                ..
6075            } => {
6076                assert_eq!(package_name, "alpha-crate");
6077                assert_eq!(crate_relative_root, "crates/alpha");
6078                assert_eq!(tag_prefix, "alpha-crate-");
6079            }
6080            _ => panic!("expected crate scope"),
6081        }
6082
6083        let _ = fs::remove_dir_all(dir);
6084    }
6085
6086    #[test]
6087    fn resolve_version_scope_detects_service_scoped_invocation() {
6088        let dir: PathBuf = temp_dir("service-scope");
6089        let service_dir: PathBuf = dir.join("apps").join("web");
6090        let nested_dir: PathBuf = service_dir.join("src");
6091        let cli_dir: PathBuf = dir.join("crates").join("cli");
6092        fs::create_dir_all(&nested_dir).expect("create nested dir");
6093        fs::create_dir_all(&cli_dir).expect("create cli dir");
6094        write_multi_service_config(&dir);
6095        fs::write(
6096            service_dir.join("package.json"),
6097            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
6098        )
6099        .expect("write package");
6100        fs::write(
6101            cli_dir.join("Cargo.toml"),
6102            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6103        )
6104        .expect("write cargo");
6105
6106        let scope = resolve_version_scope(&dir, &nested_dir);
6107        match scope {
6108            VersionScope::Service {
6109                service_name,
6110                service_relative_root,
6111                tag_prefix,
6112                ..
6113            } => {
6114                assert_eq!(service_name, "web");
6115                assert_eq!(service_relative_root, "apps/web");
6116                assert_eq!(tag_prefix, "web-");
6117            }
6118            _ => panic!("expected service scope"),
6119        }
6120
6121        let _ = fs::remove_dir_all(dir);
6122    }
6123
6124    #[test]
6125    fn service_scoped_configured_targets_only_include_selected_service() {
6126        let dir: PathBuf = temp_dir("service-targets");
6127        let service_dir: PathBuf = dir.join("apps").join("web");
6128        let nested_dir: PathBuf = service_dir.join("src");
6129        let cli_dir: PathBuf = dir.join("crates").join("cli");
6130        fs::create_dir_all(&nested_dir).expect("create nested dir");
6131        fs::create_dir_all(&cli_dir).expect("create cli dir");
6132        write_multi_service_config(&dir);
6133        fs::write(
6134            service_dir.join("package.json"),
6135            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
6136        )
6137        .expect("write package");
6138        fs::write(
6139            cli_dir.join("Cargo.toml"),
6140            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6141        )
6142        .expect("write cargo");
6143
6144        let scope = resolve_version_scope(&dir, &nested_dir);
6145        let targets = resolve_configured_version_target_paths(&dir, &nested_dir, &scope);
6146        assert_eq!(targets.len(), 1);
6147        assert_eq!(targets[0].relative, "apps/web/package.json");
6148
6149        let _ = fs::remove_dir_all(dir);
6150    }
6151
6152    #[test]
6153    fn service_scoped_release_publish_filter_selects_matching_target() {
6154        let dir: PathBuf = temp_dir("service-publish-filter");
6155        let service_dir: PathBuf = dir.join("apps").join("web");
6156        let nested_dir: PathBuf = service_dir.join("src");
6157        let cli_dir: PathBuf = dir.join("crates").join("cli");
6158        fs::create_dir_all(&nested_dir).expect("create nested dir");
6159        fs::create_dir_all(&cli_dir).expect("create cli dir");
6160        write_multi_service_config(&dir);
6161        fs::write(
6162            service_dir.join("package.json"),
6163            r#"{"name":"@xbp/web","version":"10.29.0"}"#,
6164        )
6165        .expect("write package");
6166        fs::write(
6167            cli_dir.join("Cargo.toml"),
6168            "[package]\nname = \"xbp\"\nversion = \"10.29.0\"\n",
6169        )
6170        .expect("write cargo");
6171
6172        let scope = resolve_version_scope(&dir, &nested_dir);
6173        let publish_target =
6174            resolve_release_publish_target_filter(&nested_dir, &scope).expect("resolve publish");
6175        assert_eq!(publish_target.as_deref(), Some("npm"));
6176
6177        let _ = fs::remove_dir_all(dir);
6178    }
6179
6180    #[test]
6181    fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
6182        let dir: PathBuf = temp_dir("crate-writer");
6183        let crate_dir: PathBuf = dir.join("crates").join("alpha");
6184        fs::create_dir_all(&crate_dir).expect("create crate dir");
6185        fs::write(
6186            crate_dir.join("Cargo.toml"),
6187            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
6188        )
6189        .expect("write crate Cargo.toml");
6190        fs::write(
6191            dir.join("Cargo.lock"),
6192            "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
6193        )
6194        .expect("write Cargo.lock");
6195        fs::write(
6196            dir.join("README.md"),
6197            "# root\n\ncurrent version: `9.9.9`\n",
6198        )
6199        .expect("write root readme");
6200
6201        let scope = resolve_version_scope(&dir, &crate_dir);
6202        let updated = write_version_to_configured_files(
6203            &dir,
6204            &crate_dir,
6205            &[
6206                "Cargo.toml".to_string(),
6207                "Cargo.lock".to_string(),
6208                "README.md".to_string(),
6209            ],
6210            &scope,
6211            &Version::new(1, 3, 0),
6212        )
6213        .expect("write versions");
6214
6215        assert_eq!(updated, 2);
6216        assert_eq!(
6217            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
6218            Some("1.3.0".to_string())
6219        );
6220        assert_eq!(
6221            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
6222                .expect("read cargo lock"),
6223            Some("1.3.0".to_string())
6224        );
6225        assert_eq!(
6226            read_readme_version(&dir.join("README.md")).expect("read readme"),
6227            Some("9.9.9".to_string())
6228        );
6229
6230        let _ = fs::remove_dir_all(dir);
6231    }
6232
6233    #[test]
6234    fn release_openapi_resolution_prefers_crate_scope() {
6235        let dir: PathBuf = temp_dir("release-openapi-crate");
6236        let crate_dir: PathBuf = dir.join("crates").join("monitor");
6237        let nested_dir: PathBuf = crate_dir.join("src");
6238        fs::create_dir_all(&nested_dir).expect("create nested dir");
6239        fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
6240        let crate_openapi: PathBuf = crate_dir.join("openapi.json");
6241        fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
6242
6243        let scope = resolve_version_scope(&dir, &nested_dir);
6244        let resolved = resolve_release_openapi_specs(&dir, &nested_dir, &scope)
6245            .expect("crate-scoped openapi")
6246            .into_iter()
6247            .next()
6248            .expect("crate-scoped openapi");
6249        assert_eq!(resolved, crate_openapi);
6250
6251        let _ = fs::remove_dir_all(dir);
6252    }
6253
6254    #[test]
6255    fn release_openapi_resolution_falls_back_to_repo_root() {
6256        let dir: PathBuf = temp_dir("release-openapi-root");
6257        let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
6258        fs::create_dir_all(&crate_dir).expect("create crate dir");
6259        let root_openapi: PathBuf = dir.join("openapi.json");
6260        fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
6261
6262        let scope = resolve_version_scope(&dir, &dir);
6263        let resolved = resolve_release_openapi_specs(&dir, &crate_dir, &scope)
6264            .expect("repo root openapi")
6265            .into_iter()
6266            .next()
6267            .expect("repo root openapi");
6268        assert_eq!(resolved, root_openapi);
6269
6270        let _ = fs::remove_dir_all(dir);
6271    }
6272
6273    #[test]
6274    fn release_openapi_assets_use_versioned_release_tag_names() {
6275        let dir: PathBuf = temp_dir("release-openapi-assets");
6276        fs::write(
6277            dir.join("openapi.yaml"),
6278            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.2\n",
6279        )
6280        .expect("write http openapi");
6281        fs::write(
6282            dir.join("openapi-wss.yaml"),
6283            "openapi: 3.0.3\ninfo:\n  title: Athena WSS\n  version: 1.3.0\n",
6284        )
6285        .expect("write wss openapi");
6286
6287        let assets = prepare_release_openapi_assets(
6288            &dir,
6289            &dir,
6290            &VersionScope::Repository,
6291            &Version::new(3, 15, 2),
6292            "v3.15.2",
6293        )
6294        .expect("prepare release assets");
6295
6296        assert_eq!(
6297            assets
6298                .iter()
6299                .map(|asset| asset.asset_name.as_str())
6300                .collect::<Vec<_>>(),
6301            vec!["openapi-v3.15.2.yaml", "openapi-wss-v3.15.2.yaml"]
6302        );
6303        assert_eq!(assets[0].source_path, dir.join("openapi.yaml"));
6304        assert_eq!(assets[1].source_path, dir.join("openapi-wss.yaml"));
6305
6306        let _ = fs::remove_dir_all(dir);
6307    }
6308
6309    #[test]
6310    fn release_openapi_assets_reject_http_version_mismatch() {
6311        let dir: PathBuf = temp_dir("release-openapi-assets-mismatch");
6312        fs::write(
6313            dir.join("openapi.yaml"),
6314            "openapi: 3.0.3\ninfo:\n  title: Athena RS\n  version: 3.15.1\n",
6315        )
6316        .expect("write http openapi");
6317
6318        let error = prepare_release_openapi_assets(
6319            &dir,
6320            &dir,
6321            &VersionScope::Repository,
6322            &Version::new(3, 15, 2),
6323            "v3.15.2",
6324        )
6325        .expect_err("mismatched OpenAPI version should fail");
6326
6327        assert!(error.contains("does not match release version"));
6328
6329        let _ = fs::remove_dir_all(dir);
6330    }
6331
6332    #[test]
6333    fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
6334        let dir: PathBuf = temp_dir("invocation-dedupe");
6335        let app_dir: PathBuf = dir.join("apps").join("web");
6336        fs::create_dir_all(&app_dir).expect("create app dir");
6337
6338        let app_package: PathBuf = app_dir.join("package.json");
6339        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
6340            .expect("write app package");
6341
6342        let updated: usize = write_version_to_configured_files(
6343            &dir,
6344            &app_dir,
6345            &[
6346                "package.json".to_string(),
6347                "apps/web/package.json".to_string(),
6348            ],
6349            &VersionScope::Repository,
6350            &Version::new(2, 14, 0),
6351        )
6352        .expect("write versions");
6353        assert_eq!(updated, 1);
6354
6355        assert_eq!(
6356            read_json_root_version(&app_package).expect("read app"),
6357            Some("2.14.0".to_string())
6358        );
6359
6360        let _ = fs::remove_dir_all(dir);
6361    }
6362
6363    #[test]
6364    fn configured_file_writer_errors_when_no_targets_exist() {
6365        let dir: PathBuf = temp_dir("no-targets");
6366        let error: String = write_version_to_configured_files(
6367            &dir,
6368            &dir,
6369            &["missing.toml".to_string()],
6370            &VersionScope::Repository,
6371            &Version::new(1, 0, 0),
6372        )
6373        .expect_err("missing targets should fail");
6374
6375        assert!(error.contains("No configured version files were found"));
6376
6377        let _ = fs::remove_dir_all(dir);
6378    }
6379
6380    #[test]
6381    fn remote_git_tag_parser_deduplicates_peeled_refs() {
6382        let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
6383            "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
6384        );
6385
6386        assert_eq!(parsed.len(), 2);
6387        assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
6388        assert_eq!(
6389            parsed[1].version,
6390            Version::parse("0.1.7-exp").expect("version")
6391        );
6392        assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
6393    }
6394
6395    #[test]
6396    fn local_git_tag_parser_normalizes_prefixed_versions() {
6397        let parsed: Vec<crate::commands::version::GitTagObservation> =
6398            parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
6399
6400        assert_eq!(parsed.len(), 2);
6401        assert_eq!(parsed[0].version, Version::new(1, 0, 0));
6402        assert_eq!(
6403            parsed[0].raw_tags,
6404            vec!["1.0.0".to_string(), "v1.0.0".to_string()]
6405        );
6406    }
6407
6408    #[test]
6409    fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
6410        let scope = VersionScope::Crate {
6411            crate_root: PathBuf::from("/tmp/crates/alpha"),
6412            crate_relative_root: "crates/alpha".to_string(),
6413            package_name: "alpha-crate".to_string(),
6414            tag_prefix: "alpha-crate-".to_string(),
6415        };
6416
6417        let parsed = parse_local_git_tag_output_for_scope(
6418            "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
6419            &scope,
6420        );
6421
6422        assert_eq!(parsed.len(), 2);
6423        assert_eq!(parsed[0].version, Version::new(1, 2, 0));
6424        assert_eq!(parsed[1].version, Version::new(1, 0, 0));
6425    }
6426
6427    #[test]
6428    fn crate_scoped_release_tags_default_to_package_prefix() {
6429        let scope = VersionScope::Crate {
6430            crate_root: PathBuf::from("/tmp/crates/alpha"),
6431            crate_relative_root: "crates/alpha".to_string(),
6432            package_name: "alpha-crate".to_string(),
6433            tag_prefix: "alpha-crate-".to_string(),
6434        };
6435
6436        assert_eq!(
6437            default_release_tag_name(&scope, &Version::new(1, 2, 3)),
6438            "alpha-crate-1.2.3"
6439        );
6440    }
6441
6442    #[test]
6443    fn blob_reader_handles_head_readme_versions() {
6444        assert_eq!(
6445            read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
6446                .expect("read"),
6447            Some("0.4.0".to_string())
6448        );
6449    }
6450
6451    #[test]
6452    fn blob_reader_handles_head_cargo_lock_versions() {
6453        let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
6454        let cargo_lock: &str =
6455            "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
6456
6457        assert_eq!(
6458            read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
6459            Some("0.2.0".to_string())
6460        );
6461    }
6462
6463    #[test]
6464    fn package_name_lookup_reads_json_name_for_npm() {
6465        let lookup: PackageNameLookup = PackageNameLookup {
6466            file: "package.json".to_string(),
6467            format: "json".to_string(),
6468            key: "name".to_string(),
6469            registry: "npm".to_string(),
6470        };
6471
6472        assert_eq!(
6473            read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
6474                .expect("read"),
6475            Some("@xylex/athena-mcp".to_string())
6476        );
6477    }
6478
6479    #[test]
6480    fn package_name_lookup_reads_toml_nested_package_name() {
6481        let lookup: PackageNameLookup = PackageNameLookup {
6482            file: "Cargo.toml".to_string(),
6483            format: "toml".to_string(),
6484            key: "package.name".to_string(),
6485            registry: "crates.io".to_string(),
6486        };
6487
6488        assert_eq!(
6489            read_package_name_from_lookup(
6490                &lookup,
6491                "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
6492            )
6493            .expect("read"),
6494            Some("athena-mcp".to_string())
6495        );
6496    }
6497
6498    #[test]
6499    fn package_name_lookup_errors_on_unknown_format() {
6500        let lookup: PackageNameLookup = PackageNameLookup {
6501            file: "meta.txt".to_string(),
6502            format: "ini".to_string(),
6503            key: "name".to_string(),
6504            registry: "npm".to_string(),
6505        };
6506
6507        let error = read_package_name_from_lookup(&lookup, "name=demo")
6508            .expect_err("unsupported format should fail");
6509        assert!(error.contains("Unsupported lookup format"));
6510    }
6511
6512    #[test]
6513    fn highest_version_observation_returns_max_version() {
6514        let entries: Vec<VersionObservation> = vec![
6515            VersionObservation {
6516                location: "README.md".to_string(),
6517                version: Version::new(1, 0, 0),
6518            },
6519            VersionObservation {
6520                location: "Cargo.toml".to_string(),
6521                version: Version::new(1, 2, 0),
6522            },
6523        ];
6524
6525        assert_eq!(
6526            highest_version_observation(&entries).expect("max version"),
6527            Version::new(1, 2, 0)
6528        );
6529    }
6530
6531    #[test]
6532    fn stale_version_observations_only_returns_outdated_entries() {
6533        let entries: Vec<VersionObservation> = vec![
6534            VersionObservation {
6535                location: "README.md".to_string(),
6536                version: Version::new(1, 1, 0),
6537            },
6538            VersionObservation {
6539                location: "Cargo.toml".to_string(),
6540                version: Version::new(1, 2, 0),
6541            },
6542            VersionObservation {
6543                location: "openapi.yaml".to_string(),
6544                version: Version::new(1, 0, 5),
6545            },
6546        ];
6547
6548        let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
6549        assert_eq!(stale.len(), 2);
6550        assert!(stale.iter().any(|entry| entry.location == "README.md"));
6551        assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
6552        assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
6553    }
6554
6555    #[test]
6556    fn parses_github_remote_urls() {
6557        assert_eq!(
6558            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
6559            Some(("xylex-group".to_string(), "xbp".to_string()))
6560        );
6561        assert_eq!(
6562            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
6563            Some(("xylex-group".to_string(), "xbp".to_string()))
6564        );
6565        assert_eq!(
6566            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
6567            Some(("xylex-group".to_string(), "xbp".to_string()))
6568        );
6569        assert_eq!(
6570            parse_github_repo_from_remote_url(
6571                "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
6572            ),
6573            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
6574        );
6575        assert_eq!(
6576            parse_github_repo_from_remote_url(
6577                "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
6578            ),
6579            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
6580        );
6581        assert_eq!(
6582            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
6583            None
6584        );
6585    }
6586
6587    #[test]
6588    fn redacts_credentials_in_remote_urls() {
6589        let redacted = redact_remote_url_credentials(
6590            "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
6591        );
6592        assert!(redacted.contains("REDACTED"));
6593        assert!(!redacted.contains("ghp_secretvalue"));
6594
6595        let username_only = redact_remote_url_credentials(
6596            "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
6597        );
6598        assert!(username_only.contains("REDACTED@github.com"));
6599        assert!(!username_only.contains("floris-xlx@github.com"));
6600
6601        let ssh_remote =
6602            redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
6603        assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
6604    }
6605
6606    #[test]
6607    fn builds_github_release_urls_with_encoded_tag_segments() {
6608        let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
6609        assert_eq!(
6610            create_url.as_str(),
6611            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
6612        );
6613
6614        let lookup_url =
6615            github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
6616                .expect("url");
6617        assert_eq!(
6618            lookup_url.as_str(),
6619            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
6620        );
6621
6622        let update_url =
6623            github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
6624        assert_eq!(
6625            update_url.as_str(),
6626            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
6627        );
6628
6629        let lookup_with_special_tag = github_release_by_tag_endpoint(
6630            "SuitsBooks",
6631            "suits-invoicing",
6632            "release candidate/v0.0.1+build",
6633        )
6634        .expect("url");
6635        assert_eq!(
6636            lookup_with_special_tag.as_str(),
6637            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
6638        );
6639    }
6640
6641    #[test]
6642    fn builds_github_release_asset_urls() {
6643        let list_url =
6644            github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
6645        assert_eq!(
6646            list_url.as_str(),
6647            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
6648        );
6649
6650        let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
6651            .expect("url");
6652        assert_eq!(
6653            delete_url.as_str(),
6654            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
6655        );
6656
6657        let upload_url = github_release_asset_upload_endpoint(
6658            "SuitsBooks",
6659            "suits-invoicing",
6660            42,
6661            "openapi spec.json",
6662        )
6663        .expect("url");
6664        assert_eq!(
6665            upload_url.as_str(),
6666            "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
6667        );
6668    }
6669
6670    #[test]
6671    fn maps_release_latest_policy_to_github_api_values() {
6672        assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
6673        assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
6674        assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
6675    }
6676
6677    #[test]
6678    fn release_channel_from_semver_prerelease_labels() {
6679        let stable = Version::parse("3.6.2").expect("version");
6680        let nightly = Version::parse("3.6.2-nightly.1").expect("version");
6681        let experimental = Version::parse("0.1.1-alpha.1").expect("version");
6682        assert_eq!(release_channel(&stable), "stable");
6683        assert_eq!(release_channel(&nightly), "nightly");
6684        assert_eq!(release_channel(&experimental), "experimental");
6685    }
6686
6687    #[test]
6688    fn renders_release_docs_from_entries() {
6689        let entries = vec![
6690            ReleaseDocEntry {
6691                tag: "v3.6.2".to_string(),
6692                version: Version::parse("3.6.2").expect("version"),
6693                date: "2026-04-27".to_string(),
6694            },
6695            ReleaseDocEntry {
6696                tag: "docs-0.1.1-alpha.1".to_string(),
6697                version: Version::parse("0.1.1-alpha.1").expect("version"),
6698                date: "2026-04-20".to_string(),
6699            },
6700        ];
6701        let changelog = render_changelog("xylex-group", "athena", &entries);
6702        assert!(changelog.contains("## [3.6.2]"));
6703        assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
6704        assert!(changelog.contains("Release channel: stable"));
6705        assert!(changelog.contains("Release channel: experimental"));
6706
6707        let security = render_security_policy(&entries);
6708        assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
6709        assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
6710    }
6711
6712    #[test]
6713    fn formats_release_commit_lines_with_sha_and_pr_links() {
6714        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
6715        let formatted =
6716            format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
6717                .expect("formatted line");
6718
6719        assert_eq!(
6720            formatted,
6721            "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
6722        );
6723    }
6724
6725    #[test]
6726    fn formats_release_commit_lines_with_linear_links_when_available() {
6727        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
6728        let issue_infos = BTreeMap::from([(
6729            "SUI-1336".to_string(),
6730            LinearIssueInfo {
6731                title: "Release flow".to_string(),
6732                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
6733            },
6734        )]);
6735        let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
6736            .expect("formatted line");
6737
6738        assert_eq!(
6739            formatted,
6740            "[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)"
6741        );
6742    }
6743
6744    #[test]
6745    fn renders_release_notes_in_requested_layout() {
6746        let commits = vec![
6747            ReleaseCommitEntry {
6748                full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
6749                short_sha: "abcdef1".to_string(),
6750                subject: "Improve release docs (#42)".to_string(),
6751                date: "2026-05-24".to_string(),
6752            },
6753            ReleaseCommitEntry {
6754                full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
6755                short_sha: "fedcba9".to_string(),
6756                subject: "Fix release flow for SUI-1336".to_string(),
6757                date: "2026-05-25".to_string(),
6758            },
6759        ];
6760        let pull_request_infos = BTreeMap::from([(
6761            "42".to_string(),
6762            super::release_notes::GithubPullRequestInfo {
6763                title: "Improve release docs".to_string(),
6764                url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
6765            },
6766        )]);
6767        let issue_infos = BTreeMap::from([(
6768            "SUI-1336".to_string(),
6769            LinearIssueInfo {
6770                title: "Release flow".to_string(),
6771                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
6772            },
6773        )]);
6774        let sections = build_fallback_sections(&commits);
6775        let rendered = render_release_notes(&ReleaseNotesRenderInput {
6776            release_title: "1.7.0 - athena-auth",
6777            current_tag_name: "v1.7.0",
6778            owner: "xylex-group",
6779            repo: "athena-auth",
6780            previous_tag: Some("v1.6.0"),
6781            sections: &sections,
6782            commit_entries: &commits,
6783            pull_request_infos: &pull_request_infos,
6784            linear_issue_infos: &issue_infos,
6785        });
6786
6787        assert_eq!(
6788            rendered,
6789            "# [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)"
6790        );
6791    }
6792
6793    #[test]
6794    fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
6795        let commits = vec![
6796            ReleaseCommitEntry {
6797                full_sha: "a".repeat(40),
6798                short_sha: "aaaaaaa".to_string(),
6799                subject: "Fix SUI-1336 and SUI-1440".to_string(),
6800                date: "2026-05-24".to_string(),
6801            },
6802            ReleaseCommitEntry {
6803                full_sha: "b".repeat(40),
6804                short_sha: "bbbbbbb".to_string(),
6805                subject: "Touch SUI-1336 again".to_string(),
6806                date: "2026-05-25".to_string(),
6807            },
6808        ];
6809
6810        assert_eq!(
6811            collect_linear_issue_identifiers(&commits),
6812            vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
6813        );
6814    }
6815
6816    #[test]
6817    fn release_title_defaults_to_version_and_repo() {
6818        assert_eq!(
6819            default_release_title(&Version::new(1, 7, 0), "athena-auth"),
6820            "1.7.0 - athena-auth"
6821        );
6822    }
6823
6824    #[test]
6825    fn deduplicates_release_commit_entries_by_exact_subject() {
6826        let commits = vec![
6827            ReleaseCommitEntry {
6828                full_sha: "a".repeat(40),
6829                short_sha: "aaaaaaa".to_string(),
6830                subject: "Improve release docs".to_string(),
6831                date: "2026-05-24".to_string(),
6832            },
6833            ReleaseCommitEntry {
6834                full_sha: "b".repeat(40),
6835                short_sha: "bbbbbbb".to_string(),
6836                subject: "Improve release docs".to_string(),
6837                date: "2026-05-25".to_string(),
6838            },
6839        ];
6840
6841        let deduplicated = deduplicate_release_commit_entries(&commits);
6842        assert_eq!(deduplicated.len(), 1);
6843        assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
6844    }
6845
6846    #[test]
6847    fn fallback_sections_collapse_related_commit_themes() {
6848        let commits = vec![
6849            ReleaseCommitEntry {
6850                full_sha: "a".repeat(40),
6851                short_sha: "chat001".to_string(),
6852                subject: "Add optimistic chat retries".to_string(),
6853                date: "2026-06-01".to_string(),
6854            },
6855            ReleaseCommitEntry {
6856                full_sha: "b".repeat(40),
6857                short_sha: "chat002".to_string(),
6858                subject: "Persist deleted-message state in chat".to_string(),
6859                date: "2026-06-01".to_string(),
6860            },
6861            ReleaseCommitEntry {
6862                full_sha: "c".repeat(40),
6863                short_sha: "file001".to_string(),
6864                subject: "Fix upload UTF-8 audit retry handling".to_string(),
6865                date: "2026-06-01".to_string(),
6866            },
6867            ReleaseCommitEntry {
6868                full_sha: "d".repeat(40),
6869                short_sha: "ath001".to_string(),
6870                subject: "Migrate form progress routes to Athena".to_string(),
6871                date: "2026-06-01".to_string(),
6872            },
6873            ReleaseCommitEntry {
6874                full_sha: "e".repeat(40),
6875                short_sha: "ath002".to_string(),
6876                subject: "Update Athena models and package wiring".to_string(),
6877                date: "2026-06-01".to_string(),
6878            },
6879        ];
6880
6881        let sections = build_fallback_sections(&commits);
6882        assert_eq!(sections.len(), 3);
6883        assert_eq!(sections[0].title, "Cases & Communication");
6884        assert!(!sections[0].summary.is_empty());
6885        assert_eq!(sections[0].bullets.len(), 2);
6886        assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
6887        assert!(sections[0].bullets[0].summary.contains("chat"));
6888        assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
6889        assert!(sections[0].bullets[1].summary.contains("deleted-message"));
6890        assert_eq!(sections[1].title, "Reliability");
6891        assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
6892        assert_eq!(sections[2].title, "Athena Migration");
6893        assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
6894    }
6895
6896    #[test]
6897    fn appends_release_label_footer_for_pre_release() {
6898        let with_label = append_release_label_footer("# Release", true);
6899        assert_eq!(
6900            with_label,
6901            format!(
6902                "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
6903                env!("CARGO_PKG_VERSION")
6904            )
6905        );
6906    }
6907}