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 makefile_lossless::Makefile;
32use toml_edit::DocumentMut;
33
34use crate::{Error, Version};
35
36/// An editor handle for a single file in a [`Workspace`].
37///
38/// The parsed value is reachable via `Deref`/`DerefMut`; mutate it as you
39/// would the bare type. Changes are persisted by calling
40/// [`commit`](Self::commit). Dropping an editor without committing discards
41/// the changes (and emits a warning) — explicit commit is required so that
42/// serialisation failures can be reported.
43///
44/// `T` is the parsed representation (e.g.
45/// [`debian_control::lossless::Control`]).
46pub trait Editor<T>: std::ops::Deref<Target = T> + std::ops::DerefMut<Target = T> {
47    /// Persist any modifications to the underlying workspace.
48    ///
49    /// For a tree-backed workspace this writes the file back to disk; for an
50    /// editor-backed workspace it records a `TextEdit` against the buffer.
51    /// Calling `commit` more than once is a no-op.
52    fn commit(self: Box<Self>) -> Result<(), Error>;
53}
54
55/// Access to a Debian source package, as seen by a fixer.
56///
57/// Each typed accessor returns an editor for a well-known file. Callers can
58/// also reach less-common files via [`read_file`](Self::read_file) /
59/// [`write_file`](Self::write_file).
60pub trait Workspace {
61    /// The source package name, as read from `debian/changelog`.
62    ///
63    /// Returns `None` when the changelog is missing or unreadable. Hosts
64    /// that legitimately don't have a changelog (e.g. an LSP that lost
65    /// access to it) should return `None` rather than fabricating a name.
66    fn package(&self) -> Option<&str>;
67
68    /// The current version of the package, as read from `debian/changelog`.
69    ///
70    /// Returns `None` when the changelog is missing or unreadable.
71    fn current_version(&self) -> Option<&Version>;
72
73    /// Read `debian/control` and return a parsed value.
74    ///
75    /// Returns `Err(Error::NotFound)` if the file is missing —
76    /// detectors typically want that exact response.
77    ///
78    /// Parsing is relaxed: syntax errors are tolerated and the resulting
79    /// AST may have missing or partially-recovered nodes. Detectors that
80    /// need to reject malformed input should validate the structure they
81    /// care about (e.g. that the source paragraph or a particular field
82    /// exists) rather than expecting `Err`.
83    ///
84    /// Implementations may cache the parse; the returned value is owned
85    /// (`Control` is cheap to clone — its rowan green nodes are shared
86    /// internally).
87    fn parsed_control(&self) -> Result<Control, Error>;
88
89    /// Read `debian/changelog` and return a parsed value.
90    ///
91    /// Returns `Err(Error::NotFound)` if the file is missing. Parsing is
92    /// relaxed; see [`parsed_control`](Self::parsed_control) for details
93    /// on what that means.
94    fn parsed_changelog(&self) -> Result<ChangeLog, Error>;
95
96    /// Read `debian/copyright` and return a parsed value.
97    ///
98    /// Returns `Err(Error::NotFound)` if the file is missing, and
99    /// `Err(Error::Parse)` only when the file isn't a machine-readable
100    /// DEP-5 document at all (i.e. doesn't start with `Format:`).
101    /// Parsing is otherwise relaxed; see
102    /// [`parsed_control`](Self::parsed_control) for details on what that
103    /// means.
104    fn parsed_copyright(&self) -> Result<Copyright, Error>;
105
106    /// Read `debian/upstream/metadata` and return its parsed YAML.
107    ///
108    /// Returns `Err(Error::NotFound)` if the file is missing or
109    /// unparseable.
110    fn parsed_upstream_metadata(&self) -> Result<yaml_edit::YamlFile, Error>;
111
112    /// Read `debian/watch` and return a parsed value.
113    ///
114    /// Returns `Err(Error::NotFound)` if the file is missing.
115    fn parsed_watch(&self) -> Result<ParsedWatchFile, Error>;
116
117    /// Read `debian/rules` and return the parsed Makefile.
118    ///
119    /// Returns `Err(Error::NotFound)` if the file is missing. Uses
120    /// `Makefile::read_relaxed`, mirroring the behaviour every fixer
121    /// currently expects from `debian/rules` parsing.
122    fn parsed_rules(&self) -> Result<Makefile, Error>;
123
124    /// Read the trimmed contents of `debian/source/format`.
125    ///
126    /// Returns `Ok(None)` if the file is missing. The default format
127    /// (`1.0`) is *not* substituted — callers see exactly what is on
128    /// disk so they can distinguish "no file" from "explicit 1.0".
129    fn source_format(&self) -> Result<Option<String>, Error>;
130
131    /// Open `debian/control` for editing.
132    ///
133    /// Takes `&self` so that fixers can hold an editor and still call
134    /// other workspace methods (`read_file`, …). Implementations
135    /// that need to record edits on the workspace itself should use interior
136    /// mutability.
137    ///
138    /// Detectors don't need this — they emit `Action`s for the appliers to
139    /// run. Use [`parsed_control`](Self::parsed_control) instead.
140    fn control(&self) -> Result<Box<dyn Editor<Control> + '_>, Error>;
141
142    /// Open `debian/changelog` for editing. See [`control`](Self::control).
143    fn changelog(&self) -> Result<Box<dyn Editor<ChangeLog> + '_>, Error>;
144
145    /// Read `debian/debcargo.toml` and return a parsed TOML document.
146    ///
147    /// Returns `Ok(None)` if the file does not exist (package is not a
148    /// debcargo-managed crate). Returns `Err` if the file exists but cannot
149    /// be parsed.
150    fn parsed_debcargo(&self) -> Result<Option<DocumentMut>, Error> {
151        let rel = Path::new("debian/debcargo.toml");
152        match self.read_file(rel)? {
153            None => Ok(None),
154            Some(bytes) => {
155                let text = String::from_utf8(bytes.into_owned()).map_err(|e| {
156                    Error::Parse(format!("debcargo.toml is not valid UTF-8: {}", e))
157                })?;
158                let doc: DocumentMut = text
159                    .parse()
160                    .map_err(|e| Error::Parse(format!("Failed to parse debcargo.toml: {}", e)))?;
161                Ok(Some(doc))
162            }
163        }
164    }
165
166    /// Open `debian/debcargo.toml` for editing.
167    ///
168    /// Returns `Ok(None)` if the file does not exist.
169    /// Returns `Err` if the file exists but cannot be parsed.
170    fn debcargo(&self) -> Result<Option<Box<dyn Editor<DocumentMut> + '_>>, Error>;
171
172    /// Read raw bytes of an arbitrary file relative to the package root.
173    ///
174    /// Returns `Ok(None)` if the file does not exist.
175    ///
176    /// The returned `Cow` is borrowed when the host has the bytes
177    /// already in memory (an LSP host with the file open in an editor
178    /// buffer) and owned when they had to be fetched (a disk read).
179    /// Detectors that need owned bytes can call `.into_owned()`.
180    fn read_file(&self, rel: &Path) -> Result<Option<std::borrow::Cow<'_, [u8]>>, Error>;
181
182    /// Write raw bytes to an arbitrary file relative to the package root.
183    ///
184    /// Creates the file if it does not exist.
185    fn write_file(&self, rel: &Path, content: &[u8]) -> Result<(), Error>;
186
187    /// List the entries of a directory relative to the package root.
188    ///
189    /// Returns the file (and subdirectory) names within `rel`, without any
190    /// path prefix. Returns `Ok(None)` if the directory does not exist.
191    ///
192    /// The order of returned entries is unspecified — a non-`Tree` host
193    /// (an LSP) may not have a meaningful directory ordering.
194    fn list_dir(&self, rel: &Path) -> Result<Option<Vec<String>>, Error>;
195
196    /// Recursively walk `rel`, returning the relative paths of every
197    /// regular file beneath it (paths are relative to the package root,
198    /// not to `rel`).
199    ///
200    /// Symbolic links and other non-regular entries are skipped. Returns
201    /// `Ok(None)` if `rel` does not exist.
202    ///
203    /// The order of returned paths is unspecified. Hosts that can't
204    /// meaningfully walk a tree (e.g. an LSP that only knows about open
205    /// buffers) may return only the files they currently track.
206    fn walk_dir(&self, rel: &Path) -> Result<Option<Vec<PathBuf>>, Error> {
207        // Default impl: depth-first walk via list_dir + read_file.
208        // Hosts that have a faster path can override.
209        let Some(top_entries) = self.list_dir(rel)? else {
210            return Ok(None);
211        };
212        let mut out = Vec::new();
213        let mut stack: Vec<(PathBuf, Vec<String>)> = vec![(rel.to_path_buf(), top_entries)];
214        while let Some((dir, entries)) = stack.pop() {
215            for name in entries {
216                let child = dir.join(&name);
217                match self.list_dir(&child)? {
218                    Some(sub) => stack.push((child, sub)),
219                    None => out.push(child),
220                }
221            }
222        }
223        Ok(Some(out))
224    }
225
226    /// Read the Unix file mode of `rel`, or `None` if the file is missing.
227    ///
228    /// Hosts that don't track a meaningful mode (e.g. an LSP serving an
229    /// in-memory buffer) may return `Ok(None)` even when the file exists.
230    /// Detectors that key off mode (e.g. checking that `debian/rules` is
231    /// executable) treat that the same as "not present" and skip.
232    fn file_mode(&self, rel: &Path) -> Result<Option<u32>, Error>;
233
234    /// On-disk root for hosts that have one.
235    ///
236    /// Returns `Some` for the lintian-brush CLI ([`FsWorkspace`])
237    /// where the package has been materialised to disk. Returns `None`
238    /// for in-memory hosts (an LSP serving open buffers); detectors that
239    /// genuinely need to walk the source tree (e.g. an upstream-metadata
240    /// guesser, a license scanner) should treat `None` as "skip — we
241    /// can't help here".
242    ///
243    /// Prefer the typed accessors ([`read_file`](Self::read_file),
244    /// [`list_dir`](Self::list_dir), …) wherever possible. Reach for
245    /// this only when an external library insists on a `&Path` for the
246    /// whole tree.
247    fn base_path(&self) -> Option<&Path> {
248        None
249    }
250}
251
252/// Read the debhelper compat level from a workspace.
253///
254/// Looks at `debian/compat` first, then falls back to the `X-DH-Compat`
255/// field or a `debhelper-compat` build dependency in `debian/control`.
256/// Returns `Ok(None)` when neither source is present or parseable.
257pub fn compat_level(ws: &dyn Workspace) -> Result<Option<u8>, Error> {
258    if let Some(bytes) = ws.read_file(Path::new("debian/compat"))? {
259        if let Ok(text) = std::str::from_utf8(&bytes) {
260            let trimmed = text
261                .split_once('#')
262                .map_or(text, |(before, _)| before)
263                .trim();
264            if let Ok(level) = trimmed.parse::<u8>() {
265                return Ok(Some(level));
266            }
267        }
268    }
269
270    let control = match ws.parsed_control() {
271        Ok(c) => c,
272        Err(Error::NotFound) => return Ok(None),
273        Err(e) => return Err(e),
274    };
275    let Some(source) = control.source() else {
276        return Ok(None);
277    };
278
279    if let Some(dh_compat) = source.as_deb822().get("X-DH-Compat") {
280        let trimmed = dh_compat
281            .split_once('#')
282            .map_or(dh_compat.as_str(), |(before, _)| before)
283            .trim();
284        if let Ok(level) = trimmed.parse::<u8>() {
285            return Ok(Some(level));
286        }
287    }
288
289    let Some(build_depends) = source.build_depends() else {
290        return Ok(None);
291    };
292    let Some(rel) = build_depends
293        .entries()
294        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
295        .find(|r| r.try_name().as_deref() == Some("debhelper-compat"))
296    else {
297        return Ok(None);
298    };
299    Ok(rel
300        .version()
301        .and_then(|(_op, v)| v.to_string().parse::<u8>().ok()))
302}