Skip to main content

debian_watch/
mangle.rs

1//! Functions for parsing and applying version and URL mangling expressions.
2//!
3//! Debian watch files use sed-style expressions for transforming versions and URLs.
4
5use regex::Regex;
6
7/// Error type for mangling expression parsing
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum MangleError {
10    /// Not a substitution or translation expression
11    NotMangleExpr(String),
12    /// Invalid substitution expression
13    InvalidSubstExpr(String),
14    /// Invalid translation expression
15    InvalidTranslExpr(String),
16    /// Regex compilation error
17    RegexError(String),
18}
19
20impl std::fmt::Display for MangleError {
21    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
22        match self {
23            MangleError::NotMangleExpr(s) => {
24                write!(f, "not a substitution or translation expression: {}", s)
25            }
26            MangleError::InvalidSubstExpr(s) => write!(f, "invalid substitution expression: {}", s),
27            MangleError::InvalidTranslExpr(s) => write!(f, "invalid translation expression: {}", s),
28            MangleError::RegexError(s) => write!(f, "regex error: {}", s),
29        }
30    }
31}
32
33impl std::error::Error for MangleError {}
34
35/// Type of mangling expression
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum MangleExprKind {
38    /// Substitution (s/pattern/replacement/flags)
39    Subst,
40    /// Translation (tr/pattern/replacement/flags or y/pattern/replacement/flags)
41    Transl,
42}
43
44/// A parsed mangling expression
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct MangleExpr {
47    /// The kind of expression
48    pub kind: MangleExprKind,
49    /// The pattern to match
50    pub pattern: String,
51    /// The replacement string
52    pub replacement: String,
53    /// Optional flags
54    pub flags: Option<String>,
55}
56
57/// Parse a mangling expression
58///
59/// # Examples
60///
61/// ```
62/// use debian_watch::mangle::parse_mangle_expr;
63///
64/// let expr = parse_mangle_expr("s/foo/bar/g").unwrap();
65/// assert_eq!(expr.pattern, "foo");
66/// assert_eq!(expr.replacement, "bar");
67/// assert_eq!(expr.flags.as_deref(), Some("g"));
68/// ```
69pub fn parse_mangle_expr(vm: &str) -> Result<MangleExpr, MangleError> {
70    if vm.starts_with('s') {
71        parse_subst_expr(vm)
72    } else if vm.starts_with("tr") {
73        parse_transl_expr(vm)
74    } else if vm.starts_with('y') {
75        parse_transl_expr(vm)
76    } else {
77        Err(MangleError::NotMangleExpr(vm.to_string()))
78    }
79}
80
81/// Parse a substitution expression (s/pattern/replacement/flags)
82///
83/// # Examples
84///
85/// ```
86/// use debian_watch::mangle::parse_subst_expr;
87///
88/// let expr = parse_subst_expr("s/foo/bar/g").unwrap();
89/// assert_eq!(expr.pattern, "foo");
90/// assert_eq!(expr.replacement, "bar");
91/// assert_eq!(expr.flags.as_deref(), Some("g"));
92///
93/// let expr = parse_subst_expr("s|foo|bar|").unwrap();
94/// assert_eq!(expr.pattern, "foo");
95/// assert_eq!(expr.replacement, "bar");
96/// ```
97pub fn parse_subst_expr(vm: &str) -> Result<MangleExpr, MangleError> {
98    if !vm.starts_with('s') {
99        return Err(MangleError::InvalidSubstExpr(
100            "not a substitution expression".to_string(),
101        ));
102    }
103
104    if vm.len() < 2 {
105        return Err(MangleError::InvalidSubstExpr(
106            "expression too short".to_string(),
107        ));
108    }
109
110    let delimiter = vm.chars().nth(1).unwrap();
111    let rest = &vm[2..];
112
113    // Split by unescaped delimiter
114    let parts = split_by_unescaped_delimiter(rest, delimiter);
115
116    if parts.len() < 2 {
117        return Err(MangleError::InvalidSubstExpr(
118            "not enough parts".to_string(),
119        ));
120    }
121
122    let pattern = parts[0].clone();
123    let replacement = parts[1].clone();
124    let flags = if parts.len() > 2 && !parts[2].is_empty() {
125        Some(parts[2].clone())
126    } else {
127        None
128    };
129
130    Ok(MangleExpr {
131        kind: MangleExprKind::Subst,
132        pattern,
133        replacement,
134        flags,
135    })
136}
137
138/// Parse a translation expression (tr/pattern/replacement/flags or y/pattern/replacement/flags)
139///
140/// # Examples
141///
142/// ```
143/// use debian_watch::mangle::parse_transl_expr;
144///
145/// let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap();
146/// assert_eq!(expr.pattern, "a-z");
147/// assert_eq!(expr.replacement, "A-Z");
148/// ```
149pub fn parse_transl_expr(vm: &str) -> Result<MangleExpr, MangleError> {
150    let rest = if vm.starts_with("tr") {
151        &vm[2..]
152    } else if vm.starts_with('y') {
153        &vm[1..]
154    } else {
155        return Err(MangleError::InvalidTranslExpr(
156            "not a translation expression".to_string(),
157        ));
158    };
159
160    if rest.is_empty() {
161        return Err(MangleError::InvalidTranslExpr(
162            "expression too short".to_string(),
163        ));
164    }
165
166    let delimiter = rest.chars().next().unwrap();
167    let rest = &rest[1..];
168
169    // Split by unescaped delimiter
170    let parts = split_by_unescaped_delimiter(rest, delimiter);
171
172    if parts.len() < 2 {
173        return Err(MangleError::InvalidTranslExpr(
174            "not enough parts".to_string(),
175        ));
176    }
177
178    let pattern = parts[0].clone();
179    let replacement = parts[1].clone();
180    let flags = if parts.len() > 2 && !parts[2].is_empty() {
181        Some(parts[2].clone())
182    } else {
183        None
184    };
185
186    Ok(MangleExpr {
187        kind: MangleExprKind::Transl,
188        pattern,
189        replacement,
190        flags,
191    })
192}
193
194/// Split a string by an unescaped delimiter
195fn split_by_unescaped_delimiter(s: &str, delimiter: char) -> Vec<String> {
196    let mut parts = Vec::new();
197    let mut current = String::new();
198    let mut escaped = false;
199
200    for c in s.chars() {
201        if escaped {
202            current.push(c);
203            escaped = false;
204        } else if c == '\\' {
205            current.push(c);
206            escaped = true;
207        } else if c == delimiter {
208            parts.push(current.clone());
209            current.clear();
210        } else {
211            current.push(c);
212        }
213    }
214
215    // Don't forget the last part
216    parts.push(current);
217
218    parts
219}
220
221/// Apply a mangling expression to a string
222///
223/// # Examples
224///
225/// ```
226/// use debian_watch::mangle::apply_mangle;
227///
228/// let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap();
229/// assert_eq!(result, "bar baz foo");
230///
231/// let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap();
232/// assert_eq!(result, "bar baz bar");
233/// ```
234pub fn apply_mangle(vm: &str, orig: &str) -> Result<String, MangleError> {
235    let expr = parse_mangle_expr(vm)?;
236
237    match expr.kind {
238        MangleExprKind::Subst => {
239            let re =
240                Regex::new(&expr.pattern).map_err(|e| MangleError::RegexError(e.to_string()))?;
241
242            // Check if 'g' flag is present for global replacement
243            let global = expr.flags.as_ref().is_some_and(|f| f.contains('g'));
244
245            if global {
246                Ok(re.replace_all(orig, expr.replacement.as_str()).to_string())
247            } else {
248                Ok(re.replace(orig, expr.replacement.as_str()).to_string())
249            }
250        }
251        MangleExprKind::Transl => {
252            // Translation: character-by-character replacement
253            apply_translation(&expr.pattern, &expr.replacement, orig)
254        }
255    }
256}
257
258/// Apply a mangling expression with template variable substitution
259///
260/// This first substitutes template variables like @PACKAGE@ and @COMPONENT@ in the
261/// mangle expression itself, then applies the mangle to the input string.
262///
263/// # Examples
264///
265/// ```
266/// use debian_watch::mangle::apply_mangle_with_subst;
267///
268/// let result = apply_mangle_with_subst(
269///     "s/@PACKAGE@/bar/",
270///     "foo baz foo",
271///     || "foo".to_string(),
272///     || String::new()
273/// ).unwrap();
274/// assert_eq!(result, "bar baz foo");
275/// ```
276pub fn apply_mangle_with_subst(
277    vm: &str,
278    orig: &str,
279    package: impl FnOnce() -> String,
280    component: impl FnOnce() -> String,
281) -> Result<String, MangleError> {
282    // Apply template substitution to the mangle expression
283    let substituted_vm = crate::subst::subst(vm, package, component);
284
285    // Apply the mangle expression
286    apply_mangle(&substituted_vm, orig)
287}
288
289/// Apply character-by-character translation
290fn apply_translation(pattern: &str, replacement: &str, orig: &str) -> Result<String, MangleError> {
291    // Expand ranges like a-z
292    let from_chars = expand_char_range(pattern);
293    let to_chars = expand_char_range(replacement);
294
295    if from_chars.len() != to_chars.len() {
296        return Err(MangleError::InvalidTranslExpr(
297            "pattern and replacement must have same length".to_string(),
298        ));
299    }
300
301    let mut result = String::new();
302    for c in orig.chars() {
303        if let Some(pos) = from_chars.iter().position(|&fc| fc == c) {
304            result.push(to_chars[pos]);
305        } else {
306            result.push(c);
307        }
308    }
309
310    Ok(result)
311}
312
313/// Expand character ranges like a-z to actual characters
314fn expand_char_range(s: &str) -> Vec<char> {
315    let mut result = Vec::new();
316    let chars: Vec<char> = s.chars().collect();
317    let mut i = 0;
318
319    while i < chars.len() {
320        if i + 2 < chars.len() && chars[i + 1] == '-' {
321            // Range found
322            let start = chars[i];
323            let end = chars[i + 2];
324            for c in (start as u32)..=(end as u32) {
325                if let Some(ch) = char::from_u32(c) {
326                    result.push(ch);
327                }
328            }
329            i += 3;
330        } else {
331            result.push(chars[i]);
332            i += 1;
333        }
334    }
335
336    result
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_parse_subst_expr() {
345        let expr = parse_subst_expr("s/foo/bar/g").unwrap();
346        assert_eq!(expr.pattern, "foo");
347        assert_eq!(expr.replacement, "bar");
348        assert_eq!(expr.flags.as_deref(), Some("g"));
349
350        let expr = parse_subst_expr("s|foo|bar|").unwrap();
351        assert_eq!(expr.pattern, "foo");
352        assert_eq!(expr.replacement, "bar");
353        assert_eq!(expr.flags, None);
354
355        let expr = parse_subst_expr("s#a/b#c/d#").unwrap();
356        assert_eq!(expr.pattern, "a/b");
357        assert_eq!(expr.replacement, "c/d");
358    }
359
360    #[test]
361    fn test_parse_transl_expr() {
362        let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap();
363        assert_eq!(expr.pattern, "a-z");
364        assert_eq!(expr.replacement, "A-Z");
365
366        let expr = parse_transl_expr("y/abc/xyz/").unwrap();
367        assert_eq!(expr.pattern, "abc");
368        assert_eq!(expr.replacement, "xyz");
369    }
370
371    #[test]
372    fn test_apply_mangle_subst() {
373        let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap();
374        assert_eq!(result, "bar baz foo");
375
376        let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap();
377        assert_eq!(result, "bar baz bar");
378
379        // Test with regex
380        let result = apply_mangle("s/[0-9]+/X/g", "a1b2c3").unwrap();
381        assert_eq!(result, "aXbXcX");
382    }
383
384    #[test]
385    fn test_apply_mangle_transl() {
386        let result = apply_mangle("tr/a-z/A-Z/", "hello").unwrap();
387        assert_eq!(result, "HELLO");
388
389        let result = apply_mangle("y/abc/xyz/", "aabbcc").unwrap();
390        assert_eq!(result, "xxyyzz");
391    }
392
393    #[test]
394    fn test_expand_char_range() {
395        let result = expand_char_range("a-z");
396        assert_eq!(result.len(), 26);
397        assert_eq!(result[0], 'a');
398        assert_eq!(result[25], 'z');
399
400        let result = expand_char_range("a-c");
401        assert_eq!(result, vec!['a', 'b', 'c']);
402
403        let result = expand_char_range("abc");
404        assert_eq!(result, vec!['a', 'b', 'c']);
405    }
406
407    #[test]
408    fn test_split_by_unescaped_delimiter() {
409        let result = split_by_unescaped_delimiter("foo/bar/baz", '/');
410        assert_eq!(result, vec!["foo", "bar", "baz"]);
411
412        let result = split_by_unescaped_delimiter("foo\\/bar/baz", '/');
413        assert_eq!(result, vec!["foo\\/bar", "baz"]);
414    }
415
416    #[test]
417    fn test_real_world_examples() {
418        // Example from Python code: dversionmangle=s/\+ds//
419        let result = apply_mangle(r"s/\+ds//", "1.0+ds").unwrap();
420        assert_eq!(result, "1.0");
421
422        // Example: filenamemangle
423        let result = apply_mangle(
424            r"s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1.tar.gz/",
425            "https://github.com/syncthing/syncthing-gtk/archive/v0.9.4.tar.gz",
426        )
427        .unwrap();
428        assert_eq!(result, "syncthing-gtk-0.9.4.tar.gz");
429    }
430
431    #[test]
432    fn test_apply_mangle_with_subst_package() {
433        // Template substitution happens in the mangle expression, so @PACKAGE@
434        // becomes "mypackage" in the pattern, then it matches against the input
435        let result = apply_mangle_with_subst(
436            "s/@PACKAGE@/replaced/",
437            "foo mypackage bar",
438            || "mypackage".to_string(),
439            || String::new(),
440        )
441        .unwrap();
442        assert_eq!(result, "foo replaced bar");
443    }
444
445    #[test]
446    fn test_apply_mangle_with_subst_component() {
447        // Template substitution happens in the mangle expression, so @COMPONENT@
448        // becomes "upstream" in the pattern, then it matches against the input
449        let result = apply_mangle_with_subst(
450            "s/@COMPONENT@/replaced/g",
451            "upstream foo upstream",
452            || unreachable!(),
453            || "upstream".to_string(),
454        )
455        .unwrap();
456        assert_eq!(result, "replaced foo replaced");
457    }
458
459    #[test]
460    fn test_apply_mangle_with_subst_filenamemangle() {
461        // Example: filenamemangle with @PACKAGE@ template
462        let result = apply_mangle_with_subst(
463            r"s/.+\/v?(\d\S+)\.tar\.gz/@PACKAGE@-$1.tar.gz/",
464            "https://github.com/example/repo/archive/v0.9.4.tar.gz",
465            || "myapp".to_string(),
466            || String::new(),
467        )
468        .unwrap();
469        assert_eq!(result, "myapp-0.9.4.tar.gz");
470    }
471
472    #[test]
473    fn test_apply_mangle_with_subst_no_templates() {
474        // Ensure it still works when no templates are present
475        let result = apply_mangle_with_subst(
476            "s/foo/bar/g",
477            "foo baz foo",
478            || unreachable!(),
479            || unreachable!(),
480        )
481        .unwrap();
482        assert_eq!(result, "bar baz bar");
483    }
484}