Skip to main content

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}