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