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