Skip to main content

backhopper_core/config/
path_translation.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Path prefix translations for cross-branch backport assessment.
6
7use std::cmp::Reverse;
8use std::collections::BTreeSet;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::config::PathTranslationRaw;
15use crate::errors::ConfigError;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum TranslationDirection {
20    SourceToTarget,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct PathTranslation {
25    pub name: String,
26    pub direction: TranslationDirection,
27    pub source_prefix: String,
28    pub target_prefix: String,
29    pub origin: TranslationOrigin,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "kind", rename_all = "snake_case")]
34pub enum TranslationOrigin {
35    ConfigStanza,
36    ExternalFile { path: PathBuf },
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
40pub struct PathTranslations {
41    pub entries: Vec<PathTranslation>,
42}
43
44impl PathTranslations {
45    pub fn from_config_stanzas(raws: Vec<PathTranslationRaw>) -> Result<Self, ConfigError> {
46        let entries = raws
47            .into_iter()
48            .map(|r| parse_one(r, TranslationOrigin::ConfigStanza))
49            .collect::<Result<Vec<_>, _>>()?;
50        let mut out = Self { entries };
51        out.validate_uniqueness_and_overlap()?;
52        out.sort_by_specificity();
53        Ok(out)
54    }
55
56    pub fn load_external(path: &Path) -> Result<Vec<PathTranslation>, ConfigError> {
57        if !path.exists() {
58            return Err(ConfigError::PathTranslationFileMissing(path.to_path_buf()));
59        }
60        let text = fs::read_to_string(path)?;
61        let raw: ExternalFile = toml::from_str(&text)?;
62        let origin = TranslationOrigin::ExternalFile {
63            path: path.to_path_buf(),
64        };
65        raw.path_translations
66            .into_iter()
67            .map(|r| parse_one(r, origin.clone()))
68            .collect()
69    }
70
71    pub fn merge_external(&mut self, external: Vec<PathTranslation>) -> Result<(), ConfigError> {
72        self.entries.extend(external);
73        self.validate_uniqueness_and_overlap()?;
74        self.sort_by_specificity();
75        Ok(())
76    }
77
78    // Most specific (longest source prefix) first so `translate` can return on the first match.
79    fn sort_by_specificity(&mut self) {
80        self.entries.sort_by_key(|e| Reverse(e.source_prefix.len()));
81    }
82
83    fn validate_uniqueness_and_overlap(&self) -> Result<(), ConfigError> {
84        let mut seen_names: BTreeSet<&str> = BTreeSet::new();
85        let mut seen_sources: BTreeSet<&str> = BTreeSet::new();
86        for entry in &self.entries {
87            if !seen_names.insert(&entry.name) {
88                return Err(ConfigError::PathTranslationDuplicateName(
89                    entry.name.clone(),
90                ));
91            }
92            if !seen_sources.insert(&entry.source_prefix) {
93                return Err(ConfigError::PathTranslationDuplicateSource(
94                    entry.source_prefix.clone(),
95                ));
96            }
97        }
98        for (i, a) in self.entries.iter().enumerate() {
99            for b in self.entries.iter().skip(i + 1) {
100                if a.source_prefix == b.source_prefix {
101                    continue;
102                }
103                if a.source_prefix.starts_with(&b.source_prefix)
104                    || b.source_prefix.starts_with(&a.source_prefix)
105                {
106                    return Err(ConfigError::PathTranslationPrefixOverlap {
107                        a: a.source_prefix.clone(),
108                        b: b.source_prefix.clone(),
109                    });
110                }
111            }
112        }
113        Ok(())
114    }
115
116    /// Apply the first matching translation to `path`. `entries` is kept
117    /// sorted by descending `source_prefix` length so a more specific prefix wins.
118    pub fn translate(&self, path: &str) -> Option<(PathBuf, &PathTranslation)> {
119        for entry in &self.entries {
120            if let Some(suffix) = path.strip_prefix(&entry.source_prefix) {
121                let rewritten = format!("{}{}", entry.target_prefix, suffix);
122                return Some((PathBuf::from(rewritten), entry));
123            }
124        }
125        None
126    }
127}
128
129#[derive(Debug, Deserialize)]
130#[serde(deny_unknown_fields)]
131struct ExternalFile {
132    #[serde(default, rename = "path_translation")]
133    path_translations: Vec<PathTranslationRaw>,
134}
135
136fn parse_one(
137    raw: PathTranslationRaw,
138    origin: TranslationOrigin,
139) -> Result<PathTranslation, ConfigError> {
140    if raw.name.is_empty() {
141        return Err(ConfigError::PathTranslationEmptyName);
142    }
143    if raw.source_prefix.is_empty() {
144        return Err(ConfigError::PathTranslationEmptyPrefix {
145            name: raw.name.clone(),
146            field: "source_prefix",
147        });
148    }
149    if raw.target_prefix.is_empty() {
150        return Err(ConfigError::PathTranslationEmptyPrefix {
151            name: raw.name.clone(),
152            field: "target_prefix",
153        });
154    }
155    if !raw.source_prefix.ends_with('/') {
156        return Err(ConfigError::PathTranslationPrefixNoTrailingSlash {
157            name: raw.name.clone(),
158            field: "source_prefix",
159            value: raw.source_prefix.clone(),
160        });
161    }
162    if !raw.target_prefix.ends_with('/') {
163        return Err(ConfigError::PathTranslationPrefixNoTrailingSlash {
164            name: raw.name.clone(),
165            field: "target_prefix",
166            value: raw.target_prefix.clone(),
167        });
168    }
169    let direction = match raw.direction.as_str() {
170        "source_to_target" => TranslationDirection::SourceToTarget,
171        other => {
172            return Err(ConfigError::PathTranslationUnknownDirection {
173                name: raw.name.clone(),
174                direction: other.to_owned(),
175            });
176        }
177    };
178    Ok(PathTranslation {
179        name: raw.name,
180        direction,
181        source_prefix: raw.source_prefix,
182        target_prefix: raw.target_prefix,
183        origin,
184    })
185}