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}