Skip to main content

xbp_cli/commands/
version.rs

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