debian_watch/
convert.rs

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