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/// Uscan's v4→v5 converter (Devscripts::Uscan::Version4) applies `ucfirst`
181/// to the option name and capitalizes letters after hyphens. Since most v4
182/// option names have no hyphens, the result is simply the first letter
183/// capitalized. The exceptions are `user-agent` → `User-Agent`, and the
184/// renamed options `date` → `Git-Date` and `pretty` → `Git-Pretty`.
185///
186/// Examples:
187/// - "filenamemangle" -> "Filenamemangle"
188/// - "mode" -> "Mode"
189/// - "pgpmode" -> "Pgpmode"
190/// - "user-agent" -> "User-Agent"
191/// - "date" -> "Git-Date"
192/// - "pretty" -> "Git-Pretty"
193fn option_to_field_name(option: &str) -> Result<String, ConversionError> {
194    // Options renamed in v5 (from uscan's %RENAMED hash)
195    match option {
196        "date" => return Ok("Git-Date".to_string()),
197        "pretty" => return Ok("Git-Pretty".to_string()),
198        _ => {}
199    }
200
201    // Known options: apply ucfirst + capitalize after hyphens (matching uscan)
202    match option {
203        "mode" => Ok("Mode".to_string()),
204        "component" => Ok("Component".to_string()),
205        "ctype" => Ok("Ctype".to_string()),
206        "compression" => Ok("Compression".to_string()),
207        "repack" => Ok("Repack".to_string()),
208        "repacksuffix" => Ok("Repacksuffix".to_string()),
209        "bare" => Ok("Bare".to_string()),
210        "user-agent" => Ok("User-Agent".to_string()),
211        "pasv" | "passive" => Ok("Passive".to_string()),
212        "active" | "nopasv" => Ok("Active".to_string()),
213        "unzipopt" => Ok("Unzipopt".to_string()),
214        "decompress" => Ok("Decompress".to_string()),
215        "dversionmangle" => Ok("Dversionmangle".to_string()),
216        "uversionmangle" => Ok("Uversionmangle".to_string()),
217        "downloadurlmangle" => Ok("Downloadurlmangle".to_string()),
218        "filenamemangle" => Ok("Filenamemangle".to_string()),
219        "pgpsigurlmangle" => Ok("Pgpsigurlmangle".to_string()),
220        "oversionmangle" => Ok("Oversionmangle".to_string()),
221        "pagemangle" => Ok("Pagemangle".to_string()),
222        "dirversionmangle" => Ok("Dirversionmangle".to_string()),
223        "versionmangle" => Ok("Versionmangle".to_string()),
224        "hrefdecode" => Ok("Hrefdecode".to_string()),
225        "pgpmode" => Ok("Pgpmode".to_string()),
226        "gitmode" => Ok("Gitmode".to_string()),
227        "gitexport" => Ok("Gitexport".to_string()),
228        "searchmode" => Ok("Searchmode".to_string()),
229        // Return error for unknown options
230        _ => Err(ConversionError::UnknownOption(option.to_string())),
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_simple_conversion() {
240        let v4_input = r#"version=4
241https://example.com/files .*/v?(\d+\.\d+)\.tar\.gz
242"#;
243
244        let v4_file: WatchFile = v4_input.parse().unwrap();
245        let v5_file = convert_to_v5(&v4_file).unwrap();
246
247        assert_eq!(v5_file.version(), 5);
248
249        let entries: Vec<_> = v5_file.entries().collect();
250        assert_eq!(entries.len(), 1);
251        assert_eq!(entries[0].url(), "https://example.com/files");
252        assert_eq!(
253            entries[0].matching_pattern().unwrap(),
254            Some(".*/v?(\\d+\\.\\d+)\\.tar\\.gz".to_string())
255        );
256    }
257
258    #[test]
259    fn test_conversion_with_options() {
260        let v4_input = r#"version=4
261opts=filenamemangle=s/.*\/(.*)/$1/,compression=xz https://example.com/files .*/v?(\d+)\.tar\.gz
262"#;
263
264        let v4_file: WatchFile = v4_input.parse().unwrap();
265        let v5_file = convert_to_v5(&v4_file).unwrap();
266
267        let entries: Vec<_> = v5_file.entries().collect();
268        assert_eq!(entries.len(), 1);
269
270        let entry = &entries[0];
271        assert_eq!(
272            entry.get_option("Filenamemangle"),
273            Some("s/.*\\/(.*)/$1/".to_string())
274        );
275        assert_eq!(entry.get_option("Compression"), Some("xz".to_string()));
276    }
277
278    #[test]
279    fn test_conversion_with_comments() {
280        // Use a simpler case for now - comment at the beginning before version
281        let v4_input = r#"# This is a comment about the package
282version=4
283opts=filenamemangle=s/.*\/(.*)/$1/ https://example.com/files .*/v?(\d+)\.tar\.gz
284"#;
285
286        let v4_file: WatchFile = v4_input.parse().unwrap();
287        let v5_file = convert_to_v5(&v4_file).unwrap();
288
289        let output = ToString::to_string(&v5_file);
290
291        // Check that comment is preserved and output structure is correct
292        let expected = "Version: 5
293
294# This is a comment about the package
295Source: https://example.com/files
296Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz
297Filenamemangle: s/.*\\/(.*)/$1/
298";
299        assert_eq!(output, expected);
300    }
301
302    #[test]
303    fn test_conversion_multiple_entries() {
304        let v4_input = r#"version=4
305https://example.com/repo1 .*/v?(\d+)\.tar\.gz
306https://example.com/repo2 .*/release-(\d+)\.tar\.gz
307"#;
308
309        let v4_file: WatchFile = v4_input.parse().unwrap();
310        let v5_file = convert_to_v5(&v4_file).unwrap();
311
312        let entries: Vec<_> = v5_file.entries().collect();
313        assert_eq!(entries.len(), 2);
314        assert_eq!(entries[0].url(), "https://example.com/repo1");
315        assert_eq!(entries[1].url(), "https://example.com/repo2");
316    }
317
318    #[test]
319    fn test_option_to_field_name() {
320        assert_eq!(option_to_field_name("mode").unwrap(), "Mode");
321        assert_eq!(
322            option_to_field_name("filenamemangle").unwrap(),
323            "Filenamemangle"
324        );
325        assert_eq!(option_to_field_name("pgpmode").unwrap(), "Pgpmode");
326        assert_eq!(option_to_field_name("user-agent").unwrap(), "User-Agent");
327        assert_eq!(option_to_field_name("compression").unwrap(), "Compression");
328        assert_eq!(option_to_field_name("date").unwrap(), "Git-Date");
329        assert_eq!(option_to_field_name("pretty").unwrap(), "Git-Pretty");
330    }
331
332    #[test]
333    fn test_option_to_field_name_unknown() {
334        let result = option_to_field_name("unknownoption");
335        assert!(result.is_err());
336        match result {
337            Err(ConversionError::UnknownOption(opt)) => {
338                assert_eq!(opt, "unknownoption");
339            }
340            _ => panic!("Expected UnknownOption error"),
341        }
342    }
343
344    #[test]
345    fn test_roundtrip_conversion() {
346        let v4_input = r#"version=4
347opts=compression=xz,component=foo https://example.com/files .*/(\d+)\.tar\.gz
348"#;
349
350        let v4_file: WatchFile = v4_input.parse().unwrap();
351        let v5_file = convert_to_v5(&v4_file).unwrap();
352
353        // Verify the v5 file can be parsed back
354        let v5_str = ToString::to_string(&v5_file);
355        let v5_reparsed: crate::deb822::WatchFile = v5_str.parse().unwrap();
356
357        let entries: Vec<_> = v5_reparsed.entries().collect();
358        assert_eq!(entries.len(), 1);
359        assert_eq!(entries[0].component(), Some("foo".to_string()));
360    }
361
362    #[test]
363    fn test_conversion_with_version_policy_and_script() {
364        let v4_input = r#"version=4
365https://example.com/files .*/v?(\d+)\.tar\.gz debian uupdate
366"#;
367
368        let v4_file: WatchFile = v4_input.parse().unwrap();
369        let v5_file = convert_to_v5(&v4_file).unwrap();
370
371        let entries: Vec<_> = v5_file.entries().collect();
372        assert_eq!(entries.len(), 1);
373
374        let entry = &entries[0];
375        assert_eq!(entry.url(), "https://example.com/files");
376        assert_eq!(
377            entry.version_policy().unwrap(),
378            Some(crate::VersionPolicy::Debian)
379        );
380        assert_eq!(entry.script(), Some("uupdate".to_string()));
381
382        // Verify the output structure is exactly as expected
383        let output = v5_file.to_string();
384        let expected = "Version: 5
385
386Source: https://example.com/files
387Matching-Pattern: .*/v?(\\d+)\\.tar\\.gz
388Version-Policy: debian
389Script: uupdate
390";
391        assert_eq!(output, expected);
392    }
393
394    #[test]
395    fn test_conversion_with_mangle_options() {
396        let v4_input = r#"version=4
397opts=uversionmangle=s/-/~/g,dversionmangle=s/\+dfsg// https://example.com/files .*/(\d+)\.tar\.gz
398"#;
399
400        let v4_file: WatchFile = v4_input.parse().unwrap();
401        let v5_file = convert_to_v5(&v4_file).unwrap();
402
403        let entries: Vec<_> = v5_file.entries().collect();
404        assert_eq!(entries.len(), 1);
405
406        let entry = &entries[0];
407        assert_eq!(
408            entry.get_option("Uversionmangle"),
409            Some("s/-/~/g".to_string())
410        );
411        assert_eq!(
412            entry.get_option("Dversionmangle"),
413            Some("s/\\+dfsg//".to_string())
414        );
415
416        // Verify exact output structure
417        let output = v5_file.to_string();
418        let expected = "Version: 5
419
420Source: https://example.com/files
421Matching-Pattern: .*/(\\d+)\\.tar\\.gz
422Uversionmangle: s/-/~/g
423Dversionmangle: s/\\+dfsg//
424";
425        assert_eq!(output, expected);
426    }
427
428    #[test]
429    fn test_conversion_with_comment_before_entry() {
430        // Regression test for https://bugs.debian.org/1128319:
431        // A comment line before an entry with a continuation line was not converted correctly
432        // - the entry was silently dropped and only "Version: 5" was produced.
433        let v4_input = concat!(
434            "version=4\n",
435            "# try also https://pypi.debian.net/tomoscan/watch\n",
436            "opts=uversionmangle=s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/ \\\n",
437            "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))\n"
438        );
439
440        let v4_file: WatchFile = v4_input.parse().unwrap();
441        let v5_file = convert_to_v5(&v4_file).unwrap();
442
443        assert_eq!(v5_file.version(), 5);
444
445        let entries: Vec<_> = v5_file.entries().collect();
446        assert_eq!(entries.len(), 1);
447        assert_eq!(
448            entries[0].url(),
449            "https://pypi.debian.net/tomoscan/tomoscan-(.+)\\.(?:zip|tgz|tbz|txz|(?:tar\\.(?:gz|bz2|xz)))"
450        );
451        assert_eq!(
452            entries[0].get_option("Uversionmangle"),
453            Some("s/(rc|a|b|c)/~$1/;s/\\.dev/~dev/".to_string())
454        );
455    }
456}