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