debian_workspace/action.rs
1use debian_control::relations::VersionConstraint;
2use debversion::Version;
3use std::path::PathBuf;
4
5/// One self-consistent set of actions that fixes a [`Diagnostic`].
6#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub struct ActionPlan {
8 /// Imperative description of what this plan would do, shown to the
9 /// user (LSP code-action menu, `lintian-brush --interactive`). Every
10 /// plan must have one — a diagnostic with multiple plans needs each
11 /// titled distinctly so the user can pick.
12 pub label: String,
13 /// If true, this plan only applies when the user has opted into
14 /// opinionated fixes (`--opinionated` / `preferences.opinionated`).
15 /// The driver skips opinionated plans otherwise.
16 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
17 pub opinionated: bool,
18 /// Actions applied as a unit.
19 pub actions: Vec<Action>,
20}
21
22/// A change to apply to the working tree.
23///
24/// Dispatched on file kind: each per-file enum carries the actual operations.
25#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum Action {
28 /// An edit to a deb822 file (debian/control, debian/copyright, …).
29 Deb822(Deb822Action),
30 /// An edit to a systemd unit file (.service, .socket, .target, …).
31 Systemd(SystemdAction),
32 /// An edit to a freedesktop .desktop entry file.
33 DesktopIni(DesktopIniAction),
34 /// An edit to a YAML file.
35 Yaml(YamlAction),
36 /// An edit to a `debian/changelog` file.
37 Changelog(ChangelogAction),
38 /// An edit to a `debian/watch` file.
39 Watch(WatchAction),
40 /// An edit to a Makefile (typically `debian/rules`).
41 Makefile(MakefileAction),
42 /// An edit to a DEP-3 patch header (a quilt patch under
43 /// `debian/patches/`).
44 Dep3(Dep3Action),
45 /// An edit to a lintian-overrides file (`debian/source/lintian-overrides`
46 /// or `debian/<pkg>.lintian-overrides`).
47 LintianOverrides(LintianOverridesAction),
48 /// An edit to a maintscript file (`debian/maintscript` or
49 /// `debian/<pkg>.maintscript`).
50 Maintscript(MaintscriptAction),
51 /// An edit to a `debian/debcargo.toml` file. Used for Rust crate
52 /// packages where the control file is generated.
53 Debcargo(DebcargoAction),
54 /// Invoke an external tool that mutates files in the working tree (e.g.
55 /// `debconf-updatepo`). Use this only when the operation can't be
56 /// expressed as one of the typed file actions above.
57 RunCommand(RunCommandAction),
58 /// A filesystem-level edit (chmod, write, delete, byte-range replace).
59 Filesystem(FilesystemAction),
60}
61
62/// Continuation-line indent pattern for multi-line deb822 field values.
63///
64/// Mirrors [`deb822_lossless::IndentPattern`] but is `serde`-serialisable
65/// so it can travel over the LSP wire alongside actions.
66#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67#[serde(tag = "kind", rename_all = "snake_case")]
68pub enum IndentPattern {
69 /// All continuation lines use exactly `spaces` leading spaces.
70 Fixed {
71 /// Number of leading spaces (typically `1` for DEP-5 / Description).
72 spaces: usize,
73 },
74 /// Continuation lines align with the column after the field name and
75 /// `": "`, i.e. `field_name.len() + 2` spaces. The deb822 default for
76 /// most fields.
77 FieldNameLength,
78}
79
80impl IndentPattern {
81 /// Convert to the underlying `deb822_lossless` pattern for the
82 /// applier.
83 pub fn to_deb822(&self) -> deb822_lossless::IndentPattern {
84 match self {
85 IndentPattern::Fixed { spaces } => deb822_lossless::IndentPattern::Fixed(*spaces),
86 IndentPattern::FieldNameLength => deb822_lossless::IndentPattern::FieldNameLength,
87 }
88 }
89}
90
91/// Edits to a deb822 file.
92#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
93#[serde(tag = "op", rename_all = "snake_case")]
94pub enum Deb822Action {
95 /// Set a field value, inserting it if missing.
96 ///
97 /// Continuation-line indentation for multi-line values follows the
98 /// deb822 default: align continuations to the field-name column. Use
99 /// [`SetFieldWithIndent`](Self::SetFieldWithIndent) when a field
100 /// needs a specific indent (e.g. Description / DEP-5 mandate a
101 /// single-space indent).
102 SetField {
103 /// File to edit, relative to the package root.
104 file: PathBuf,
105 /// Which paragraph to edit.
106 paragraph: ParagraphSelector,
107 /// Field name.
108 field: String,
109 /// New value.
110 value: String,
111 },
112 /// Like [`SetField`](Self::SetField), but with an explicit
113 /// continuation-line indent pattern. Used for fields whose
114 /// formatting convention diverges from the deb822 default — most
115 /// notably binary-package `Description:` (single-space indent per
116 /// DEP-5) and debian/copyright bodies.
117 SetFieldWithIndent {
118 /// File to edit, relative to the package root.
119 file: PathBuf,
120 /// Which paragraph to edit.
121 paragraph: ParagraphSelector,
122 /// Field name.
123 field: String,
124 /// New value.
125 value: String,
126 /// Continuation-line indent pattern.
127 indent: IndentPattern,
128 },
129 /// Remove a field if present.
130 RemoveField {
131 /// File to edit, relative to the package root.
132 file: PathBuf,
133 /// Which paragraph to edit.
134 paragraph: ParagraphSelector,
135 /// Field name.
136 field: String,
137 },
138 /// Rename a field, preserving its value.
139 RenameField {
140 /// File to edit, relative to the package root.
141 file: PathBuf,
142 /// Which paragraph to edit.
143 paragraph: ParagraphSelector,
144 /// Current field name.
145 from: String,
146 /// New field name.
147 to: String,
148 },
149 /// Remove the paragraph identified by `paragraph`.
150 RemoveParagraph {
151 /// File to edit, relative to the package root.
152 file: PathBuf,
153 /// Which paragraph to drop.
154 paragraph: ParagraphSelector,
155 },
156 /// Append a new paragraph at the end of the file with the given
157 /// (field, value) pairs in order.
158 AppendParagraph {
159 /// File to edit, relative to the package root.
160 file: PathBuf,
161 /// Fields to populate the new paragraph with.
162 fields: Vec<(String, String)>,
163 /// Continuation-line indent for multi-line values, in spaces.
164 /// `None` lets the deb822 renderer auto-align to the field-name
165 /// column (the default for debian/control). Use `Some(1)` for
166 /// debian/copyright, where DEP-5 mandates a single-space indent.
167 #[serde(default, skip_serializing_if = "Option::is_none")]
168 indent: Option<usize>,
169 },
170 /// Normalize the whitespace around a field's separator (`:` and the
171 /// continuation indent). The deb822 spec allows arbitrary spacing
172 /// after the colon, but the convention is exactly one space; this
173 /// action collapses unusual spacing without otherwise touching the
174 /// value. A no-op if the field already has canonical spacing.
175 NormalizeFieldSpacing {
176 /// File to edit, relative to the package root.
177 file: PathBuf,
178 /// Which paragraph to edit.
179 paragraph: ParagraphSelector,
180 /// Field name.
181 field: String,
182 },
183 /// Drop every relation matching `package` from a relations field
184 /// (Depends, Build-Depends, etc.). Empty alternative groups are
185 /// removed; if the field becomes empty it is removed entirely. A
186 /// no-op if the package isn't named in the field.
187 DropRelation {
188 /// File to edit, relative to the package root.
189 file: PathBuf,
190 /// Which paragraph to edit.
191 paragraph: ParagraphSelector,
192 /// Relations field name (e.g. `Build-Depends`).
193 field: String,
194 /// Package name to drop.
195 package: String,
196 },
197 /// Replace the first relation that names `from_package` with the
198 /// `to_entry` text, keeping the entry's position in the field. A
199 /// no-op if `from_package` isn't named. If `to_entry` parses as a
200 /// relation whose package is already named elsewhere in the field,
201 /// the original `from_package` entry is dropped without inserting a
202 /// duplicate.
203 ReplaceRelation {
204 /// File to edit, relative to the package root.
205 file: PathBuf,
206 /// Which paragraph to edit.
207 paragraph: ParagraphSelector,
208 /// Relations field name (e.g. `Build-Depends`).
209 field: String,
210 /// Package name (matched exactly) of the relation to replace.
211 from_package: String,
212 /// New entry text (e.g. `perl`, `debhelper (>= 12)`).
213 to_entry: String,
214 },
215 /// Ensure a substvar (`${...}`) is present in a relations field. If
216 /// the field doesn't exist it's created with just the substvar; if
217 /// it exists and already mentions the substvar it's a no-op.
218 EnsureSubstvar {
219 /// File to edit, relative to the package root.
220 file: PathBuf,
221 /// Which paragraph to edit.
222 paragraph: ParagraphSelector,
223 /// Relations field name (e.g. `Depends`).
224 field: String,
225 /// Substvar to ensure, including the surrounding `${...}`.
226 substvar: String,
227 },
228 /// Drop a substvar (`${...}`) from a relations field. If the field
229 /// becomes empty it's removed entirely. A no-op if the substvar is
230 /// already absent.
231 DropSubstvar {
232 /// File to edit, relative to the package root.
233 file: PathBuf,
234 /// Which paragraph to edit.
235 paragraph: ParagraphSelector,
236 /// Relations field name.
237 field: String,
238 /// Substvar to drop, including the surrounding `${...}`.
239 substvar: String,
240 },
241 /// Ensure a relation entry is present in a relations field, creating
242 /// the field if necessary. `entry` is a literal relation entry string
243 /// (e.g. `python3-poetry-core` or `debhelper-compat (= 13)`).
244 ///
245 /// If `entry` carries no version constraint the action is a no-op
246 /// when any relation with the same package name is already present.
247 /// If `entry` has an exact version, the action upgrades any existing
248 /// relation to that exact version.
249 EnsureRelation {
250 /// File to edit, relative to the package root.
251 file: PathBuf,
252 /// Which paragraph to edit.
253 paragraph: ParagraphSelector,
254 /// Relations field name (e.g. `Build-Depends`).
255 field: String,
256 /// Literal relation entry to ensure.
257 entry: String,
258 },
259 /// Set the version constraint on every relation in `field` that names
260 /// `package`. Acts per-relation, so the constraint is replaced without
261 /// removing the package from the field or affecting any alternatives in
262 /// the same entry. Passing `None` drops the constraint entirely. A no-op
263 /// if the package isn't named in `field` or every matching relation
264 /// already has the requested constraint.
265 SetRelationVersionConstraint {
266 /// File to edit, relative to the package root.
267 file: PathBuf,
268 /// Which paragraph to edit.
269 paragraph: ParagraphSelector,
270 /// Relations field name (e.g. `Depends`).
271 field: String,
272 /// Package name to set the version constraint on.
273 package: String,
274 /// New constraint, or `None` to strip the constraint entirely.
275 constraint: Option<(VersionConstraint, Version)>,
276 },
277 /// Move a relation entry between two fields of the same paragraph,
278 /// preserving its version constraint and any alternatives. The entry
279 /// is identified by `package`. If `from_field` becomes empty after
280 /// the move it is removed entirely. A no-op if the package isn't
281 /// present in `from_field`.
282 MoveRelation {
283 /// File to edit, relative to the package root.
284 file: PathBuf,
285 /// Which paragraph to edit.
286 paragraph: ParagraphSelector,
287 /// Source relations field name.
288 from_field: String,
289 /// Destination relations field name.
290 to_field: String,
291 /// Package name identifying the entry to move.
292 package: String,
293 },
294 /// Reorder a subset of paragraphs in a deb822 file. Paragraphs that
295 /// have `key_field` are pulled out and re-inserted in the order
296 /// given by `order` (which lists their `key_field` values). Other
297 /// paragraphs stay in place: the i-th slot occupied by a
298 /// participating paragraph in the original document is filled by
299 /// the i-th key from `order`. Keys in `order` that aren't present
300 /// in the document are skipped.
301 ReorderParagraphs {
302 /// File to edit, relative to the package root.
303 file: PathBuf,
304 /// Field whose presence marks a paragraph as participating in
305 /// the reorder, and whose value identifies it.
306 key_field: String,
307 /// Desired order of `key_field` values among the participating
308 /// paragraphs.
309 order: Vec<String>,
310 },
311}
312
313/// Edits to a systemd unit file.
314///
315/// Systemd unit files are sectioned ini-style files (`[Unit]`, `[Service]`,
316/// `[Install]`, …). Each variant identifies a single section by name and
317/// targets one entry within it.
318///
319/// Multi-valued fields (e.g. `Alias=`, `After=`) are handled by
320/// [`Add`](Self::Add) / [`RemoveValue`](Self::RemoveValue) — these append a
321/// new value or remove a specific one without touching siblings.
322/// [`SetField`](Self::SetField) replaces every occurrence of the key with a
323/// single value, which is the right thing for scalar fields like `PIDFile=`
324/// but the wrong thing for multi-valued ones.
325#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
326#[serde(tag = "op", rename_all = "snake_case")]
327pub enum SystemdAction {
328 /// Set a scalar field. Replaces every existing entry with the given key.
329 SetField {
330 /// File to edit, relative to the package root.
331 file: PathBuf,
332 /// Section name, e.g. "Service".
333 section: String,
334 /// Field name (no trailing `=`).
335 field: String,
336 /// New value.
337 value: String,
338 },
339 /// Remove every entry with the given key.
340 RemoveField {
341 /// File to edit, relative to the package root.
342 file: PathBuf,
343 /// Section name.
344 section: String,
345 /// Field name.
346 field: String,
347 },
348 /// Rename every entry with `from` to `to`, preserving values.
349 RenameField {
350 /// File to edit, relative to the package root.
351 file: PathBuf,
352 /// Section name.
353 section: String,
354 /// Current field name.
355 from: String,
356 /// New field name.
357 to: String,
358 },
359 /// Append a new entry. Use for multi-valued fields like `After=` or
360 /// `Alias=` to add another value without disturbing siblings.
361 Add {
362 /// File to edit, relative to the package root.
363 file: PathBuf,
364 /// Section name.
365 section: String,
366 /// Field name.
367 field: String,
368 /// Value to append.
369 value: String,
370 },
371 /// Remove a specific value from a multi-valued field. Other values for
372 /// the same key are preserved.
373 RemoveValue {
374 /// File to edit, relative to the package root.
375 file: PathBuf,
376 /// Section name.
377 section: String,
378 /// Field name.
379 field: String,
380 /// Value to drop.
381 value: String,
382 },
383}
384
385/// Edits to a freedesktop `.desktop` entry file.
386///
387/// Desktop entry files are sectioned ini-style files with `[Group]`
388/// headers and locale-tagged keys (e.g. `Name[de]=...`). Each variant
389/// identifies one group and one entry within it.
390#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
391#[serde(tag = "op", rename_all = "snake_case")]
392pub enum DesktopIniAction {
393 /// Set a key. If `locale` is `None`, sets the unlocalised entry;
394 /// otherwise sets the entry tagged with `locale` (e.g. `de`).
395 SetField {
396 /// File to edit, relative to the package root.
397 file: PathBuf,
398 /// Group name, e.g. "Desktop Entry".
399 group: String,
400 /// Key name.
401 field: String,
402 /// Locale tag, if any.
403 #[serde(skip_serializing_if = "Option::is_none", default)]
404 locale: Option<String>,
405 /// New value.
406 value: String,
407 },
408 /// Remove a key. If `locale` is `None`, removes the unlocalised entry
409 /// only; if a locale is given, removes only that locale variant. To
410 /// drop every locale variant of a key, use [`RemoveAll`](Self::RemoveAll).
411 RemoveField {
412 /// File to edit, relative to the package root.
413 file: PathBuf,
414 /// Group name.
415 group: String,
416 /// Key name.
417 field: String,
418 /// Locale tag, if any.
419 #[serde(skip_serializing_if = "Option::is_none", default)]
420 locale: Option<String>,
421 },
422 /// Remove a key together with every locale variant.
423 RemoveAll {
424 /// File to edit, relative to the package root.
425 file: PathBuf,
426 /// Group name.
427 group: String,
428 /// Key name.
429 field: String,
430 },
431 /// Rename a key, preserving its value (and every locale variant).
432 RenameField {
433 /// File to edit, relative to the package root.
434 file: PathBuf,
435 /// Group name.
436 group: String,
437 /// Current key name.
438 from: String,
439 /// New key name.
440 to: String,
441 },
442}
443
444/// Edits to a YAML file.
445///
446/// A YAML file is a tree of mappings, sequences and scalars; the
447/// `parent_path` field navigates from the top-level document down to the
448/// mapping that owns the key being edited. An empty `parent_path` means
449/// the top-level mapping (the common case for Debian's flat YAML files
450/// like `debian/upstream/metadata`).
451///
452/// Each path component is either a string (mapping key) or an index
453/// (sequence position).
454#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
455#[serde(tag = "op", rename_all = "snake_case")]
456pub enum YamlAction {
457 /// Set a scalar value at `parent_path`'s mapping under `key`. Inserts
458 /// the key if missing. New keys are appended at the end of the
459 /// mapping; use [`SetFieldOrdered`](Self::SetFieldOrdered) to
460 /// position the new key according to a canonical field order
461 /// (e.g. DEP-12).
462 SetField {
463 /// File to edit, relative to the package root.
464 file: PathBuf,
465 /// Path from the document root to the parent mapping.
466 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 parent_path: Vec<YamlPathComponent>,
468 /// Key to set (string scalar).
469 key: String,
470 /// New value (string scalar).
471 value: String,
472 },
473 /// Like [`SetField`](Self::SetField), but when inserting a new key,
474 /// position it according to `field_order`. Keys not listed in
475 /// `field_order` are placed at the end. A no-op if the key already
476 /// exists with the requested value.
477 SetFieldOrdered {
478 /// File to edit, relative to the package root.
479 file: PathBuf,
480 /// Path from the document root to the parent mapping.
481 #[serde(default, skip_serializing_if = "Vec::is_empty")]
482 parent_path: Vec<YamlPathComponent>,
483 /// Key to set (string scalar).
484 key: String,
485 /// New value (string scalar).
486 value: String,
487 /// Canonical field order. Keys appearing earlier in this list
488 /// are placed earlier in the mapping.
489 field_order: Vec<String>,
490 },
491 /// Remove a key from the mapping at `parent_path`.
492 RemoveField {
493 /// File to edit, relative to the package root.
494 file: PathBuf,
495 /// Path from the document root to the parent mapping.
496 #[serde(default, skip_serializing_if = "Vec::is_empty")]
497 parent_path: Vec<YamlPathComponent>,
498 /// Key to remove.
499 key: String,
500 },
501 /// Rename a key in the mapping at `parent_path`, preserving its
502 /// value and position.
503 RenameField {
504 /// File to edit, relative to the package root.
505 file: PathBuf,
506 /// Path from the document root to the parent mapping.
507 #[serde(default, skip_serializing_if = "Vec::is_empty")]
508 parent_path: Vec<YamlPathComponent>,
509 /// Current key name.
510 from: String,
511 /// New key name.
512 to: String,
513 },
514}
515
516/// One step in a [`YamlAction`] path.
517#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
518#[serde(tag = "kind", rename_all = "snake_case")]
519pub enum YamlPathComponent {
520 /// A mapping key (string).
521 Key {
522 /// Key name.
523 key: String,
524 },
525 /// A sequence index (0-based).
526 Index {
527 /// Position.
528 index: usize,
529 },
530}
531
532/// Edits to a `debian/changelog`.
533///
534/// Operations target entries by their version, which is stable across
535/// minor edits. Change-line content is supplied verbatim — the applier
536/// preserves the changelog's existing indentation rules when re-rendering.
537#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
538#[serde(tag = "op", rename_all = "snake_case")]
539pub enum ChangelogAction {
540 /// Replace the change lines of the entry with the given version. The
541 /// `lines` are stored verbatim with their leading ` *`/` `
542 /// continuation prefix; the applier writes them as-is into the entry.
543 ReplaceEntryChanges {
544 /// File to edit, relative to the package root. Almost always
545 /// `debian/changelog`, but kept explicit for symmetry.
546 file: PathBuf,
547 /// Version string of the target entry (e.g. `2.6.0-1`).
548 version: String,
549 /// Replacement change lines (one per line, no trailing newline).
550 lines: Vec<String>,
551 },
552 /// Set the trailer datetime of the entry with the given version.
553 ///
554 /// The datetime is stored as an RFC 2822 string (`"Sun, 22 Apr 2018
555 /// 00:58:14 +0000"`) — what `chrono::DateTime::to_rfc2822` produces
556 /// and what changelog trailers use natively.
557 SetEntryDate {
558 /// File to edit, relative to the package root.
559 file: PathBuf,
560 /// Version string of the target entry.
561 version: String,
562 /// New datetime as an RFC 2822 string.
563 rfc2822: String,
564 },
565 /// Remove a bullet from the entry with the given version.
566 ///
567 /// The bullet is identified by its author attribution (the `[ Name ]`
568 /// header that introduces multi-author groups, or `None` for an entry
569 /// without one) and its body text (the bullet's lines joined with
570 /// `\n`, exactly as `debian_changelog`'s `Bullet::lines()` returns
571 /// them).
572 ///
573 /// `occurrence` is a 0-based index that disambiguates when several
574 /// bullets share the same `(author, text)` key: `0` removes the first
575 /// match, `1` the second, etc. The applier walks bullets in
576 /// `iter_changes_by_author` order. Whitespace between surviving
577 /// bullets is preserved.
578 RemoveBullet {
579 /// File to edit, relative to the package root.
580 file: PathBuf,
581 /// Version string of the target entry.
582 version: String,
583 /// Author header above the bullet, if any.
584 author: Option<String>,
585 /// Body text of the bullet (lines joined by `\n`).
586 text: String,
587 /// 0-based index among bullets sharing the same `(author, text)`
588 /// key. Defaults to `0` over the wire when omitted.
589 #[serde(default)]
590 occurrence: usize,
591 },
592 /// Replace the body lines of a bullet, identified the same way as in
593 /// [`RemoveBullet`](Self::RemoveBullet). `new_lines` are stored
594 /// without their ` *`/` ` continuation prefix — the applier
595 /// passes them straight to `Bullet::replace_with`, which re-adds the
596 /// proper indentation.
597 ReplaceBullet {
598 /// File to edit, relative to the package root.
599 file: PathBuf,
600 /// Version string of the target entry.
601 version: String,
602 /// Author header above the bullet, if any.
603 author: Option<String>,
604 /// Current body text of the bullet (lines joined by `\n`).
605 text: String,
606 /// 0-based index among bullets sharing the same `(author, text)`
607 /// key.
608 #[serde(default)]
609 occurrence: usize,
610 /// Replacement body lines.
611 new_lines: Vec<String>,
612 },
613 /// Replace the version of the entry currently identified by `version`
614 /// with `new_version`. A no-op if no entry has that version.
615 SetEntryVersion {
616 /// File to edit, relative to the package root.
617 file: PathBuf,
618 /// Current version string of the target entry.
619 version: String,
620 /// New version string to write into the entry header.
621 new_version: String,
622 },
623}
624/// Edits to a `debian/watch` file.
625///
626/// Watch files are line-oriented, with each non-comment line describing a
627/// release-monitor entry: a URL, a matching regexp for the version, and
628/// optional `opts=...` flags. We address an entry by its current URL,
629/// which is unique across the watch files we routinely fix.
630#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
631#[serde(tag = "op", rename_all = "snake_case")]
632pub enum WatchAction {
633 /// Replace the matching pattern (the regexp following the URL) of the
634 /// entry whose current URL is `url`. A no-op if no entry matches.
635 SetEntryMatchingPattern {
636 /// File to edit, relative to the package root. Almost always
637 /// `debian/watch`.
638 file: PathBuf,
639 /// Current URL of the target entry.
640 url: String,
641 /// New matching pattern.
642 new_pattern: String,
643 },
644 /// Remove an `opts=...` option from the entry whose current URL is
645 /// `url`. A no-op if no entry matches or the option isn't set.
646 RemoveEntryOption {
647 /// File to edit, relative to the package root.
648 file: PathBuf,
649 /// Current URL of the target entry.
650 url: String,
651 /// Name of the option to remove (e.g. `filenamemangle`).
652 option: String,
653 },
654 /// Set (or insert) an `opts=...` option on the entry whose current URL
655 /// is `url`. A no-op if no entry matches or the option already has the
656 /// requested value.
657 SetEntryOption {
658 /// File to edit, relative to the package root.
659 file: PathBuf,
660 /// Current URL of the target entry.
661 url: String,
662 /// Name of the option to set (e.g. `dversionmangle`).
663 option: String,
664 /// New value for the option.
665 value: String,
666 },
667 /// Replace the URL of the entry whose current URL is `url`. A no-op if
668 /// no entry matches.
669 SetEntryUrl {
670 /// File to edit, relative to the package root.
671 file: PathBuf,
672 /// Current URL of the target entry.
673 url: String,
674 /// New URL.
675 new_url: String,
676 },
677 /// Convert the v5 entry whose current URL is `url` to its template
678 /// form (Template:/Owner:/Project: for GitHub, Template:/Dist: for
679 /// CPAN/PyPI, etc.). A no-op if the entry is already a template,
680 /// no template matches the URL/pattern, or no entry has that URL.
681 ConvertEntryToTemplate {
682 /// File to edit, relative to the package root.
683 file: PathBuf,
684 /// Current URL of the target entry.
685 url: String,
686 },
687}
688
689/// Edits to a Makefile (typically `debian/rules`).
690///
691/// Recipes are addressed by their exact current text (including leading
692/// indentation). This avoids index drift when multiple recipe edits target
693/// the same rule.
694#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
695#[serde(tag = "op", rename_all = "snake_case")]
696pub enum MakefileAction {
697 /// Replace the first recipe whose text exactly matches `recipe` in the
698 /// rule whose primary target is `target`. A no-op if no rule or recipe
699 /// matches.
700 ReplaceRecipe {
701 /// File to edit, relative to the package root.
702 file: PathBuf,
703 /// Primary target of the rule containing the recipe.
704 target: String,
705 /// Current recipe text, matched verbatim (including indentation).
706 recipe: String,
707 /// Replacement recipe text. The applier preserves the original
708 /// recipe's leading whitespace if `new_recipe` doesn't start with
709 /// whitespace itself.
710 new_recipe: String,
711 },
712 /// Remove the first recipe whose text exactly matches `recipe` from the
713 /// rule whose primary target is `target`. A no-op if no rule or recipe
714 /// matches.
715 RemoveRecipe {
716 /// File to edit, relative to the package root.
717 file: PathBuf,
718 /// Primary target of the rule containing the recipe.
719 target: String,
720 /// Recipe text, matched verbatim (including indentation).
721 recipe: String,
722 },
723 /// Replace the value of the first variable definition for `name`.
724 /// A no-op if no such variable exists.
725 SetVariable {
726 /// File to edit, relative to the package root.
727 file: PathBuf,
728 /// Variable name (matched exactly).
729 name: String,
730 /// New right-hand side, verbatim (no quoting applied).
731 value: String,
732 },
733 /// Change the assignment operator on the first variable definition
734 /// for `name` (e.g. `:=` to `?=`). A no-op if no such variable
735 /// exists or it already uses `operator`.
736 SetVariableOperator {
737 /// File to edit, relative to the package root.
738 file: PathBuf,
739 /// Variable name (matched exactly).
740 name: String,
741 /// New assignment operator (`=`, `:=`, `?=`, `+=`).
742 operator: String,
743 },
744 /// Remove the first variable definition for `name`. A no-op if no such
745 /// variable exists.
746 RemoveVariable {
747 /// File to edit, relative to the package root.
748 file: PathBuf,
749 /// Variable name (matched exactly).
750 name: String,
751 },
752 /// Remove the first rule whose primary target is `target`. A no-op if
753 /// no such rule exists.
754 RemoveRule {
755 /// File to edit, relative to the package root.
756 file: PathBuf,
757 /// Primary target of the rule to remove.
758 target: String,
759 },
760 /// Remove `target` from the prerequisites of the `.PHONY` rule. If
761 /// `.PHONY` becomes empty, the rule itself is removed. A no-op if
762 /// the target is not listed.
763 RemovePhonyTarget {
764 /// File to edit, relative to the package root.
765 file: PathBuf,
766 /// Target name to remove from `.PHONY`.
767 target: String,
768 },
769 /// Rename a target on the first rule that has it. A no-op if no rule
770 /// has the old target.
771 RenameRuleTarget {
772 /// File to edit, relative to the package root.
773 file: PathBuf,
774 /// Old target name (matched exactly after trimming).
775 from_target: String,
776 /// New target name.
777 to_target: String,
778 },
779 /// Append a new rule with `target` and the given (possibly empty)
780 /// prerequisites. The applier does not check for an existing rule —
781 /// detectors must guard against duplicates themselves.
782 AddRule {
783 /// File to edit, relative to the package root.
784 file: PathBuf,
785 /// Target name for the new rule.
786 target: String,
787 /// Prerequisite targets (in order).
788 prerequisites: Vec<String>,
789 },
790 /// Add `target` to the prerequisites of the `.PHONY` rule. A no-op if
791 /// `.PHONY` already lists `target`. If no `.PHONY` rule exists, the
792 /// applier creates one.
793 AddPhonyTarget {
794 /// File to edit, relative to the package root.
795 file: PathBuf,
796 /// Target name to add to `.PHONY`.
797 target: String,
798 },
799 /// Add an `include <path>` directive. A no-op if the file is already
800 /// included.
801 AddInclude {
802 /// File to edit, relative to the package root.
803 file: PathBuf,
804 /// Path to include (e.g. `/usr/share/dpkg/pkg-info.mk`).
805 path: String,
806 },
807 /// Replace the first variable definition for `name` with an
808 /// `include <path>` directive. A no-op if the variable doesn't
809 /// exist or `path` is already included. Used to migrate
810 /// `DEB_HOST_ARCH := $(shell dpkg-architecture -qDEB_HOST_ARCH)` and
811 /// friends to a single `include /usr/share/dpkg/architecture.mk`,
812 /// keeping the include in the variable's old position.
813 ReplaceVariableWithInclude {
814 /// File to edit, relative to the package root.
815 file: PathBuf,
816 /// Variable name to replace (matched exactly).
817 name: String,
818 /// Path to include in place of the variable.
819 path: String,
820 },
821 /// Insert `include <path>` immediately before the first variable
822 /// definition whose name is `before_variable`. A no-op if the
823 /// variable doesn't exist or `path` is already included.
824 InsertIncludeBeforeVariable {
825 /// File to edit, relative to the package root.
826 file: PathBuf,
827 /// Path to include.
828 path: String,
829 /// Variable name to anchor the insertion against.
830 before_variable: String,
831 },
832}
833
834/// Edits to a DEP-3 patch header.
835///
836/// DEP-3 headers live at the top of a quilt patch (under
837/// `debian/patches/`) followed by a blank line and the unified diff. The
838/// applier parses just the header (everything before the first `---`,
839/// `diff `, or `Index:` line), edits it, and reassembles the file.
840#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
841#[serde(tag = "op", rename_all = "snake_case")]
842pub enum Dep3Action {
843 /// Set a field's value, inserting it if missing. The field is added in
844 /// the patch header's existing position when present, or appended.
845 SetField {
846 /// Patch file to edit, relative to the package root (e.g.
847 /// `debian/patches/foo.patch`).
848 file: PathBuf,
849 /// Field name (case-sensitive, e.g. `Author`).
850 field: String,
851 /// New value.
852 value: String,
853 },
854 /// Remove a field. A no-op if the field isn't present.
855 RemoveField {
856 /// Patch file to edit, relative to the package root.
857 file: PathBuf,
858 /// Field name to remove.
859 field: String,
860 },
861 /// Rename `from_field` to `to_field`, preserving its value. A no-op
862 /// if `from_field` isn't present. If `to_field` already exists, it is
863 /// overwritten.
864 RenameField {
865 /// Patch file to edit, relative to the package root.
866 file: PathBuf,
867 /// Current field name.
868 from_field: String,
869 /// New field name.
870 to_field: String,
871 },
872}
873
874/// Identifies a specific override line for in-place edits.
875///
876/// We address lines by their visible content rather than by index because
877/// other actions may shift the line numbering between detect and apply.
878#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
879pub struct OverrideLineSelector {
880 /// Tag name (matched exactly).
881 pub tag: String,
882 /// Optional info string the override carries (matched exactly, no
883 /// wildcard expansion). `None` matches lines with no info.
884 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub info: Option<String>,
886 /// Optional package name from the `package:` prefix. `None` matches
887 /// lines without a package spec.
888 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub package: Option<String>,
890}
891
892/// Edits to a `debian/source/lintian-overrides` or
893/// `debian/<pkg>.lintian-overrides` file.
894#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
895#[serde(tag = "op", rename_all = "snake_case")]
896pub enum LintianOverridesAction {
897 /// Append a new override line. If the file does not exist it is
898 /// created (including any missing parent directories). The line is
899 /// only added when no existing line already overrides the same tag
900 /// (same tag + same optional info), so the action is idempotent.
901 AddLine {
902 /// File to edit, relative to the package root (e.g.
903 /// `debian/source/lintian-overrides` or
904 /// `debian/mypkg.lintian-overrides`).
905 file: PathBuf,
906 /// Optional package name prefix (e.g. `mypkg`).
907 #[serde(default, skip_serializing_if = "Option::is_none")]
908 package: Option<String>,
909 /// Tag name.
910 tag: String,
911 /// Optional info string.
912 #[serde(default, skip_serializing_if = "Option::is_none")]
913 info: Option<String>,
914 },
915 /// Drop the first override line that matches `selector`. Each
916 /// DropLine action consumes one line — to remove N copies of the
917 /// same line, emit N actions. If the file becomes empty (no
918 /// override lines remain), it is removed entirely.
919 DropLine {
920 /// File to edit, relative to the package root.
921 file: PathBuf,
922 /// Which override line to drop.
923 selector: OverrideLineSelector,
924 },
925 /// Rename the tag on every line whose current tag is `from_tag`. The
926 /// rest of each line (whitespace, comments, package spec, info) is
927 /// preserved verbatim.
928 RenameTag {
929 /// File to edit, relative to the package root.
930 file: PathBuf,
931 /// Old tag name (matched exactly).
932 from_tag: String,
933 /// New tag name.
934 to_tag: String,
935 },
936 /// Rewrite the info text on the first line that matches `selector`.
937 /// Only the info portion changes — the package spec, tag, and
938 /// surrounding whitespace are preserved.
939 SetLineInfo {
940 /// File to edit, relative to the package root.
941 file: PathBuf,
942 /// Which override line to update.
943 selector: OverrideLineSelector,
944 /// New info text. Empty string removes the info entirely.
945 new_info: String,
946 },
947}
948
949/// Edits to a maintscript file.
950///
951/// Each line in a maintscript file is an independent dpkg-maintscript-helper
952/// invocation. We address entries by their exact text, mirroring how
953/// [`MakefileAction::ReplaceRecipe`] addresses recipe lines.
954#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
955#[serde(tag = "op", rename_all = "snake_case")]
956pub enum MaintscriptAction {
957 /// Drop the first entry whose trimmed line text equals `entry`.
958 /// Comments immediately preceding the dropped line are also removed.
959 /// If the file ends up empty (no entries remain), it is removed
960 /// entirely. Each `DropEntry` consumes one matching line — to remove
961 /// N copies of the same entry, emit N actions.
962 DropEntry {
963 /// File to edit, relative to the package root.
964 file: PathBuf,
965 /// Entry text to drop, matched after trimming surrounding
966 /// whitespace.
967 entry: String,
968 },
969}
970
971/// Edits to a `debian/debcargo.toml` file.
972///
973/// Debcargo manages its own control file; we manipulate scalar fields under
974/// the `[source]` table directly. Only a small set of operations is needed
975/// in practice — the equivalent of typed setters on the generated control
976/// fields (Vcs-Git, Vcs-Browser, Standards-Version, Section).
977#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
978#[serde(tag = "op", rename_all = "snake_case")]
979pub enum DebcargoAction {
980 /// Set a string field on the `[source]` table. Creates the table and/or
981 /// the field if absent. Overwrites any existing value.
982 SetSourceField {
983 /// File to edit, relative to the package root. Almost always
984 /// `debian/debcargo.toml`.
985 file: PathBuf,
986 /// Key inside `[source]` (e.g. `vcs_git`, `vcs_browser`,
987 /// `section`, `standards_version`).
988 field: String,
989 /// New string value.
990 value: String,
991 },
992 /// Set a boolean field at the top level of the file. Creates the field if
993 /// absent. Overwrites any existing value.
994 SetTopLevelBool {
995 /// File to edit, relative to the package root. Almost always
996 /// `debian/debcargo.toml`.
997 file: PathBuf,
998 /// Top-level key (e.g. `collapse_features`).
999 field: String,
1000 /// New boolean value.
1001 value: bool,
1002 },
1003}
1004
1005/// Run an external command that mutates the working tree.
1006///
1007/// Use sparingly: prefer typed actions whenever possible. The intended
1008/// use is tools like `debconf-updatepo` that produce changes we can't
1009/// describe declaratively.
1010#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1011#[serde(tag = "op", rename_all = "snake_case")]
1012pub enum RunCommandAction {
1013 /// Run `argv` in the package root. The applier snapshots `scope`
1014 /// before and after the run, considering the action a change iff any
1015 /// file under `scope` was added, removed, or had its bytes change.
1016 /// A non-zero exit code is a fixer error. ENOENT on `argv[0]` is
1017 /// reported as [`FixerError::MissingDependency`].
1018 Run {
1019 /// Argument vector. `argv[0]` is resolved via PATH.
1020 argv: Vec<String>,
1021 /// Subtree to monitor for changes, relative to the package root.
1022 /// Use `.` to monitor the entire tree.
1023 scope: PathBuf,
1024 /// Environment overrides applied on top of the inherited env.
1025 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1026 env: Vec<(String, String)>,
1027 },
1028}
1029
1030/// Filesystem-level edits.
1031#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1032#[serde(tag = "op", rename_all = "snake_case")]
1033pub enum FilesystemAction {
1034 /// Set the file mode (e.g. mark a script executable).
1035 SetMode {
1036 /// File to chmod, relative to the package root.
1037 file: PathBuf,
1038 /// New mode bits.
1039 mode: u32,
1040 },
1041 /// Delete a file.
1042 Delete {
1043 /// File to delete, relative to the package root.
1044 file: PathBuf,
1045 },
1046 /// Move a file from one path to another, atomically when possible.
1047 /// Creates the destination's parent directory if needed.
1048 Rename {
1049 /// Source path, relative to the package root.
1050 file: PathBuf,
1051 /// Destination path, relative to the package root.
1052 to: PathBuf,
1053 },
1054 /// Remove a directory if it is empty. A no-op if the directory has
1055 /// any remaining entries — useful as a follow-up to a `Delete` that
1056 /// might have been the last file in its parent directory.
1057 RemoveDirIfEmpty {
1058 /// Directory to remove, relative to the package root. The
1059 /// applier reuses the `file` field name for grouping purposes.
1060 file: PathBuf,
1061 },
1062 /// Overwrite (or create) a file with the given content.
1063 Write {
1064 /// File to write, relative to the package root.
1065 file: PathBuf,
1066 /// Bytes to write.
1067 content: Vec<u8>,
1068 },
1069 /// Replace a byte range in a file.
1070 ReplaceText {
1071 /// File to edit, relative to the package root.
1072 file: PathBuf,
1073 /// Range to replace.
1074 range: TextRange,
1075 /// Replacement text.
1076 replacement: String,
1077 },
1078 /// Replace every occurrence of a literal string with another. Operates
1079 /// on the file's textual content with no awareness of file structure.
1080 Substitute {
1081 /// File to edit, relative to the package root.
1082 file: PathBuf,
1083 /// String to find (literal, not a regex).
1084 from: String,
1085 /// Replacement string.
1086 to: String,
1087 },
1088 /// Normalise the file's line endings to LF (i.e. convert any CRLF
1089 /// sequences to LF). Carries no payload other than the path: each
1090 /// applier reads the current file, performs the conversion, and
1091 /// writes back. Modelling this as its own variant (rather than as a
1092 /// `Write` carrying the converted bytes) keeps the diagnostic stream
1093 /// declarative — anyone reading it sees the *intent* and not a byte
1094 /// blob — and lets an LSP host emit a structural `TextEdit` derived
1095 /// from the open buffer rather than from a possibly-stale snapshot.
1096 NormalizeLineEndings {
1097 /// File to convert, relative to the package root.
1098 file: PathBuf,
1099 },
1100}
1101
1102/// Identifies a paragraph in a deb822 file.
1103///
1104/// The variants are a union of file-format vocabularies. Each variant is
1105/// labelled with the family of files it applies to; the applier validates
1106/// that a selector matches the file it's targeting (e.g.
1107/// [`Binary`](Self::Binary) on `debian/copyright` is an error).
1108///
1109/// File-format-agnostic selectors ([`Index`](Self::Index),
1110/// [`ByKey`](Self::ByKey)) work on any deb822 file, including ones we
1111/// don't have a typed wrapper for.
1112#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1113#[serde(tag = "kind", rename_all = "snake_case")]
1114pub enum ParagraphSelector {
1115 /// debian/control: the source paragraph.
1116 Source,
1117 /// debian/control: a binary paragraph identified by its `Package:` field.
1118 Binary {
1119 /// Package name.
1120 package: String,
1121 },
1122 /// debian/copyright: the header paragraph (carrying `Format:`,
1123 /// `Upstream-Name:`, etc.).
1124 CopyrightHeader,
1125 /// debian/copyright: the paragraph whose `Files:` field matches the
1126 /// given glob string exactly.
1127 CopyrightFiles {
1128 /// Files-glob string, matched literally against the field value.
1129 glob: String,
1130 },
1131 /// debian/copyright: a standalone License paragraph (no `Files:` field)
1132 /// whose License synopsis equals `name`.
1133 CopyrightLicense {
1134 /// License short-name as it appears on the first line of the
1135 /// `License:` field (e.g. `GPL-2+`).
1136 name: String,
1137 },
1138 /// File-format-agnostic: the Nth paragraph (0-indexed). Use sparingly:
1139 /// indices shift as paragraphs are inserted or removed.
1140 Index {
1141 /// Zero-based paragraph index.
1142 index: usize,
1143 },
1144 /// File-format-agnostic: the first paragraph whose `field` has exactly
1145 /// the given `value`.
1146 ByKey {
1147 /// Field name to match (case-sensitive, as deb822 keys are).
1148 field: String,
1149 /// Required value.
1150 value: String,
1151 },
1152}
1153
1154/// A byte range in a file.
1155#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1156pub struct TextRange {
1157 /// Start byte offset (inclusive).
1158 pub start: usize,
1159 /// End byte offset (exclusive).
1160 pub end: usize,
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166
1167 #[test]
1168 fn action_serializes_with_kind_tag() {
1169 let action = Action::Deb822(Deb822Action::SetField {
1170 file: PathBuf::from("debian/control"),
1171 paragraph: ParagraphSelector::Binary {
1172 package: "foo".into(),
1173 },
1174 field: "Priority".into(),
1175 value: "optional".into(),
1176 });
1177 let json = serde_json::to_value(&action).unwrap();
1178 assert_eq!(json["kind"], "deb822");
1179 assert_eq!(json["op"], "set_field");
1180 assert_eq!(json["field"], "Priority");
1181 assert_eq!(json["value"], "optional");
1182 assert_eq!(json["paragraph"]["kind"], "binary");
1183 assert_eq!(json["paragraph"]["package"], "foo");
1184 }
1185
1186 #[test]
1187 fn action_roundtrips_through_json() {
1188 let original = Action::Filesystem(FilesystemAction::SetMode {
1189 file: PathBuf::from("debian/rules"),
1190 mode: 0o755,
1191 });
1192 let json = serde_json::to_string(&original).unwrap();
1193 let parsed: Action = serde_json::from_str(&json).unwrap();
1194 assert_eq!(original, parsed);
1195 }
1196}