Skip to main content

xbp_cli/commands/
version.rs

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