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