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