Skip to main content

multiarch_hints/
lib.rs

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