Skip to main content

debian_workspace/
action.rs

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