Skip to main content

debian_watch/
convert.rs

1//! Conversion between watch file formats
2
3use crate::linebased::{Entry, WatchFile};
4use crate::SyntaxKind::*;
5use deb822_lossless::{Deb822, Paragraph};
6
7/// Error type for conversion failures
8#[derive(Debug)]
9pub enum ConversionError {
10    /// Unknown option that cannot be converted to v5 field name
11    UnknownOption(String),
12    /// Invalid version policy value
13    InvalidVersionPolicy(String),
14}
15
16impl std::fmt::Display for ConversionError {
17    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
18        match self {
19            ConversionError::UnknownOption(opt) => {
20                write!(f, "Unknown option '{}' cannot be converted to v5", opt)
21            }
22            ConversionError::InvalidVersionPolicy(err) => {
23                write!(f, "Invalid version policy: {}", err)
24            }
25        }
26    }
27}
28
29impl std::error::Error for ConversionError {}
30
31/// Convert a watch file from formats 1-4 to format 5
32///
33/// This function preserves comments from the original file by inserting them
34/// into the CST of the generated v5 watch file.
35pub fn convert_to_v5(watch_file: &WatchFile) -> Result<crate::deb822::WatchFile, ConversionError> {
36    // Create a Deb822 with version header as first paragraph
37    let mut paragraphs = vec![vec![("Version", "5")].into_iter().collect()];
38
39    // Extract leading comments (before any entries)
40    let leading_comments = extract_leading_comments(watch_file);
41
42    // Convert each entry to a paragraph
43    for _entry in watch_file.entries() {
44        let para: deb822_lossless::Paragraph =
45            vec![("Source", "placeholder")].into_iter().collect();
46        paragraphs.push(para);
47    }
48
49    let deb822: Deb822 = paragraphs.into_iter().collect();
50
51    // Now populate the entry paragraphs
52    let mut para_iter = deb822.paragraphs();
53    para_iter.next(); // Skip version paragraph
54
55    for (entry, mut para) in watch_file.entries().zip(para_iter) {
56        // Extract and insert comments associated with this entry
57        let entry_comments = extract_entry_comments(&entry);
58        for comment in entry_comments {
59            para.insert_comment_before(&comment);
60        }
61
62        // Convert entry to v5 format
63        convert_entry_to_v5(&entry, &mut para)?;
64    }
65
66    // Insert leading comments before the first entry paragraph if any
67    if !leading_comments.is_empty() {
68        if let Some(mut first_entry_para) = deb822.paragraphs().nth(1) {
69            for comment in leading_comments.iter().rev() {
70                first_entry_para.insert_comment_before(comment);
71            }
72        }
73    }
74
75    // Convert to crate::deb822::WatchFile
76    let output = deb822.to_string();
77    output
78        .parse()
79        .map_err(|_| ConversionError::UnknownOption("Failed to parse generated v5".to_string()))
80}
81
82/// Extract leading comments from the watch file (before any entries)
83fn extract_leading_comments(watch_file: &WatchFile) -> Vec<String> {
84    let mut comments = Vec::new();
85    let syntax = watch_file.syntax();
86
87    for child in syntax.children_with_tokens() {
88        match child {
89            rowan::NodeOrToken::Token(token) => {
90                if token.kind() == COMMENT {
91                    // Extract comment text without the leading '# ' since
92                    // insert_comment_before() will add "# {comment}"
93                    let text = token.text();
94                    let comment = text
95                        .strip_prefix("# ")
96                        .or_else(|| text.strip_prefix('#'))
97                        .unwrap_or(text);
98                    comments.push(comment.to_string());
99                }
100            }
101            rowan::NodeOrToken::Node(node) => {
102                // Stop when we hit an entry
103                if node.kind() == ENTRY {
104                    break;
105                }
106            }
107        }
108    }
109
110    comments
111}
112
113/// Extract comments associated with an entry
114fn extract_entry_comments(entry: &Entry) -> Vec<String> {
115    let mut comments = Vec::new();
116    let syntax = entry.syntax();
117
118    // Get comments that appear before or within this entry
119    for child in syntax.children_with_tokens() {
120        if let rowan::NodeOrToken::Token(token) = child {
121            if token.kind() == COMMENT {
122                // Extract comment text without the leading '# ' since
123                // insert_comment_before() will add "# {comment}"
124                let text = token.text();
125                let comment = text
126                    .strip_prefix("# ")
127                    .or_else(|| text.strip_prefix('#'))
128                    .unwrap_or(text);
129                comments.push(comment.to_string());
130            }
131        }
132    }
133
134    comments
135}
136
137/// Convert a single entry from v1-v4 format to v5 format
138fn convert_entry_to_v5(entry: &Entry, para: &mut Paragraph) -> Result<(), ConversionError> {
139    // Source field (URL)
140    let url = entry.url();
141    if !url.is_empty() {
142        para.set("Source", &url);
143    }
144
145    // Matching-Pattern field
146    if let Some(pattern) = entry.matching_pattern() {
147        para.set("Matching-Pattern", &pattern);
148    }
149
150    // Version policy
151    match entry.version() {
152        Ok(Some(version_policy)) => {
153            para.set("Version-Policy", &version_policy.to_string());
154        }
155        Err(err) => return Err(ConversionError::InvalidVersionPolicy(err)),
156        Ok(None) => {}
157    }
158
159    // Script
160    if let Some(script) = entry.script() {
161        para.set("Script", &script);
162    }
163
164    // Convert all options to fields
165    if let Some(opts_list) = entry.option_list() {
166        for (key, value) in opts_list.iter_key_values() {
167            // Convert option names to Title-Case with hyphens
168            let field_name = option_to_field_name(&key)?;
169            para.set(&field_name, &value);
170        }
171    }
172
173    Ok(())
174}
175
176/// Convert option names from v1-v4 format to v5 field names
177///
178/// Returns an error for unknown options instead of using heuristics.
179///
180/// Examples:
181/// - "filenamemangle" -> "Filename-Mangle"
182/// - "mode" -> "Mode"
183/// - "pgpmode" -> "PGP-Mode"
184fn option_to_field_name(option: &str) -> Result<String, ConversionError> {
185    // Special cases for known options
186    match option {
187        "mode" => Ok("Mode".to_string()),
188        "component" => Ok("Component".to_string()),
189        "ctype" => Ok("Component-Type".to_string()),
190        "compression" => Ok("Compression".to_string()),
191        "repack" => Ok("Repack".to_string()),
192        "repacksuffix" => Ok("Repack-Suffix".to_string()),
193        "bare" => Ok("Bare".to_string()),
194        "user-agent" => Ok("User-Agent".to_string()),
195        "pasv" | "passive" => Ok("Passive".to_string()),
196        "active" | "nopasv" => Ok("Active".to_string()),
197        "unzipopt" => Ok("Unzip-Options".to_string()),
198        "decompress" => Ok("Decompress".to_string()),
199        "dversionmangle" => Ok("Debian-Version-Mangle".to_string()),
200        "uversionmangle" => Ok("Upstream-Version-Mangle".to_string()),
201        "downloadurlmangle" => Ok("Download-URL-Mangle".to_string()),
202        "filenamemangle" => Ok("Filename-Mangle".to_string()),
203        "pgpsigurlmangle" => Ok("PGP-Signature-URL-Mangle".to_string()),
204        "oversionmangle" => Ok("Original-Version-Mangle".to_string()),
205        "pagemangle" => Ok("Page-Mangle".to_string()),
206        "dirversionmangle" => Ok("Directory-Version-Mangle".to_string()),
207        "versionmangle" => Ok("Version-Mangle".to_string()),
208        "hrefdecode" => Ok("Href-Decode".to_string()),
209        "pgpmode" => Ok("PGP-Mode".to_string()),
210        "gitmode" => Ok("Git-Mode".to_string()),
211        "gitexport" => Ok("Git-Export".to_string()),
212        "pretty" => Ok("Pretty".to_string()),
213        "date" => Ok("Date".to_string()),
214        "searchmode" => Ok("Search-Mode".to_string()),
215        // Return error for unknown options
216        _ => Err(ConversionError::UnknownOption(option.to_string())),
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_simple_conversion() {
226        let v4_input = r#"version=4
227https://example.com/files .*/v?(\d+\.\d+)\.tar\.gz
228"#;
229
230        let v4_file: WatchFile = v4_input.parse().unwrap();
231        let v5_file = convert_to_v5(&v4_file).unwrap();
232
233        assert_eq!(v5_file.version(), 5);
234
235        let entries: Vec<_> = v5_file.entries().collect();
236        assert_eq!(entries.len(), 1);
237        assert_eq!(entries[0].url(), "https://example.com/files");
238        assert_eq!(
239            entries[0].matching_pattern(),
240            Some(".*/v?(\\d+\\.\\d+)\\.tar\\.gz".to_string())
241        );
242    }
243
244    #[test]
245    fn test_conversion_with_options() {
246        let v4_input = r#"version=4
247opts=filenamemangle=s/.*\/(.*)/$1/,compression=xz https://example.com/files .*/v?(\d+)\.tar\.gz
248"#;
249
250        let v4_file: WatchFile = v4_input.parse().unwrap();
251        let v5_file = convert_to_v5(&v4_file).unwrap();
252
253        let entries: Vec<_> = v5_file.entries().collect();
254        assert_eq!(entries.len(), 1);
255
256        let entry = &entries[0];
257        assert_eq!(
258            entry.get_option("Filename-Mangle"),
259            Some("s/.*\\/(.*)/$1/".to_string())
260        );
261        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
262    }
263
264    #[test]
265    fn test_conversion_with_comments() {
266        // Use a simpler case for now - comment at the beginning before version
267        let v4_input = r#"# This is a comment about the package
268version=4
269opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/files .*/v?(\d+)\.tar\.gz
270"#;
271
272        let v4_file: WatchFile = v4_input.parse().unwrap();
273        let v5_file = convert_to_v5(&v4_file).unwrap();
274
275        let output = ToString::to_string(&v5_file);
276
277        // Check that comment is preserved and output structure is correct
278        let expected = "Version: 5
279
280# This is a comment about the package
281Source: https://example.com/files
282Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz
283Filename-Mangle: s/.*\\/(.*)/$1/
284";
285        assert_eq!(output, expected);
286    }
287
288    #[test]
289    fn test_conversion_multiple_entries() {
290        let v4_input = r#"version=4
291https://example.com/repo1 .*/v?(\d+)\.tar\.gz
292https://example.com/repo2 .*/release-(\d+)\.tar\.gz
293"#;
294
295        let v4_file: WatchFile = v4_input.parse().unwrap();
296        let v5_file = convert_to_v5(&v4_file).unwrap();
297
298        let entries: Vec<_> = v5_file.entries().collect();
299        assert_eq!(entries.len(), 2);
300        assert_eq!(entries[0].url(), "https://example.com/repo1");
301        assert_eq!(entries[1].url(), "https://example.com/repo2");
302    }
303
304    #[test]
305    fn test_option_to_field_name() {
306        assert_eq!(option_to_field_name("mode").unwrap(), "Mode");
307        assert_eq!(
308            option_to_field_name("filenamemangle").unwrap(),
309            "Filename-Mangle"
310        );
311        assert_eq!(option_to_field_name("pgpmode").unwrap(), "PGP-Mode");
312        assert_eq!(option_to_field_name("user-agent").unwrap(), "User-Agent");
313        assert_eq!(option_to_field_name("compression").unwrap(), "Compression");
314    }
315
316    #[test]
317    fn test_option_to_field_name_unknown() {
318        let result = option_to_field_name("unknownoption");
319        assert!(result.is_err());
320        match result {
321            Err(ConversionError::UnknownOption(opt)) => {
322                assert_eq!(opt, "unknownoption");
323            }
324            _ => panic!("Expected UnknownOption error"),
325        }
326    }
327
328    #[test]
329    fn test_roundtrip_conversion() {
330        let v4_input = r#"version=4
331opts=compression=xz,component=foo https://example.com/files .*/(\d+)\.tar\.gz
332"#;
333
334        let v4_file: WatchFile = v4_input.parse().unwrap();
335        let v5_file = convert_to_v5(&v4_file).unwrap();
336
337        // Verify the v5 file can be parsed back
338        let v5_str = ToString::to_string(&v5_file);
339        let v5_reparsed: crate::deb822::WatchFile = v5_str.parse().unwrap();
340
341        let entries: Vec<_> = v5_reparsed.entries().collect();
342        assert_eq!(entries.len(), 1);
343        assert_eq!(entries[0].component(), Some("foo".to_string()));
344    }
345
346    #[test]
347    fn test_conversion_with_version_policy_and_script() {
348        let v4_input = r#"version=4
349https://example.com/files .*/v?(\d+)\.tar\.gz debian uupdate
350"#;
351
352        let v4_file: WatchFile = v4_input.parse().unwrap();
353        let v5_file = convert_to_v5(&v4_file).unwrap();
354
355        let entries: Vec<_> = v5_file.entries().collect();
356        assert_eq!(entries.len(), 1);
357
358        let entry = &entries[0];
359        assert_eq!(entry.url(), "https://example.com/files");
360        assert_eq!(
361            entry.version_policy().unwrap(),
362            Some(crate::VersionPolicy::Debian)
363        );
364        assert_eq!(entry.script(), Some("uupdate".to_string()));
365
366        // Verify the output structure is exactly as expected
367        let output = v5_file.to_string();
368        let expected = "Version: 5
369
370Source: https://example.com/files
371Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz
372Version-Policy: debian
373Script: uupdate
374";
375        assert_eq!(output, expected);
376    }
377
378    #[test]
379    fn test_conversion_with_mangle_options() {
380        let v4_input = r#"version=4
381opts=uversionmangle=s/-/~/g,dversionmangle=s/\+dfsg// https://example.com/files .*/(\d+)\.tar\.gz
382"#;
383
384        let v4_file: WatchFile = v4_input.parse().unwrap();
385        let v5_file = convert_to_v5(&v4_file).unwrap();
386
387        let entries: Vec<_> = v5_file.entries().collect();
388        assert_eq!(entries.len(), 1);
389
390        let entry = &entries[0];
391        assert_eq!(
392            entry.get_option("Upstream-Version-Mangle"),
393            Some("s/-/~/g".to_string())
394        );
395        assert_eq!(
396            entry.get_option("Debian-Version-Mangle"),
397            Some("s/\\+dfsg//".to_string())
398        );
399
400        // Verify exact output structure
401        let output = v5_file.to_string();
402        let expected = "Version: 5
403
404Source: https://example.com/files
405Matching-Pattern: .*/(\\d+)\\.tar\\.gz
406Upstream-Version-Mangle: s/-/~/g
407Debian-Version-Mangle: s/\\+dfsg//
408";
409        assert_eq!(output, expected);
410    }
411}