Skip to main content

debian_workspace/
workspace.rs

1//! `Workspace`: an abstraction over the on-disk or in-editor state of a
2//! Debian source package.
3//!
4//! Fixers historically reached into the working tree directly via
5//! `std::fs`. That ties them to a particular host (the lintian-brush CLI,
6//! which writes the tree to disk before invoking fixers). The
7//! `Workspace` trait abstracts that access so the same fixer code can
8//! also run inside an editor host (debian-lsp), where the source of truth for
9//! a file is the open buffer rather than the path on disk.
10//!
11//! Two implementations are intended:
12//!
13//! * [`FsWorkspace`] — pure-`std` shim that operates on a base
14//!   directory on disk. Used by the lintian-brush CLI; preserves the
15//!   existing semantics where the harness writes the tree to disk, the
16//!   fixer mutates files there, and the harness diffs the result.
17//! * `LspWorkspace` (lives in debian-lsp) — wraps a salsa-backed
18//!   in-memory workspace. Mutations are accumulated as a single
19//!   `WorkspaceEdit` rather than being written back to disk.
20//!
21//! The trait is deliberately `breezyshim`-free so that hosts that don't want
22//! a Python runtime (notably debian-lsp) can depend on it without pulling in
23//! PyO3.
24
25use std::path::{Path, PathBuf};
26
27use debian_changelog::ChangeLog;
28use debian_control::lossless::Control;
29use debian_copyright::lossless::Copyright;
30use debian_watch::parse::ParsedWatchFile;
31use dep3::lossless::PatchHeader;
32use makefile_lossless::Makefile;
33use patchkit::edit::Patch;
34use patchkit::quilt::Series;
35use toml_edit::DocumentMut;
36
37use crate::{Error, Version};
38
39/// An editor handle for a single file in a [`Workspace`].
40///
41/// The parsed value is reachable via `Deref`/`DerefMut`; mutate it as you
42/// would the bare type. Changes are persisted by calling
43/// [`commit`](Self::commit). Dropping an editor without committing discards
44/// the changes (and emits a warning) — explicit commit is required so that
45/// serialisation failures can be reported.
46///
47/// `T` is the parsed representation (e.g.
48/// [`debian_control::lossless::Control`]).
49pub trait Editor<T>: std::ops::Deref<Target = T> + std::ops::DerefMut<Target = T> {
50    /// Persist any modifications to the underlying workspace.
51    ///
52    /// For a tree-backed workspace this writes the file back to disk; for an
53    /// editor-backed workspace it records a `TextEdit` against the buffer.
54    /// Calling `commit` more than once is a no-op.
55    fn commit(self: Box<Self>) -> Result<(), Error>;
56}
57
58/// Access to a Debian source package, as seen by a fixer.
59///
60/// Each typed accessor returns an editor for a well-known file. Callers can
61/// also reach less-common files via [`read_file`](Self::read_file) /
62/// [`write_file`](Self::write_file).
63pub trait Workspace {
64    /// The source package name, as read from `debian/changelog`.
65    ///
66    /// Returns `None` when the changelog is missing or unreadable. Hosts
67    /// that legitimately don't have a changelog (e.g. an LSP that lost
68    /// access to it) should return `None` rather than fabricating a name.
69    fn package(&self) -> Option<&str>;
70
71    /// The current version of the package, as read from `debian/changelog`.
72    ///
73    /// Returns `None` when the changelog is missing or unreadable.
74    fn current_version(&self) -> Option<&Version>;
75
76    /// Read `debian/control` and return a parsed value.
77    ///
78    /// Returns `Err(Error::NotFound)` if the file is missing —
79    /// detectors typically want that exact response.
80    ///
81    /// Parsing is relaxed: syntax errors are tolerated and the resulting
82    /// AST may have missing or partially-recovered nodes. Detectors that
83    /// need to reject malformed input should validate the structure they
84    /// care about (e.g. that the source paragraph or a particular field
85    /// exists) rather than expecting `Err`.
86    ///
87    /// Implementations may cache the parse; the returned value is owned
88    /// (`Control` is cheap to clone — its rowan green nodes are shared
89    /// internally).
90    fn parsed_control(&self) -> Result<Control, Error>;
91
92    /// Read `debian/changelog` and return a parsed value.
93    ///
94    /// Returns `Err(Error::NotFound)` if the file is missing. Parsing is
95    /// relaxed; see [`parsed_control`](Self::parsed_control) for details
96    /// on what that means.
97    fn parsed_changelog(&self) -> Result<ChangeLog, Error>;
98
99    /// Read `debian/NEWS` and return a parsed value.
100    ///
101    /// `debian/NEWS` shares the `debian/changelog` syntax, so it is parsed
102    /// with the same relaxed parser. Returns `Err(Error::NotFound)` if the
103    /// file is missing (a package shipping no NEWS entries).
104    fn parsed_news(&self) -> Result<ChangeLog, Error> {
105        let rel = Path::new("debian/NEWS");
106        match self.read_file(rel)? {
107            None => Err(Error::NotFound),
108            Some(bytes) => ChangeLog::read_relaxed(&bytes[..])
109                .map_err(|e| Error::Parse(format!("Failed to parse {}: {}", rel.display(), e))),
110        }
111    }
112
113    /// Read `debian/copyright` and return a parsed value.
114    ///
115    /// Returns `Err(Error::NotFound)` if the file is missing, and
116    /// `Err(Error::Parse)` only when the file isn't a machine-readable
117    /// DEP-5 document at all (i.e. doesn't start with `Format:`).
118    /// Parsing is otherwise relaxed; see
119    /// [`parsed_control`](Self::parsed_control) for details on what that
120    /// means.
121    fn parsed_copyright(&self) -> Result<Copyright, Error>;
122
123    /// Read `debian/upstream/metadata` and return its parsed YAML.
124    ///
125    /// Returns `Err(Error::NotFound)` if the file is missing or
126    /// unparseable.
127    fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error>;
128
129    /// Read `debian/watch` and return a parsed value.
130    ///
131    /// Returns `Err(Error::NotFound)` if the file is missing.
132    fn parsed_watch(&self) -> Result<ParsedWatchFile, Error>;
133
134    /// Read `debian/rules` and return the parsed Makefile.
135    ///
136    /// Returns `Err(Error::NotFound)` if the file is missing. Uses
137    /// `Makefile::read_relaxed`, mirroring the behaviour every fixer
138    /// currently expects from `debian/rules` parsing.
139    fn parsed_rules(&self) -> Result<Makefile, Error>;
140
141    /// Read and parse `debian/patches/series`, the quilt patch series.
142    ///
143    /// Returns `Ok(None)` if the file does not exist (the package ships
144    /// no quilt patches). Returns `Err` only if the file exists but
145    /// cannot be read as a series.
146    fn parsed_patches_series(&self) -> Result<Option<Series>, Error> {
147        let rel = Path::new("debian/patches/series");
148        match self.read_file(rel)? {
149            None => Ok(None),
150            Some(bytes) => {
151                let series = Series::read(&bytes[..]).map_err(Error::Io)?;
152                Ok(Some(series))
153            }
154        }
155    }
156
157    /// Read a quilt patch file and return its parsed DEP-3 header
158    /// together with the parsed diff.
159    ///
160    /// `rel` is the patch's path relative to the package root (e.g.
161    /// `debian/patches/fix-foo.patch`), as obtained by joining
162    /// `debian/patches` with a name from [`parsed_patches_series`].
163    ///
164    /// Returns `Ok(None)` when the file does not exist. On success the
165    /// tuple's first element is the patch's DEP-3 header, or `None` when
166    /// the patch carries no header (a bare diff) or its header does not
167    /// parse — the header is optional metadata. The second element is
168    /// the lossless parse of the diff body; that parser is
169    /// error-recovering, so a [`Patch`] is produced even for a malformed
170    /// diff.
171    ///
172    /// Returns `Err(Error::Parse)` if the file exists but is not valid
173    /// UTF-8.
174    ///
175    /// [`parsed_patches_series`]: Self::parsed_patches_series
176    fn parsed_patch(&self, rel: &Path) -> Result<Option<(Option<PatchHeader>, Patch)>, Error> {
177        let Some(bytes) = self.read_file(rel)? else {
178            return Ok(None);
179        };
180        let content = std::str::from_utf8(&bytes)
181            .map_err(|e| Error::Parse(format!("{} is not valid UTF-8: {}", rel.display(), e)))?;
182        let header_end = dep3::lossless::header_end(content);
183        let header_text = &content[..header_end];
184        let header = if header_text.trim().is_empty() {
185            None
186        } else {
187            header_text.parse::<PatchHeader>().ok()
188        };
189        let patch = patchkit::edit::parse(&content[header_end..]).tree();
190        Ok(Some((header, patch)))
191    }
192
193    /// Read the trimmed contents of `debian/source/format`.
194    ///
195    /// Returns `Ok(None)` if the file is missing. The default format
196    /// (`1.0`) is *not* substituted — callers see exactly what is on
197    /// disk so they can distinguish "no file" from "explicit 1.0".
198    fn source_format(&self) -> Result<Option<String>, Error>;
199
200    /// Open `debian/control` for editing.
201    ///
202    /// Takes `&self` so that fixers can hold an editor and still call
203    /// other workspace methods (`read_file`, …). Implementations
204    /// that need to record edits on the workspace itself should use interior
205    /// mutability.
206    ///
207    /// Detectors don't need this — they emit `Action`s for the appliers to
208    /// run. Use [`parsed_control`](Self::parsed_control) instead.
209    fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error>;
210
211    /// Open `debian/changelog` for editing. See [`control`](Self::control).
212    fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error>;
213
214    /// Read `debian/debcargo.toml` and return a parsed TOML document.
215    ///
216    /// Returns `Ok(None)` if the file does not exist (package is not a
217    /// debcargo-managed crate). Returns `Err` if the file exists but cannot
218    /// be parsed.
219    fn parsed_debcargo(&self) -> Result<Option<DocumentMut>, Error> {
220        let rel = Path::new("debian/debcargo.toml");
221        match self.read_file(rel)? {
222            None => Ok(None),
223            Some(bytes) => {
224                let text = String::from_utf8(bytes.into_owned()).map_err(|e| {
225                    Error::Parse(format!("debcargo.toml is not valid UTF-8: {}", e))
226                })?;
227                let doc: DocumentMut = text
228                    .parse()
229                    .map_err(|e| Error::Parse(format!("Failed to parse debcargo.toml: {}", e)))?;
230                Ok(Some(doc))
231            }
232        }
233    }
234
235    /// Open `debian/debcargo.toml` for editing.
236    ///
237    /// Returns `Ok(None)` if the file does not exist.
238    /// Returns `Err` if the file exists but cannot be parsed.
239    fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error>;
240
241    /// Read raw bytes of an arbitrary file relative to the package root.
242    ///
243    /// Returns `Ok(None)` if the file does not exist.
244    ///
245    /// The returned `Cow` is borrowed when the host has the bytes
246    /// already in memory (an LSP host with the file open in an editor
247    /// buffer) and owned when they had to be fetched (a disk read).
248    /// Detectors that need owned bytes can call `.into_owned()`.
249    fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error>;
250
251    /// Write raw bytes to an arbitrary file relative to the package root.
252    ///
253    /// Creates the file if it does not exist.
254    fn write_file(&self, rel: &Path, content: &[u8]) -> Result<(), Error>;
255
256    /// List the entries of a directory relative to the package root.
257    ///
258    /// Returns the file (and subdirectory) names within `rel`, without any
259    /// path prefix. Returns `Ok(None)` if the directory does not exist.
260    ///
261    /// The order of returned entries is unspecified — a non-`Tree` host
262    /// (an LSP) may not have a meaningful directory ordering.
263    fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error>;
264
265    /// Recursively walk `rel`, returning the relative paths of every
266    /// regular file beneath it (paths are relative to the package root,
267    /// not to `rel`).
268    ///
269    /// Symbolic links and other non-regular entries are skipped. Returns
270    /// `Ok(None)` if `rel` does not exist.
271    ///
272    /// The order of returned paths is unspecified. Hosts that can't
273    /// meaningfully walk a tree (e.g. an LSP that only knows about open
274    /// buffers) may return only the files they currently track.
275    fn walk_dir(&self, rel: &Path) -> Result<Option<Vec<PathBuf>>, Error> {
276        // Default impl: depth-first walk via list_dir + read_file.
277        // Hosts that have a faster path can override.
278        let Some(top_entries) = self.list_dir(rel)? else {
279            return Ok(None);
280        };
281        let mut out = Vec::new();
282        let mut stack: Vec<(PathBuf, Vec<String>)> = vec![(rel.to_path_buf(), top_entries)];
283        while let Some((dir, entries)) = stack.pop() {
284            for name in entries {
285                let child = dir.join(&name);
286                match self.list_dir(&child)? {
287                    Some(sub) => stack.push((child, sub)),
288                    None => out.push(child),
289                }
290            }
291        }
292        Ok(Some(out))
293    }
294
295    /// Read the Unix file mode of `rel`, or `None` if the file is missing.
296    ///
297    /// Hosts that don't track a meaningful mode (e.g. an LSP serving an
298    /// in-memory buffer) may return `Ok(None)` even when the file exists.
299    /// Detectors that key off mode (e.g. checking that `debian/rules` is
300    /// executable) treat that the same as "not present" and skip.
301    fn file_mode(&self, rel: &Path) -> Result<Option<u32>, Error>;
302
303    /// On-disk root for hosts that have one.
304    ///
305    /// Returns `Some` for the lintian-brush CLI ([`FsWorkspace`])
306    /// where the package has been materialised to disk. Returns `None`
307    /// for in-memory hosts (an LSP serving open buffers); detectors that
308    /// genuinely need to walk the source tree (e.g. an upstream-metadata
309    /// guesser, a license scanner) should treat `None` as "skip — we
310    /// can't help here".
311    ///
312    /// Prefer the typed accessors ([`read_file`](Self::read_file),
313    /// [`list_dir`](Self::list_dir), …) wherever possible. Reach for
314    /// this only when an external library insists on a `&Path` for the
315    /// whole tree.
316    fn base_path(&self) -> Option<&Path> {
317        None
318    }
319}
320
321/// Read the debhelper compat level from a workspace.
322///
323/// Looks at `debian/compat` first, then falls back to the `X-DH-Compat`
324/// field or a `debhelper-compat` build dependency in `debian/control`.
325/// Returns `Ok(None)` when neither source is present or parseable.
326pub fn compat_level(ws: &dyn Workspace) -> Result<Option<u8>, Error> {
327    if let Some(bytes) = ws.read_file(Path::new("debian/compat"))? {
328        if let Ok(text) = std::str::from_utf8(&bytes) {
329            let trimmed = text
330                .split_once('#')
331                .map_or(text, |(before, _)| before)
332                .trim();
333            if let Ok(level) = trimmed.parse::<u8>() {
334                return Ok(Some(level));
335            }
336        }
337    }
338
339    let control = match ws.parsed_control() {
340        Ok(c) => c,
341        Err(Error::NotFound) => return Ok(None),
342        Err(e) => return Err(e),
343    };
344    let Some(source) = control.source() else {
345        return Ok(None);
346    };
347
348    if let Some(dh_compat) = source.as_deb822().get("X-DH-Compat") {
349        let trimmed = dh_compat
350            .split_once('#')
351            .map_or(dh_compat.as_str(), |(before, _)| before)
352            .trim();
353        if let Ok(level) = trimmed.parse::<u8>() {
354            return Ok(Some(level));
355        }
356    }
357
358    let Some(build_depends) = source.build_depends() else {
359        return Ok(None);
360    };
361    let Some(rel) = build_depends
362        .entries()
363        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
364        .find(|r| r.try_name().as_deref() == Some("debhelper-compat"))
365    else {
366        return Ok(None);
367    };
368    Ok(rel
369        .version()
370        .and_then(|(_op, v)| v.to_string().parse::<u8>().ok()))
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::fs_workspace::FsWorkspace;
377    use std::collections::BTreeMap;
378    use tempfile::TempDir;
379
380    /// A minimal in-memory `Workspace` used to exercise the trait's *default*
381    /// method bodies. `FsWorkspace` overrides `walk_dir`, so it can't drive the
382    /// default walk; this mock deliberately does not.
383    #[derive(Default)]
384    struct MockWorkspace {
385        // Maps a relative path to its raw bytes. Directories are implied by
386        // the path components of the files they contain.
387        files: BTreeMap<PathBuf, Vec<u8>>,
388    }
389
390    impl MockWorkspace {
391        fn with_file(mut self, rel: &str, content: &[u8]) -> Self {
392            self.files.insert(PathBuf::from(rel), content.to_vec());
393            self
394        }
395
396        /// The set of directory paths implied by the stored files.
397        fn dirs(&self) -> std::collections::BTreeSet<PathBuf> {
398            let mut dirs = std::collections::BTreeSet::new();
399            for f in self.files.keys() {
400                let mut cur = f.parent();
401                while let Some(p) = cur {
402                    if p.as_os_str().is_empty() {
403                        break;
404                    }
405                    dirs.insert(p.to_path_buf());
406                    cur = p.parent();
407                }
408            }
409            dirs
410        }
411    }
412
413    impl Workspace for MockWorkspace {
414        fn package(&self) -> Option<&str> {
415            None
416        }
417        fn current_version(&self) -> Option<&Version> {
418            None
419        }
420        fn parsed_control(&self) -> Result<Control, Error> {
421            Err(Error::NotFound)
422        }
423        fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
424            Err(Error::NotFound)
425        }
426        fn parsed_copyright(&self) -> Result<Copyright, Error> {
427            Err(Error::NotFound)
428        }
429        fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
430            Err(Error::NotFound)
431        }
432        fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
433            Err(Error::NotFound)
434        }
435        fn parsed_rules(&self) -> Result<Makefile, Error> {
436            Err(Error::NotFound)
437        }
438        fn source_format(&self) -> Result<Option<String>, Error> {
439            Ok(None)
440        }
441        fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
442            Err(Error::NotFound)
443        }
444        fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
445            Err(Error::NotFound)
446        }
447        fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
448            Ok(None)
449        }
450        fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
451            Ok(self
452                .files
453                .get(rel)
454                .map(|v| std::borrow::Cow::Borrowed(v.as_slice())))
455        }
456        fn write_file(&self, _rel: &Path, _content: &[u8]) -> Result<(), Error> {
457            unimplemented!("not needed by tests")
458        }
459        fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
460            let dirs = self.dirs();
461            // The directory must exist (be implied by some file).
462            if !dirs.contains(rel) {
463                return Ok(None);
464            }
465            let mut names = std::collections::BTreeSet::new();
466            // Direct child files.
467            for f in self.files.keys() {
468                if f.parent() == Some(rel) {
469                    names.insert(f.file_name().unwrap().to_string_lossy().into_owned());
470                }
471            }
472            // Direct child directories.
473            for d in &dirs {
474                if d.parent() == Some(rel) {
475                    names.insert(d.file_name().unwrap().to_string_lossy().into_owned());
476                }
477            }
478            Ok(Some(names.into_iter().collect()))
479        }
480        fn file_mode(&self, _rel: &Path) -> Result<Option<u32>, Error> {
481            Ok(None)
482        }
483    }
484
485    fn make_pkg_with_control(dir: &Path, control: &str) {
486        let debian = dir.join("debian");
487        std::fs::create_dir_all(&debian).unwrap();
488        std::fs::write(debian.join("control"), control).unwrap();
489    }
490
491    fn fs_workspace(dir: &Path) -> FsWorkspace {
492        FsWorkspace::new(dir, None, None)
493    }
494
495    #[test]
496    fn default_walk_dir_recurses_nested_dirs() {
497        let ws = MockWorkspace::default()
498            .with_file("src/top.txt", b"a")
499            .with_file("src/sub/deep.txt", b"b")
500            .with_file("src/sub/other.txt", b"c");
501        let mut files = ws.walk_dir(Path::new("src")).unwrap().unwrap();
502        files.sort();
503        assert_eq!(
504            files,
505            vec![
506                PathBuf::from("src/sub/deep.txt"),
507                PathBuf::from("src/sub/other.txt"),
508                PathBuf::from("src/top.txt"),
509            ]
510        );
511    }
512
513    #[test]
514    fn default_walk_dir_missing_returns_none() {
515        let ws = MockWorkspace::default().with_file("src/top.txt", b"a");
516        assert_eq!(ws.walk_dir(Path::new("nonexistent")).unwrap(), None);
517    }
518
519    #[test]
520    fn default_walk_dir_empty_dir_returns_empty_vec() {
521        // An existing-but-empty directory must yield Some(empty), distinct from
522        // both Ok(None) (missing) and a non-empty result.
523        let ws = EmptyDirWorkspace;
524        assert_eq!(
525            ws.walk_dir(Path::new("empty")).unwrap(),
526            Some(Vec::<PathBuf>::new())
527        );
528    }
529
530    /// Workspace whose `empty` directory exists but has no entries.
531    struct EmptyDirWorkspace;
532    impl Workspace for EmptyDirWorkspace {
533        fn package(&self) -> Option<&str> {
534            None
535        }
536        fn current_version(&self) -> Option<&Version> {
537            None
538        }
539        fn parsed_control(&self) -> Result<Control, Error> {
540            Err(Error::NotFound)
541        }
542        fn parsed_changelog(&self) -> Result<ChangeLog, Error> {
543            Err(Error::NotFound)
544        }
545        fn parsed_copyright(&self) -> Result<Copyright, Error> {
546            Err(Error::NotFound)
547        }
548        fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error> {
549            Err(Error::NotFound)
550        }
551        fn parsed_watch(&self) -> Result<ParsedWatchFile, Error> {
552            Err(Error::NotFound)
553        }
554        fn parsed_rules(&self) -> Result<Makefile, Error> {
555            Err(Error::NotFound)
556        }
557        fn source_format(&self) -> Result<Option<String>, Error> {
558            Ok(None)
559        }
560        fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error> {
561            Err(Error::NotFound)
562        }
563        fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error> {
564            Err(Error::NotFound)
565        }
566        fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error> {
567            Ok(None)
568        }
569        fn read_file(&self, _rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error> {
570            Ok(None)
571        }
572        fn write_file(&self, _rel: &Path, _content: &[u8]) -> Result<(), Error> {
573            unimplemented!()
574        }
575        fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error> {
576            if rel == Path::new("empty") {
577                Ok(Some(vec![]))
578            } else {
579                Ok(None)
580            }
581        }
582        fn file_mode(&self, _rel: &Path) -> Result<Option<u32>, Error> {
583            Ok(None)
584        }
585    }
586
587    #[test]
588    fn compat_level_from_compat_file() {
589        let tmp = TempDir::new().unwrap();
590        std::fs::create_dir_all(tmp.path().join("debian")).unwrap();
591        std::fs::write(tmp.path().join("debian/compat"), "10\n").unwrap();
592        assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), Some(10));
593    }
594
595    #[test]
596    fn compat_level_from_build_depends() {
597        let tmp = TempDir::new().unwrap();
598        make_pkg_with_control(
599            tmp.path(),
600            "Source: foo\nBuild-Depends: debhelper-compat (= 13)\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
601        );
602        assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), Some(13));
603    }
604
605    #[test]
606    fn compat_level_build_depends_without_debhelper_compat() {
607        let tmp = TempDir::new().unwrap();
608        make_pkg_with_control(
609            tmp.path(),
610            "Source: foo\nBuild-Depends: debhelper (>= 12)\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
611        );
612        assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), None);
613    }
614
615    #[test]
616    fn compat_level_none_when_no_sources() {
617        let tmp = TempDir::new().unwrap();
618        make_pkg_with_control(
619            tmp.path(),
620            "Source: foo\n\nPackage: foo\nArchitecture: any\nDescription: x\n y\n",
621        );
622        assert_eq!(compat_level(&fs_workspace(tmp.path())).unwrap(), None);
623    }
624}