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    // Skip past 's' (always one byte) and the delimiter (may be multi-byte).
112    let rest = &vm[1 + delimiter.len_utf8()..];
113
114    // Split by unescaped delimiter
115    let parts = split_by_unescaped_delimiter(rest, delimiter);
116
117    if parts.len() < 2 {
118        return Err(MangleError::InvalidSubstExpr(
119            "not enough parts".to_string(),
120        ));
121    }
122
123    let pattern = parts[0].clone();
124    let replacement = parts[1].clone();
125    let flags = if parts.len() > 2 && !parts[2].is_empty() {
126        Some(parts[2].clone())
127    } else {
128        None
129    };
130
131    Ok(MangleExpr {
132        kind: MangleExprKind::Subst,
133        pattern,
134        replacement,
135        flags,
136    })
137}
138
139/// Parse a translation expression (tr/pattern/replacement/flags or y/pattern/replacement/flags)
140///
141/// # Examples
142///
143/// ```
144/// use debian_watch::mangle::parse_transl_expr;
145///
146/// let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap();
147/// assert_eq!(expr.pattern, "a-z");
148/// assert_eq!(expr.replacement, "A-Z");
149/// ```
150pub fn parse_transl_expr(vm: &str) -> Result<MangleExpr, MangleError> {
151    let rest = if vm.starts_with("tr") {
152        &vm[2..]
153    } else if vm.starts_with('y') {
154        &vm[1..]
155    } else {
156        return Err(MangleError::InvalidTranslExpr(
157            "not a translation expression".to_string(),
158        ));
159    };
160
161    if rest.is_empty() {
162        return Err(MangleError::InvalidTranslExpr(
163            "expression too short".to_string(),
164        ));
165    }
166
167    let delimiter = rest.chars().next().unwrap();
168    let rest = &rest[delimiter.len_utf8()..];
169
170    // Split by unescaped delimiter
171    let parts = split_by_unescaped_delimiter(rest, delimiter);
172
173    if parts.len() < 2 {
174        return Err(MangleError::InvalidTranslExpr(
175            "not enough parts".to_string(),
176        ));
177    }
178
179    let pattern = parts[0].clone();
180    let replacement = parts[1].clone();
181    let flags = if parts.len() > 2 && !parts[2].is_empty() {
182        Some(parts[2].clone())
183    } else {
184        None
185    };
186
187    Ok(MangleExpr {
188        kind: MangleExprKind::Transl,
189        pattern,
190        replacement,
191        flags,
192    })
193}
194
195/// Split a string by an unescaped delimiter
196fn split_by_unescaped_delimiter(s: &str, delimiter: char) -> Vec<String> {
197    let mut parts = Vec::new();
198    let mut current = String::new();
199    let mut escaped = false;
200
201    for c in s.chars() {
202        if escaped {
203            current.push(c);
204            escaped = false;
205        } else if c == '\\' {
206            current.push(c);
207            escaped = true;
208        } else if c == delimiter {
209            parts.push(current.clone());
210            current.clear();
211        } else {
212            current.push(c);
213        }
214    }
215
216    // Don't forget the last part
217    parts.push(current);
218
219    parts
220}
221
222/// Apply a mangling expression to a string
223///
224/// # Examples
225///
226/// ```
227/// use debian_watch::mangle::apply_mangle;
228///
229/// let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap();
230/// assert_eq!(result, "bar baz foo");
231///
232/// let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap();
233/// assert_eq!(result, "bar baz bar");
234/// ```
235pub fn apply_mangle(vm: &str, orig: &str) -> Result<String, MangleError> {
236    let expr = parse_mangle_expr(vm)?;
237
238    match expr.kind {
239        MangleExprKind::Subst => {
240            let re =
241                Regex::new(&expr.pattern).map_err(|e| MangleError::RegexError(e.to_string()))?;
242
243            // Check if 'g' flag is present for global replacement
244            let global = expr.flags.as_ref().is_some_and(|f| f.contains('g'));
245
246            if global {
247                Ok(re.replace_all(orig, expr.replacement.as_str()).to_string())
248            } else {
249                Ok(re.replace(orig, expr.replacement.as_str()).to_string())
250            }
251        }
252        MangleExprKind::Transl => {
253            // Translation: character-by-character replacement
254            apply_translation(&expr.pattern, &expr.replacement, orig)
255        }
256    }
257}
258
259/// Apply a mangling expression with template variable substitution
260///
261/// This first substitutes template variables like @PACKAGE@ and @COMPONENT@ in the
262/// mangle expression itself, then applies the mangle to the input string.
263///
264/// # Examples
265///
266/// ```
267/// use debian_watch::mangle::apply_mangle_with_subst;
268///
269/// let result = apply_mangle_with_subst(
270///     "s/@PACKAGE@/bar/",
271///     "foo baz foo",
272///     || "foo".to_string(),
273///     || String::new()
274/// ).unwrap();
275/// assert_eq!(result, "bar baz foo");
276/// ```
277pub fn apply_mangle_with_subst(
278    vm: &str,
279    orig: &str,
280    package: impl FnOnce() -> String,
281    component: impl FnOnce() -> String,
282) -> Result<String, MangleError> {
283    // Apply template substitution to the mangle expression
284    let substituted_vm = crate::subst::subst(vm, package, component);
285
286    // Apply the mangle expression
287    apply_mangle(&substituted_vm, orig)
288}
289
290/// Apply character-by-character translation
291fn apply_translation(pattern: &str, replacement: &str, orig: &str) -> Result<String, MangleError> {
292    // Expand ranges like a-z
293    let from_chars = expand_char_range(pattern);
294    let to_chars = expand_char_range(replacement);
295
296    if from_chars.len() != to_chars.len() {
297        return Err(MangleError::InvalidTranslExpr(
298            "pattern and replacement must have same length".to_string(),
299        ));
300    }
301
302    let mut result = String::new();
303    for c in orig.chars() {
304        if let Some(pos) = from_chars.iter().position(|&fc| fc == c) {
305            result.push(to_chars[pos]);
306        } else {
307            result.push(c);
308        }
309    }
310
311    Ok(result)
312}
313
314/// Expand character ranges like a-z to actual characters
315fn expand_char_range(s: &str) -> Vec<char> {
316    let mut result = Vec::new();
317    let chars: Vec<char> = s.chars().collect();
318    let mut i = 0;
319
320    while i < chars.len() {
321        if i + 2 < chars.len() && chars[i + 1] == '-' {
322            // Range found
323            let start = chars[i];
324            let end = chars[i + 2];
325            for c in (start as u32)..=(end as u32) {
326                if let Some(ch) = char::from_u32(c) {
327                    result.push(ch);
328                }
329            }
330            i += 3;
331        } else {
332            result.push(chars[i]);
333            i += 1;
334        }
335    }
336
337    result
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_parse_subst_expr() {
346        let expr = parse_subst_expr("s/foo/bar/g").unwrap();
347        assert_eq!(expr.pattern, "foo");
348        assert_eq!(expr.replacement, "bar");
349        assert_eq!(expr.flags.as_deref(), Some("g"));
350
351        let expr = parse_subst_expr("s|foo|bar|").unwrap();
352        assert_eq!(expr.pattern, "foo");
353        assert_eq!(expr.replacement, "bar");
354        assert_eq!(expr.flags, None);
355
356        let expr = parse_subst_expr("s#a/b#c/d#").unwrap();
357        assert_eq!(expr.pattern, "a/b");
358        assert_eq!(expr.replacement, "c/d");
359    }
360
361    #[test]
362    fn test_parse_transl_expr() {
363        let expr = parse_transl_expr("tr/a-z/A-Z/").unwrap();
364        assert_eq!(expr.pattern, "a-z");
365        assert_eq!(expr.replacement, "A-Z");
366
367        let expr = parse_transl_expr("y/abc/xyz/").unwrap();
368        assert_eq!(expr.pattern, "abc");
369        assert_eq!(expr.replacement, "xyz");
370    }
371
372    #[test]
373    fn test_apply_mangle_subst() {
374        let result = apply_mangle("s/foo/bar/", "foo baz foo").unwrap();
375        assert_eq!(result, "bar baz foo");
376
377        let result = apply_mangle("s/foo/bar/g", "foo baz foo").unwrap();
378        assert_eq!(result, "bar baz bar");
379
380        // Test with regex
381        let result = apply_mangle("s/[0-9]+/X/g", "a1b2c3").unwrap();
382        assert_eq!(result, "aXbXcX");
383    }
384
385    #[test]
386    fn test_apply_mangle_transl() {
387        let result = apply_mangle("tr/a-z/A-Z/", "hello").unwrap();
388        assert_eq!(result, "HELLO");
389
390        let result = apply_mangle("y/abc/xyz/", "aabbcc").unwrap();
391        assert_eq!(result, "xxyyzz");
392    }
393
394    #[test]
395    fn test_expand_char_range() {
396        let result = expand_char_range("a-z");
397        assert_eq!(result.len(), 26);
398        assert_eq!(result[0], 'a');
399        assert_eq!(result[25], 'z');
400
401        let result = expand_char_range("a-c");
402        assert_eq!(result, vec!['a', 'b', 'c']);
403
404        let result = expand_char_range("abc");
405        assert_eq!(result, vec!['a', 'b', 'c']);
406    }
407
408    #[test]
409    fn test_split_by_unescaped_delimiter() {
410        let result = split_by_unescaped_delimiter("foo/bar/baz", '/');
411        assert_eq!(result, vec!["foo", "bar", "baz"]);
412
413        let result = split_by_unescaped_delimiter("foo\\/bar/baz", '/');
414        assert_eq!(result, vec!["foo\\/bar", "baz"]);
415    }
416
417    #[test]
418    fn test_real_world_examples() {
419        // Example from Python code: dversionmangle=s/\+ds//
420        let result = apply_mangle(r"s/\+ds//", "1.0+ds").unwrap();
421        assert_eq!(result, "1.0");
422
423        // Example: filenamemangle
424        let result = apply_mangle(
425            r"s/.+\/v?(\d\S+)\.tar\.gz/syncthing-gtk-$1.tar.gz/",
426            "https://github.com/syncthing/syncthing-gtk/archive/v0.9.4.tar.gz",
427        )
428        .unwrap();
429        assert_eq!(result, "syncthing-gtk-0.9.4.tar.gz");
430    }
431
432    #[test]
433    fn test_apply_mangle_with_subst_package() {
434        // Template substitution happens in the mangle expression, so @PACKAGE@
435        // becomes "mypackage" in the pattern, then it matches against the input
436        let result = apply_mangle_with_subst(
437            "s/@PACKAGE@/replaced/",
438            "foo mypackage bar",
439            || "mypackage".to_string(),
440            || String::new(),
441        )
442        .unwrap();
443        assert_eq!(result, "foo replaced bar");
444    }
445
446    #[test]
447    fn test_apply_mangle_with_subst_component() {
448        // Template substitution happens in the mangle expression, so @COMPONENT@
449        // becomes "upstream" in the pattern, then it matches against the input
450        let result = apply_mangle_with_subst(
451            "s/@COMPONENT@/replaced/g",
452            "upstream foo upstream",
453            || unreachable!(),
454            || "upstream".to_string(),
455        )
456        .unwrap();
457        assert_eq!(result, "replaced foo replaced");
458    }
459
460    #[test]
461    fn test_apply_mangle_with_subst_filenamemangle() {
462        // Example: filenamemangle with @PACKAGE@ template
463        let result = apply_mangle_with_subst(
464            r"s/.+\/v?(\d\S+)\.tar\.gz/@PACKAGE@-$1.tar.gz/",
465            "https://github.com/example/repo/archive/v0.9.4.tar.gz",
466            || "myapp".to_string(),
467            || String::new(),
468        )
469        .unwrap();
470        assert_eq!(result, "myapp-0.9.4.tar.gz");
471    }
472
473    #[test]
474    fn test_apply_mangle_with_subst_no_templates() {
475        // Ensure it still works when no templates are present
476        let result = apply_mangle_with_subst(
477            "s/foo/bar/g",
478            "foo baz foo",
479            || unreachable!(),
480            || unreachable!(),
481        )
482        .unwrap();
483        assert_eq!(result, "bar baz bar");
484    }
485
486    #[test]
487    fn test_multibyte_delimiter_does_not_panic() {
488        // Regression: slicing the delimiter off used to assume a one-byte
489        // delimiter, which panicked at a non-ASCII char boundary.
490        assert!(parse_subst_expr("s\u{383}foo\u{383}bar\u{383}").is_ok());
491        assert!(parse_transl_expr("tr\u{7d5}]").is_err());
492        // Should return an error, not panic, on malformed multi-byte input.
493        let _ = parse_subst_expr("s\u{383}");
494    }
495}