Skip to main content

debian_workspace/
action.rs

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