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