Skip to main content

multiarch_hints/
lib.rs

1use breezyshim::dirty_tracker::DirtyTreeTracker;
2use breezyshim::error::Error;
3use breezyshim::tree::WorkingTree;
4use breezyshim::workingtree::GenericWorkingTree;
5use debian_analyzer::{
6    add_changelog_entry, apply_or_revert, certainty_sufficient, get_committer, ApplyError,
7    Certainty, ChangelogError,
8};
9use debian_control::fields::MultiArch;
10use debian_workspace::action::{Action, ActionPlan, Deb822Action, ParagraphSelector};
11use debian_workspace::appliers::apply_actions;
12use debian_workspace::workspace::Workspace;
13use debversion::Version;
14use lazy_regex::regex_captures;
15use lazy_static::lazy_static;
16use reqwest::blocking::Client;
17use serde::Deserialize;
18use serde_yaml::from_value;
19use std::collections::HashMap;
20use std::fs;
21use std::io::Read;
22use std::io::Write;
23use std::path::Path;
24use std::time::SystemTime;
25
26pub const MULTIARCH_HINTS_URL: &str = "https://dedup.debian.net/static/multiarch-hints.yaml.xz";
27const USER_AGENT: &str = concat!("apply-multiarch-hints/", env!("CARGO_PKG_VERSION"));
28
29const DEFAULT_VALUE_MULTIARCH_HINT: i32 = 100;
30
31#[derive(Debug, Clone, Copy, std::hash::Hash, PartialEq, Eq)]
32pub enum HintKind {
33    MaForeign,
34    FileConflict,
35    MaForeignLibrary,
36    DepAny,
37    MaSame,
38    ArchAll,
39    MaWorkaround,
40}
41
42impl std::str::FromStr for HintKind {
43    type Err = String;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        match s {
47            "ma-foreign" => Ok(HintKind::MaForeign),
48            "file-conflict" => Ok(HintKind::FileConflict),
49            "ma-foreign-library" => Ok(HintKind::MaForeignLibrary),
50            "dep-any" => Ok(HintKind::DepAny),
51            "ma-same" => Ok(HintKind::MaSame),
52            "arch-all" => Ok(HintKind::ArchAll),
53            "ma-workaround" => Ok(HintKind::MaWorkaround),
54            _ => Err(format!("Invalid hint kind: {:?}", s)),
55        }
56    }
57}
58
59fn hint_value(hint: HintKind) -> i32 {
60    match hint {
61        HintKind::MaForeign => 20,
62        HintKind::FileConflict => 50,
63        HintKind::MaForeignLibrary => 20,
64        HintKind::DepAny => 20,
65        HintKind::MaSame => 20,
66        HintKind::ArchAll => 20,
67        HintKind::MaWorkaround => 20,
68    }
69}
70
71pub fn calculate_value(hints: &[HintKind]) -> i32 {
72    hints.iter().map(|hint| hint_value(*hint)).sum::<i32>() + DEFAULT_VALUE_MULTIARCH_HINT
73}
74
75fn format_system_time(system_time: SystemTime) -> String {
76    let datetime: chrono::DateTime<chrono::Utc> = system_time.into();
77    datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
78}
79
80#[derive(Debug, Deserialize, PartialEq, Eq, Ord, PartialOrd, Clone, Copy)]
81pub enum Severity {
82    #[serde(rename = "low")]
83    Low,
84    #[serde(rename = "normal")]
85    Normal,
86    #[serde(rename = "high")]
87    High,
88}
89
90fn deserialize_severity<'de, D>(deserializer: D) -> Result<Severity, D::Error>
91where
92    D: serde::Deserializer<'de>,
93{
94    let s = String::deserialize(deserializer)?;
95    match s.as_str() {
96        "low" => Ok(Severity::Low),
97        "normal" => Ok(Severity::Normal),
98        "high" => Ok(Severity::High),
99        _ => Err(serde::de::Error::custom(format!(
100            "Invalid severity: {:?}",
101            s
102        ))),
103    }
104}
105
106#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
107pub struct Hint {
108    pub binary: String,
109    pub description: String,
110    pub source: String,
111    pub link: String,
112    #[serde(deserialize_with = "deserialize_severity")]
113    pub severity: Severity,
114    pub version: Option<Version>,
115}
116
117impl Hint {
118    pub fn kind(&self) -> &str {
119        self.link.split('#').next_back().unwrap()
120    }
121}
122
123pub fn multiarch_hints_by_source(hints: &[Hint]) -> HashMap<&str, Vec<&Hint>> {
124    let mut map = HashMap::new();
125    for hint in hints {
126        map.entry(hint.source.as_str())
127            .or_insert_with(Vec::new)
128            .push(hint);
129    }
130    map
131}
132
133pub fn multiarch_hints_by_binary(hints: &[Hint]) -> HashMap<&str, Vec<&Hint>> {
134    let mut map = HashMap::new();
135    for hint in hints {
136        map.entry(hint.binary.as_str())
137            .or_insert_with(Vec::new)
138            .push(hint);
139    }
140    map
141}
142
143pub fn parse_multiarch_hints(f: &[u8]) -> Result<Vec<Hint>, serde_yaml::Error> {
144    let data = serde_yaml::from_slice::<serde_yaml::Value>(f)?;
145    if let Some(format) = data["format"].as_str() {
146        if format != "multiarch-hints-1.0" {
147            return Err(serde::de::Error::custom(format!(
148                "Invalid format: {:?}",
149                format
150            )));
151        }
152    } else {
153        return Err(serde::de::Error::custom("Missing format"));
154    }
155    from_value(data["hints"].clone())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_some_entries() {
164        let hints = parse_multiarch_hints(
165            r#"format: multiarch-hints-1.0
166hints:
167- binary: coinor-libcoinmp-dev
168  description: coinor-libcoinmp-dev conflicts on ...
169  link: https://wiki.debian.org/MultiArch/Hints#file-conflict
170  severity: high
171  source: coinmp
172  version: 1.8.3-2+b11
173"#
174            .as_bytes(),
175        )
176        .unwrap();
177        assert_eq!(
178            hints,
179            vec![Hint {
180                binary: "coinor-libcoinmp-dev".to_string(),
181                description: "coinor-libcoinmp-dev conflicts on ...".to_string(),
182                link: "https://wiki.debian.org/MultiArch/Hints#file-conflict".to_string(),
183                severity: Severity::High,
184                version: Some("1.8.3-2+b11".parse().unwrap()),
185                source: "coinmp".to_string(),
186            }]
187        );
188    }
189
190    #[test]
191    fn test_invalid_header() {
192        let hints = parse_multiarch_hints(
193            r#"\
194format: blah
195"#
196            .as_bytes(),
197        );
198        assert!(hints.is_err());
199    }
200
201    fn make_hint(binary: &str, kind: &str, description: &str) -> Hint {
202        Hint {
203            binary: binary.to_string(),
204            description: description.to_string(),
205            link: format!("https://wiki.debian.org/MultiArch/Hints#{}", kind),
206            severity: Severity::Normal,
207            version: None,
208            source: "src".to_string(),
209        }
210    }
211
212    fn setup_ws(
213        control: &str,
214    ) -> (
215        tempfile::TempDir,
216        debian_workspace::fs_workspace::FsWorkspace,
217    ) {
218        let tmp = tempfile::TempDir::new().unwrap();
219        let debian = tmp.path().join("debian");
220        std::fs::create_dir_all(&debian).unwrap();
221        std::fs::write(debian.join("control"), control).unwrap();
222        let ws = debian_workspace::fs_workspace::FsWorkspace::new(
223            tmp.path(),
224            Some("src".into()),
225            Some("1.0".parse().unwrap()),
226        );
227        (tmp, ws)
228    }
229
230    fn detect_one(
231        ws: &debian_workspace::fs_workspace::FsWorkspace,
232        hints_list: &[Hint],
233    ) -> Vec<(Change, ActionPlan)> {
234        let by_binary = multiarch_hints_by_binary(hints_list);
235        detect_multiarch_hints(ws, &by_binary, Certainty::Possible).unwrap()
236    }
237
238    fn apply_and_read(
239        ws: &debian_workspace::fs_workspace::FsWorkspace,
240        plans: &[ActionPlan],
241    ) -> String {
242        let actions: Vec<_> = plans
243            .iter()
244            .flat_map(|p| p.actions.iter().cloned())
245            .collect();
246        debian_workspace::appliers::apply_actions(ws.base_path(), &actions).unwrap();
247        std::fs::read_to_string(ws.base_path().join("debian/control")).unwrap()
248    }
249
250    #[test]
251    fn detect_ma_foreign() {
252        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\n");
253        let hints = vec![make_hint("foo", "ma-foreign", "foo could be MA: foreign")];
254        let results = detect_one(&ws, &hints);
255        assert_eq!(results.len(), 1);
256        assert_eq!(results[0].0.binary, "foo");
257        assert_eq!(results[0].0.description, "Add Multi-Arch: foreign.");
258
259        let control = apply_and_read(
260            &ws,
261            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
262        );
263        assert!(control.contains("Multi-Arch: foreign"), "got: {}", control);
264    }
265
266    #[test]
267    fn detect_ma_foreign_noop_when_already_set() {
268        let (_tmp, ws) =
269            setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\nMulti-Arch: foreign\n");
270        let hints = vec![make_hint("foo", "ma-foreign", "foo could be MA: foreign")];
271        let results = detect_one(&ws, &hints);
272        assert_eq!(results.len(), 0);
273    }
274
275    #[test]
276    fn detect_ma_foreign_library() {
277        let (_tmp, ws) =
278            setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\nMulti-Arch: foreign\n");
279        let hints = vec![make_hint(
280            "foo",
281            "ma-foreign-library",
282            "foo should not be MA: foreign",
283        )];
284        let results = detect_one(&ws, &hints);
285        assert_eq!(results.len(), 1);
286        assert_eq!(results[0].0.description, "Drop Multi-Arch: foreign.");
287
288        let control = apply_and_read(
289            &ws,
290            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
291        );
292        assert!(!control.contains("Multi-Arch"), "got: {}", control);
293    }
294
295    #[test]
296    fn detect_file_conflict() {
297        let (_tmp, ws) =
298            setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\nMulti-Arch: same\n");
299        let hints = vec![make_hint("foo", "file-conflict", "foo conflicts")];
300        let results = detect_one(&ws, &hints);
301        assert_eq!(results.len(), 1);
302        assert_eq!(results[0].0.description, "Drop Multi-Arch: same.");
303
304        let control = apply_and_read(
305            &ws,
306            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
307        );
308        assert!(!control.contains("Multi-Arch"), "got: {}", control);
309    }
310
311    #[test]
312    fn detect_ma_same() {
313        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\n");
314        let hints = vec![make_hint("foo", "ma-same", "foo should be MA: same")];
315        let results = detect_one(&ws, &hints);
316        assert_eq!(results.len(), 1);
317        assert_eq!(results[0].0.description, "Add Multi-Arch: same.");
318
319        let control = apply_and_read(
320            &ws,
321            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
322        );
323        assert!(control.contains("Multi-Arch: same"), "got: {}", control);
324    }
325
326    #[test]
327    fn detect_arch_all() {
328        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\n");
329        let hints = vec![make_hint("foo", "arch-all", "foo should be arch:all")];
330        let results = detect_one(&ws, &hints);
331        assert_eq!(results.len(), 1);
332        assert_eq!(results[0].0.description, "Make package Architecture: all.");
333        assert_eq!(results[0].0.certainty, Certainty::Possible);
334
335        let control = apply_and_read(
336            &ws,
337            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
338        );
339        assert!(control.contains("Architecture: all"), "got: {}", control);
340    }
341
342    #[test]
343    fn detect_arch_all_noop_when_already_all() {
344        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: all\n");
345        let hints = vec![make_hint("foo", "arch-all", "foo should be arch:all")];
346        let results = detect_one(&ws, &hints);
347        assert_eq!(results.len(), 0);
348    }
349
350    #[test]
351    fn detect_dep_any() {
352        let (_tmp, ws) =
353            setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\nDepends: libbar (>= 1.0)\n");
354        let hints = vec![make_hint(
355            "foo",
356            "dep-any",
357            "foo could have its dependency on libbar annotated with :any",
358        )];
359        let results = detect_one(&ws, &hints);
360        assert_eq!(results.len(), 1);
361        assert_eq!(
362            results[0].0.description,
363            "Add :any qualifier for libbar dependency."
364        );
365
366        let control = apply_and_read(
367            &ws,
368            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
369        );
370        assert!(control.contains("libbar:any"), "got: {}", control);
371    }
372
373    #[test]
374    fn detect_dep_any_noop_when_already_annotated() {
375        let (_tmp, ws) = setup_ws(
376            "Source: src\n\nPackage: foo\nArchitecture: any\nDepends: libbar:any (>= 1.0)\n",
377        );
378        let hints = vec![make_hint(
379            "foo",
380            "dep-any",
381            "foo could have its dependency on libbar annotated with :any",
382        )];
383        let results = detect_one(&ws, &hints);
384        assert_eq!(results.len(), 0);
385    }
386
387    #[test]
388    fn detect_ma_workaround() {
389        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: all\n");
390        let hints = vec![make_hint(
391            "foo",
392            "ma-workaround",
393            "foo should be Architecture: any + Multi-Arch: same",
394        )];
395        let results = detect_one(&ws, &hints);
396        assert_eq!(results.len(), 1);
397        assert_eq!(
398            results[0].0.description,
399            "Add Multi-Arch: same and set Architecture: any."
400        );
401
402        let control = apply_and_read(
403            &ws,
404            &results.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
405        );
406        assert!(control.contains("Multi-Arch: same"), "got: {}", control);
407        assert!(control.contains("Architecture: any"), "got: {}", control);
408    }
409
410    #[test]
411    fn detect_skips_unknown_binary() {
412        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\n");
413        let hints = vec![make_hint("bar", "ma-foreign", "bar could be MA: foreign")];
414        let results = detect_one(&ws, &hints);
415        assert_eq!(results.len(), 0);
416    }
417
418    #[test]
419    fn detect_skips_below_minimum_certainty() {
420        let (_tmp, ws) = setup_ws("Source: src\n\nPackage: foo\nArchitecture: any\n");
421        let hints = vec![make_hint("foo", "arch-all", "foo should be arch:all")];
422        let by_binary = multiarch_hints_by_binary(&hints);
423        // arch-all is Possible; requesting Certain should skip it.
424        let results = detect_multiarch_hints(&ws, &by_binary, Certainty::Certain).unwrap();
425        assert_eq!(results.len(), 0);
426    }
427
428    #[test]
429    fn detect_no_control_file() {
430        let tmp = tempfile::TempDir::new().unwrap();
431        let ws = debian_workspace::fs_workspace::FsWorkspace::new(
432            tmp.path(),
433            Some("src".into()),
434            Some("1.0".parse().unwrap()),
435        );
436        let hints = vec![make_hint("foo", "ma-foreign", "foo could be MA: foreign")];
437        let by_binary = multiarch_hints_by_binary(&hints);
438        let results = detect_multiarch_hints(&ws, &by_binary, Certainty::Possible).unwrap();
439        assert_eq!(results.len(), 0);
440    }
441}
442
443pub fn cache_download_multiarch_hints(
444    url: Option<&str>,
445) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
446    let cache_home = if let Ok(xdg_cache_home) = std::env::var("XDG_CACHE_HOME") {
447        Path::new(&xdg_cache_home).to_path_buf()
448    } else if let Ok(home) = std::env::var("HOME") {
449        Path::new(&home).join(".cache")
450    } else {
451        log::warn!("Unable to find cache directory, not caching");
452        return download_multiarch_hints(url, None)?
453            .ok_or_else(|| "Expected download data but got None".into());
454    };
455    let cache_dir = cache_home.join("lintian-brush");
456    fs::create_dir_all(&cache_dir)?;
457    let local_hints_path = cache_dir.join("multiarch-hints.yml");
458    let last_modified = match fs::metadata(&local_hints_path) {
459        Ok(metadata) => Some(metadata.modified()?),
460        Err(_) => None,
461    };
462
463    match download_multiarch_hints(url, last_modified) {
464        Ok(None) => {
465            let mut buffer = Vec::new();
466            std::fs::File::open(&local_hints_path)?.read_to_end(&mut buffer)?;
467            Ok(buffer)
468        }
469        Ok(Some(buffer)) => {
470            fs::File::create(&local_hints_path)?.write_all(&buffer)?;
471            Ok(buffer)
472        }
473        Err(e) => Err(e),
474    }
475}
476
477pub fn download_multiarch_hints(
478    url: Option<&str>,
479    since: Option<SystemTime>,
480) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
481    let url = url.unwrap_or(MULTIARCH_HINTS_URL);
482    let client = Client::builder().user_agent(USER_AGENT).build()?;
483    let mut request = client.get(url).header("Accept-Encoding", "identity");
484    if let Some(since) = since {
485        request = request.header("If-Modified-Since", format_system_time(since));
486    }
487    let response = request.send()?;
488    if response.status() == reqwest::StatusCode::NOT_MODIFIED {
489        Ok(None)
490    } else if response.status() != reqwest::StatusCode::OK {
491        Err(format!(
492            "Unable to download multiarch hints: {:?}",
493            response.status()
494        )
495        .into())
496    } else if url.ends_with(".xz") {
497        // It would be nicer if there was a content-type, but there isn't :-(
498        let mut reader = xz2::read::XzDecoder::new(response);
499        let mut buffer = Vec::new();
500        reader.read_to_end(&mut buffer)?;
501        Ok(Some(buffer))
502    } else {
503        Ok(Some(response.bytes()?.to_vec()))
504    }
505}
506
507#[derive(Debug, Clone)]
508pub struct Change {
509    pub binary: String,
510    pub hint: Hint,
511    pub description: String,
512    pub certainty: Certainty,
513}
514
515pub struct OverallResult {
516    pub changes: Vec<Change>,
517}
518
519impl OverallResult {
520    pub fn value(&self) -> i32 {
521        let kinds = self
522            .changes
523            .iter()
524            .map(|x| x.hint.kind().parse().unwrap())
525            .collect::<Vec<_>>();
526        calculate_value(&kinds)
527    }
528}
529
530fn control_file() -> std::path::PathBuf {
531    std::path::PathBuf::from("debian/control")
532}
533
534fn detect_hint_ma_foreign(
535    binary: &debian_control::lossless::control::Binary,
536    _hint: &Hint,
537) -> Option<(String, Vec<Action>)> {
538    if binary.multi_arch() == Some(MultiArch::Foreign) {
539        return None;
540    }
541    let pkg = binary.name()?;
542    Some((
543        "Add Multi-Arch: foreign.".to_string(),
544        vec![Action::Deb822(Deb822Action::SetField {
545            file: control_file(),
546            paragraph: ParagraphSelector::Binary { package: pkg },
547            field: "Multi-Arch".to_string(),
548            value: "foreign".to_string(),
549        })],
550    ))
551}
552
553fn detect_hint_ma_foreign_lib(
554    binary: &debian_control::lossless::control::Binary,
555    _hint: &Hint,
556) -> Option<(String, Vec<Action>)> {
557    if binary.multi_arch() != Some(MultiArch::Foreign) {
558        return None;
559    }
560    let pkg = binary.name()?;
561    Some((
562        "Drop Multi-Arch: foreign.".to_string(),
563        vec![Action::Deb822(Deb822Action::RemoveField {
564            file: control_file(),
565            paragraph: ParagraphSelector::Binary { package: pkg },
566            field: "Multi-Arch".to_string(),
567        })],
568    ))
569}
570
571fn detect_hint_file_conflict(
572    binary: &debian_control::lossless::control::Binary,
573    _hint: &Hint,
574) -> Option<(String, Vec<Action>)> {
575    if binary.multi_arch() != Some(MultiArch::Same) {
576        return None;
577    }
578    let pkg = binary.name()?;
579    Some((
580        "Drop Multi-Arch: same.".to_string(),
581        vec![Action::Deb822(Deb822Action::RemoveField {
582            file: control_file(),
583            paragraph: ParagraphSelector::Binary { package: pkg },
584            field: "Multi-Arch".to_string(),
585        })],
586    ))
587}
588
589fn detect_hint_ma_same(
590    binary: &debian_control::lossless::control::Binary,
591    _hint: &Hint,
592) -> Option<(String, Vec<Action>)> {
593    if binary.multi_arch() == Some(MultiArch::Same) {
594        return None;
595    }
596    let pkg = binary.name()?;
597    Some((
598        "Add Multi-Arch: same.".to_string(),
599        vec![Action::Deb822(Deb822Action::SetField {
600            file: control_file(),
601            paragraph: ParagraphSelector::Binary { package: pkg },
602            field: "Multi-Arch".to_string(),
603            value: "same".to_string(),
604        })],
605    ))
606}
607
608fn detect_hint_arch_all(
609    binary: &debian_control::lossless::control::Binary,
610    _hint: &Hint,
611) -> Option<(String, Vec<Action>)> {
612    if binary.architecture().as_deref() == Some("all") {
613        return None;
614    }
615    let pkg = binary.name()?;
616    Some((
617        "Make package Architecture: all.".to_string(),
618        vec![Action::Deb822(Deb822Action::SetField {
619            file: control_file(),
620            paragraph: ParagraphSelector::Binary { package: pkg },
621            field: "Architecture".to_string(),
622            value: "all".to_string(),
623        })],
624    ))
625}
626
627fn detect_hint_dep_any(
628    binary: &debian_control::lossless::control::Binary,
629    hint: &Hint,
630) -> Option<(String, Vec<Action>)> {
631    let Some((_whole, binary_package, dep)) = regex_captures!(
632        r"(.*) could have its dependency on (.*) annotated with :any",
633        hint.description.as_str()
634    ) else {
635        log::warn!("Unable to parse dep-any hint: {:?}", hint.description);
636        return None;
637    };
638    assert_eq!(binary_package, binary.name().unwrap());
639
640    let depends = binary.depends()?;
641    let entry_text = depends.entries().find_map(|entry| {
642        entry.relations().find_map(|r| {
643            if r.try_name().as_deref() == Some(dep) && r.archqual().as_deref() != Some("any") {
644                // Re-serialise the entry with :any added. We rebuild it from
645                // the entry's display form to preserve version constraints.
646                Some(entry.to_string().replacen(dep, &format!("{}:any", dep), 1))
647            } else {
648                None
649            }
650        })
651    })?;
652
653    let pkg = binary.name()?;
654    Some((
655        format!("Add :any qualifier for {} dependency.", dep),
656        vec![Action::Deb822(Deb822Action::ReplaceRelation {
657            file: control_file(),
658            paragraph: ParagraphSelector::Binary { package: pkg },
659            field: "Depends".to_string(),
660            from_package: dep.to_string(),
661            to_entry: entry_text,
662        })],
663    ))
664}
665
666fn detect_hint_ma_workaround(
667    binary: &debian_control::lossless::control::Binary,
668    hint: &Hint,
669) -> Option<(String, Vec<Action>)> {
670    let Some((_whole, binary_package)) = regex_captures!(
671        r"(.*) should be Architecture: any \+ Multi-Arch: same",
672        hint.description.as_str()
673    ) else {
674        log::warn!("Unable to parse ma-workaround hint: {:?}", hint.description);
675        return None;
676    };
677    assert_eq!(binary_package, binary.name().unwrap());
678    let pkg = binary.name()?;
679    Some((
680        "Add Multi-Arch: same and set Architecture: any.".to_string(),
681        vec![
682            Action::Deb822(Deb822Action::SetField {
683                file: control_file(),
684                paragraph: ParagraphSelector::Binary {
685                    package: pkg.clone(),
686                },
687                field: "Multi-Arch".to_string(),
688                value: "same".to_string(),
689            }),
690            Action::Deb822(Deb822Action::SetField {
691                file: control_file(),
692                paragraph: ParagraphSelector::Binary { package: pkg },
693                field: "Architecture".to_string(),
694                value: "any".to_string(),
695            }),
696        ],
697    ))
698}
699
700type DetectorFn =
701    fn(&debian_control::lossless::control::Binary, &Hint) -> Option<(String, Vec<Action>)>;
702
703struct Detector {
704    kind: &'static str,
705    certainty: Certainty,
706    cb: DetectorFn,
707}
708
709lazy_static! {
710    static ref DETECTORS: Vec<Detector> = vec![
711        Detector {
712            kind: "ma-foreign",
713            certainty: Certainty::Certain,
714            cb: detect_hint_ma_foreign,
715        },
716        Detector {
717            kind: "file-conflict",
718            certainty: Certainty::Certain,
719            cb: detect_hint_file_conflict,
720        },
721        Detector {
722            kind: "ma-foreign-library",
723            certainty: Certainty::Certain,
724            cb: detect_hint_ma_foreign_lib,
725        },
726        Detector {
727            kind: "dep-any",
728            certainty: Certainty::Certain,
729            cb: detect_hint_dep_any,
730        },
731        Detector {
732            kind: "ma-same",
733            certainty: Certainty::Certain,
734            cb: detect_hint_ma_same,
735        },
736        Detector {
737            kind: "arch-all",
738            certainty: Certainty::Possible,
739            cb: detect_hint_arch_all,
740        },
741        Detector {
742            kind: "ma-workaround",
743            certainty: Certainty::Certain,
744            cb: detect_hint_ma_workaround,
745        },
746    ];
747}
748
749fn find_detector(kind: &str) -> Option<&'static Detector> {
750    DETECTORS.iter().find(|x| x.kind == kind)
751}
752
753/// Detect which multiarch hints apply to the given workspace.
754///
755/// Returns one `(Change, ActionPlan)` per applicable hint. The `Change`
756/// describes what would change (for commit messages / logging); the
757/// `ActionPlan` carries the file edits to apply via `apply_actions`.
758pub fn detect_multiarch_hints(
759    ws: &dyn Workspace,
760    hints: &HashMap<&str, Vec<&Hint>>,
761    minimum_certainty: Certainty,
762) -> Result<Vec<(Change, ActionPlan)>, debian_workspace::Error> {
763    let control = match ws.parsed_control() {
764        Ok(c) => c,
765        Err(debian_workspace::Error::NotFound) => return Ok(Vec::new()),
766        Err(e) => return Err(e),
767    };
768
769    let mut results = Vec::new();
770    for binary in control.binaries() {
771        let Some(package) = binary.name() else {
772            continue;
773        };
774        let Some(package_hints) = hints.get(package.as_str()) else {
775            continue;
776        };
777        for hint in package_hints {
778            let kind = hint.kind();
779            let detector = match find_detector(kind) {
780                Some(d) => d,
781                None => {
782                    log::warn!("Unknown hint kind: {}", kind);
783                    continue;
784                }
785            };
786            if !certainty_sufficient(detector.certainty, Some(minimum_certainty)) {
787                continue;
788            }
789            if let Some((description, actions)) = (detector.cb)(&binary, hint) {
790                results.push((
791                    Change {
792                        binary: package.clone(),
793                        hint: (*hint).clone(),
794                        description: description.clone(),
795                        certainty: detector.certainty,
796                    },
797                    ActionPlan {
798                        label: description,
799                        opinionated: false,
800                        actions,
801                    },
802                ));
803            }
804        }
805    }
806    Ok(results)
807}
808
809fn changes_by_description(changes: &[Change]) -> HashMap<String, Vec<String>> {
810    let mut by_description: HashMap<String, Vec<String>> = HashMap::new();
811    for change in changes {
812        by_description
813            .entry(change.description.clone())
814            .or_default()
815            .push(change.binary.clone());
816    }
817    by_description
818}
819
820#[derive(Debug)]
821pub enum OverallError {
822    BrzError(Error),
823    NotDebianPackage(std::path::PathBuf),
824    Other(String),
825    NoWhoami,
826    NoChanges,
827    GeneratedFile(std::path::PathBuf),
828    FormattingUnpreservable(std::path::PathBuf),
829}
830
831impl From<debian_analyzer::editor::EditorError> for OverallError {
832    fn from(e: debian_analyzer::editor::EditorError) -> Self {
833        match e {
834            debian_analyzer::editor::EditorError::GeneratedFile(p, _) => {
835                OverallError::GeneratedFile(p)
836            }
837            debian_analyzer::editor::EditorError::FormattingUnpreservable(p, _) => {
838                OverallError::FormattingUnpreservable(p)
839            }
840            debian_analyzer::editor::EditorError::BrzError(e) => OverallError::BrzError(e),
841            debian_analyzer::editor::EditorError::IoError(e) => OverallError::Other(e.to_string()),
842            debian_analyzer::editor::EditorError::TemplateError(p, _e) => {
843                OverallError::GeneratedFile(p)
844            }
845        }
846    }
847}
848
849impl std::fmt::Display for OverallError {
850    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
851        match self {
852            OverallError::NotDebianPackage(p) => {
853                write!(f, "{} is not a Debian package.", p.display())
854            }
855            OverallError::GeneratedFile(p) => {
856                write!(f, "Generated file: {}", p.display())
857            }
858            OverallError::FormattingUnpreservable(p) => {
859                write!(f, "Formatting unpreservable: {}", p.display())
860            }
861            OverallError::BrzError(e) => write!(f, "{}", e),
862            OverallError::NoWhoami => write!(f, "No committer configured."),
863            OverallError::NoChanges => write!(f, "No changes to apply."),
864            OverallError::Other(e) => write!(f, "{}", e),
865        }
866    }
867}
868
869impl std::error::Error for OverallError {}
870
871impl From<Error> for OverallError {
872    fn from(e: Error) -> Self {
873        match e {
874            Error::PointlessCommit => OverallError::NoChanges,
875            Error::NoWhoami => OverallError::NoWhoami,
876            Error::Other(e) => OverallError::Other(e.to_string()),
877            e => OverallError::BrzError(e),
878        }
879    }
880}
881
882impl From<ChangelogError> for OverallError {
883    fn from(e: ChangelogError) -> Self {
884        match e {
885            ChangelogError::NotDebianPackage(p) => OverallError::NotDebianPackage(p),
886            ChangelogError::Python(e) => OverallError::Other(e.to_string()),
887        }
888    }
889}
890
891/// Configuration options for applying multiarch hints
892#[derive(Debug, Clone)]
893pub struct ApplyMultiarchHintsConfig {
894    pub minimum_certainty: Option<Certainty>,
895    pub committer: Option<String>,
896    pub update_changelog: bool,
897    pub allow_reformatting: Option<bool>,
898}
899
900impl Default for ApplyMultiarchHintsConfig {
901    fn default() -> Self {
902        Self {
903            minimum_certainty: None,
904            committer: None,
905            update_changelog: true,
906            allow_reformatting: None,
907        }
908    }
909}
910
911#[allow(clippy::result_large_err)]
912pub fn apply_multiarch_hints(
913    local_tree: &GenericWorkingTree,
914    subpath: &std::path::Path,
915    hints: &HashMap<&str, Vec<&Hint>>,
916    dirty_tracker: Option<&mut DirtyTreeTracker>,
917    config: &ApplyMultiarchHintsConfig,
918) -> Result<OverallResult, OverallError> {
919    let minimum_certainty = config.minimum_certainty.unwrap_or(Certainty::Certain);
920    let basis_tree = local_tree.basis_tree().map_err(OverallError::BrzError)?;
921    let (changes, _tree_changes, mut specific_files) = match apply_or_revert(
922        local_tree,
923        subpath,
924        &basis_tree,
925        dirty_tracker,
926        |path| -> Result<Vec<Change>, OverallError> {
927            let ws = debian_workspace::fs_workspace::FsWorkspace::new(path, None, None);
928            let detected = detect_multiarch_hints(&ws, hints, minimum_certainty)
929                .map_err(|e| OverallError::Other(e.to_string()))?;
930
931            if detected.is_empty() {
932                return Ok(Vec::new());
933            }
934
935            let all_actions: Vec<_> = detected
936                .iter()
937                .flat_map(|(_, plan)| plan.actions.iter().cloned())
938                .collect();
939            apply_actions(path, &all_actions).map_err(|e| OverallError::Other(e.to_string()))?;
940
941            Ok(detected.into_iter().map(|(change, _)| change).collect())
942        },
943    ) {
944        Ok(r) => r,
945        Err(ApplyError::NoChanges(_)) => return Err(OverallError::NoChanges),
946        Err(ApplyError::BrzError(e)) => return Err(OverallError::BrzError(e)),
947        Err(ApplyError::CallbackError(_)) => panic!("Unexpected callback error"),
948    };
949
950    let by_description = changes_by_description(changes.as_slice());
951    let mut overall_description = vec!["Apply multi-arch hints.\n".to_string()];
952    for (description, mut binaries) in by_description {
953        binaries.sort();
954        overall_description.push(format!(" + {}: {}\n", binaries.join(", "), description));
955    }
956
957    let changelog_path = subpath.join("debian/changelog");
958
959    if config.update_changelog {
960        add_changelog_entry(
961            local_tree,
962            changelog_path.as_path(),
963            overall_description
964                .iter()
965                .map(|x| x.as_str())
966                .collect::<Vec<_>>()
967                .as_slice(),
968        )?;
969        if let Some(specific_files) = specific_files.as_mut() {
970            specific_files.push(changelog_path);
971        }
972    }
973
974    overall_description.push("\n".to_string());
975    overall_description.push("Changes-By: apply-multiarch-hints\n".to_string());
976
977    let committer = config
978        .committer
979        .clone()
980        .unwrap_or_else(|| get_committer(local_tree));
981
982    let specific_files_ref = specific_files
983        .as_ref()
984        .map(|x| x.iter().map(|x| x.as_path()).collect::<Vec<_>>());
985
986    let mut builder = local_tree
987        .build_commit()
988        .message(overall_description.concat().as_str())
989        .allow_pointless(false)
990        .committer(&committer);
991
992    if let Some(specific_files) = specific_files_ref.as_deref() {
993        builder = builder.specific_files(specific_files);
994    }
995
996    builder.commit()?;
997
998    Ok(OverallResult { changes })
999}