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::{run_publish_command, PublishCommandOptions};
6use crate::config::{
7    global_xbp_paths, load_package_name_files_registry, load_versioning_files_registry,
8    resolve_github_oauth2_key, resolve_global_linear_release_config, resolve_linear_api_key,
9    resolve_openrouter_api_key, PackageNameLookup,
10};
11use crate::strategies::deployment_config::GitHubReleaseBranchSettings;
12use crate::strategies::DeploymentConfig;
13use crate::utils::{
14    command_exists, find_xbp_config_upwards, parse_github_repo_from_remote_url,
15    redact_remote_url_credentials, resolve_env_placeholders,
16};
17use colored::Colorize;
18use regex::Regex;
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use serde_json::Value as JsonValue;
22use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
23use std::collections::HashMap;
24use std::collections::{BTreeMap, BTreeSet};
25use std::env;
26use std::fs;
27use std::path::{Path, PathBuf};
28use std::process::Command;
29use toml::Value as TomlValue;
30
31#[path = "version/github_release.rs"]
32mod github_release;
33#[path = "version/release_docs.rs"]
34mod release_docs;
35#[path = "version/release_linear.rs"]
36mod release_linear;
37#[path = "version/release_notes.rs"]
38mod release_notes;
39#[path = "version/workspace_release.rs"]
40mod workspace_release;
41
42use github_release::{
43    create_github_release, get_github_release_by_tag, update_github_release,
44    upload_github_release_asset, GithubReleaseInput, GithubReleaseResult, GithubReleaseTagResponse,
45};
46use release_docs::sync_release_docs;
47use release_linear::{
48    publish_release_to_linear_initiatives, resolve_linear_release_config,
49    LinearReleasePublishInput, ResolvedLinearReleaseConfig,
50};
51use release_notes::{generate_release_notes, ReleaseNotesRequest};
52pub use workspace_release::{
53    run_version_workspace_command, WorkspacePublishRunOptions, WorkspaceVersionCheckOptions,
54    WorkspaceVersionCommand, WorkspaceVersionCommandOptions, WorkspaceVersionSyncOptions,
55    WorkspaceVersionValidateOptions,
56};
57
58#[derive(Clone, Debug)]
59struct VersionObservation {
60    location: String,
61    version: Version,
62}
63
64#[derive(Clone, Debug)]
65struct GitTagObservation {
66    version: Version,
67    raw_tags: Vec<String>,
68}
69
70#[derive(Clone, Debug)]
71struct RegistryVersionObservation {
72    registry: String,
73    package_name: String,
74    source_file: String,
75    latest: Option<Version>,
76    raw_version: Option<String>,
77    note: Option<String>,
78}
79
80#[derive(Clone, Debug)]
81struct ResolvedRegistryPath {
82    relative: String,
83    absolute: PathBuf,
84    cargo_package_override: Option<String>,
85}
86
87#[derive(Clone, Debug)]
88struct WorkspacePrimaryCargoTarget {
89    manifest_relative: String,
90    manifest_absolute: PathBuf,
91    package_name: String,
92}
93
94#[derive(Clone, Debug)]
95enum VersionScope {
96    Repository,
97    Crate {
98        crate_root: PathBuf,
99        crate_relative_root: String,
100        package_name: String,
101        tag_prefix: String,
102    },
103}
104
105#[derive(Default, Debug)]
106struct VersionReport {
107    worktree: Vec<VersionObservation>,
108    head: Vec<VersionObservation>,
109    local_tags: Vec<GitTagObservation>,
110    remote_tags: Vec<GitTagObservation>,
111    registry_versions: Vec<RegistryVersionObservation>,
112    dirty_files: Vec<String>,
113    warnings: Vec<String>,
114}
115
116const VERSION_CHANGE_GUARD_FILE_NAME: &str = "version-change-guard.yaml";
117
118#[derive(Clone, Debug, Default, Deserialize, Serialize)]
119struct VersionChangeGuardRegistry {
120    #[serde(default)]
121    entries: BTreeMap<String, VersionChangeGuardEntry>,
122}
123
124#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
125struct VersionChangeGuardEntry {
126    #[serde(default)]
127    pending_version_change_count: usize,
128    #[serde(default)]
129    head_commit: Option<String>,
130}
131
132#[derive(Clone, Debug, Default, PartialEq, Eq)]
133struct GitWorktreeState {
134    is_dirty: bool,
135    head_commit: Option<String>,
136}
137
138impl VersionReport {
139    fn highest_worktree(&self) -> Option<Version> {
140        self.worktree
141            .iter()
142            .map(|entry| entry.version.clone())
143            .max()
144    }
145
146    fn highest_head(&self) -> Option<Version> {
147        self.head.iter().map(|entry| entry.version.clone()).max()
148    }
149
150    fn highest_local_tag(&self) -> Option<Version> {
151        self.local_tags
152            .iter()
153            .map(|entry| entry.version.clone())
154            .max()
155    }
156
157    fn highest_remote_tag(&self) -> Option<Version> {
158        self.remote_tags
159            .iter()
160            .map(|entry| entry.version.clone())
161            .max()
162    }
163
164    fn highest_git(&self) -> Option<Version> {
165        self.highest_remote_tag()
166            .or_else(|| self.highest_local_tag())
167    }
168
169    fn highest_registry(&self) -> Option<Version> {
170        self.registry_versions
171            .iter()
172            .filter_map(|entry| entry.latest.clone())
173            .max()
174    }
175
176    fn highest_available(&self) -> Version {
177        self.highest_worktree()
178            .into_iter()
179            .chain(self.highest_head())
180            .chain(self.highest_git())
181            .chain(self.highest_registry())
182            .max()
183            .unwrap_or_else(default_version)
184    }
185
186    fn divergent_versions(&self) -> Vec<Version> {
187        let mut versions = BTreeSet::new();
188        for entry in &self.worktree {
189            versions.insert(entry.version.clone());
190        }
191        for entry in &self.head {
192            versions.insert(entry.version.clone());
193        }
194        for entry in &self.local_tags {
195            versions.insert(entry.version.clone());
196        }
197        for entry in &self.remote_tags {
198            versions.insert(entry.version.clone());
199        }
200        for entry in &self.registry_versions {
201            if let Some(version) = &entry.latest {
202                versions.insert(version.clone());
203            }
204        }
205        versions.into_iter().collect()
206    }
207}
208
209pub async fn run_version_command(
210    target: Option<String>,
211    git_only: bool,
212    _debug: bool,
213) -> Result<(), String> {
214    if git_only && target.is_some() {
215        return Err("`xbp version --git` does not accept `major`, `minor`, `patch`, or explicit version values.".to_string());
216    }
217
218    let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
219    let project_root: PathBuf = resolve_project_root();
220    let version_scope: VersionScope = resolve_version_scope(&project_root, &invocation_dir);
221    let registry: Vec<String> = load_versioning_files_registry()?;
222
223    if git_only {
224        print_git_versions(&project_root, &version_scope)?;
225        return Ok(());
226    }
227
228    match target.as_deref() {
229        None => {
230            let mut report: VersionReport =
231                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
232            match load_package_name_files_registry() {
233                Ok(lookups) => {
234                    report.registry_versions = collect_registry_versions(
235                        &project_root,
236                        &invocation_dir,
237                        &lookups,
238                        &version_scope,
239                        &mut report.warnings,
240                    )
241                    .await;
242                }
243                Err(err) => report.warnings.push(err),
244            }
245            print_version_report(&project_root, &report);
246            Ok(())
247        }
248        Some(bump_target @ ("major" | "minor" | "patch")) => {
249            enforce_version_change_guard(&project_root)?;
250            let current: Version = resolve_current_version_for_bump(
251                &project_root,
252                &invocation_dir,
253                &registry,
254                &version_scope,
255            );
256            let next: Version = bump_version(&current, bump_target);
257            let updated_paths = write_version_to_configured_files_with_paths(
258                &project_root,
259                &invocation_dir,
260                &registry,
261                &version_scope,
262                &next,
263            )?;
264            let updated = updated_paths.len();
265            println!(
266                "Updated {} version file(s) from {} to {}.",
267                updated, current, next
268            );
269            auto_commit_command_paths(
270                &project_root,
271                updated_paths,
272                format!("chore(version): update version to {}", next),
273                "xbp version",
274            )
275            .await;
276            record_version_change_guard(&project_root)?;
277            Ok(())
278        }
279        Some(explicit) => {
280            enforce_version_change_guard(&project_root)?;
281            if let Some((package_name, version)) = parse_package_version_target(explicit)? {
282                let updated_paths = write_package_version_to_configured_files_with_paths(
283                    &project_root,
284                    &invocation_dir,
285                    &registry,
286                    &version_scope,
287                    &package_name,
288                    &version,
289                )?;
290                let updated = updated_paths.len();
291                println!(
292                    "Updated {} file(s) for package `{}` to {}.",
293                    updated, package_name, version
294                );
295                auto_commit_command_paths(
296                    &project_root,
297                    updated_paths,
298                    format!("chore(version): set {} to {}", package_name, version),
299                    "xbp version",
300                )
301                .await;
302                record_version_change_guard(&project_root)?;
303            } else {
304                let version: Version = parse_version(explicit)?;
305                let updated_paths = write_version_to_configured_files_with_paths(
306                    &project_root,
307                    &invocation_dir,
308                    &registry,
309                    &version_scope,
310                    &version,
311                )?;
312                let updated = updated_paths.len();
313                println!("Updated {} version file(s) to {}.", updated, version);
314                auto_commit_command_paths(
315                    &project_root,
316                    updated_paths,
317                    format!("chore(version): update version to {}", version),
318                    "xbp version",
319                )
320                .await;
321                record_version_change_guard(&project_root)?;
322            }
323            Ok(())
324        }
325    }
326}
327
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum ReleaseLatestPolicy {
330    True,
331    False,
332    Legacy,
333}
334
335impl ReleaseLatestPolicy {
336    pub(crate) fn as_github_api_value(self) -> &'static str {
337        match self {
338            Self::True => "true",
339            Self::False => "false",
340            Self::Legacy => "legacy",
341        }
342    }
343}
344
345#[derive(Debug, Clone)]
346pub struct VersionReleaseOptions {
347    pub explicit_version: Option<String>,
348    pub allow_dirty: bool,
349    pub title: Option<String>,
350    pub notes: Option<String>,
351    pub notes_file: Option<PathBuf>,
352    pub draft: bool,
353    pub prerelease: bool,
354    pub publish: bool,
355    pub latest_policy: ReleaseLatestPolicy,
356}
357
358struct ReleaseWorkflowSummary {
359    tag_name: String,
360    release_url: String,
361    uploaded_openapi_asset: Option<String>,
362    published_initiatives: Vec<String>,
363    release_branch: Option<String>,
364}
365
366pub async fn run_version_release_command(options: VersionReleaseOptions) -> Result<(), String> {
367    let loader = Loader::start("Publishing release");
368    let result: Result<ReleaseWorkflowSummary, String> = async {
369        let VersionReleaseOptions {
370            explicit_version,
371            allow_dirty,
372            title,
373            notes,
374            notes_file,
375            draft,
376            prerelease,
377            publish,
378            latest_policy,
379        } = options;
380
381        if notes.is_some() && notes_file.is_some() {
382            return Err("Use either `--notes` or `--notes-file`, not both.".to_string());
383        }
384
385        if !command_exists("git") {
386            return Err(
387                "Git is required for `xbp version release`, but it is not installed.".to_string(),
388            );
389        }
390
391        let invocation_dir: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
392        let project_root: PathBuf = resolve_project_root();
393        let version_scope: VersionScope = resolve_version_scope(&project_root, &invocation_dir);
394
395        loader.update("[1/8] Validating git state and resolving release target");
396        if !allow_dirty {
397            let dirty: Vec<String> = git_dirty_entries(&project_root)?;
398            if !dirty.is_empty() {
399                let preview = dirty.into_iter().take(8).collect::<Vec<_>>().join(", ");
400                return Err(format!(
401                    "Working tree is dirty. Commit/stash changes first or use `--allow-dirty`. Pending entries: {}",
402                    preview
403                ));
404            }
405        }
406
407        let (release_version, tag_name) = if let Some(raw) = explicit_version {
408            let (version, parsed_tag_name) = parse_release_version_target(&raw)?;
409            (
410                version.clone(),
411                scoped_release_tag_name(&version_scope, &version, &parsed_tag_name),
412            )
413        } else {
414            let registry: Vec<String> = load_versioning_files_registry()?;
415            let report: VersionReport =
416                collect_version_report(&project_root, &invocation_dir, &registry, &version_scope);
417            let release_version: Version = report.highest_available();
418            let tag_name: String = default_release_tag_name(&version_scope, &release_version);
419            (release_version, tag_name)
420        };
421        ensure_remote_exists(&project_root, "origin")?;
422        let tag_exists_local: bool = git_tag_exists(&project_root, &tag_name)?;
423        let tag_exists_remote: bool = git_remote_tag_exists(&project_root, "origin", &tag_name)?;
424
425        if publish {
426            loader.update("[2/9] Publishing configured packages");
427            run_publish_command(PublishCommandOptions {
428                dry_run: false,
429                allow_dirty,
430                target: None,
431                expected_version: Some(release_version.to_string()),
432            })
433            .await?;
434        }
435
436        loader.update("[3/9] Resolving GitHub repository and auth");
437        let origin_url: String = git_remote_url(&project_root, "origin")?;
438        let (owner, repo) = parse_github_repo_from_remote_url(&origin_url).ok_or_else(|| {
439            format!(
440                "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.",
441                redact_remote_url_credentials(&origin_url)
442            )
443        })?;
444
445        let github_token: String = resolve_github_oauth2_key().ok_or_else(|| {
446            "No GitHub token found. Configure with `xbp config github set-key` or export `GITHUB_TOKEN`."
447                .to_string()
448        })?;
449        let release_title: String = title.unwrap_or_else(|| {
450            default_release_title(
451                &release_version,
452                release_title_subject(&version_scope, &repo),
453            )
454        });
455        let release_branch_config =
456            resolve_project_github_release_branch_config(&project_root, &invocation_dir).await?;
457
458        loader.update("[4/9] Generating release notes");
459        let release_notes_body: String = if let Some(path) = notes_file {
460            fs::read_to_string(&path).map_err(|e| {
461                format!(
462                    "Failed to read release notes file {}: {}",
463                    path.display(),
464                    e
465                )
466            })?
467        } else if let Some(body) = notes {
468            body
469        } else {
470            generate_release_notes(&ReleaseNotesRequest {
471                project_root: &project_root,
472                release_title: &release_title,
473                current_tag_name: &tag_name,
474                owner: &owner,
475                repo: &repo,
476                github_token: &github_token,
477                linear_api_key: resolve_linear_api_key().as_deref(),
478                openrouter_api_key: resolve_openrouter_api_key().as_deref(),
479                path_filter: release_notes_scope_path(&version_scope).as_deref(),
480            })
481            .await?
482        };
483        let release_notes: String = append_release_label_footer(&release_notes_body, prerelease);
484
485        loader.update("[5/9] Creating and pushing release tag");
486        let tag_message: String = format!("Release {}", tag_name);
487        let target_commitish: String = git_head_commitish(&project_root)?;
488        let created_release_branch = if let Some(branch_config) = &release_branch_config {
489            Some(ensure_release_branch(
490                &project_root,
491                branch_config,
492                &release_version,
493                &tag_name,
494                &target_commitish,
495            )?)
496        } else {
497            None
498        };
499        if !tag_exists_local {
500            run_git_command(&project_root, &["tag", "-a", &tag_name, "-m", &tag_message])?;
501        }
502        if !tag_exists_remote {
503            run_git_command(&project_root, &["push", "origin", &tag_name])?;
504        }
505
506        let release_input: GithubReleaseInput = GithubReleaseInput {
507            owner: owner.clone(),
508            repo: repo.clone(),
509            token: github_token,
510            tag_name: tag_name.clone(),
511            target_commitish,
512            title: release_title,
513            notes: release_notes,
514            draft,
515            prerelease,
516            latest_policy,
517        };
518
519        loader.update("[6/9] Publishing GitHub release");
520        let release_result: GithubReleaseResult = match create_github_release(&release_input).await {
521            Ok(result) => result,
522            Err(create_error) => {
523                let existing_release: Option<GithubReleaseTagResponse> = get_github_release_by_tag(&release_input).await.map_err(|e| {
524                    format!(
525                        "{}\nTag `{}` is available in git, but checking existing GitHub release failed: {}",
526                        create_error, tag_name, e
527                    )
528                })?;
529
530                let Some(existing_release) = existing_release else {
531                    return Err(format!(
532                        "{}\nTag `{}` is available in git. You can retry release creation manually in GitHub.",
533                        create_error, tag_name
534                    ));
535                };
536
537                let needs_update: bool = existing_release.prerelease.unwrap_or(false)
538                    != release_input.prerelease
539                    || existing_release.draft.unwrap_or(false) != release_input.draft
540                    || release_input.latest_policy != ReleaseLatestPolicy::Legacy;
541
542                if needs_update {
543                    update_github_release(&release_input, existing_release.id)
544                        .await
545                        .map_err(|e| {
546                            format!(
547                                "{}\nTag `{}` already has a GitHub release, but updating release flags failed: {}",
548                                create_error, tag_name, e
549                            )
550                        })?
551                } else {
552                    GithubReleaseResult {
553                        id: existing_release.id,
554                        html_url: existing_release.html_url.unwrap_or_else(|| {
555                            format!(
556                                "https://github.com/{}/{}/releases/tag/{}",
557                                release_input.owner, release_input.repo, release_input.tag_name
558                            )
559                        }),
560                    }
561                }
562            }
563        };
564        let release_url = release_result.html_url.clone();
565
566        loader.update("[7/9] Publishing release integrations");
567        let openapi_path = resolve_release_openapi_spec(&project_root, &invocation_dir);
568        let linear_release_config =
569            resolve_effective_linear_release_config(&project_root, &invocation_dir).await?;
570        let openapi_release_input = release_input.clone();
571        let linear_release_input = release_input.clone();
572        let openapi_project_root = project_root.clone();
573        let openapi_tag_name = tag_name.clone();
574        let linear_tag_name = tag_name.clone();
575        let linear_release_url = release_url.clone();
576
577        let openapi_future = async move {
578            if let Some(openapi_path) = openapi_path {
579                upload_github_release_asset(
580                    &openapi_release_input,
581                    release_result.id,
582                    &openapi_path,
583                )
584                .await
585                .map_err(|e| {
586                    format!(
587                        "Release `{}` was published, but uploading OpenAPI asset `{}` failed: {}",
588                        openapi_tag_name,
589                        openapi_path.display(),
590                        e
591                    )
592                })?;
593                Ok(Some(
594                    openapi_path
595                        .strip_prefix(&openapi_project_root)
596                        .map(normalized_relative_path)
597                        .unwrap_or_else(|_| normalized_relative_path(&openapi_path)),
598                ))
599            } else {
600                Ok(None)
601            }
602        };
603
604        let linear_future = async move {
605            if let Some(linear_release_config) = linear_release_config {
606                let linear_api_key: String = resolve_linear_api_key().ok_or_else(|| {
607                    "A Linear release target is configured, but no Linear API key was found. Configure `xbp config linear set-key`."
608                        .to_string()
609                })?;
610                publish_release_to_linear_initiatives(&LinearReleasePublishInput {
611                    api_key: linear_api_key,
612                    initiative_ids: linear_release_config.initiative_ids,
613                    organization_name: linear_release_config.organization_name,
614                    health: linear_release_config.health,
615                    release_title: linear_release_input.title.clone(),
616                    release_tag: linear_release_input.tag_name.clone(),
617                    release_url: linear_release_url,
618                    release_notes: linear_release_input.notes.clone(),
619                })
620                .await
621                .map_err(|e| {
622                    format!(
623                        "Release `{}` was published, but publishing to configured Linear initiatives failed: {}",
624                        linear_tag_name, e
625                    )
626                })
627            } else {
628                Ok(Vec::new())
629            }
630        };
631
632        let (uploaded_openapi_asset, published_initiatives) =
633            tokio::try_join!(openapi_future, linear_future)?;
634
635        loader.update("[8/9] Syncing release docs");
636        let release_doc_paths = sync_release_docs(&project_root, &owner, &repo)?;
637        loader.update("[9/9] Auto-committing release docs");
638        auto_commit_command_paths(
639            &project_root,
640            release_doc_paths,
641            format!("docs(release): sync release docs for {}", tag_name),
642            "xbp version release",
643        )
644        .await;
645
646        Ok(ReleaseWorkflowSummary {
647            tag_name,
648            release_url,
649            uploaded_openapi_asset,
650            published_initiatives,
651            release_branch: created_release_branch,
652        })
653    }
654    .await;
655
656    match result {
657        Ok(summary) => {
658            loader.success_with(&format!("Published {}", summary.tag_name));
659            println!("Released {} successfully.", summary.tag_name);
660            println!("GitHub release: {}", summary.release_url);
661            if let Some(openapi_asset) = summary.uploaded_openapi_asset {
662                println!("Uploaded OpenAPI asset: {}", openapi_asset);
663            }
664            if !summary.published_initiatives.is_empty() {
665                println!(
666                    "Published release update to Linear initiative(s): {}",
667                    summary.published_initiatives.join(", ")
668                );
669            }
670            if let Some(release_branch) = summary.release_branch {
671                println!("Release branch: {}", release_branch);
672            }
673            println!("Updated release docs: CHANGELOG.md and SECURITY.md");
674            Ok(())
675        }
676        Err(error) => {
677            loader.fail(&error);
678            Err(error)
679        }
680    }
681}
682
683/// Print program version from Cargo metadata.
684pub async fn print_version() {
685    println!("XBP Version: {}", env!("CARGO_PKG_VERSION"));
686}
687
688fn resolve_project_root() -> PathBuf {
689    let cwd: PathBuf = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
690
691    if let Some(root) = git_repository_root(&cwd) {
692        return root;
693    }
694
695    if let Some(found) = find_xbp_config_upwards(&cwd) {
696        return found.project_root;
697    }
698
699    cwd
700}
701
702fn collect_version_report(
703    project_root: &Path,
704    invocation_dir: &Path,
705    registry: &[String],
706    version_scope: &VersionScope,
707) -> VersionReport {
708    let mut report: VersionReport = VersionReport::default();
709    report.worktree = collect_local_versions(
710        project_root,
711        invocation_dir,
712        registry,
713        version_scope,
714        &mut report.warnings,
715    );
716    match collect_head_versions(project_root, invocation_dir, registry, version_scope) {
717        Ok(entries) => report.head = entries,
718        Err(err) => report.warnings.push(err),
719    }
720    match collect_git_versions(project_root, version_scope) {
721        Ok(tags) => report.local_tags = tags,
722        Err(err) => report.warnings.push(err),
723    }
724    match collect_remote_git_versions(project_root, "origin", version_scope) {
725        Ok(tags) => report.remote_tags = tags,
726        Err(err) => report.warnings.push(err),
727    }
728    match collect_dirty_version_files(project_root, invocation_dir, registry, version_scope) {
729        Ok(files) => report.dirty_files = files,
730        Err(err) => report.warnings.push(err),
731    }
732    report
733}
734
735fn resolve_current_version_for_bump(
736    project_root: &Path,
737    invocation_dir: &Path,
738    registry: &[String],
739    version_scope: &VersionScope,
740) -> Version {
741    let mut _warnings = Vec::new();
742    let local_versions = collect_local_versions(
743        project_root,
744        invocation_dir,
745        registry,
746        version_scope,
747        &mut _warnings,
748    );
749    let head_versions =
750        collect_head_versions(project_root, invocation_dir, registry, version_scope)
751            .unwrap_or_default();
752    let local_tags = collect_git_versions(project_root, version_scope).unwrap_or_default();
753
754    local_versions
755        .iter()
756        .map(|entry| entry.version.clone())
757        .chain(head_versions.iter().map(|entry| entry.version.clone()))
758        .chain(local_tags.iter().map(|entry| entry.version.clone()))
759        .max()
760        .unwrap_or_else(default_version)
761}
762
763async fn auto_commit_command_paths(
764    project_root: &Path,
765    paths: Vec<PathBuf>,
766    message: String,
767    action_label: &'static str,
768) {
769    match commit_paths(AutoCommitRequest {
770        project_root,
771        paths,
772        message,
773        action_label,
774    })
775    .await
776    {
777        Ok(AutoCommitResult::Committed(_)) => {}
778        Ok(AutoCommitResult::Skipped(reason)) => print_skip(action_label, &reason),
779        Err(e) => print_skip(action_label, &e),
780    }
781}
782
783async fn collect_registry_versions(
784    project_root: &Path,
785    invocation_dir: &Path,
786    lookups: &[PackageNameLookup],
787    version_scope: &VersionScope,
788    warnings: &mut Vec<String>,
789) -> Vec<RegistryVersionObservation> {
790    let mut entries: Vec<RegistryVersionObservation> = Vec::new();
791    let mut seen: BTreeSet<String> = BTreeSet::new();
792    let client: reqwest::Client = reqwest::Client::new();
793
794    for lookup in lookups {
795        let dedupe_key: String = format!(
796            "{}|{}|{}|{}",
797            lookup.file, lookup.format, lookup.key, lookup.registry
798        );
799        if !seen.insert(dedupe_key) {
800            continue;
801        }
802
803        let source_file = resolve_registry_relative_path(
804            project_root,
805            invocation_dir,
806            version_scope,
807            &lookup.file,
808        );
809        let path = project_root.join(&source_file);
810        if !path.exists() {
811            continue;
812        }
813
814        let content: String = match fs::read_to_string(&path) {
815            Ok(content) => content,
816            Err(err) => {
817                warnings.push(format!("Failed to read {}: {}", path.display(), err));
818                continue;
819            }
820        };
821
822        let package_name: String = match read_package_name_from_lookup(lookup, &content) {
823            Ok(Some(value)) => value,
824            Ok(None) => continue,
825            Err(err) => {
826                warnings.push(format!("{}: {}", source_file, err));
827                continue;
828            }
829        };
830
831        let (latest, raw_version, note) =
832            match fetch_registry_latest_version(&client, &lookup.registry, &package_name).await {
833                Ok(version) => {
834                    let parsed: Option<Version> = parse_version(&version).ok();
835                    let note: Option<String> = if parsed.is_none() {
836                        Some(format!("Non-semver registry version: {}", version))
837                    } else {
838                        None
839                    };
840                    (parsed, Some(version), note)
841                }
842                Err(err) => (None, None, Some(err)),
843            };
844
845        entries.push(RegistryVersionObservation {
846            registry: lookup.registry.clone(),
847            package_name,
848            source_file,
849            latest,
850            raw_version,
851            note,
852        });
853    }
854
855    entries.sort_by(|a, b| {
856        a.registry
857            .cmp(&b.registry)
858            .then_with(|| a.package_name.cmp(&b.package_name))
859    });
860    entries
861}
862
863fn read_package_name_from_lookup(
864    lookup: &PackageNameLookup,
865    content: &str,
866) -> Result<Option<String>, String> {
867    let key_parts: Vec<&str> = lookup
868        .key
869        .split('.')
870        .map(|part| part.trim())
871        .filter(|part| !part.is_empty())
872        .collect();
873    if key_parts.is_empty() {
874        return Err("Lookup key cannot be empty".to_string());
875    }
876
877    let format: String = lookup.format.trim().to_ascii_lowercase();
878    match format.as_str() {
879        "json" => {
880            let value: JsonValue = serde_json::from_str(content)
881                .map_err(|e| format!("Failed to parse JSON: {}", e))?;
882            Ok(json_lookup_string(&value, &key_parts))
883        }
884        "yaml" | "yml" => {
885            let value: YamlValue = serde_yaml::from_str(content)
886                .map_err(|e| format!("Failed to parse YAML: {}", e))?;
887            Ok(yaml_lookup_string(&value, &key_parts))
888        }
889        "toml" => {
890            let value: TomlValue =
891                toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
892            Ok(toml_lookup_string(&value, &key_parts))
893        }
894        other => Err(format!("Unsupported lookup format `{}`", other)),
895    }
896}
897
898async fn fetch_registry_latest_version(
899    client: &reqwest::Client,
900    registry: &str,
901    package_name: &str,
902) -> Result<String, String> {
903    let normalized_registry: String = registry.trim().to_ascii_lowercase();
904    match normalized_registry.as_str() {
905        "npm" => fetch_npm_latest_version(client, package_name).await,
906        "crates.io" | "crate" | "crates" => fetch_crates_latest_version(client, package_name).await,
907        _ => Err(format!("Unsupported registry `{}`", registry)),
908    }
909}
910
911#[derive(Debug, Deserialize)]
912struct NpmLatestResponse {
913    version: String,
914}
915
916async fn fetch_npm_latest_version(
917    client: &reqwest::Client,
918    package_name: &str,
919) -> Result<String, String> {
920    let mut url = reqwest::Url::parse("https://registry.npmjs.org/")
921        .map_err(|e| format!("Failed to build npm URL: {}", e))?;
922    {
923        let mut segments = url
924            .path_segments_mut()
925            .map_err(|_| "Failed to compose npm URL segments".to_string())?;
926        segments.push(package_name);
927        segments.push("latest");
928    }
929
930    let response: reqwest::Response = client
931        .get(url)
932        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
933        .send()
934        .await
935        .map_err(|e| format!("Failed npm lookup for {}: {}", package_name, e))?;
936
937    if !response.status().is_success() {
938        return Err(format!(
939            "npm lookup for {} returned status {}",
940            package_name,
941            response.status()
942        ));
943    }
944
945    let payload: NpmLatestResponse = response
946        .json()
947        .await
948        .map_err(|e| format!("Failed to parse npm response for {}: {}", package_name, e))?;
949    Ok(payload.version)
950}
951
952#[derive(Debug, Deserialize)]
953struct CratesIoResponse {
954    #[serde(rename = "crate")]
955    crate_meta: CratesIoMeta,
956}
957
958#[derive(Debug, Deserialize)]
959struct CratesIoMeta {
960    newest_version: String,
961}
962
963async fn fetch_crates_latest_version(
964    client: &reqwest::Client,
965    package_name: &str,
966) -> Result<String, String> {
967    let mut url: reqwest::Url = reqwest::Url::parse("https://crates.io/api/v1/crates/")
968        .map_err(|e| format!("Failed to build crates.io URL: {}", e))?;
969    {
970        let mut segments = url
971            .path_segments_mut()
972            .map_err(|_| "Failed to compose crates.io URL segments".to_string())?;
973        segments.push(package_name);
974    }
975
976    let response: reqwest::Response = client
977        .get(url)
978        .header(reqwest::header::USER_AGENT, "xbp-version-checker/1.0")
979        .send()
980        .await
981        .map_err(|e| format!("Failed crates.io lookup for {}: {}", package_name, e))?;
982
983    if !response.status().is_success() {
984        return Err(format!(
985            "crates.io lookup for {} returned status {}",
986            package_name,
987            response.status()
988        ));
989    }
990
991    let payload: CratesIoResponse = response.json().await.map_err(|e| {
992        format!(
993            "Failed to parse crates.io response for {}: {}",
994            package_name, e
995        )
996    })?;
997    Ok(payload.crate_meta.newest_version)
998}
999
1000fn collect_local_versions(
1001    project_root: &Path,
1002    invocation_dir: &Path,
1003    registry: &[String],
1004    version_scope: &VersionScope,
1005    warnings: &mut Vec<String>,
1006) -> Vec<VersionObservation> {
1007    let mut observed = Vec::new();
1008
1009    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1010        let path: &PathBuf = &entry.absolute;
1011        if !path.exists() {
1012            continue;
1013        }
1014
1015        match read_version_from_resolved_path(&entry) {
1016            Ok(Some(version)) => {
1017                if let Ok(parsed) = parse_version(&version) {
1018                    observed.push(VersionObservation {
1019                        location: entry.relative.clone(),
1020                        version: parsed,
1021                    });
1022                } else {
1023                    warnings.push(format!("Ignoring non-semver version in {}", path.display()));
1024                }
1025            }
1026            Ok(None) => {}
1027            Err(err) => warnings.push(format!("{}: {}", path.display(), err)),
1028        }
1029    }
1030
1031    observed.sort_by(|a, b| a.location.cmp(&b.location));
1032    observed
1033}
1034
1035fn collect_git_versions(
1036    project_root: &Path,
1037    version_scope: &VersionScope,
1038) -> Result<Vec<GitTagObservation>, String> {
1039    if !command_exists("git") {
1040        return Err("Git is not installed; skipping git tag inspection.".to_string());
1041    }
1042
1043    let output: std::process::Output = Command::new("git")
1044        .current_dir(project_root)
1045        .args(["tag", "--list"])
1046        .output()
1047        .map_err(|e| format!("Failed to execute `git tag --list`: {}", e))?;
1048
1049    if !output.status.success() {
1050        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1051        if stderr.is_empty() {
1052            return Err("`git tag --list` failed in the current directory.".to_string());
1053        }
1054        return Err(format!("`git tag --list` failed: {}", stderr));
1055    }
1056
1057    Ok(parse_local_git_tag_output_for_scope(
1058        &String::from_utf8_lossy(&output.stdout),
1059        version_scope,
1060    ))
1061}
1062
1063fn collect_remote_git_versions(
1064    project_root: &Path,
1065    remote: &str,
1066    version_scope: &VersionScope,
1067) -> Result<Vec<GitTagObservation>, String> {
1068    if !command_exists("git") {
1069        return Err("Git is not installed; skipping remote tag inspection.".to_string());
1070    }
1071
1072    let output: std::process::Output = Command::new("git")
1073        .current_dir(project_root)
1074        .args(["ls-remote", "--tags", remote])
1075        .output()
1076        .map_err(|e| format!("Failed to execute `git ls-remote --tags {}`: {}", remote, e))?;
1077
1078    if !output.status.success() {
1079        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
1080        if stderr.is_empty() {
1081            return Err(format!("`git ls-remote --tags {}` failed.", remote));
1082        }
1083        return Err(format!(
1084            "`git ls-remote --tags {}` failed: {}",
1085            remote, stderr
1086        ));
1087    }
1088
1089    Ok(parse_remote_git_tag_output_for_scope(
1090        &String::from_utf8_lossy(&output.stdout),
1091        version_scope,
1092    ))
1093}
1094
1095fn print_git_versions(project_root: &Path, version_scope: &VersionScope) -> Result<(), String> {
1096    let tags: Vec<GitTagObservation> = collect_git_versions(project_root, version_scope)?;
1097
1098    if tags.is_empty() {
1099        println!("No semantic git tags found in {}.", project_root.display());
1100        return Ok(());
1101    }
1102
1103    println!("Git versions from `git tag --list`:");
1104    for tag in tags {
1105        if tag.raw_tags.len() > 1 {
1106            println!("  {}  ({})", tag.version, tag.raw_tags.join(", "));
1107        } else {
1108            println!("  {}", tag.version);
1109        }
1110    }
1111
1112    Ok(())
1113}
1114
1115fn print_version_observations(
1116    title: &str,
1117    entries: &[VersionObservation],
1118    dirty_files: Option<&[String]>,
1119) {
1120    println!();
1121    println!("{}", title.bright_cyan().bold());
1122    println!("{}", "─".repeat(72).bright_black());
1123
1124    if entries.is_empty() {
1125        println!("  {}", "none found".dimmed());
1126        return;
1127    }
1128
1129    let Some(highest) = highest_version_observation(entries) else {
1130        println!("  {}", "none found".dimmed());
1131        return;
1132    };
1133
1134    let stale_entries: Vec<&VersionObservation> = stale_version_observations(entries);
1135    let latest_count: usize = entries.len().saturating_sub(stale_entries.len());
1136    println!(
1137        "  {:<28} {} ({}/{})",
1138        "latest".bright_white(),
1139        highest.to_string().bright_green().bold(),
1140        latest_count,
1141        entries.len()
1142    );
1143
1144    if stale_entries.is_empty() {
1145        return;
1146    }
1147
1148    println!("  {}", "stale entries".bright_yellow().bold());
1149    for entry in stale_entries {
1150        let dirty: bool = dirty_files
1151            .map(|files| files.iter().any(|file| file == &entry.location))
1152            .unwrap_or(false);
1153        let dirty_suffix: String = if dirty {
1154            format!(" {}", "modified".bright_magenta())
1155        } else {
1156            String::new()
1157        };
1158
1159        println!(
1160            "  {:<28} {}{}",
1161            entry.location.bright_white(),
1162            entry.version.to_string().bright_green(),
1163            dirty_suffix
1164        );
1165    }
1166}
1167
1168fn print_git_tag_observations(title: &str, tags: &[GitTagObservation]) {
1169    println!();
1170    println!("{}", title.bright_cyan().bold());
1171    println!("{}", "─".repeat(72).bright_black());
1172
1173    if tags.is_empty() {
1174        println!("  {}", "none found".dimmed());
1175        return;
1176    }
1177
1178    let latest = &tags[0];
1179    if latest.raw_tags.len() > 1 {
1180        println!(
1181            "  {:<20} {}",
1182            latest.version.to_string().bright_green().bold(),
1183            latest.raw_tags.join(", ").dimmed()
1184        );
1185    } else {
1186        println!("  {}", latest.version.to_string().bright_green().bold());
1187    }
1188
1189    if tags.len() > 1 {
1190        println!(
1191            "  {:<20} {}",
1192            "older tags".bright_white(),
1193            format!("{} hidden", tags.len() - 1).dimmed()
1194        );
1195    }
1196}
1197
1198fn collect_head_versions(
1199    project_root: &Path,
1200    invocation_dir: &Path,
1201    registry: &[String],
1202    version_scope: &VersionScope,
1203) -> Result<Vec<VersionObservation>, String> {
1204    if !command_exists("git") {
1205        return Err("Git is not installed; skipping committed HEAD inspection.".to_string());
1206    }
1207
1208    let head_check = Command::new("git")
1209        .current_dir(project_root)
1210        .args(["rev-parse", "--verify", "HEAD"])
1211        .output()
1212        .map_err(|e| format!("Failed to execute `git rev-parse --verify HEAD`: {}", e))?;
1213
1214    if !head_check.status.success() {
1215        return Ok(Vec::new());
1216    }
1217
1218    let mut observed = Vec::new();
1219    let cargo_toml_content = git_show_head_file(project_root, "Cargo.toml").ok();
1220
1221    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1222        let Ok(content) = git_show_head_file(project_root, &entry.relative) else {
1223            continue;
1224        };
1225
1226        match read_version_from_blob_with_override(
1227            &entry.relative,
1228            &content,
1229            cargo_toml_content.as_deref(),
1230            entry.cargo_package_override.as_deref(),
1231        ) {
1232            Ok(Some(version)) => {
1233                if let Ok(parsed) = parse_version(&version) {
1234                    observed.push(VersionObservation {
1235                        location: entry.relative.clone(),
1236                        version: parsed,
1237                    });
1238                }
1239            }
1240            Ok(None) => {}
1241            Err(_) => {}
1242        }
1243    }
1244
1245    observed.sort_by(|a, b| a.location.cmp(&b.location));
1246    Ok(observed)
1247}
1248
1249fn collect_dirty_version_files(
1250    project_root: &Path,
1251    invocation_dir: &Path,
1252    registry: &[String],
1253    version_scope: &VersionScope,
1254) -> Result<Vec<String>, String> {
1255    if !command_exists("git") {
1256        return Err("Git is not installed; skipping worktree status inspection.".to_string());
1257    }
1258
1259    let mut args = vec!["status", "--porcelain", "--"];
1260    let resolved = resolve_registry_paths(project_root, invocation_dir, registry, version_scope);
1261    for entry in &resolved {
1262        args.push(entry.relative.as_str());
1263    }
1264
1265    let output = Command::new("git")
1266        .current_dir(project_root)
1267        .args(&args)
1268        .output()
1269        .map_err(|e| format!("Failed to execute `git status --porcelain`: {}", e))?;
1270
1271    if !output.status.success() {
1272        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1273        if stderr.is_empty() {
1274            return Err("`git status --porcelain` failed.".to_string());
1275        }
1276        return Err(format!("`git status --porcelain` failed: {}", stderr));
1277    }
1278
1279    let mut dirty = Vec::new();
1280    for line in String::from_utf8_lossy(&output.stdout).lines() {
1281        if line.len() < 4 {
1282            continue;
1283        }
1284        let path = line[3..].trim();
1285        if !path.is_empty() {
1286            dirty.push(path.replace('\\', "/"));
1287        }
1288    }
1289
1290    dirty.sort();
1291    dirty.dedup();
1292    Ok(dirty)
1293}
1294
1295fn git_show_head_file(project_root: &Path, relative: &str) -> Result<String, String> {
1296    let output = Command::new("git")
1297        .current_dir(project_root)
1298        .args(["show", &format!("HEAD:{}", relative)])
1299        .output()
1300        .map_err(|e| format!("Failed to read {} from HEAD: {}", relative, e))?;
1301
1302    if !output.status.success() {
1303        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1304        if stderr.is_empty() {
1305            return Err(format!("{} is not present in HEAD", relative));
1306        }
1307        return Err(format!("Failed to read {} from HEAD: {}", relative, stderr));
1308    }
1309
1310    String::from_utf8(output.stdout)
1311        .map_err(|e| format!("{} in HEAD is not valid UTF-8: {}", relative, e))
1312}
1313
1314fn parse_local_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1315    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1316    for line in output.lines() {
1317        let tag = line.trim();
1318        if tag.is_empty() {
1319            continue;
1320        }
1321        if let Ok(version) = parse_version(tag) {
1322            by_version.entry(version).or_default().push(tag.to_string());
1323        }
1324    }
1325    git_tag_map_to_vec(by_version)
1326}
1327
1328fn parse_local_git_tag_output_for_scope(
1329    output: &str,
1330    version_scope: &VersionScope,
1331) -> Vec<GitTagObservation> {
1332    match version_scope {
1333        VersionScope::Repository => parse_local_git_tag_output(output),
1334        VersionScope::Crate { tag_prefix, .. } => parse_scoped_git_tag_output(output, tag_prefix),
1335    }
1336}
1337
1338fn parse_remote_git_tag_output(output: &str) -> Vec<GitTagObservation> {
1339    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1340    for line in output.lines() {
1341        let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1342        let tag = reference
1343            .strip_prefix("refs/tags/")
1344            .unwrap_or(reference)
1345            .trim_end_matches("^{}")
1346            .trim();
1347
1348        if tag.is_empty() {
1349            continue;
1350        }
1351        if let Ok(version) = parse_version(tag) {
1352            by_version.entry(version).or_default().push(tag.to_string());
1353        }
1354    }
1355    git_tag_map_to_vec(by_version)
1356}
1357
1358fn parse_remote_git_tag_output_for_scope(
1359    output: &str,
1360    version_scope: &VersionScope,
1361) -> Vec<GitTagObservation> {
1362    match version_scope {
1363        VersionScope::Repository => parse_remote_git_tag_output(output),
1364        VersionScope::Crate { tag_prefix, .. } => {
1365            let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1366            for line in output.lines() {
1367                let reference = line.split_whitespace().nth(1).unwrap_or_default().trim();
1368                let tag = reference
1369                    .strip_prefix("refs/tags/")
1370                    .unwrap_or(reference)
1371                    .trim_end_matches("^{}")
1372                    .trim();
1373
1374                if tag.is_empty() {
1375                    continue;
1376                }
1377                if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1378                    by_version.entry(version).or_default().push(tag.to_string());
1379                }
1380            }
1381            git_tag_map_to_vec(by_version)
1382        }
1383    }
1384}
1385
1386fn parse_scoped_git_tag_output(output: &str, tag_prefix: &str) -> Vec<GitTagObservation> {
1387    let mut by_version: BTreeMap<Version, Vec<String>> = BTreeMap::new();
1388    for line in output.lines() {
1389        let tag = line.trim();
1390        if tag.is_empty() {
1391            continue;
1392        }
1393        if let Some(version) = parse_release_family_version(tag, tag_prefix) {
1394            by_version.entry(version).or_default().push(tag.to_string());
1395        }
1396    }
1397    git_tag_map_to_vec(by_version)
1398}
1399
1400fn git_tag_map_to_vec(by_version: BTreeMap<Version, Vec<String>>) -> Vec<GitTagObservation> {
1401    let mut versions: Vec<GitTagObservation> = by_version
1402        .into_iter()
1403        .map(|(version, mut raw_tags)| {
1404            raw_tags.sort();
1405            raw_tags.dedup();
1406            GitTagObservation { version, raw_tags }
1407        })
1408        .collect();
1409    versions.sort_by(|a, b| b.version.cmp(&a.version));
1410    versions
1411}
1412
1413#[cfg(test)]
1414fn read_version_from_blob(
1415    relative: &str,
1416    content: &str,
1417    cargo_toml_content: Option<&str>,
1418) -> Result<Option<String>, String> {
1419    read_version_from_blob_with_override(relative, content, cargo_toml_content, None)
1420}
1421
1422fn read_version_from_blob_with_override(
1423    relative: &str,
1424    content: &str,
1425    cargo_toml_content: Option<&str>,
1426    cargo_package_override: Option<&str>,
1427) -> Result<Option<String>, String> {
1428    let file_name = Path::new(relative)
1429        .file_name()
1430        .and_then(|n| n.to_str())
1431        .unwrap_or_default();
1432
1433    match file_name {
1434        "README.md" => read_readme_version_from_content(content),
1435        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1436            read_openapi_version_from_content(content)
1437        }
1438        "openapi.json" | "swagger.json" => read_json_openapi_version_from_content(content),
1439        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1440        | "xbp.json" | "deno.json" => read_json_root_version_from_content(content),
1441        "deno.jsonc" => read_regex_version_from_content(content, r#""version"\s*:\s*"([^"]+)""#),
1442        "Cargo.toml" => read_cargo_toml_version_from_content(content),
1443        "Cargo.lock" => read_cargo_lock_version_from_content_with_package(
1444            content,
1445            cargo_toml_content,
1446            cargo_package_override,
1447        ),
1448        "pyproject.toml" => read_pyproject_version_from_content(content),
1449        "Chart.yaml" => read_yaml_root_version_from_content(content, "version"),
1450        "xbp.yaml" | "xbp.yml" => read_yaml_root_version_from_content(content, "version"),
1451        "pom.xml" => {
1452            read_regex_version_from_content(content, r"<version>\s*([^<\s]+)\s*</version>")
1453        }
1454        "build.gradle" | "build.gradle.kts" => {
1455            read_regex_version_from_content(content, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1456        }
1457        "mix.exs" => read_regex_version_from_content(content, r#"version:\s*"([^"]+)""#),
1458        _ => match Path::new(relative).extension().and_then(|ext| ext.to_str()) {
1459            Some("json") => read_json_root_version_from_content(content),
1460            Some("yaml") | Some("yml") => read_yaml_root_version_from_content(content, "version"),
1461            Some("toml") => read_toml_root_version_from_content(content),
1462            Some("md") => read_readme_version_from_content(content),
1463            _ => Ok(None),
1464        },
1465    }
1466}
1467
1468fn print_version_report(project_root: &Path, report: &VersionReport) {
1469    let dirty_suffix = if report.dirty_files.is_empty() {
1470        "clean".green().to_string()
1471    } else {
1472        format!("dirty ({})", report.dirty_files.len())
1473            .bright_magenta()
1474            .to_string()
1475    };
1476
1477    println!(
1478        "\n{} {}",
1479        "Version Summary".bright_cyan().bold(),
1480        project_root.display().to_string().bright_white()
1481    );
1482    println!("{}", "─".repeat(72).bright_black());
1483    println!(
1484        "{:<20} {}",
1485        "Highest available".bright_white(),
1486        report.highest_available().to_string().bright_green().bold()
1487    );
1488    println!(
1489        "{:<20} {}",
1490        "Worktree".bright_white(),
1491        report
1492            .highest_worktree()
1493            .unwrap_or_else(default_version)
1494            .to_string()
1495            .bright_yellow()
1496    );
1497    println!(
1498        "{:<20} {}",
1499        "Committed HEAD".bright_white(),
1500        report
1501            .highest_head()
1502            .map(|v| v.to_string())
1503            .unwrap_or_else(|| "none".dimmed().to_string())
1504    );
1505    println!(
1506        "{:<20} {}",
1507        "GitHub tags".bright_white(),
1508        report
1509            .highest_remote_tag()
1510            .map(|v| v.to_string())
1511            .unwrap_or_else(|| "none".dimmed().to_string())
1512    );
1513    println!(
1514        "{:<20} {}",
1515        "Registry latest".bright_white(),
1516        report
1517            .highest_registry()
1518            .map(|v| v.to_string())
1519            .unwrap_or_else(|| "none".dimmed().to_string())
1520    );
1521    println!(
1522        "{:<20} {}",
1523        "Local tags".bright_white(),
1524        report
1525            .highest_local_tag()
1526            .map(|v| v.to_string())
1527            .unwrap_or_else(|| "none".dimmed().to_string())
1528    );
1529    println!("{:<20} {}", "Worktree status".bright_white(), dirty_suffix);
1530
1531    print_version_observations(
1532        "Worktree version files",
1533        &report.worktree,
1534        Some(&report.dirty_files),
1535    );
1536    print_version_observations("Committed HEAD version files", &report.head, None);
1537    print_registry_observations("Published package versions", &report.registry_versions);
1538    print_git_tag_observations("GitHub tags", &report.remote_tags);
1539    print_git_tag_observations("Local git tags", &report.local_tags);
1540
1541    let divergent = report.divergent_versions();
1542    let highest = report.highest_available();
1543    let outdated: Vec<_> = divergent
1544        .into_iter()
1545        .filter(|version| version != &highest)
1546        .collect();
1547    println!();
1548    println!("{}", "Divergence".bright_cyan().bold());
1549    println!("{}", "─".repeat(72).bright_black());
1550    println!(
1551        "  {:<20} {}",
1552        "latest target".bright_white(),
1553        highest.to_string().bright_green().bold()
1554    );
1555    if !outdated.is_empty() {
1556        for version in outdated {
1557            println!(
1558                "  {} {}",
1559                "•".bright_yellow(),
1560                version.to_string().bright_yellow()
1561            );
1562        }
1563        println!();
1564        println!(
1565            "{} {}",
1566            "Fix local files with".bright_white(),
1567            format!("xbp version {}", highest).black().on_bright_green()
1568        );
1569    } else {
1570        println!("  {}", "all relevant sources are aligned".green());
1571    }
1572
1573    if !report.warnings.is_empty() {
1574        println!();
1575        println!("{}", "Warnings".bright_yellow().bold());
1576        println!("{}", "─".repeat(72).bright_black());
1577        for warning in &report.warnings {
1578            println!("  {} {}", "!".bright_yellow(), warning);
1579        }
1580    }
1581}
1582
1583fn highest_version_observation(entries: &[VersionObservation]) -> Option<Version> {
1584    entries.iter().map(|entry| entry.version.clone()).max()
1585}
1586
1587fn stale_version_observations(entries: &[VersionObservation]) -> Vec<&VersionObservation> {
1588    let Some(highest) = highest_version_observation(entries) else {
1589        return Vec::new();
1590    };
1591
1592    entries
1593        .iter()
1594        .filter(|entry| entry.version < highest)
1595        .collect()
1596}
1597
1598fn print_registry_observations(title: &str, entries: &[RegistryVersionObservation]) {
1599    println!();
1600    println!("{}", title.bright_cyan().bold());
1601    println!("{}", "─".repeat(72).bright_black());
1602
1603    if entries.is_empty() {
1604        println!("  {}", "none found".dimmed());
1605        return;
1606    }
1607
1608    for entry in entries {
1609        let latest_display = match (&entry.latest, &entry.raw_version) {
1610            (Some(version), _) => version.to_string().bright_green().to_string(),
1611            (None, Some(raw)) => raw.as_str().bright_yellow().to_string(),
1612            (None, None) => "unavailable".dimmed().to_string(),
1613        };
1614
1615        let note = entry
1616            .note
1617            .as_ref()
1618            .map(|value| format!(" {}", value.bright_yellow()))
1619            .unwrap_or_default();
1620
1621        println!(
1622            "  {:<9} {:<28} {:<16} {}{}",
1623            entry.registry.bright_white(),
1624            entry.package_name.bright_white(),
1625            latest_display,
1626            entry.source_file.dimmed(),
1627            note
1628        );
1629    }
1630}
1631
1632#[cfg(test)]
1633fn write_version_to_configured_files(
1634    project_root: &Path,
1635    invocation_dir: &Path,
1636    registry: &[String],
1637    version_scope: &VersionScope,
1638    version: &Version,
1639) -> Result<usize, String> {
1640    write_version_to_configured_files_with_paths(
1641        project_root,
1642        invocation_dir,
1643        registry,
1644        version_scope,
1645        version,
1646    )
1647    .map(|paths| paths.len())
1648}
1649
1650fn write_version_to_configured_files_with_paths(
1651    project_root: &Path,
1652    invocation_dir: &Path,
1653    registry: &[String],
1654    version_scope: &VersionScope,
1655    version: &Version,
1656) -> Result<Vec<PathBuf>, String> {
1657    let mut updated = 0usize;
1658    let mut updated_paths = Vec::new();
1659    let mut errors = Vec::new();
1660
1661    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
1662        let path = &entry.absolute;
1663        if !path.exists() {
1664            continue;
1665        }
1666
1667        match write_version_to_resolved_path(&entry, version) {
1668            Ok(true) => {
1669                updated += 1;
1670                updated_paths.push(path.clone());
1671            }
1672            Ok(false) => {}
1673            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
1674        }
1675    }
1676
1677    if updated == 0 && errors.is_empty() {
1678        return Err("No configured version files were found to update.".to_string());
1679    }
1680
1681    if !errors.is_empty() {
1682        return Err(format!(
1683            "Updated {} file(s), but some version targets failed:\n{}",
1684            updated,
1685            errors.join("\n")
1686        ));
1687    }
1688
1689    Ok(updated_paths)
1690}
1691
1692fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
1693    let file_name = path
1694        .file_name()
1695        .and_then(|n| n.to_str())
1696        .unwrap_or_default();
1697
1698    match file_name {
1699        "README.md" => read_readme_version(path),
1700        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1701            read_openapi_version(path)
1702        }
1703        "openapi.json" | "swagger.json" => read_json_openapi_version(path),
1704        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1705        | "xbp.json" => read_json_root_version(path),
1706        "deno.json" => read_json_root_version(path),
1707        "deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
1708        "Cargo.toml" => read_cargo_toml_version(path),
1709        "Cargo.lock" => read_cargo_lock_version(path),
1710        "pyproject.toml" => read_pyproject_version(path),
1711        "Chart.yaml" => read_yaml_root_version(path, "version"),
1712        "xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
1713        "pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
1714        "build.gradle" | "build.gradle.kts" => {
1715            read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
1716        }
1717        "mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
1718        _ => match path.extension().and_then(|ext| ext.to_str()) {
1719            Some("json") => read_json_root_version(path),
1720            Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
1721            Some("toml") => read_toml_root_version(path),
1722            Some("md") => read_readme_version(path),
1723            _ => Ok(None),
1724        },
1725    }
1726}
1727
1728fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
1729    let path = &entry.absolute;
1730    let file_name = path
1731        .file_name()
1732        .and_then(|n| n.to_str())
1733        .unwrap_or_default();
1734
1735    if file_name == "Cargo.lock" {
1736        if let Some(package_name) = entry.cargo_package_override.as_deref() {
1737            return read_cargo_lock_version_for_package(path, package_name);
1738        }
1739    }
1740
1741    read_version_from_path(path)
1742}
1743
1744fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
1745    let file_name = path
1746        .file_name()
1747        .and_then(|n| n.to_str())
1748        .unwrap_or_default();
1749
1750    match file_name {
1751        "README.md" => write_readme_version(path, version).map(|_| true),
1752        "openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
1753            write_openapi_version(path, version).map(|_| true)
1754        }
1755        "openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
1756        "package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
1757        | "xbp.json" => write_json_root_version(path, version).map(|_| true),
1758        "deno.json" => write_json_root_version(path, version).map(|_| true),
1759        "deno.jsonc" => {
1760            write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
1761        }
1762        "Cargo.toml" => write_cargo_toml_version(path, version),
1763        "Cargo.lock" => write_cargo_lock_version(path, version),
1764        "pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
1765        "Chart.yaml" => write_chart_version(path, version).map(|_| true),
1766        "xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
1767        "pom.xml" => {
1768            write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
1769        }
1770        "build.gradle" | "build.gradle.kts" => {
1771            write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
1772                .map(|_| true)
1773        }
1774        "mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
1775        _ => match path.extension().and_then(|ext| ext.to_str()) {
1776            Some("json") => write_json_root_version(path, version).map(|_| true),
1777            Some("yaml") | Some("yml") => {
1778                write_yaml_root_version(path, "version", version).map(|_| true)
1779            }
1780            Some("toml") => write_toml_root_version(path, version).map(|_| true),
1781            Some("md") => write_readme_version(path, version).map(|_| true),
1782            _ => Err("Unsupported version file type".to_string()),
1783        },
1784    }
1785}
1786
1787fn write_version_to_resolved_path(
1788    entry: &ResolvedRegistryPath,
1789    version: &Version,
1790) -> Result<bool, String> {
1791    let path = &entry.absolute;
1792    let file_name = path
1793        .file_name()
1794        .and_then(|n| n.to_str())
1795        .unwrap_or_default();
1796
1797    if file_name == "Cargo.lock" {
1798        if let Some(package_name) = entry.cargo_package_override.as_deref() {
1799            return write_cargo_lock_version_for_package(path, Some(package_name), version);
1800        }
1801    }
1802
1803    write_version_to_path(path, version)
1804}
1805
1806fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
1807    let value: JsonValue =
1808        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1809    Ok(value
1810        .get("version")
1811        .and_then(JsonValue::as_str)
1812        .map(|value| value.to_string()))
1813}
1814
1815fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
1816    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1817    read_json_root_version_from_content(&content)
1818}
1819
1820fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
1821    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1822    let mut value: JsonValue =
1823        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1824
1825    let object = value
1826        .as_object_mut()
1827        .ok_or_else(|| "Expected a JSON object".to_string())?;
1828    object.insert(
1829        "version".to_string(),
1830        JsonValue::String(version.to_string()),
1831    );
1832
1833    fs::write(
1834        path,
1835        serde_json::to_string_pretty(&value)
1836            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
1837    )
1838    .map_err(|e| format!("Failed to write file: {}", e))
1839}
1840
1841fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
1842    let value: YamlValue =
1843        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1844    Ok(yaml_get_string(&value, key))
1845}
1846
1847fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
1848    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1849    read_yaml_root_version_from_content(&content, key)
1850}
1851
1852fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
1853    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1854    let mut value: YamlValue =
1855        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1856
1857    let mapping = yaml_root_mapping_mut(&mut value)?;
1858    mapping.insert(
1859        YamlValue::String(key.to_string()),
1860        YamlValue::String(version.to_string()),
1861    );
1862
1863    fs::write(
1864        path,
1865        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
1866    )
1867    .map_err(|e| format!("Failed to write file: {}", e))
1868}
1869
1870fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
1871    let value: YamlValue =
1872        serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1873
1874    let info = yaml_get_mapping(&value, "info");
1875    Ok(info.and_then(|mapping| {
1876        mapping
1877            .get(YamlValue::String("version".to_string()))
1878            .and_then(YamlValue::as_str)
1879            .map(|value| value.to_string())
1880    }))
1881}
1882
1883fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
1884    let content: String =
1885        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1886    read_openapi_version_from_content(&content)
1887}
1888
1889fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
1890    let value: JsonValue =
1891        serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1892    Ok(value
1893        .get("info")
1894        .and_then(JsonValue::as_object)
1895        .and_then(|info| info.get("version"))
1896        .and_then(JsonValue::as_str)
1897        .map(|value| value.to_string()))
1898}
1899
1900fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
1901    let content: String =
1902        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1903    read_json_openapi_version_from_content(&content)
1904}
1905
1906fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
1907    let content: String =
1908        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1909    let mut value: YamlValue =
1910        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
1911
1912    let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
1913    let info_key: YamlValue = YamlValue::String("info".to_string());
1914    if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
1915        root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
1916    }
1917
1918    let info: &mut YamlMapping = root
1919        .get_mut(&info_key)
1920        .and_then(YamlValue::as_mapping_mut)
1921        .ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
1922    info.insert(
1923        YamlValue::String("version".to_string()),
1924        YamlValue::String(version.to_string()),
1925    );
1926
1927    fs::write(
1928        path,
1929        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
1930    )
1931    .map_err(|e| format!("Failed to write file: {}", e))
1932}
1933
1934fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
1935    let content: String =
1936        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1937    let mut value: JsonValue =
1938        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
1939
1940    let root = value
1941        .as_object_mut()
1942        .ok_or_else(|| "Expected a JSON object".to_string())?;
1943    let info = root
1944        .entry("info".to_string())
1945        .or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
1946    let info_object = info
1947        .as_object_mut()
1948        .ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
1949    info_object.insert(
1950        "version".to_string(),
1951        JsonValue::String(version.to_string()),
1952    );
1953
1954    fs::write(
1955        path,
1956        serde_json::to_string_pretty(&value)
1957            .map_err(|e| format!("Failed to serialize JSON: {}", e))?,
1958    )
1959    .map_err(|e| format!("Failed to write file: {}", e))
1960}
1961
1962fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
1963    let value: TomlValue =
1964        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1965    Ok(value
1966        .get("version")
1967        .and_then(TomlValue::as_str)
1968        .map(|value| value.to_string()))
1969}
1970
1971fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
1972    let content: String =
1973        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1974    read_toml_root_version_from_content(&content)
1975}
1976
1977fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
1978    let content: String =
1979        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
1980    let mut value: TomlValue =
1981        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1982    let table = value
1983        .as_table_mut()
1984        .ok_or_else(|| "Expected a TOML table".to_string())?;
1985    table.insert(
1986        "version".to_string(),
1987        TomlValue::String(version.to_string()),
1988    );
1989    fs::write(
1990        path,
1991        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
1992    )
1993    .map_err(|e| format!("Failed to write file: {}", e))
1994}
1995
1996fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
1997    let value: TomlValue =
1998        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
1999    Ok(value
2000        .get("package")
2001        .and_then(TomlValue::as_table)
2002        .and_then(|package| package.get("version"))
2003        .and_then(TomlValue::as_str)
2004        .map(|value| value.to_string()))
2005}
2006
2007fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
2008    let content: String =
2009        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2010    read_cargo_toml_version_from_content(&content)
2011}
2012
2013fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
2014    let content: String =
2015        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2016    let mut value: TomlValue =
2017        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2018
2019    let Some(package) = value.get_mut("package").and_then(TomlValue::as_table_mut) else {
2020        return Ok(false);
2021    };
2022    package.insert(
2023        "version".to_string(),
2024        TomlValue::String(version.to_string()),
2025    );
2026
2027    fs::write(
2028        path,
2029        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2030    )
2031    .map_err(|e| format!("Failed to write file: {}", e))?;
2032
2033    Ok(true)
2034}
2035
2036fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
2037    let value: TomlValue =
2038        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2039
2040    let project_version = value
2041        .get("project")
2042        .and_then(TomlValue::as_table)
2043        .and_then(|project| project.get("version"))
2044        .and_then(TomlValue::as_str);
2045
2046    let poetry_version = value
2047        .get("tool")
2048        .and_then(TomlValue::as_table)
2049        .and_then(|tool| tool.get("poetry"))
2050        .and_then(TomlValue::as_table)
2051        .and_then(|poetry| poetry.get("version"))
2052        .and_then(TomlValue::as_str);
2053
2054    Ok(project_version
2055        .or(poetry_version)
2056        .map(|value| value.to_string()))
2057}
2058
2059fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
2060    let content: String =
2061        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2062    read_pyproject_version_from_content(&content)
2063}
2064
2065fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
2066    let content: String =
2067        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2068    let mut value: TomlValue =
2069        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2070
2071    if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
2072        project.insert(
2073            "version".to_string(),
2074            TomlValue::String(version.to_string()),
2075        );
2076    } else if let Some(poetry) = value
2077        .get_mut("tool")
2078        .and_then(TomlValue::as_table_mut)
2079        .and_then(|tool| tool.get_mut("poetry"))
2080        .and_then(TomlValue::as_table_mut)
2081    {
2082        poetry.insert(
2083            "version".to_string(),
2084            TomlValue::String(version.to_string()),
2085        );
2086    } else {
2087        let table: &mut toml::map::Map<String, TomlValue> = value
2088            .as_table_mut()
2089            .ok_or_else(|| "Expected a TOML table".to_string())?;
2090        table.insert(
2091            "version".to_string(),
2092            TomlValue::String(version.to_string()),
2093        );
2094    }
2095
2096    fs::write(
2097        path,
2098        toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2099    )
2100    .map_err(|e| format!("Failed to write file: {}", e))
2101}
2102
2103fn read_cargo_lock_version_from_content(
2104    content: &str,
2105    cargo_toml_content: Option<&str>,
2106) -> Result<Option<String>, String> {
2107    read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
2108}
2109
2110fn read_cargo_lock_version_from_content_with_package(
2111    content: &str,
2112    cargo_toml_content: Option<&str>,
2113    package_name_override: Option<&str>,
2114) -> Result<Option<String>, String> {
2115    let value: TomlValue =
2116        toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2117    let package_name = if let Some(package_name_override) = package_name_override {
2118        package_name_override.trim().to_string()
2119    } else {
2120        let cargo_toml_content = cargo_toml_content
2121            .ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
2122        cargo_package_name_from_content(cargo_toml_content)?
2123    };
2124
2125    Ok(value
2126        .get("package")
2127        .and_then(TomlValue::as_array)
2128        .and_then(|packages| {
2129            packages.iter().find_map(|package| {
2130                let table = package.as_table()?;
2131                if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2132                    table
2133                        .get("version")
2134                        .and_then(TomlValue::as_str)
2135                        .map(|value| value.to_string())
2136                } else {
2137                    None
2138                }
2139            })
2140        }))
2141}
2142
2143fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
2144    let content: String =
2145        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2146    let cargo_toml: String = fs::read_to_string(
2147        path.parent()
2148            .unwrap_or_else(|| Path::new("."))
2149            .join("Cargo.toml"),
2150    )
2151    .map_err(|e| format!("Failed to read file: {}", e))?;
2152    read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
2153}
2154
2155fn read_cargo_lock_version_for_package(
2156    path: &Path,
2157    package_name: &str,
2158) -> Result<Option<String>, String> {
2159    let content: String =
2160        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2161    read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
2162}
2163
2164fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
2165    write_cargo_lock_version_for_package(path, None, version)
2166}
2167
2168fn write_cargo_lock_version_for_package(
2169    path: &Path,
2170    package_name_override: Option<&str>,
2171    version: &Version,
2172) -> Result<bool, String> {
2173    let content: String =
2174        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2175    let mut value: TomlValue =
2176        toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
2177    let package_name = if let Some(package_name_override) = package_name_override {
2178        package_name_override.trim().to_string()
2179    } else {
2180        let Some(package_name) = cargo_package_name(path)? else {
2181            return Ok(false);
2182        };
2183        package_name
2184    };
2185
2186    let packages: &mut Vec<TomlValue> = value
2187        .get_mut("package")
2188        .and_then(TomlValue::as_array_mut)
2189        .ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
2190
2191    let mut updated = false;
2192    for package in packages {
2193        if let Some(table) = package.as_table_mut() {
2194            if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
2195                table.insert(
2196                    "version".to_string(),
2197                    TomlValue::String(version.to_string()),
2198                );
2199                updated = true;
2200            }
2201        }
2202    }
2203
2204    if !updated {
2205        return Err(format!(
2206            "Could not find package `{}` in Cargo.lock",
2207            package_name
2208        ));
2209    }
2210
2211    fs::write(
2212        path,
2213        toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
2214    )
2215    .map_err(|e| format!("Failed to write file: {}", e))?;
2216
2217    Ok(true)
2218}
2219
2220fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
2221    let cargo_toml: PathBuf = path
2222        .parent()
2223        .unwrap_or_else(|| Path::new("."))
2224        .join("Cargo.toml");
2225    let content: String = fs::read_to_string(&cargo_toml)
2226        .map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
2227    cargo_package_name_from_content_optional(&content)
2228}
2229
2230fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
2231    cargo_package_name_from_content_optional(content)?
2232        .ok_or_else(|| "Could not determine Cargo package name".to_string())
2233}
2234
2235fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
2236    let value: TomlValue =
2237        toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
2238    Ok(value
2239        .get("package")
2240        .and_then(TomlValue::as_table)
2241        .and_then(|package| package.get("name"))
2242        .and_then(TomlValue::as_str)
2243        .map(|value| value.to_string()))
2244}
2245
2246fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
2247    read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2248}
2249
2250fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
2251    let content: String =
2252        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2253    read_readme_version_from_content(&content)
2254}
2255
2256fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
2257    let content: String =
2258        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2259    let marker: String = format!("current version: `{}`", version);
2260    let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
2261        .map_err(|e| format!("Failed to build README regex: {}", e))?;
2262
2263    let updated: String = if regex.is_match(&content) {
2264        regex.replace(&content, marker.as_str()).to_string()
2265    } else if let Some(first_break) = content.find('\n') {
2266        let mut next = String::new();
2267        next.push_str(&content[..=first_break]);
2268        next.push('\n');
2269        next.push_str(&marker);
2270        next.push('\n');
2271        next.push_str(&content[first_break + 1..]);
2272        next
2273    } else {
2274        format!("{}\n\n{}\n", content, marker)
2275    };
2276
2277    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2278}
2279
2280fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
2281    let content: String =
2282        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2283    read_regex_version_from_content(&content, pattern)
2284}
2285
2286fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
2287    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2288    Ok(regex
2289        .captures(content)
2290        .and_then(|captures| captures.get(1))
2291        .map(|matched| matched.as_str().trim().to_string()))
2292}
2293
2294fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
2295    let content: String =
2296        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2297    let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
2298
2299    if !regex.is_match(&content) {
2300        return Err("No version pattern found".to_string());
2301    }
2302
2303    let updated: String = regex
2304        .replace(&content, |caps: &regex::Captures<'_>| {
2305            caps[0].replace(&caps[1], &version.to_string())
2306        })
2307        .to_string();
2308    fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
2309}
2310
2311#[cfg(test)]
2312fn write_package_version_to_configured_files(
2313    project_root: &Path,
2314    invocation_dir: &Path,
2315    registry: &[String],
2316    version_scope: &VersionScope,
2317    package_name: &str,
2318    version: &Version,
2319) -> Result<usize, String> {
2320    write_package_version_to_configured_files_with_paths(
2321        project_root,
2322        invocation_dir,
2323        registry,
2324        version_scope,
2325        package_name,
2326        version,
2327    )
2328    .map(|paths| paths.len())
2329}
2330
2331fn write_package_version_to_configured_files_with_paths(
2332    project_root: &Path,
2333    invocation_dir: &Path,
2334    registry: &[String],
2335    version_scope: &VersionScope,
2336    package_name: &str,
2337    version: &Version,
2338) -> Result<Vec<PathBuf>, String> {
2339    let mut updated: usize = 0usize;
2340    let mut updated_paths = Vec::new();
2341    let mut errors: Vec<String> = Vec::new();
2342
2343    for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
2344        let path: &PathBuf = &entry.absolute;
2345        if !path.exists() {
2346            continue;
2347        }
2348
2349        match write_package_version_to_path(path, package_name, version) {
2350            Ok(true) => {
2351                updated += 1;
2352                updated_paths.push(path.clone());
2353            }
2354            Ok(false) => {}
2355            Err(err) => errors.push(format!("{}: {}", path.display(), err)),
2356        }
2357    }
2358
2359    if updated == 0 && errors.is_empty() {
2360        return Err(format!(
2361            "No configured TOML files contained package assignment `{}`.",
2362            package_name
2363        ));
2364    }
2365
2366    if !errors.is_empty() {
2367        return Err(format!(
2368            "Updated {} file(s), but some package version targets failed:\n{}",
2369            updated,
2370            errors.join("\n")
2371        ));
2372    }
2373
2374    Ok(updated_paths)
2375}
2376
2377fn write_package_version_to_path(
2378    path: &Path,
2379    package_name: &str,
2380    version: &Version,
2381) -> Result<bool, String> {
2382    let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
2383    if !is_toml {
2384        return Ok(false);
2385    }
2386
2387    let content: String =
2388        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2389    let (updated, changed) =
2390        rewrite_toml_package_assignment_versions(&content, package_name, version)?;
2391    if changed {
2392        fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
2393    }
2394    Ok(changed)
2395}
2396
2397fn rewrite_toml_package_assignment_versions(
2398    content: &str,
2399    package_name: &str,
2400    version: &Version,
2401) -> Result<(String, bool), String> {
2402    let escaped_name: String = regex::escape(package_name);
2403    let inline_pattern: String = format!(
2404        r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
2405        escaped_name
2406    );
2407    let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
2408
2409    let inline_regex: Regex =
2410        Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
2411    let string_regex: Regex =
2412        Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
2413
2414    let replacement: String = version.to_string();
2415
2416    let after_inline: String = inline_regex
2417        .replace_all(content, |caps: &regex::Captures<'_>| {
2418            format!("{}{}{}", &caps[1], replacement, &caps[3])
2419        })
2420        .to_string();
2421    let after_string: String = string_regex
2422        .replace_all(&after_inline, |caps: &regex::Captures<'_>| {
2423            format!("{}{}{}", &caps[1], replacement, &caps[3])
2424        })
2425        .to_string();
2426
2427    Ok((after_string.clone(), after_string != content))
2428}
2429
2430fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
2431    let content: String =
2432        fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
2433    let mut value: YamlValue =
2434        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
2435    let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
2436    mapping.insert(
2437        YamlValue::String("version".to_string()),
2438        YamlValue::String(version.to_string()),
2439    );
2440    if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
2441        mapping.insert(
2442            YamlValue::String("appVersion".to_string()),
2443            YamlValue::String(version.to_string()),
2444        );
2445    }
2446    fs::write(
2447        path,
2448        serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
2449    )
2450    .map_err(|e| format!("Failed to write file: {}", e))
2451}
2452
2453fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
2454    value
2455        .as_mapping_mut()
2456        .ok_or_else(|| "Expected a YAML mapping".to_string())
2457}
2458
2459fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
2460    value
2461        .as_mapping()
2462        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2463        .and_then(YamlValue::as_mapping)
2464}
2465
2466fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
2467    value
2468        .as_mapping()
2469        .and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
2470        .and_then(YamlValue::as_str)
2471        .map(|value| value.to_string())
2472}
2473
2474fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
2475    let mut current: &JsonValue = value;
2476    for part in key_parts {
2477        current = current.get(*part)?;
2478    }
2479    current.as_str().map(|value| value.to_string())
2480}
2481
2482fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
2483    let mut current = value;
2484    for part in key_parts {
2485        let mapping = current.as_mapping()?;
2486        current = mapping.get(YamlValue::String((*part).to_string()))?;
2487    }
2488    current.as_str().map(|value| value.to_string())
2489}
2490
2491fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
2492    let mut current = value;
2493    for part in key_parts {
2494        current = current.get(*part)?;
2495    }
2496    current.as_str().map(|value| value.to_string())
2497}
2498
2499fn parse_version(input: &str) -> Result<Version, String> {
2500    let trimmed: &str = input.trim();
2501    let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
2502    Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
2503}
2504
2505fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
2506    let trimmed = input.trim();
2507    if trimmed.is_empty() {
2508        return Err("Release version cannot be empty.".to_string());
2509    }
2510
2511    if let Ok(version) = parse_version(trimmed) {
2512        return Ok((version.clone(), format!("v{}", version)));
2513    }
2514
2515    let prefixed = Regex::new(
2516        r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
2517    )
2518    .map_err(|e| format!("Failed to build release target parser: {}", e))?;
2519
2520    if let Some(captures) = prefixed.captures(trimmed) {
2521        let semver = captures
2522            .name("semver")
2523            .map(|m| m.as_str())
2524            .ok_or_else(|| format!("Invalid release target `{}`.", input))?;
2525        let version = Version::parse(semver)
2526            .map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
2527        return Ok((version, trimmed.to_string()));
2528    }
2529
2530    Err(format!(
2531        "Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
2532        input
2533    ))
2534}
2535
2536fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
2537    let Some((raw_package, raw_version)) = input.split_once('=') else {
2538        return Ok(None);
2539    };
2540
2541    let package_name = raw_package.trim();
2542    if package_name.is_empty() {
2543        return Ok(None);
2544    }
2545
2546    let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
2547        .map_err(|e| format!("Failed to build package-name validator: {}", e))?;
2548    if !package_name_regex.is_match(package_name) {
2549        return Err(format!(
2550            "Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
2551            input
2552        ));
2553    }
2554
2555    let version = parse_version(raw_version.trim())?;
2556    Ok(Some((package_name.to_string(), version)))
2557}
2558
2559fn bump_version(current: &Version, kind: &str) -> Version {
2560    let mut next = current.clone();
2561    match kind {
2562        "major" => {
2563            next.major += 1;
2564            next.minor = 0;
2565            next.patch = 0;
2566            next.pre = semver::Prerelease::EMPTY;
2567            next.build = semver::BuildMetadata::EMPTY;
2568        }
2569        "minor" => {
2570            next.minor += 1;
2571            next.patch = 0;
2572            next.pre = semver::Prerelease::EMPTY;
2573            next.build = semver::BuildMetadata::EMPTY;
2574        }
2575        _ => {
2576            next.patch += 1;
2577            next.pre = semver::Prerelease::EMPTY;
2578            next.build = semver::BuildMetadata::EMPTY;
2579        }
2580    }
2581    next
2582}
2583
2584fn default_version() -> Version {
2585    Version::new(0, 1, 0)
2586}
2587
2588fn resolve_registry_paths(
2589    project_root: &Path,
2590    invocation_dir: &Path,
2591    registry: &[String],
2592    version_scope: &VersionScope,
2593) -> Vec<ResolvedRegistryPath> {
2594    let mut resolved: Vec<ResolvedRegistryPath> = Vec::new();
2595    let mut seen: BTreeSet<String> = BTreeSet::new();
2596    let workspace_primary_target = match version_scope {
2597        VersionScope::Repository => resolve_workspace_primary_cargo_target(project_root),
2598        VersionScope::Crate { .. } => None,
2599    };
2600
2601    for relative in registry {
2602        match version_scope {
2603            VersionScope::Repository => {
2604                if *relative == *"Cargo.lock" {
2605                    let resolved_relative = resolve_registry_relative_path(
2606                        project_root,
2607                        invocation_dir,
2608                        version_scope,
2609                        relative,
2610                    );
2611                    if !seen.insert(resolved_relative.clone()) {
2612                        continue;
2613                    }
2614
2615                    resolved.push(ResolvedRegistryPath {
2616                        absolute: project_root.join(&resolved_relative),
2617                        relative: resolved_relative,
2618                        cargo_package_override: workspace_primary_target
2619                            .as_ref()
2620                            .map(|target| target.package_name.clone()),
2621                    });
2622                    continue;
2623                }
2624
2625                if *relative == *"Cargo.toml" {
2626                    if let Some(target) = workspace_primary_target.as_ref() {
2627                        if !seen.insert(target.manifest_relative.clone()) {
2628                            continue;
2629                        }
2630
2631                        resolved.push(ResolvedRegistryPath {
2632                            absolute: target.manifest_absolute.clone(),
2633                            relative: target.manifest_relative.clone(),
2634                            cargo_package_override: None,
2635                        });
2636                        continue;
2637                    }
2638                }
2639
2640                let resolved_relative = resolve_registry_relative_path(
2641                    project_root,
2642                    invocation_dir,
2643                    version_scope,
2644                    relative,
2645                );
2646                if !seen.insert(resolved_relative.clone()) {
2647                    continue;
2648                }
2649
2650                resolved.push(ResolvedRegistryPath {
2651                    absolute: project_root.join(&resolved_relative),
2652                    relative: resolved_relative,
2653                    cargo_package_override: None,
2654                });
2655            }
2656            VersionScope::Crate {
2657                crate_root,
2658                package_name,
2659                ..
2660            } => {
2661                if *relative == *"Cargo.lock" {
2662                    let cargo_lock = project_root.join("Cargo.lock");
2663                    if cargo_lock.exists() && seen.insert("Cargo.lock".to_string()) {
2664                        resolved.push(ResolvedRegistryPath {
2665                            absolute: cargo_lock,
2666                            relative: "Cargo.lock".to_string(),
2667                            cargo_package_override: Some(package_name.clone()),
2668                        });
2669                    }
2670                    continue;
2671                }
2672
2673                let preferred = crate_root.join(relative);
2674                if !preferred.exists() {
2675                    continue;
2676                }
2677                let Ok(stripped) = preferred.strip_prefix(project_root) else {
2678                    continue;
2679                };
2680                let resolved_relative = normalized_relative_path(stripped);
2681                if !seen.insert(resolved_relative.clone()) {
2682                    continue;
2683                }
2684
2685                resolved.push(ResolvedRegistryPath {
2686                    absolute: preferred,
2687                    relative: resolved_relative,
2688                    cargo_package_override: None,
2689                });
2690            }
2691        }
2692    }
2693
2694    resolved
2695}
2696
2697fn resolve_registry_relative_path(
2698    project_root: &Path,
2699    invocation_dir: &Path,
2700    version_scope: &VersionScope,
2701    relative: &str,
2702) -> String {
2703    if let VersionScope::Crate { crate_root, .. } = version_scope {
2704        let preferred = crate_root.join(relative);
2705        if preferred.exists() {
2706            if let Ok(stripped) = preferred.strip_prefix(project_root) {
2707                return normalized_relative_path(stripped);
2708            }
2709        }
2710        return relative.replace('\\', "/");
2711    }
2712
2713    if relative == "Cargo.toml" {
2714        if let Some(target) = resolve_workspace_primary_cargo_target(project_root) {
2715            return target.manifest_relative;
2716        }
2717    }
2718
2719    let preferred: PathBuf = invocation_dir.join(relative);
2720    if preferred.exists() {
2721        if let Ok(stripped) = preferred.strip_prefix(project_root) {
2722            return normalized_relative_path(stripped);
2723        }
2724    }
2725
2726    relative.replace('\\', "/")
2727}
2728
2729fn resolve_version_scope(project_root: &Path, invocation_dir: &Path) -> VersionScope {
2730    if let Some(crate_scope) = resolve_crate_scope(project_root, invocation_dir) {
2731        return crate_scope;
2732    }
2733
2734    VersionScope::Repository
2735}
2736
2737fn resolve_crate_scope(project_root: &Path, invocation_dir: &Path) -> Option<VersionScope> {
2738    let crate_root = resolve_release_scope_root(project_root, invocation_dir)?;
2739    let cargo_toml = crate_root.join("Cargo.toml");
2740    let cargo_toml_content = fs::read_to_string(&cargo_toml).ok()?;
2741    let package_name = cargo_package_name_from_content_optional(&cargo_toml_content).ok()??;
2742    let crate_relative_root = crate_root
2743        .strip_prefix(project_root)
2744        .ok()
2745        .map(normalized_relative_path)?;
2746
2747    Some(VersionScope::Crate {
2748        crate_root,
2749        crate_relative_root,
2750        tag_prefix: format!("{}-", package_name),
2751        package_name,
2752    })
2753}
2754
2755fn resolve_release_openapi_spec(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
2756    let mut roots: Vec<PathBuf> = Vec::new();
2757    if let Some(crate_root) = resolve_release_scope_root(project_root, invocation_dir) {
2758        roots.push(crate_root);
2759    }
2760    roots.push(project_root.to_path_buf());
2761
2762    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
2763    for root in roots {
2764        if !seen.insert(root.clone()) {
2765            continue;
2766        }
2767
2768        for file_name in [
2769            "openapi.yaml",
2770            "openapi.yml",
2771            "openapi.json",
2772            "swagger.yaml",
2773            "swagger.yml",
2774            "swagger.json",
2775        ] {
2776            let path = root.join(file_name);
2777            if path.is_file() {
2778                return Some(path);
2779            }
2780        }
2781    }
2782
2783    None
2784}
2785
2786fn resolve_release_scope_root(project_root: &Path, invocation_dir: &Path) -> Option<PathBuf> {
2787    let crates_root = project_root.join("crates");
2788    let relative = invocation_dir.strip_prefix(&crates_root).ok()?;
2789    let mut components = relative.components();
2790    let crate_name = components.next()?;
2791    Some(crates_root.join(crate_name.as_os_str()))
2792}
2793
2794fn resolve_workspace_primary_cargo_target(
2795    project_root: &Path,
2796) -> Option<WorkspacePrimaryCargoTarget> {
2797    let workspace_manifest = project_root.join("Cargo.toml");
2798    let content = fs::read_to_string(&workspace_manifest).ok()?;
2799    let value: TomlValue = toml::from_str(&content).ok()?;
2800    if value.get("package").and_then(TomlValue::as_table).is_some() {
2801        return None;
2802    }
2803
2804    let workspace = value.get("workspace").and_then(TomlValue::as_table)?;
2805    let mut candidate_roots = workspace
2806        .get("default-members")
2807        .and_then(TomlValue::as_array)
2808        .map(|members| {
2809            members
2810                .iter()
2811                .filter_map(TomlValue::as_str)
2812                .map(str::trim)
2813                .filter(|member| !member.is_empty() && !member.contains('*'))
2814                .map(str::to_string)
2815                .collect::<Vec<_>>()
2816        })
2817        .unwrap_or_default();
2818
2819    if candidate_roots.is_empty() {
2820        let members = workspace
2821            .get("members")
2822            .and_then(TomlValue::as_array)
2823            .map(|members| {
2824                members
2825                    .iter()
2826                    .filter_map(TomlValue::as_str)
2827                    .map(str::trim)
2828                    .filter(|member| !member.is_empty() && !member.contains('*'))
2829                    .map(str::to_string)
2830                    .collect::<Vec<_>>()
2831            })
2832            .unwrap_or_default();
2833        if members.len() == 1 {
2834            candidate_roots = members;
2835        }
2836    }
2837
2838    candidate_roots.sort();
2839    candidate_roots.dedup();
2840    if candidate_roots.len() != 1 {
2841        return None;
2842    }
2843
2844    let crate_relative_root = candidate_roots.into_iter().next()?;
2845    let manifest_absolute = project_root.join(&crate_relative_root).join("Cargo.toml");
2846    let manifest_content = fs::read_to_string(&manifest_absolute).ok()?;
2847    let package_name = cargo_package_name_from_content_optional(&manifest_content).ok()??;
2848
2849    Some(WorkspacePrimaryCargoTarget {
2850        manifest_relative: format!("{}/Cargo.toml", crate_relative_root.replace('\\', "/")),
2851        manifest_absolute,
2852        package_name,
2853    })
2854}
2855
2856async fn resolve_effective_linear_release_config(
2857    project_root: &Path,
2858    invocation_dir: &Path,
2859) -> Result<Option<ResolvedLinearReleaseConfig>, String> {
2860    let global_config = resolve_global_linear_release_config();
2861    let project_config = if let Some(found) = find_xbp_config_upwards(invocation_dir) {
2862        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
2863        config.linear.and_then(|linear| linear.release)
2864    } else {
2865        None
2866    };
2867
2868    Ok(resolve_linear_release_config(global_config, project_config)
2869        .map(|config| resolve_linear_release_placeholders(project_root, config)))
2870}
2871
2872fn resolve_linear_release_placeholders(
2873    project_root: &Path,
2874    mut config: ResolvedLinearReleaseConfig,
2875) -> ResolvedLinearReleaseConfig {
2876    let mut env_map = HashMap::new();
2877    for (index, initiative_id) in config.initiative_ids.iter().enumerate() {
2878        env_map.insert(format!("initiative_id_{}", index), initiative_id.clone());
2879    }
2880    if let Some(organization_name) = config.organization_name.clone() {
2881        env_map.insert("organization_name".to_string(), organization_name);
2882    }
2883
2884    let resolved = resolve_env_placeholders(project_root, &env_map);
2885    config.initiative_ids = config
2886        .initiative_ids
2887        .iter()
2888        .enumerate()
2889        .map(|(index, initiative_id)| {
2890            resolved
2891                .get(&format!("initiative_id_{}", index))
2892                .cloned()
2893                .unwrap_or_else(|| initiative_id.clone())
2894        })
2895        .map(|value| value.trim().to_string())
2896        .filter(|value| !value.is_empty())
2897        .collect();
2898    config.organization_name = resolved
2899        .get("organization_name")
2900        .cloned()
2901        .or(config.organization_name)
2902        .map(|value| value.trim().to_string())
2903        .filter(|value| !value.is_empty());
2904    config
2905}
2906
2907async fn resolve_project_github_release_branch_config(
2908    _project_root: &Path,
2909    invocation_dir: &Path,
2910) -> Result<Option<GitHubReleaseBranchSettings>, String> {
2911    if let Some(found) = find_xbp_config_upwards(invocation_dir) {
2912        let config = DeploymentConfig::load_xbp_config(Some(found.config_path)).await?;
2913        Ok(config.github_release_branch_settings())
2914    } else {
2915        Ok(None)
2916    }
2917}
2918
2919fn normalized_relative_path(path: &Path) -> String {
2920    path.to_string_lossy().replace('\\', "/")
2921}
2922
2923fn git_repository_root(dir: &Path) -> Option<PathBuf> {
2924    if !command_exists("git") {
2925        return None;
2926    }
2927
2928    let output: std::process::Output = Command::new("git")
2929        .current_dir(dir)
2930        .args(["rev-parse", "--show-toplevel"])
2931        .output()
2932        .ok()?;
2933
2934    if !output.status.success() {
2935        return None;
2936    }
2937
2938    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
2939    if root.is_empty() {
2940        None
2941    } else {
2942        Some(PathBuf::from(root))
2943    }
2944}
2945
2946fn run_git_command(project_root: &Path, args: &[&str]) -> Result<String, String> {
2947    let output: std::process::Output = Command::new("git")
2948        .current_dir(project_root)
2949        .args(args)
2950        .output()
2951        .map_err(|e| format!("Failed to run `git {}`: {}", args.join(" "), e))?;
2952
2953    if !output.status.success() {
2954        let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
2955        if stderr.is_empty() {
2956            return Err(format!(
2957                "`git {}` failed with status {}",
2958                args.join(" "),
2959                output.status
2960            ));
2961        }
2962        return Err(format!("`git {}` failed: {}", args.join(" "), stderr));
2963    }
2964
2965    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
2966}
2967
2968fn git_dirty_entries(project_root: &Path) -> Result<Vec<String>, String> {
2969    let output: String = run_git_command(project_root, &["status", "--porcelain"])?;
2970    Ok(output
2971        .lines()
2972        .map(|line| line.trim())
2973        .filter(|line| !line.is_empty())
2974        .map(|line| line.to_string())
2975        .collect())
2976}
2977
2978fn version_change_guard_state_path() -> Result<PathBuf, String> {
2979    let paths = global_xbp_paths()?;
2980    Ok(paths.cache_dir.join(VERSION_CHANGE_GUARD_FILE_NAME))
2981}
2982
2983fn version_change_guard_repo_key(project_root: &Path) -> String {
2984    fs::canonicalize(project_root)
2985        .unwrap_or_else(|_| project_root.to_path_buf())
2986        .to_string_lossy()
2987        .replace('\\', "/")
2988}
2989
2990fn load_version_change_guard_registry(path: &Path) -> Result<VersionChangeGuardRegistry, String> {
2991    if !path.exists() {
2992        return Ok(VersionChangeGuardRegistry::default());
2993    }
2994
2995    let content = fs::read_to_string(path).map_err(|e| {
2996        format!(
2997            "Failed to read version-change guard state {}: {}",
2998            path.display(),
2999            e
3000        )
3001    })?;
3002
3003    Ok(serde_yaml::from_str::<VersionChangeGuardRegistry>(&content).unwrap_or_default())
3004}
3005
3006fn save_version_change_guard_registry(
3007    path: &Path,
3008    registry: &VersionChangeGuardRegistry,
3009) -> Result<(), String> {
3010    if let Some(parent) = path.parent() {
3011        fs::create_dir_all(parent).map_err(|e| {
3012            format!(
3013                "Failed to create guard state directory {}: {}",
3014                parent.display(),
3015                e
3016            )
3017        })?;
3018    }
3019
3020    let content = serde_yaml::to_string(registry)
3021        .map_err(|e| format!("Failed to serialize version-change guard state: {}", e))?;
3022    fs::write(path, content).map_err(|e| {
3023        format!(
3024            "Failed to write version-change guard state {}: {}",
3025            path.display(),
3026            e
3027        )
3028    })
3029}
3030
3031fn git_worktree_state(project_root: &Path) -> Result<Option<GitWorktreeState>, String> {
3032    if !command_exists("git") {
3033        return Ok(None);
3034    }
3035
3036    let status_output: std::process::Output = Command::new("git")
3037        .current_dir(project_root)
3038        .args(["status", "--porcelain"])
3039        .output()
3040        .map_err(|e| format!("Failed to run `git status --porcelain`: {}", e))?;
3041    if !status_output.status.success() {
3042        return Ok(None);
3043    }
3044
3045    let is_dirty: bool = String::from_utf8_lossy(&status_output.stdout)
3046        .lines()
3047        .any(|line| !line.trim().is_empty());
3048
3049    let head_output: std::process::Output = Command::new("git")
3050        .current_dir(project_root)
3051        .args(["rev-parse", "HEAD"])
3052        .output()
3053        .map_err(|e| format!("Failed to run `git rev-parse HEAD`: {}", e))?;
3054    let head_commit: Option<String> = if head_output.status.success() {
3055        let value: String = String::from_utf8_lossy(&head_output.stdout)
3056            .trim()
3057            .to_string();
3058        if value.is_empty() {
3059            None
3060        } else {
3061            Some(value)
3062        }
3063    } else {
3064        None
3065    };
3066
3067    Ok(Some(GitWorktreeState {
3068        is_dirty,
3069        head_commit,
3070    }))
3071}
3072
3073fn should_clear_version_change_guard(
3074    entry: &VersionChangeGuardEntry,
3075    state: &GitWorktreeState,
3076) -> bool {
3077    if entry.pending_version_change_count == 0 {
3078        return true;
3079    }
3080    if !state.is_dirty {
3081        return true;
3082    }
3083
3084    match (&entry.head_commit, &state.head_commit) {
3085        (Some(previous), Some(current)) => previous != current,
3086        (Some(_), None) => true,
3087        _ => false,
3088    }
3089}
3090
3091fn enforce_version_change_guard(project_root: &Path) -> Result<(), String> {
3092    let Some(state) = git_worktree_state(project_root)? else {
3093        return Ok(());
3094    };
3095
3096    let state_path: PathBuf = version_change_guard_state_path()?;
3097    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3098    let repo_key: String = version_change_guard_repo_key(project_root);
3099    let mut changed = false;
3100
3101    if let Some(entry) = registry.entries.get(&repo_key).cloned() {
3102        if should_clear_version_change_guard(&entry, &state) {
3103            registry.entries.remove(&repo_key);
3104            changed = true;
3105        }
3106    }
3107
3108    if changed {
3109        save_version_change_guard_registry(&state_path, &registry)?;
3110    }
3111
3112    if state.is_dirty {
3113        if let Some(entry) = registry.entries.get(&repo_key) {
3114            if entry.pending_version_change_count >= 1 {
3115                return Err(format!(
3116                    "Cannot run another version change on a dirty worktree: pending version-change count is {}. Commit, stash, or revert first. Guard state: {}",
3117                    entry.pending_version_change_count,
3118                    state_path.display()
3119                ));
3120            }
3121        }
3122    }
3123
3124    Ok(())
3125}
3126
3127fn record_version_change_guard(project_root: &Path) -> Result<(), String> {
3128    let Some(state) = git_worktree_state(project_root)? else {
3129        return Ok(());
3130    };
3131
3132    let state_path: PathBuf = version_change_guard_state_path()?;
3133    let mut registry: VersionChangeGuardRegistry = load_version_change_guard_registry(&state_path)?;
3134    let repo_key: String = version_change_guard_repo_key(project_root);
3135
3136    if state.is_dirty {
3137        registry.entries.insert(
3138            repo_key,
3139            VersionChangeGuardEntry {
3140                pending_version_change_count: 1,
3141                head_commit: state.head_commit,
3142            },
3143        );
3144    } else {
3145        registry.entries.remove(&repo_key);
3146    }
3147
3148    save_version_change_guard_registry(&state_path, &registry)
3149}
3150
3151fn git_tag_exists(project_root: &Path, tag: &str) -> Result<bool, String> {
3152    let output: String = run_git_command(project_root, &["tag", "--list", tag])?;
3153    Ok(!output.trim().is_empty())
3154}
3155
3156fn ensure_remote_exists(project_root: &Path, remote: &str) -> Result<(), String> {
3157    let remotes: String = run_git_command(project_root, &["remote"])?;
3158    let exists: bool = remotes.lines().any(|line| line.trim() == remote);
3159    if exists {
3160        Ok(())
3161    } else {
3162        Err(format!(
3163            "Git remote `{}` is not configured for this repository.",
3164            remote
3165        ))
3166    }
3167}
3168
3169fn git_remote_url(project_root: &Path, remote: &str) -> Result<String, String> {
3170    run_git_command(project_root, &["remote", "get-url", remote])
3171}
3172
3173fn git_remote_tag_exists(project_root: &Path, remote: &str, tag: &str) -> Result<bool, String> {
3174    let query: String = format!("refs/tags/{}", tag);
3175    let output: String = run_git_command(project_root, &["ls-remote", "--tags", remote, &query])?;
3176    Ok(!output.trim().is_empty())
3177}
3178
3179fn git_head_commitish(project_root: &Path) -> Result<String, String> {
3180    let commitish: String = run_git_command(project_root, &["rev-parse", "HEAD"])?;
3181    if commitish.is_empty() {
3182        Err("Unable to resolve HEAD commit for release target.".to_string())
3183    } else {
3184        Ok(commitish)
3185    }
3186}
3187
3188fn render_release_branch_name(
3189    naming_template: &str,
3190    release_version: &Version,
3191    tag_name: &str,
3192) -> Result<String, String> {
3193    let branch_name = naming_template
3194        .replace("${GITHUB_VERSION}", &release_version.to_string())
3195        .replace("${GITHUB_TAG}", tag_name);
3196    let branch_name = branch_name.trim();
3197    if branch_name.is_empty() {
3198        return Err(
3199            "GitHub release branch naming template resolved to an empty branch name.".to_string(),
3200        );
3201    }
3202    Ok(branch_name.to_string())
3203}
3204
3205fn git_local_branch_commit(
3206    project_root: &Path,
3207    branch_name: &str,
3208) -> Result<Option<String>, String> {
3209    let output = Command::new("git")
3210        .current_dir(project_root)
3211        .args([
3212            "rev-parse",
3213            "--verify",
3214            &format!("refs/heads/{}", branch_name),
3215        ])
3216        .output()
3217        .map_err(|e| format!("Failed to inspect local branch `{}`: {}", branch_name, e))?;
3218    if !output.status.success() {
3219        return Ok(None);
3220    }
3221    let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
3222    if commit.is_empty() {
3223        Ok(None)
3224    } else {
3225        Ok(Some(commit))
3226    }
3227}
3228
3229fn git_remote_branch_commit(
3230    project_root: &Path,
3231    remote: &str,
3232    branch_name: &str,
3233) -> Result<Option<String>, String> {
3234    let output = run_git_command(
3235        project_root,
3236        &[
3237            "ls-remote",
3238            "--heads",
3239            remote,
3240            &format!("refs/heads/{}", branch_name),
3241        ],
3242    )?;
3243    let commit = output
3244        .split_whitespace()
3245        .next()
3246        .map(str::trim)
3247        .filter(|value| !value.is_empty())
3248        .map(str::to_string);
3249    Ok(commit)
3250}
3251
3252fn ensure_release_branch(
3253    project_root: &Path,
3254    branch_config: &GitHubReleaseBranchSettings,
3255    release_version: &Version,
3256    tag_name: &str,
3257    target_commitish: &str,
3258) -> Result<String, String> {
3259    let branch_name =
3260        render_release_branch_name(&branch_config.naming_template, release_version, tag_name)?;
3261
3262    if let Some(existing_local_commit) = git_local_branch_commit(project_root, &branch_name)? {
3263        if existing_local_commit != target_commitish {
3264            return Err(format!(
3265                "Configured release branch `{}` already exists locally at {}, expected {}.",
3266                branch_name, existing_local_commit, target_commitish
3267            ));
3268        }
3269    } else {
3270        run_git_command(project_root, &["branch", &branch_name, target_commitish])?;
3271    }
3272
3273    if let Some(existing_remote_commit) =
3274        git_remote_branch_commit(project_root, "origin", &branch_name)?
3275    {
3276        if existing_remote_commit != target_commitish {
3277            return Err(format!(
3278                "Configured release branch `{}` already exists on origin at {}, expected {}.",
3279                branch_name, existing_remote_commit, target_commitish
3280            ));
3281        }
3282    } else {
3283        run_git_command(
3284            project_root,
3285            &[
3286                "push",
3287                "origin",
3288                &format!("{}:refs/heads/{}", target_commitish, branch_name),
3289            ],
3290        )?;
3291    }
3292
3293    Ok(branch_name)
3294}
3295
3296fn release_tag_family(tag_name: &str) -> String {
3297    if tag_name.starts_with('v')
3298        && tag_name
3299            .chars()
3300            .nth(1)
3301            .map(|ch| ch.is_ascii_digit())
3302            .unwrap_or(false)
3303    {
3304        return "v".to_string();
3305    }
3306
3307    let mut family: String = String::new();
3308    for ch in tag_name.chars() {
3309        if ch.is_ascii_digit() {
3310            break;
3311        }
3312        family.push(ch);
3313    }
3314    family
3315}
3316
3317fn parse_release_family_version(tag: &str, family: &str) -> Option<Version> {
3318    if family == "v" {
3319        return parse_version(tag).ok();
3320    }
3321
3322    tag.strip_prefix(family)
3323        .and_then(|rest| parse_version(rest).ok())
3324}
3325
3326fn git_tag_distance_from_head(project_root: &Path, tag: &str) -> Option<usize> {
3327    let range: String = format!("{}..HEAD", tag);
3328    run_git_command(project_root, &["rev-list", "--count", &range])
3329        .ok()
3330        .and_then(|raw| raw.trim().parse::<usize>().ok())
3331}
3332
3333fn previous_release_tag(
3334    project_root: &Path,
3335    current_tag_name: &str,
3336) -> Result<Option<String>, String> {
3337    let family: String = release_tag_family(current_tag_name);
3338    let tag_pattern: String = format!("{}*", family);
3339    let merged_tags: String = run_git_command(
3340        project_root,
3341        &["tag", "--merged", "HEAD", "--list", &tag_pattern],
3342    )?;
3343
3344    let mut best: Option<(usize, String)> = None;
3345    for raw in merged_tags.lines() {
3346        let tag = raw.trim();
3347        if tag.is_empty() || tag == current_tag_name {
3348            continue;
3349        }
3350        if parse_release_family_version(tag, &family).is_none() {
3351            continue;
3352        }
3353
3354        let Some(distance) = git_tag_distance_from_head(project_root, tag) else {
3355            continue;
3356        };
3357        if distance == 0 {
3358            continue;
3359        }
3360
3361        match &best {
3362            None => best = Some((distance, tag.to_string())),
3363            Some((best_distance, _)) if distance < *best_distance => {
3364                best = Some((distance, tag.to_string()))
3365            }
3366            _ => {}
3367        }
3368    }
3369
3370    Ok(best.map(|(_, tag)| tag))
3371}
3372
3373fn default_release_title(version: &Version, repo: &str) -> String {
3374    format!("{} - {}", version, repo)
3375}
3376
3377fn release_title_subject<'a>(version_scope: &'a VersionScope, repo: &'a str) -> &'a str {
3378    match version_scope {
3379        VersionScope::Repository => repo,
3380        VersionScope::Crate { package_name, .. } => package_name.as_str(),
3381    }
3382}
3383
3384fn default_release_tag_name(version_scope: &VersionScope, version: &Version) -> String {
3385    match version_scope {
3386        VersionScope::Repository => format!("v{}", version),
3387        VersionScope::Crate { tag_prefix, .. } => format!("{}{}", tag_prefix, version),
3388    }
3389}
3390
3391fn scoped_release_tag_name(
3392    version_scope: &VersionScope,
3393    version: &Version,
3394    parsed_tag_name: &str,
3395) -> String {
3396    match version_scope {
3397        VersionScope::Repository => parsed_tag_name.to_string(),
3398        VersionScope::Crate { .. } => {
3399            if release_tag_family(parsed_tag_name) == "v" {
3400                default_release_tag_name(version_scope, version)
3401            } else {
3402                parsed_tag_name.to_string()
3403            }
3404        }
3405    }
3406}
3407
3408fn release_notes_scope_path(version_scope: &VersionScope) -> Option<String> {
3409    match version_scope {
3410        VersionScope::Repository => None,
3411        VersionScope::Crate {
3412            crate_relative_root,
3413            ..
3414        } => Some(crate_relative_root.clone()),
3415    }
3416}
3417
3418fn append_release_label_footer(notes: &str, prerelease: bool) -> String {
3419    let release_label: &str = if prerelease { "Pre-release" } else { "Release" };
3420    let mut rendered_notes: String = notes.trim_end().to_string();
3421    if !rendered_notes.is_empty() {
3422        rendered_notes.push('\n');
3423    }
3424    rendered_notes.push_str("Release label: ");
3425    rendered_notes.push_str(release_label);
3426    rendered_notes.push('\n');
3427    rendered_notes.push_str("Generated by XBP ");
3428    rendered_notes.push_str(env!("CARGO_PKG_VERSION"));
3429    rendered_notes
3430}
3431
3432#[cfg(test)]
3433mod tests {
3434    use super::github_release::{
3435        github_release_asset_delete_endpoint, github_release_asset_upload_endpoint,
3436        github_release_assets_endpoint, github_release_by_tag_endpoint, github_release_endpoint,
3437        github_release_update_endpoint,
3438    };
3439    use super::release_docs::{
3440        release_channel, render_changelog, render_security_policy, ReleaseDocEntry,
3441    };
3442    use super::release_notes::{
3443        build_fallback_sections, collect_linear_issue_identifiers,
3444        deduplicate_release_commit_entries, format_release_commit_line, render_release_notes,
3445        LinearIssueInfo, ReleaseCommitEntry, ReleaseNotesRenderInput,
3446    };
3447    use super::{
3448        append_release_label_footer, bump_version, cargo_package_name, default_release_tag_name,
3449        default_release_title, highest_version_observation, parse_github_repo_from_remote_url,
3450        parse_local_git_tag_output, parse_local_git_tag_output_for_scope,
3451        parse_package_version_target, parse_release_version_target, parse_remote_git_tag_output,
3452        parse_version, read_cargo_lock_version, read_cargo_lock_version_for_package,
3453        read_cargo_toml_version, read_json_openapi_version, read_json_root_version,
3454        read_openapi_version, read_package_name_from_lookup, read_pyproject_version,
3455        read_readme_version, read_regex_version, read_toml_root_version, read_version_from_blob,
3456        read_version_from_path, read_yaml_root_version, redact_remote_url_credentials,
3457        render_release_branch_name, resolve_linear_release_placeholders,
3458        resolve_release_openapi_spec, resolve_version_scope,
3459        rewrite_toml_package_assignment_versions, should_clear_version_change_guard,
3460        stale_version_observations, write_cargo_lock_version, write_cargo_toml_version,
3461        write_chart_version, write_json_openapi_version, write_json_root_version,
3462        write_openapi_version, write_package_version_to_configured_files, write_pyproject_version,
3463        write_readme_version, write_regex_version, write_toml_root_version,
3464        write_version_to_configured_files, write_yaml_root_version, GitWorktreeState,
3465        ReleaseLatestPolicy, VersionChangeGuardEntry, VersionObservation, VersionScope,
3466    };
3467
3468    use crate::commands::version::release_linear::ResolvedLinearReleaseConfig;
3469    use crate::config::PackageNameLookup;
3470    use semver::Version;
3471    use std::collections::BTreeMap;
3472    use std::fs;
3473    use std::path::PathBuf;
3474    use std::time::{SystemTime, UNIX_EPOCH};
3475
3476    fn temp_dir(label: &str) -> PathBuf {
3477        let nanos: u128 = SystemTime::now()
3478            .duration_since(UNIX_EPOCH)
3479            .expect("time")
3480            .as_nanos();
3481        let dir: PathBuf = std::env::temp_dir().join(format!("xbp-version-{}-{}", label, nanos));
3482        fs::create_dir_all(&dir).expect("create temp dir");
3483        dir
3484    }
3485
3486    #[test]
3487    fn parses_prefixed_semver() {
3488        assert_eq!(
3489            parse_version("v1.2.3").expect("version"),
3490            Version::new(1, 2, 3)
3491        );
3492    }
3493
3494    #[test]
3495    fn rejects_invalid_semver() {
3496        let error: String = parse_version("not-a-version").expect_err("invalid semver should fail");
3497        assert!(error.contains("Invalid semantic version"));
3498    }
3499
3500    #[test]
3501    fn release_target_parser_supports_plain_semver() {
3502        let (version, tag_name) =
3503            parse_release_version_target("1.2.3-alpha.1").expect("release target");
3504        assert_eq!(version.major, 1);
3505        assert_eq!(version.minor, 2);
3506        assert_eq!(version.patch, 3);
3507        assert_eq!(version.pre.as_str(), "alpha.1");
3508        assert_eq!(tag_name, "v1.2.3-alpha.1");
3509    }
3510
3511    #[test]
3512    fn release_target_parser_supports_prefixed_semver() {
3513        let (version, tag_name) =
3514            parse_release_version_target("studio-0.3.2-alpha").expect("release target");
3515        assert_eq!(version.major, 0);
3516        assert_eq!(version.minor, 3);
3517        assert_eq!(version.patch, 2);
3518        assert_eq!(version.pre.as_str(), "alpha");
3519        assert_eq!(tag_name, "studio-0.3.2-alpha");
3520    }
3521
3522    #[test]
3523    fn bumps_versions_correctly() {
3524        let base: Version = Version::new(0, 1, 0);
3525        assert_eq!(bump_version(&base, "major"), Version::new(1, 0, 0));
3526        assert_eq!(bump_version(&base, "minor"), Version::new(0, 2, 0));
3527        assert_eq!(bump_version(&base, "patch"), Version::new(0, 1, 1));
3528    }
3529
3530    #[test]
3531    fn version_change_guard_clears_when_worktree_is_clean() {
3532        let entry = VersionChangeGuardEntry {
3533            pending_version_change_count: 1,
3534            head_commit: Some("abc123".to_string()),
3535        };
3536        let state = GitWorktreeState {
3537            is_dirty: false,
3538            head_commit: Some("abc123".to_string()),
3539        };
3540        assert!(should_clear_version_change_guard(&entry, &state));
3541    }
3542
3543    #[test]
3544    fn version_change_guard_clears_when_head_changes() {
3545        let entry = VersionChangeGuardEntry {
3546            pending_version_change_count: 1,
3547            head_commit: Some("abc123".to_string()),
3548        };
3549        let state = GitWorktreeState {
3550            is_dirty: true,
3551            head_commit: Some("def456".to_string()),
3552        };
3553        assert!(should_clear_version_change_guard(&entry, &state));
3554    }
3555
3556    #[test]
3557    fn version_change_guard_keeps_entry_when_dirty_and_head_matches() {
3558        let entry = VersionChangeGuardEntry {
3559            pending_version_change_count: 1,
3560            head_commit: Some("abc123".to_string()),
3561        };
3562        let state = GitWorktreeState {
3563            is_dirty: true,
3564            head_commit: Some("abc123".to_string()),
3565        };
3566        assert!(!should_clear_version_change_guard(&entry, &state));
3567    }
3568
3569    #[test]
3570    fn render_release_branch_name_replaces_supported_tokens() {
3571        let branch = render_release_branch_name(
3572            "releases/${GITHUB_VERSION}/${GITHUB_TAG}",
3573            &Version::new(10, 27, 0),
3574            "v10.27.0",
3575        )
3576        .expect("branch name");
3577
3578        assert_eq!(branch, "releases/10.27.0/v10.27.0");
3579    }
3580
3581    #[test]
3582    fn resolve_linear_release_placeholders_reads_env_files() {
3583        let temp_dir = std::env::temp_dir().join(format!(
3584            "xbp-linear-release-placeholders-{}",
3585            std::time::SystemTime::now()
3586                .duration_since(std::time::UNIX_EPOCH)
3587                .expect("time")
3588                .as_nanos()
3589        ));
3590        fs::create_dir_all(&temp_dir).expect("temp dir");
3591        fs::write(
3592            temp_dir.join(".env.local"),
3593            "LINEAR_INITIATIVE_ID=fd28f67f-8dc8-44b2-bf14-3821ce389145\nLINEAR_ORG_NAME=suits-formations\n",
3594        )
3595        .expect("env file");
3596
3597        let resolved = resolve_linear_release_placeholders(
3598            &temp_dir,
3599            ResolvedLinearReleaseConfig {
3600                initiative_ids: vec!["${LINEAR_INITIATIVE_ID}".to_string()],
3601                organization_name: Some("${LINEAR_ORG_NAME}".to_string()),
3602                health: "on_track".to_string(),
3603            },
3604        );
3605
3606        assert_eq!(
3607            resolved.initiative_ids,
3608            vec!["fd28f67f-8dc8-44b2-bf14-3821ce389145".to_string()]
3609        );
3610        assert_eq!(
3611            resolved.organization_name.as_deref(),
3612            Some("suits-formations")
3613        );
3614
3615        let _ = fs::remove_dir_all(temp_dir);
3616    }
3617
3618    #[test]
3619    fn version_change_guard_clears_when_pending_count_is_zero() {
3620        let entry = VersionChangeGuardEntry {
3621            pending_version_change_count: 0,
3622            head_commit: Some("abc123".to_string()),
3623        };
3624        let state = GitWorktreeState {
3625            is_dirty: true,
3626            head_commit: Some("abc123".to_string()),
3627        };
3628        assert!(should_clear_version_change_guard(&entry, &state));
3629    }
3630
3631    #[test]
3632    fn parse_package_version_target_supports_assignment_syntax() {
3633        let parsed: (String, Version) = parse_package_version_target("demo_pkg=1.2.3")
3634            .expect("parse")
3635            .expect("target");
3636        assert_eq!(parsed.0, "demo_pkg".to_string());
3637        assert_eq!(parsed.1, Version::new(1, 2, 3));
3638    }
3639
3640    #[test]
3641    fn parse_package_version_target_rejects_invalid_package_names() {
3642        let error: String = parse_package_version_target("bad package=1.2.3")
3643            .expect_err("invalid package target should fail");
3644        assert!(error.contains("Invalid package target"));
3645    }
3646
3647    #[test]
3648    fn parse_package_version_target_returns_none_without_assignment() {
3649        assert!(parse_package_version_target("1.2.3")
3650            .expect("parse")
3651            .is_none());
3652    }
3653
3654    #[test]
3655    fn parse_package_version_target_returns_none_for_empty_package_name() {
3656        assert!(parse_package_version_target(" =1.2.3")
3657            .expect("parse")
3658            .is_none());
3659    }
3660
3661    #[test]
3662    fn bumping_clears_prerelease_and_build_metadata() {
3663        let base: Version = Version::parse("1.2.3-beta.1+sha").expect("version");
3664        assert_eq!(bump_version(&base, "patch"), Version::new(1, 2, 4));
3665        assert_eq!(bump_version(&base, "minor"), Version::new(1, 3, 0));
3666        assert_eq!(bump_version(&base, "major"), Version::new(2, 0, 0));
3667    }
3668
3669    #[test]
3670    fn cargo_toml_adapter_reads_and_writes() {
3671        let dir: PathBuf = temp_dir("cargo");
3672        let path: PathBuf = dir.join("Cargo.toml");
3673        fs::write(
3674            &path,
3675            r#"[package]
3676            name = "xbp"
3677            version = "1.0.0"
3678            "#,
3679        )
3680        .expect("write Cargo.toml");
3681
3682        assert_eq!(
3683            read_cargo_toml_version(&path).expect("read"),
3684            Some("1.0.0".to_string())
3685        );
3686
3687        write_cargo_toml_version(&path, &Version::new(1, 1, 0)).expect("write");
3688        assert_eq!(
3689            read_version_from_path(&path).expect("read"),
3690            Some("1.1.0".to_string())
3691        );
3692
3693        let _ = fs::remove_dir_all(dir);
3694    }
3695
3696    #[test]
3697    fn json_root_adapter_reads_and_writes() {
3698        let dir: PathBuf = temp_dir("json");
3699        let path: PathBuf = dir.join("package.json");
3700        fs::write(&path, r#"{ "name": "xbp", "version": "1.4.0" }"#).expect("write json");
3701
3702        assert_eq!(
3703            read_json_root_version(&path).expect("read"),
3704            Some("1.4.0".to_string())
3705        );
3706
3707        write_json_root_version(&path, &Version::new(1, 5, 0)).expect("write");
3708        assert_eq!(
3709            read_version_from_path(&path).expect("read"),
3710            Some("1.5.0".to_string())
3711        );
3712
3713        let _ = fs::remove_dir_all(dir);
3714    }
3715
3716    #[test]
3717    fn yaml_root_adapter_reads_and_writes() {
3718        let dir: PathBuf = temp_dir("yaml");
3719        let path: PathBuf = dir.join("xbp.yaml");
3720        fs::write(&path, "project_name: demo\nversion: 0.2.0\n").expect("write yaml");
3721
3722        assert_eq!(
3723            read_yaml_root_version(&path, "version").expect("read"),
3724            Some("0.2.0".to_string())
3725        );
3726
3727        write_yaml_root_version(&path, "version", &Version::new(0, 3, 0)).expect("write");
3728        assert_eq!(
3729            read_version_from_path(&path).expect("read"),
3730            Some("0.3.0".to_string())
3731        );
3732
3733        let _ = fs::remove_dir_all(dir);
3734    }
3735
3736    #[test]
3737    fn toml_root_adapter_reads_and_writes() {
3738        let dir: PathBuf = temp_dir("toml");
3739        let path: PathBuf = dir.join("config.toml");
3740        fs::write(&path, "name = \"demo\"\nversion = \"3.1.4\"\n").expect("write toml");
3741
3742        assert_eq!(
3743            read_toml_root_version(&path).expect("read"),
3744            Some("3.1.4".to_string())
3745        );
3746
3747        write_toml_root_version(&path, &Version::new(3, 2, 0)).expect("write");
3748        assert_eq!(
3749            read_toml_root_version(&path).expect("read"),
3750            Some("3.2.0".to_string())
3751        );
3752
3753        let _ = fs::remove_dir_all(dir);
3754    }
3755
3756    #[test]
3757    fn openapi_adapter_reads_and_writes_nested_version() {
3758        let dir: PathBuf = temp_dir("openapi");
3759        let path: PathBuf = dir.join("openapi.yaml");
3760        fs::write(
3761            &path,
3762            "openapi: 3.0.3\ninfo:\n  title: Test\n  version: 1.2.3\n",
3763        )
3764        .expect("write openapi");
3765
3766        assert_eq!(
3767            read_openapi_version(&path).expect("read"),
3768            Some("1.2.3".to_string())
3769        );
3770
3771        write_openapi_version(&path, &Version::new(2, 0, 0)).expect("write");
3772        assert_eq!(
3773            read_openapi_version(&path).expect("read"),
3774            Some("2.0.0".to_string())
3775        );
3776
3777        let _ = fs::remove_dir_all(dir);
3778    }
3779
3780    #[test]
3781    fn openapi_writer_creates_missing_info_mapping() {
3782        let dir: PathBuf = temp_dir("openapi-missing-info");
3783        let path: PathBuf = dir.join("openapi.yaml");
3784        fs::write(&path, "openapi: 3.1.0\npaths: {}\n").expect("write openapi");
3785
3786        write_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
3787        assert_eq!(
3788            read_openapi_version(&path).expect("read"),
3789            Some("4.0.0".to_string())
3790        );
3791
3792        let _ = fs::remove_dir_all(dir);
3793    }
3794
3795    #[test]
3796    fn json_openapi_adapter_reads_and_writes_nested_version() {
3797        let dir: PathBuf = temp_dir("openapi-json");
3798        let path: PathBuf = dir.join("openapi.json");
3799        fs::write(
3800            &path,
3801            r#"{ "openapi": "3.1.0", "info": { "title": "Test", "version": "1.2.3" } }"#,
3802        )
3803        .expect("write openapi json");
3804
3805        assert_eq!(
3806            read_json_openapi_version(&path).expect("read"),
3807            Some("1.2.3".to_string())
3808        );
3809
3810        write_json_openapi_version(&path, &Version::new(2, 1, 0)).expect("write");
3811        assert_eq!(
3812            read_json_openapi_version(&path).expect("read"),
3813            Some("2.1.0".to_string())
3814        );
3815
3816        let _ = fs::remove_dir_all(dir);
3817    }
3818
3819    #[test]
3820    fn json_openapi_writer_creates_missing_info_object() {
3821        let dir: PathBuf = temp_dir("openapi-json-missing-info");
3822        let path: PathBuf = dir.join("openapi.json");
3823        fs::write(&path, r#"{ "openapi": "3.1.0", "paths": {} }"#).expect("write openapi json");
3824
3825        write_json_openapi_version(&path, &Version::new(4, 0, 0)).expect("write");
3826        assert_eq!(
3827            read_json_openapi_version(&path).expect("read"),
3828            Some("4.0.0".to_string())
3829        );
3830
3831        let _ = fs::remove_dir_all(dir);
3832    }
3833
3834    #[test]
3835    fn pyproject_reader_prefers_project_version() {
3836        let dir: PathBuf = temp_dir("pyproject-project");
3837        let path: PathBuf = dir.join("pyproject.toml");
3838        fs::write(
3839            &path,
3840            "[project]\nname = \"demo\"\nversion = \"0.8.0\"\n\n[tool.poetry]\nversion = \"9.9.9\"\n",
3841        )
3842        .expect("write pyproject");
3843
3844        assert_eq!(
3845            read_pyproject_version(&path).expect("read"),
3846            Some("0.8.0".to_string())
3847        );
3848
3849        let _ = fs::remove_dir_all(dir);
3850    }
3851
3852    #[test]
3853    fn pyproject_reader_falls_back_to_poetry_version() {
3854        let dir: PathBuf = temp_dir("pyproject-poetry");
3855        let path: PathBuf = dir.join("pyproject.toml");
3856        fs::write(
3857            &path,
3858            "[tool.poetry]\nname = \"demo\"\nversion = \"1.9.0\"\n",
3859        )
3860        .expect("write pyproject");
3861
3862        assert_eq!(
3863            read_pyproject_version(&path).expect("read"),
3864            Some("1.9.0".to_string())
3865        );
3866
3867        let _ = fs::remove_dir_all(dir);
3868    }
3869
3870    #[test]
3871    fn pyproject_writer_updates_project_table() {
3872        let dir: PathBuf = temp_dir("pyproject-write-project");
3873        let path: PathBuf = dir.join("pyproject.toml");
3874        fs::write(&path, "[project]\nname = \"demo\"\nversion = \"1.0.0\"\n")
3875            .expect("write pyproject");
3876
3877        write_pyproject_version(&path, &Version::new(1, 1, 0)).expect("write");
3878        assert_eq!(
3879            read_pyproject_version(&path).expect("read"),
3880            Some("1.1.0".to_string())
3881        );
3882
3883        let _ = fs::remove_dir_all(dir);
3884    }
3885
3886    #[test]
3887    fn pyproject_writer_updates_poetry_table() {
3888        let dir: PathBuf = temp_dir("pyproject-write-poetry");
3889        let path: PathBuf = dir.join("pyproject.toml");
3890        fs::write(
3891            &path,
3892            "[tool.poetry]\nname = \"demo\"\nversion = \"2.0.0\"\n",
3893        )
3894        .expect("write pyproject");
3895
3896        write_pyproject_version(&path, &Version::new(2, 1, 0)).expect("write");
3897        assert_eq!(
3898            read_pyproject_version(&path).expect("read"),
3899            Some("2.1.0".to_string())
3900        );
3901
3902        let _ = fs::remove_dir_all(dir);
3903    }
3904
3905    #[test]
3906    fn cargo_lock_reader_and_writer_follow_package_name() {
3907        let dir: PathBuf = temp_dir("cargo-lock");
3908        let cargo_toml: PathBuf = dir.join("Cargo.toml");
3909        let cargo_lock: PathBuf = dir.join("Cargo.lock");
3910        fs::write(
3911            &cargo_toml,
3912            r#"[package]
3913            name = "xbp"
3914            version = "1.0.0"
3915            "#,
3916        )
3917        .expect("write Cargo.toml");
3918        fs::write(
3919            &cargo_lock,
3920            r#"version = 4
3921
3922            [[package]]
3923            name = "xbp"
3924            version = "1.0.0"
3925
3926            [[package]]
3927            name = "other"
3928            version = "9.9.9"
3929            "#,
3930        )
3931        .expect("write Cargo.lock");
3932
3933        assert_eq!(
3934            read_cargo_lock_version(&cargo_lock).expect("read"),
3935            Some("1.0.0".to_string())
3936        );
3937
3938        write_cargo_lock_version(&cargo_lock, &Version::new(1, 0, 1)).expect("write");
3939        assert_eq!(
3940            read_cargo_lock_version(&cargo_lock).expect("read"),
3941            Some("1.0.1".to_string())
3942        );
3943
3944        let updated = fs::read_to_string(&cargo_lock).expect("read updated lock");
3945        assert!(updated.contains("name = \"other\"\nversion = \"9.9.9\""));
3946
3947        let _ = fs::remove_dir_all(dir);
3948    }
3949
3950    #[test]
3951    fn cargo_lock_writer_errors_when_package_missing() {
3952        let dir: PathBuf = temp_dir("cargo-lock-missing");
3953        fs::write(
3954            dir.join("Cargo.toml"),
3955            "[package]\nname = \"xbp\"\nversion = \"1.0.0\"\n",
3956        )
3957        .expect("write Cargo.toml");
3958        let cargo_lock: PathBuf = dir.join("Cargo.lock");
3959        fs::write(
3960            &cargo_lock,
3961            "version = 4\n\n[[package]]\nname = \"other\"\nversion = \"0.1.0\"\n",
3962        )
3963        .expect("write Cargo.lock");
3964
3965        let error: String = write_cargo_lock_version(&cargo_lock, &Version::new(2, 0, 0))
3966            .expect_err("missing package should fail");
3967        assert!(error.contains("Could not find package `xbp`"));
3968
3969        let _ = fs::remove_dir_all(dir);
3970    }
3971
3972    #[test]
3973    fn cargo_package_name_reads_package_section() {
3974        let dir: PathBuf = temp_dir("cargo-package-name");
3975        let cargo_lock: PathBuf = dir.join("Cargo.lock");
3976        fs::write(
3977            dir.join("Cargo.toml"),
3978            "[package]\nname = \"xbp-cli\"\nversion = \"1.0.0\"\n",
3979        )
3980        .expect("write Cargo.toml");
3981        fs::write(&cargo_lock, "version = 4\n").expect("write Cargo.lock");
3982
3983        assert_eq!(
3984            cargo_package_name(&cargo_lock).expect("name"),
3985            Some("xbp-cli".to_string())
3986        );
3987
3988        let _ = fs::remove_dir_all(dir);
3989    }
3990
3991    #[test]
3992    fn cargo_toml_writer_skips_workspace_manifest_without_package() {
3993        let dir: PathBuf = temp_dir("cargo-workspace-manifest");
3994        let path: PathBuf = dir.join("Cargo.toml");
3995        fs::write(
3996            &path,
3997            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
3998        )
3999        .expect("write Cargo.toml");
4000
4001        let changed = write_cargo_toml_version(&path, &Version::new(2, 0, 0)).expect("write");
4002        assert!(!changed);
4003        assert_eq!(
4004            fs::read_to_string(&path).expect("read Cargo.toml"),
4005            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n"
4006        );
4007
4008        let _ = fs::remove_dir_all(dir);
4009    }
4010
4011    #[test]
4012    fn configured_writer_skips_workspace_cargo_files_without_counting_them() {
4013        let dir: PathBuf = temp_dir("workspace-cargo-skip");
4014        fs::write(
4015            dir.join("Cargo.toml"),
4016            "[workspace]\nmembers = [\"crates/cli\"]\nresolver = \"2\"\n",
4017        )
4018        .expect("write Cargo.toml");
4019        fs::write(
4020            dir.join("Cargo.lock"),
4021            "version = 4\n\n[[package]]\nname = \"xbp_cli\"\nversion = \"1.0.0\"\n",
4022        )
4023        .expect("write Cargo.lock");
4024        fs::write(dir.join("README.md"), "# XBP\n\ncurrent version: `1.0.0`\n")
4025            .expect("write README");
4026
4027        let updated = write_version_to_configured_files(
4028            &dir,
4029            &dir,
4030            &[
4031                "Cargo.toml".to_string(),
4032                "Cargo.lock".to_string(),
4033                "README.md".to_string(),
4034            ],
4035            &VersionScope::Repository,
4036            &Version::new(1, 1, 0),
4037        )
4038        .expect("write versions");
4039
4040        assert_eq!(updated, 1);
4041        assert_eq!(
4042            read_readme_version(&dir.join("README.md")).expect("read"),
4043            Some("1.1.0".to_string())
4044        );
4045
4046        let _ = fs::remove_dir_all(dir);
4047    }
4048
4049    #[test]
4050    fn repository_scope_prefers_workspace_default_member_manifest() {
4051        let dir: PathBuf = temp_dir("workspace-default-member-path");
4052        let crate_dir: PathBuf = dir.join("crates").join("cli");
4053        fs::create_dir_all(&crate_dir).expect("create crate dir");
4054        fs::write(
4055            dir.join("Cargo.toml"),
4056            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4057        )
4058        .expect("write workspace cargo");
4059        fs::write(
4060            crate_dir.join("Cargo.toml"),
4061            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4062        )
4063        .expect("write crate cargo");
4064
4065        let resolved = super::resolve_registry_relative_path(
4066            &dir,
4067            &dir,
4068            &VersionScope::Repository,
4069            "Cargo.toml",
4070        );
4071
4072        assert_eq!(resolved, "crates/cli/Cargo.toml");
4073
4074        let _ = fs::remove_dir_all(dir);
4075    }
4076
4077    #[test]
4078    fn configured_writer_updates_workspace_default_member_manifest_and_lock() {
4079        let dir: PathBuf = temp_dir("workspace-default-member-writer");
4080        let crate_dir: PathBuf = dir.join("crates").join("cli");
4081        fs::create_dir_all(&crate_dir).expect("create crate dir");
4082        fs::write(
4083            dir.join("Cargo.toml"),
4084            "[workspace]\ndefault-members = [\"crates/cli\"]\nmembers = [\"crates/cli\", \"crates/logs\"]\nresolver = \"2\"\n",
4085        )
4086        .expect("write workspace cargo");
4087        fs::write(
4088            crate_dir.join("Cargo.toml"),
4089            "[package]\nname = \"xbp\"\nversion = \"10.21.0\"\n",
4090        )
4091        .expect("write crate cargo");
4092        fs::write(
4093            dir.join("Cargo.lock"),
4094            "version = 4\n\n[[package]]\nname = \"xbp\"\nversion = \"10.21.0\"\n\n[[package]]\nname = \"xbp-logs\"\nversion = \"10.21.0\"\n",
4095        )
4096        .expect("write cargo lock");
4097        fs::write(
4098            dir.join("README.md"),
4099            "# XBP\n\ncurrent version: `10.21.0`\n",
4100        )
4101        .expect("write readme");
4102
4103        let updated = write_version_to_configured_files(
4104            &dir,
4105            &dir,
4106            &[
4107                "Cargo.toml".to_string(),
4108                "Cargo.lock".to_string(),
4109                "README.md".to_string(),
4110            ],
4111            &VersionScope::Repository,
4112            &Version::new(10, 22, 0),
4113        )
4114        .expect("write versions");
4115
4116        assert_eq!(updated, 3);
4117        assert_eq!(
4118            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate cargo"),
4119            Some("10.22.0".to_string())
4120        );
4121        assert_eq!(
4122            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "xbp").expect("read lock"),
4123            Some("10.22.0".to_string())
4124        );
4125        assert_eq!(
4126            read_readme_version(&dir.join("README.md")).expect("read readme"),
4127            Some("10.22.0".to_string())
4128        );
4129
4130        let _ = fs::remove_dir_all(dir);
4131    }
4132
4133    #[test]
4134    fn readme_adapter_updates_current_version_marker() {
4135        let dir: PathBuf = temp_dir("readme");
4136        let path: PathBuf = dir.join("README.md");
4137        fs::write(&path, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
4138
4139        write_readme_version(&path, &Version::new(1, 2, 0)).expect("write");
4140        assert_eq!(
4141            read_readme_version(&path).expect("read"),
4142            Some("1.2.0".to_string())
4143        );
4144
4145        let _ = fs::remove_dir_all(dir);
4146    }
4147
4148    #[test]
4149    fn readme_writer_inserts_marker_when_missing() {
4150        let dir: PathBuf = temp_dir("readme-insert");
4151        let path: PathBuf = dir.join("README.md");
4152        fs::write(&path, "# XBP\n\nTight readme.\n").expect("write readme");
4153
4154        write_readme_version(&path, &Version::new(3, 0, 0)).expect("write");
4155        let content: String = fs::read_to_string(&path).expect("read readme");
4156        assert!(content.contains("current version: `3.0.0`"));
4157
4158        let _ = fs::remove_dir_all(dir);
4159    }
4160
4161    #[test]
4162    fn regex_adapter_reads_and_writes_versions() {
4163        let dir: PathBuf = temp_dir("regex");
4164        let path: PathBuf = dir.join("build.gradle");
4165        fs::write(&path, "version = '5.4.3'\n").expect("write gradle");
4166
4167        assert_eq!(
4168            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
4169            Some("5.4.3".to_string())
4170        );
4171
4172        write_regex_version(
4173            &path,
4174            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
4175            &Version::new(5, 5, 0),
4176        )
4177        .expect("write");
4178
4179        assert_eq!(
4180            read_regex_version(&path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#).expect("read"),
4181            Some("5.5.0".to_string())
4182        );
4183
4184        let _ = fs::remove_dir_all(dir);
4185    }
4186
4187    #[test]
4188    fn regex_writer_errors_without_matching_pattern() {
4189        let dir: PathBuf = temp_dir("regex-miss");
4190        let path: PathBuf = dir.join("build.gradle");
4191        fs::write(&path, "group = 'demo'\n").expect("write gradle");
4192
4193        let error: String = write_regex_version(
4194            &path,
4195            r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#,
4196            &Version::new(1, 0, 0),
4197        )
4198        .expect_err("missing version should fail");
4199        assert!(error.contains("No version pattern found"));
4200
4201        let _ = fs::remove_dir_all(dir);
4202    }
4203
4204    #[test]
4205    fn toml_package_assignment_rewriter_updates_string_and_inline_table() {
4206        let original: &str = r#"[dependencies]
4207            serde = "1.0.219"
4208            tokio = { version = "1.44.1", features = ["full"] }
4209            "#;
4210
4211        let (updated, changed) =
4212            rewrite_toml_package_assignment_versions(original, "tokio", &Version::new(1, 45, 0))
4213                .expect("rewrite");
4214        assert!(changed);
4215        assert!(updated.contains(r#"tokio = { version = "1.45.0", features = ["full"] }"#));
4216
4217        let (updated, changed) =
4218            rewrite_toml_package_assignment_versions(&updated, "serde", &Version::new(1, 1, 0))
4219                .expect("rewrite");
4220        assert!(changed);
4221        assert!(updated.contains(r#"serde = "1.1.0""#));
4222    }
4223
4224    #[test]
4225    fn package_version_writer_updates_registry_toml_targets() {
4226        let dir: PathBuf = temp_dir("package-version-registry");
4227        let cargo_toml: PathBuf = dir.join("Cargo.toml");
4228        fs::write(
4229            &cargo_toml,
4230            r#"[package]
4231            name = "demo"
4232            version = "0.1.0"
4233
4234            [dependencies]
4235            serde = "1.0.219"
4236            tokio = { version = "1.44.1", features = ["full"] }
4237            "#,
4238        )
4239        .expect("write Cargo.toml");
4240
4241        let updated: usize = write_package_version_to_configured_files(
4242            &dir,
4243            &dir,
4244            &["Cargo.toml".to_string()],
4245            &VersionScope::Repository,
4246            "tokio",
4247            &Version::new(1, 45, 1),
4248        )
4249        .expect("update package assignment");
4250        assert_eq!(updated, 1);
4251
4252        let content = fs::read_to_string(&cargo_toml).expect("read Cargo.toml");
4253        assert!(content.contains(r#"tokio = { version = "1.45.1", features = ["full"] }"#));
4254
4255        let _ = fs::remove_dir_all(dir);
4256    }
4257
4258    #[test]
4259    fn package_version_writer_errors_when_package_assignment_not_found() {
4260        let dir: PathBuf = temp_dir("package-version-missing");
4261        let cargo_toml: PathBuf = dir.join("Cargo.toml");
4262        fs::write(
4263            &cargo_toml,
4264            r#"[package]
4265        name = "demo"
4266        version = "0.1.0"
4267
4268        [dependencies]
4269        serde = "1.0.219"
4270        "#,
4271        )
4272        .expect("write Cargo.toml");
4273
4274        let error: String = write_package_version_to_configured_files(
4275            &dir,
4276            &dir,
4277            &["Cargo.toml".to_string()],
4278            &VersionScope::Repository,
4279            "tokio",
4280            &Version::new(1, 45, 1),
4281        )
4282        .expect_err("missing package assignment should fail");
4283        assert!(error.contains("No configured TOML files contained package assignment `tokio`"));
4284
4285        let _ = fs::remove_dir_all(dir);
4286    }
4287
4288    #[test]
4289    fn chart_writer_updates_app_version_when_present() {
4290        let dir: PathBuf = temp_dir("chart");
4291        let path: PathBuf = dir.join("Chart.yaml");
4292        fs::write(
4293            &path,
4294            "apiVersion: v2\nname: demo\nversion: 0.1.0\nappVersion: 0.1.0\n",
4295        )
4296        .expect("write chart");
4297
4298        write_chart_version(&path, &Version::new(0, 2, 0)).expect("write");
4299        let content: String = fs::read_to_string(&path).expect("read chart");
4300        assert!(content.contains("version: 0.2.0"));
4301        assert!(content.contains("appVersion: 0.2.0"));
4302
4303        let _ = fs::remove_dir_all(dir);
4304    }
4305
4306    #[test]
4307    fn configured_file_writer_deduplicates_registry_entries() {
4308        let dir: PathBuf = temp_dir("dedupe");
4309        let readme: PathBuf = dir.join("README.md");
4310        fs::write(&readme, "# XBP\n\ncurrent version: `1.0.0`\n").expect("write readme");
4311
4312        let updated: usize = write_version_to_configured_files(
4313            &dir,
4314            &dir,
4315            &[
4316                "README.md".to_string(),
4317                "README.md".to_string(),
4318                "missing.md".to_string(),
4319            ],
4320            &VersionScope::Repository,
4321            &Version::new(1, 1, 0),
4322        )
4323        .expect("write versions");
4324
4325        assert_eq!(updated, 1);
4326        assert_eq!(
4327            read_readme_version(&readme).expect("read"),
4328            Some("1.1.0".to_string())
4329        );
4330
4331        let _ = fs::remove_dir_all(dir);
4332    }
4333
4334    #[test]
4335    fn configured_file_writer_prefers_invocation_directory_targets() {
4336        let dir: PathBuf = temp_dir("invocation-precedence");
4337        let app_dir: PathBuf = dir.join("apps").join("web");
4338        fs::create_dir_all(&app_dir).expect("create app dir");
4339
4340        let root_package: PathBuf = dir.join("package.json");
4341        let app_package: PathBuf = app_dir.join("package.json");
4342        fs::write(&root_package, r#"{ "name": "root", "version": "9.9.9" }"#)
4343            .expect("write root package");
4344        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
4345            .expect("write app package");
4346
4347        let updated: usize = write_version_to_configured_files(
4348            &dir,
4349            &app_dir,
4350            &["package.json".to_string()],
4351            &VersionScope::Repository,
4352            &Version::new(2, 14, 0),
4353        )
4354        .expect("write versions");
4355        assert_eq!(updated, 1);
4356
4357        assert_eq!(
4358            read_json_root_version(&root_package).expect("read root"),
4359            Some("9.9.9".to_string())
4360        );
4361        assert_eq!(
4362            read_json_root_version(&app_package).expect("read app"),
4363            Some("2.14.0".to_string())
4364        );
4365
4366        let _ = fs::remove_dir_all(dir);
4367    }
4368
4369    #[test]
4370    fn resolve_version_scope_detects_crate_scoped_invocation() {
4371        let dir: PathBuf = temp_dir("crate-scope");
4372        let crate_dir: PathBuf = dir.join("crates").join("alpha");
4373        let nested_dir: PathBuf = crate_dir.join("src");
4374        fs::create_dir_all(&nested_dir).expect("create nested dir");
4375        fs::write(
4376            crate_dir.join("Cargo.toml"),
4377            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
4378        )
4379        .expect("write Cargo.toml");
4380
4381        let scope = resolve_version_scope(&dir, &nested_dir);
4382        match scope {
4383            VersionScope::Crate {
4384                package_name,
4385                crate_relative_root,
4386                tag_prefix,
4387                ..
4388            } => {
4389                assert_eq!(package_name, "alpha-crate");
4390                assert_eq!(crate_relative_root, "crates/alpha");
4391                assert_eq!(tag_prefix, "alpha-crate-");
4392            }
4393            VersionScope::Repository => panic!("expected crate scope"),
4394        }
4395
4396        let _ = fs::remove_dir_all(dir);
4397    }
4398
4399    #[test]
4400    fn crate_scoped_version_writer_updates_local_manifest_and_workspace_lock() {
4401        let dir: PathBuf = temp_dir("crate-writer");
4402        let crate_dir: PathBuf = dir.join("crates").join("alpha");
4403        fs::create_dir_all(&crate_dir).expect("create crate dir");
4404        fs::write(
4405            crate_dir.join("Cargo.toml"),
4406            "[package]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n",
4407        )
4408        .expect("write crate Cargo.toml");
4409        fs::write(
4410            dir.join("Cargo.lock"),
4411            "version = 4\n\n[[package]]\nname = \"alpha-crate\"\nversion = \"1.2.3\"\n\n[[package]]\nname = \"other-crate\"\nversion = \"9.9.9\"\n",
4412        )
4413        .expect("write Cargo.lock");
4414        fs::write(
4415            dir.join("README.md"),
4416            "# root\n\ncurrent version: `9.9.9`\n",
4417        )
4418        .expect("write root readme");
4419
4420        let scope = resolve_version_scope(&dir, &crate_dir);
4421        let updated = write_version_to_configured_files(
4422            &dir,
4423            &crate_dir,
4424            &[
4425                "Cargo.toml".to_string(),
4426                "Cargo.lock".to_string(),
4427                "README.md".to_string(),
4428            ],
4429            &scope,
4430            &Version::new(1, 3, 0),
4431        )
4432        .expect("write versions");
4433
4434        assert_eq!(updated, 2);
4435        assert_eq!(
4436            read_cargo_toml_version(&crate_dir.join("Cargo.toml")).expect("read crate toml"),
4437            Some("1.3.0".to_string())
4438        );
4439        assert_eq!(
4440            read_cargo_lock_version_for_package(&dir.join("Cargo.lock"), "alpha-crate")
4441                .expect("read cargo lock"),
4442            Some("1.3.0".to_string())
4443        );
4444        assert_eq!(
4445            read_readme_version(&dir.join("README.md")).expect("read readme"),
4446            Some("9.9.9".to_string())
4447        );
4448
4449        let _ = fs::remove_dir_all(dir);
4450    }
4451
4452    #[test]
4453    fn release_openapi_resolution_prefers_crate_scope() {
4454        let dir: PathBuf = temp_dir("release-openapi-crate");
4455        let crate_dir: PathBuf = dir.join("crates").join("monitor");
4456        let nested_dir: PathBuf = crate_dir.join("src");
4457        fs::create_dir_all(&nested_dir).expect("create nested dir");
4458        fs::write(dir.join("openapi.yaml"), "openapi: 3.1.0\n").expect("write root openapi");
4459        let crate_openapi: PathBuf = crate_dir.join("openapi.json");
4460        fs::write(&crate_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write crate openapi");
4461
4462        let resolved =
4463            resolve_release_openapi_spec(&dir, &nested_dir).expect("crate-scoped openapi");
4464        assert_eq!(resolved, crate_openapi);
4465
4466        let _ = fs::remove_dir_all(dir);
4467    }
4468
4469    #[test]
4470    fn release_openapi_resolution_falls_back_to_repo_root() {
4471        let dir: PathBuf = temp_dir("release-openapi-root");
4472        let crate_dir: PathBuf = dir.join("crates").join("monitor").join("src");
4473        fs::create_dir_all(&crate_dir).expect("create crate dir");
4474        let root_openapi: PathBuf = dir.join("openapi.json");
4475        fs::write(&root_openapi, r#"{ "openapi": "3.1.0" }"#).expect("write root openapi");
4476
4477        let resolved = resolve_release_openapi_spec(&dir, &crate_dir).expect("repo root openapi");
4478        assert_eq!(resolved, root_openapi);
4479
4480        let _ = fs::remove_dir_all(dir);
4481    }
4482
4483    #[test]
4484    fn configured_file_writer_deduplicates_when_local_and_root_relative_match_same_file() {
4485        let dir: PathBuf = temp_dir("invocation-dedupe");
4486        let app_dir: PathBuf = dir.join("apps").join("web");
4487        fs::create_dir_all(&app_dir).expect("create app dir");
4488
4489        let app_package: PathBuf = app_dir.join("package.json");
4490        fs::write(&app_package, r#"{ "name": "web", "version": "2.13.0" }"#)
4491            .expect("write app package");
4492
4493        let updated: usize = write_version_to_configured_files(
4494            &dir,
4495            &app_dir,
4496            &[
4497                "package.json".to_string(),
4498                "apps/web/package.json".to_string(),
4499            ],
4500            &VersionScope::Repository,
4501            &Version::new(2, 14, 0),
4502        )
4503        .expect("write versions");
4504        assert_eq!(updated, 1);
4505
4506        assert_eq!(
4507            read_json_root_version(&app_package).expect("read app"),
4508            Some("2.14.0".to_string())
4509        );
4510
4511        let _ = fs::remove_dir_all(dir);
4512    }
4513
4514    #[test]
4515    fn configured_file_writer_errors_when_no_targets_exist() {
4516        let dir: PathBuf = temp_dir("no-targets");
4517        let error: String = write_version_to_configured_files(
4518            &dir,
4519            &dir,
4520            &["missing.toml".to_string()],
4521            &VersionScope::Repository,
4522            &Version::new(1, 0, 0),
4523        )
4524        .expect_err("missing targets should fail");
4525
4526        assert!(error.contains("No configured version files were found"));
4527
4528        let _ = fs::remove_dir_all(dir);
4529    }
4530
4531    #[test]
4532    fn remote_git_tag_parser_deduplicates_peeled_refs() {
4533        let parsed: Vec<crate::commands::version::GitTagObservation> = parse_remote_git_tag_output(
4534            "abc refs/tags/v0.1.7-exp\nabc refs/tags/v0.1.7-exp^{}\ndef refs/tags/v0.2.0\n",
4535        );
4536
4537        assert_eq!(parsed.len(), 2);
4538        assert_eq!(parsed[0].version, Version::parse("0.2.0").expect("version"));
4539        assert_eq!(
4540            parsed[1].version,
4541            Version::parse("0.1.7-exp").expect("version")
4542        );
4543        assert_eq!(parsed[1].raw_tags, vec!["v0.1.7-exp".to_string()]);
4544    }
4545
4546    #[test]
4547    fn local_git_tag_parser_normalizes_prefixed_versions() {
4548        let parsed: Vec<crate::commands::version::GitTagObservation> =
4549            parse_local_git_tag_output("v1.0.0\n1.0.0\nv0.9.0\n");
4550
4551        assert_eq!(parsed.len(), 2);
4552        assert_eq!(parsed[0].version, Version::new(1, 0, 0));
4553        assert_eq!(
4554            parsed[0].raw_tags,
4555            vec!["1.0.0".to_string(), "v1.0.0".to_string()]
4556        );
4557    }
4558
4559    #[test]
4560    fn crate_scoped_git_tag_parser_reads_prefixed_tags() {
4561        let scope = VersionScope::Crate {
4562            crate_root: PathBuf::from("/tmp/crates/alpha"),
4563            crate_relative_root: "crates/alpha".to_string(),
4564            package_name: "alpha-crate".to_string(),
4565            tag_prefix: "alpha-crate-".to_string(),
4566        };
4567
4568        let parsed = parse_local_git_tag_output_for_scope(
4569            "alpha-crate-1.0.0\nalpha-crate-1.2.0\nother-crate-9.9.9\n",
4570            &scope,
4571        );
4572
4573        assert_eq!(parsed.len(), 2);
4574        assert_eq!(parsed[0].version, Version::new(1, 2, 0));
4575        assert_eq!(parsed[1].version, Version::new(1, 0, 0));
4576    }
4577
4578    #[test]
4579    fn crate_scoped_release_tags_default_to_package_prefix() {
4580        let scope = VersionScope::Crate {
4581            crate_root: PathBuf::from("/tmp/crates/alpha"),
4582            crate_relative_root: "crates/alpha".to_string(),
4583            package_name: "alpha-crate".to_string(),
4584            tag_prefix: "alpha-crate-".to_string(),
4585        };
4586
4587        assert_eq!(
4588            default_release_tag_name(&scope, &Version::new(1, 2, 3)),
4589            "alpha-crate-1.2.3"
4590        );
4591    }
4592
4593    #[test]
4594    fn blob_reader_handles_head_readme_versions() {
4595        assert_eq!(
4596            read_version_from_blob("README.md", "# Demo\n\ncurrent version: `0.4.0`\n", None)
4597                .expect("read"),
4598            Some("0.4.0".to_string())
4599        );
4600    }
4601
4602    #[test]
4603    fn blob_reader_handles_head_cargo_lock_versions() {
4604        let cargo_toml: &str = "[package]\nname = \"athena-mcp\"\nversion = \"0.1.0\"\n";
4605        let cargo_lock: &str =
4606            "version = 4\n\n[[package]]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n";
4607
4608        assert_eq!(
4609            read_version_from_blob("Cargo.lock", cargo_lock, Some(cargo_toml)).expect("read"),
4610            Some("0.2.0".to_string())
4611        );
4612    }
4613
4614    #[test]
4615    fn package_name_lookup_reads_json_name_for_npm() {
4616        let lookup: PackageNameLookup = PackageNameLookup {
4617            file: "package.json".to_string(),
4618            format: "json".to_string(),
4619            key: "name".to_string(),
4620            registry: "npm".to_string(),
4621        };
4622
4623        assert_eq!(
4624            read_package_name_from_lookup(&lookup, r#"{ "name": "@xylex/athena-mcp" }"#)
4625                .expect("read"),
4626            Some("@xylex/athena-mcp".to_string())
4627        );
4628    }
4629
4630    #[test]
4631    fn package_name_lookup_reads_toml_nested_package_name() {
4632        let lookup: PackageNameLookup = PackageNameLookup {
4633            file: "Cargo.toml".to_string(),
4634            format: "toml".to_string(),
4635            key: "package.name".to_string(),
4636            registry: "crates.io".to_string(),
4637        };
4638
4639        assert_eq!(
4640            read_package_name_from_lookup(
4641                &lookup,
4642                "[package]\nname = \"athena-mcp\"\nversion = \"0.2.0\"\n"
4643            )
4644            .expect("read"),
4645            Some("athena-mcp".to_string())
4646        );
4647    }
4648
4649    #[test]
4650    fn package_name_lookup_errors_on_unknown_format() {
4651        let lookup: PackageNameLookup = PackageNameLookup {
4652            file: "meta.txt".to_string(),
4653            format: "ini".to_string(),
4654            key: "name".to_string(),
4655            registry: "npm".to_string(),
4656        };
4657
4658        let error = read_package_name_from_lookup(&lookup, "name=demo")
4659            .expect_err("unsupported format should fail");
4660        assert!(error.contains("Unsupported lookup format"));
4661    }
4662
4663    #[test]
4664    fn highest_version_observation_returns_max_version() {
4665        let entries: Vec<VersionObservation> = vec![
4666            VersionObservation {
4667                location: "README.md".to_string(),
4668                version: Version::new(1, 0, 0),
4669            },
4670            VersionObservation {
4671                location: "Cargo.toml".to_string(),
4672                version: Version::new(1, 2, 0),
4673            },
4674        ];
4675
4676        assert_eq!(
4677            highest_version_observation(&entries).expect("max version"),
4678            Version::new(1, 2, 0)
4679        );
4680    }
4681
4682    #[test]
4683    fn stale_version_observations_only_returns_outdated_entries() {
4684        let entries: Vec<VersionObservation> = vec![
4685            VersionObservation {
4686                location: "README.md".to_string(),
4687                version: Version::new(1, 1, 0),
4688            },
4689            VersionObservation {
4690                location: "Cargo.toml".to_string(),
4691                version: Version::new(1, 2, 0),
4692            },
4693            VersionObservation {
4694                location: "openapi.yaml".to_string(),
4695                version: Version::new(1, 0, 5),
4696            },
4697        ];
4698
4699        let stale: Vec<&VersionObservation> = stale_version_observations(&entries);
4700        assert_eq!(stale.len(), 2);
4701        assert!(stale.iter().any(|entry| entry.location == "README.md"));
4702        assert!(stale.iter().any(|entry| entry.location == "openapi.yaml"));
4703        assert!(!stale.iter().any(|entry| entry.location == "Cargo.toml"));
4704    }
4705
4706    #[test]
4707    fn parses_github_remote_urls() {
4708        assert_eq!(
4709            parse_github_repo_from_remote_url("https://github.com/xylex-group/xbp.git"),
4710            Some(("xylex-group".to_string(), "xbp".to_string()))
4711        );
4712        assert_eq!(
4713            parse_github_repo_from_remote_url("git@github.com:xylex-group/xbp.git"),
4714            Some(("xylex-group".to_string(), "xbp".to_string()))
4715        );
4716        assert_eq!(
4717            parse_github_repo_from_remote_url("ssh://git@github.com/xylex-group/xbp"),
4718            Some(("xylex-group".to_string(), "xbp".to_string()))
4719        );
4720        assert_eq!(
4721            parse_github_repo_from_remote_url(
4722                "https://floris-xlx:ghp_exampletoken@github.com/SuitsBooks/suits-invoicing.git"
4723            ),
4724            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
4725        );
4726        assert_eq!(
4727            parse_github_repo_from_remote_url(
4728                "https://floris-xlx@github.com/SuitsBooks/suits-invoicing/"
4729            ),
4730            Some(("SuitsBooks".to_string(), "suits-invoicing".to_string()))
4731        );
4732        assert_eq!(
4733            parse_github_repo_from_remote_url("https://gitlab.com/xylex-group/xbp.git"),
4734            None
4735        );
4736    }
4737
4738    #[test]
4739    fn redacts_credentials_in_remote_urls() {
4740        let redacted = redact_remote_url_credentials(
4741            "https://floris-xlx:ghp_secretvalue@github.com/SuitsBooks/suits-invoicing.git",
4742        );
4743        assert!(redacted.contains("REDACTED"));
4744        assert!(!redacted.contains("ghp_secretvalue"));
4745
4746        let username_only = redact_remote_url_credentials(
4747            "https://floris-xlx@github.com/SuitsBooks/suits-invoicing",
4748        );
4749        assert!(username_only.contains("REDACTED@github.com"));
4750        assert!(!username_only.contains("floris-xlx@github.com"));
4751
4752        let ssh_remote =
4753            redact_remote_url_credentials("git@github.com:SuitsBooks/suits-invoicing.git");
4754        assert_eq!(ssh_remote, "git@github.com:SuitsBooks/suits-invoicing.git");
4755    }
4756
4757    #[test]
4758    fn builds_github_release_urls_with_encoded_tag_segments() {
4759        let create_url = github_release_endpoint("SuitsBooks", "suits-invoicing").expect("url");
4760        assert_eq!(
4761            create_url.as_str(),
4762            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases"
4763        );
4764
4765        let lookup_url =
4766            github_release_by_tag_endpoint("SuitsBooks", "suits-invoicing", "release/0.0.1")
4767                .expect("url");
4768        assert_eq!(
4769            lookup_url.as_str(),
4770            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%2F0.0.1"
4771        );
4772
4773        let update_url =
4774            github_release_update_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
4775        assert_eq!(
4776            update_url.as_str(),
4777            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42"
4778        );
4779
4780        let lookup_with_special_tag = github_release_by_tag_endpoint(
4781            "SuitsBooks",
4782            "suits-invoicing",
4783            "release candidate/v0.0.1+build",
4784        )
4785        .expect("url");
4786        assert_eq!(
4787            lookup_with_special_tag.as_str(),
4788            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/tags/release%20candidate%2Fv0.0.1+build"
4789        );
4790    }
4791
4792    #[test]
4793    fn builds_github_release_asset_urls() {
4794        let list_url =
4795            github_release_assets_endpoint("SuitsBooks", "suits-invoicing", 42).expect("url");
4796        assert_eq!(
4797            list_url.as_str(),
4798            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets"
4799        );
4800
4801        let delete_url = github_release_asset_delete_endpoint("SuitsBooks", "suits-invoicing", 314)
4802            .expect("url");
4803        assert_eq!(
4804            delete_url.as_str(),
4805            "https://api.github.com/repos/SuitsBooks/suits-invoicing/releases/assets/314"
4806        );
4807
4808        let upload_url = github_release_asset_upload_endpoint(
4809            "SuitsBooks",
4810            "suits-invoicing",
4811            42,
4812            "openapi spec.json",
4813        )
4814        .expect("url");
4815        assert_eq!(
4816            upload_url.as_str(),
4817            "https://uploads.github.com/repos/SuitsBooks/suits-invoicing/releases/42/assets?name=openapi+spec.json"
4818        );
4819    }
4820
4821    #[test]
4822    fn maps_release_latest_policy_to_github_api_values() {
4823        assert_eq!(ReleaseLatestPolicy::True.as_github_api_value(), "true");
4824        assert_eq!(ReleaseLatestPolicy::False.as_github_api_value(), "false");
4825        assert_eq!(ReleaseLatestPolicy::Legacy.as_github_api_value(), "legacy");
4826    }
4827
4828    #[test]
4829    fn release_channel_from_semver_prerelease_labels() {
4830        let stable = Version::parse("3.6.2").expect("version");
4831        let nightly = Version::parse("3.6.2-nightly.1").expect("version");
4832        let experimental = Version::parse("0.1.1-alpha.1").expect("version");
4833        assert_eq!(release_channel(&stable), "stable");
4834        assert_eq!(release_channel(&nightly), "nightly");
4835        assert_eq!(release_channel(&experimental), "experimental");
4836    }
4837
4838    #[test]
4839    fn renders_release_docs_from_entries() {
4840        let entries = vec![
4841            ReleaseDocEntry {
4842                tag: "v3.6.2".to_string(),
4843                version: Version::parse("3.6.2").expect("version"),
4844                date: "2026-04-27".to_string(),
4845            },
4846            ReleaseDocEntry {
4847                tag: "docs-0.1.1-alpha.1".to_string(),
4848                version: Version::parse("0.1.1-alpha.1").expect("version"),
4849                date: "2026-04-20".to_string(),
4850            },
4851        ];
4852        let changelog = render_changelog("xylex-group", "athena", &entries);
4853        assert!(changelog.contains("## [3.6.2]"));
4854        assert!(changelog.contains("compare/docs-0.1.1-alpha.1...v3.6.2"));
4855        assert!(changelog.contains("Release channel: stable"));
4856        assert!(changelog.contains("Release channel: experimental"));
4857
4858        let security = render_security_policy(&entries);
4859        assert!(security.contains("| 3.6.2 | stable | :white_check_mark: |"));
4860        assert!(security.contains("| 0.1.1-alpha.1 | experimental | :white_check_mark: |"));
4861    }
4862
4863    #[test]
4864    fn formats_release_commit_lines_with_sha_and_pr_links() {
4865        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Improve release docs (#42)\u{1f}2026-05-24";
4866        let formatted =
4867            format_release_commit_line(raw_line, "xylex-group", "xbp", &BTreeMap::new())
4868                .expect("formatted line");
4869
4870        assert_eq!(
4871            formatted,
4872            "[abcdef1](https://github.com/xylex-group/xbp/commit/abcdef1234567890abcdef1234567890abcdef12) Improve release docs ([#42](https://github.com/xylex-group/xbp/pull/42)) (2026-05-24)"
4873        );
4874    }
4875
4876    #[test]
4877    fn formats_release_commit_lines_with_linear_links_when_available() {
4878        let raw_line = "abcdef1234567890abcdef1234567890abcdef12\u{1f}abcdef1\u{1f}Fix release flow for SUI-1336 (#42)\u{1f}2026-05-24";
4879        let issue_infos = BTreeMap::from([(
4880            "SUI-1336".to_string(),
4881            LinearIssueInfo {
4882                title: "Release flow".to_string(),
4883                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
4884            },
4885        )]);
4886        let formatted = format_release_commit_line(raw_line, "xylex-group", "xbp", &issue_infos)
4887            .expect("formatted line");
4888
4889        assert_eq!(
4890            formatted,
4891            "[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)"
4892        );
4893    }
4894
4895    #[test]
4896    fn renders_release_notes_in_requested_layout() {
4897        let commits = vec![
4898            ReleaseCommitEntry {
4899                full_sha: "abcdef1234567890abcdef1234567890abcdef12".to_string(),
4900                short_sha: "abcdef1".to_string(),
4901                subject: "Improve release docs (#42)".to_string(),
4902                date: "2026-05-24".to_string(),
4903            },
4904            ReleaseCommitEntry {
4905                full_sha: "fedcba9876543210fedcba9876543210fedcba98".to_string(),
4906                short_sha: "fedcba9".to_string(),
4907                subject: "Fix release flow for SUI-1336".to_string(),
4908                date: "2026-05-25".to_string(),
4909            },
4910        ];
4911        let pull_request_infos = BTreeMap::from([(
4912            "42".to_string(),
4913            super::release_notes::GithubPullRequestInfo {
4914                title: "Improve release docs".to_string(),
4915                url: "https://github.com/xylex-group/athena-auth/pull/42".to_string(),
4916            },
4917        )]);
4918        let issue_infos = BTreeMap::from([(
4919            "SUI-1336".to_string(),
4920            LinearIssueInfo {
4921                title: "Release flow".to_string(),
4922                url: "https://linear.app/suitsbooks/issue/SUI-1336/release-flow".to_string(),
4923            },
4924        )]);
4925        let sections = build_fallback_sections(&commits);
4926        let rendered = render_release_notes(&ReleaseNotesRenderInput {
4927            release_title: "1.7.0 - athena-auth",
4928            current_tag_name: "v1.7.0",
4929            owner: "xylex-group",
4930            repo: "athena-auth",
4931            previous_tag: Some("v1.6.0"),
4932            sections: &sections,
4933            commit_entries: &commits,
4934            pull_request_infos: &pull_request_infos,
4935            linear_issue_infos: &issue_infos,
4936        });
4937
4938        assert_eq!(
4939            rendered,
4940            "# [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)"
4941        );
4942    }
4943
4944    #[test]
4945    fn collects_unique_linear_issue_identifiers_from_commit_subjects() {
4946        let commits = vec![
4947            ReleaseCommitEntry {
4948                full_sha: "a".repeat(40),
4949                short_sha: "aaaaaaa".to_string(),
4950                subject: "Fix SUI-1336 and SUI-1440".to_string(),
4951                date: "2026-05-24".to_string(),
4952            },
4953            ReleaseCommitEntry {
4954                full_sha: "b".repeat(40),
4955                short_sha: "bbbbbbb".to_string(),
4956                subject: "Touch SUI-1336 again".to_string(),
4957                date: "2026-05-25".to_string(),
4958            },
4959        ];
4960
4961        assert_eq!(
4962            collect_linear_issue_identifiers(&commits),
4963            vec!["SUI-1336".to_string(), "SUI-1440".to_string()]
4964        );
4965    }
4966
4967    #[test]
4968    fn release_title_defaults_to_version_and_repo() {
4969        assert_eq!(
4970            default_release_title(&Version::new(1, 7, 0), "athena-auth"),
4971            "1.7.0 - athena-auth"
4972        );
4973    }
4974
4975    #[test]
4976    fn deduplicates_release_commit_entries_by_exact_subject() {
4977        let commits = vec![
4978            ReleaseCommitEntry {
4979                full_sha: "a".repeat(40),
4980                short_sha: "aaaaaaa".to_string(),
4981                subject: "Improve release docs".to_string(),
4982                date: "2026-05-24".to_string(),
4983            },
4984            ReleaseCommitEntry {
4985                full_sha: "b".repeat(40),
4986                short_sha: "bbbbbbb".to_string(),
4987                subject: "Improve release docs".to_string(),
4988                date: "2026-05-25".to_string(),
4989            },
4990        ];
4991
4992        let deduplicated = deduplicate_release_commit_entries(&commits);
4993        assert_eq!(deduplicated.len(), 1);
4994        assert_eq!(deduplicated[0].short_sha, "aaaaaaa");
4995    }
4996
4997    #[test]
4998    fn fallback_sections_collapse_related_commit_themes() {
4999        let commits = vec![
5000            ReleaseCommitEntry {
5001                full_sha: "a".repeat(40),
5002                short_sha: "chat001".to_string(),
5003                subject: "Add optimistic chat retries".to_string(),
5004                date: "2026-06-01".to_string(),
5005            },
5006            ReleaseCommitEntry {
5007                full_sha: "b".repeat(40),
5008                short_sha: "chat002".to_string(),
5009                subject: "Persist deleted-message state in chat".to_string(),
5010                date: "2026-06-01".to_string(),
5011            },
5012            ReleaseCommitEntry {
5013                full_sha: "c".repeat(40),
5014                short_sha: "file001".to_string(),
5015                subject: "Fix upload UTF-8 audit retry handling".to_string(),
5016                date: "2026-06-01".to_string(),
5017            },
5018            ReleaseCommitEntry {
5019                full_sha: "d".repeat(40),
5020                short_sha: "ath001".to_string(),
5021                subject: "Migrate form progress routes to Athena".to_string(),
5022                date: "2026-06-01".to_string(),
5023            },
5024            ReleaseCommitEntry {
5025                full_sha: "e".repeat(40),
5026                short_sha: "ath002".to_string(),
5027                subject: "Update Athena models and package wiring".to_string(),
5028                date: "2026-06-01".to_string(),
5029            },
5030        ];
5031
5032        let sections = build_fallback_sections(&commits);
5033        assert_eq!(sections.len(), 3);
5034        assert_eq!(sections[0].title, "Cases & Communication");
5035        assert!(!sections[0].summary.is_empty());
5036        assert_eq!(sections[0].bullets.len(), 2);
5037        assert_eq!(sections[0].bullets[0].commit_shas, vec!["chat001"]);
5038        assert!(sections[0].bullets[0].summary.contains("chat"));
5039        assert_eq!(sections[0].bullets[1].commit_shas, vec!["chat002"]);
5040        assert!(sections[0].bullets[1].summary.contains("deleted-message"));
5041        assert_eq!(sections[1].title, "Reliability");
5042        assert_eq!(sections[1].bullets[0].commit_shas, vec!["file001"]);
5043        assert_eq!(sections[2].title, "Athena Migration");
5044        assert_eq!(sections[2].bullets[0].commit_shas, vec!["ath001", "ath002"]);
5045    }
5046
5047    #[test]
5048    fn appends_release_label_footer_for_pre_release() {
5049        let with_label = append_release_label_footer("# Release", true);
5050        assert_eq!(
5051            with_label,
5052            format!(
5053                "# Release\nRelease label: Pre-release\nGenerated by XBP {}",
5054                env!("CARGO_PKG_VERSION")
5055            )
5056        );
5057    }
5058}