Skip to main content

pushkin_core/
manifest.rs

1//! `pushkin.toml` parsing (spec §11). Strict by construction: serde with
2//! `deny_unknown_fields` everywhere, unknown-key errors enriched with
3//! nearest-candidate suggestions, mapping→contract references resolved once
4//! at the boundary (spec §7.1) so shorthand never silently changes meaning.
5//!
6//! One proportionality exception, mechanical rather than judged (F79,
7//! ADR-0007): a field wrapped in [`DisplayOnly`] degrades an unrecognized
8//! string to its default and carries the raw value for disclosure, because a
9//! typo in a field that reaches no verdict should cost a NOTE line, not every
10//! verb. Every other key and value keeps rejecting.
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use serde::de::value::StrDeserializer;
14use serde::de::{DeserializeOwned, Deserializer};
15use serde::Deserialize;
16use thiserror::Error;
17
18pub const SUPPORTED_VERSION: u32 = 1;
19
20/// Keys a typo in the manifest is matched against for candidate suggestions.
21const KNOWN_KEYS: &[&str] = &[
22    "version",
23    "schema_epoch",
24    "canonical",
25    "authoring",
26    "contracts",
27    "name",
28    "source",
29    "emit",
30    "mappings",
31    "glob",
32    "require",
33    "gates",
34    "suppression_comments",
35    "protected_paths",
36    "read_only_paths",
37    "retrieval_paths",
38    "retrieval_tool",
39    "db",
40    "direction",
41    "provider",
42    "rls_tests",
43    "features",
44    "git_hooks",
45    "floor",
46    "commands",
47    "run",
48    "scope",
49    "inputs",
50    "install",
51    "on_stop",
52    "on_new_read_only",
53    "reconcile_ignored",
54    "covers_ignored_of",
55];
56
57#[derive(Debug, Error)]
58pub enum ManifestError {
59    #[error("manifest is not valid TOML or violates the schema: {message}")]
60    Invalid { message: String },
61    #[error("manifest version {found} is unsupported (this binary supports {supported})")]
62    UnsupportedVersion { found: u32, supported: u32 },
63    #[error(
64        "mapping references undeclared contract '{reference}'; declared contracts: {candidates}"
65    )]
66    UnknownContract {
67        reference: String,
68        candidates: String,
69    },
70    #[error("glob '{glob}' is invalid: {message}")]
71    BadGlob { glob: String, message: String },
72    #[error(
73        "schema_epoch must be a positive integer (a human increments it on \
74         epoch-sensitive change, R9); found {found}"
75    )]
76    NonPositiveEpoch { found: u32 },
77    #[error(
78        "[[floor.commands]] declares duplicate name '{name}'; every floor \
79         command needs a unique name (--skip and covers_ignored_of both \
80         address commands by name)"
81    )]
82    DuplicateFloorCommand { name: String },
83    #[error(
84        "floor command '{name}' declares an empty `run` array; a command with \
85         nothing to run cannot produce a verdict (remove the entry, or give it \
86         an argv: run = [\"cargo\", \"fmt\", \"--check\"])"
87    )]
88    EmptyFloorRun { name: String },
89    #[error(
90        "floor command '{name}' declares covers_ignored_of = '{reference}', \
91         which is not a declared command; declared commands: {candidates}"
92    )]
93    UnknownFloorCoverage {
94        name: String,
95        reference: String,
96        candidates: String,
97    },
98    #[error(
99        "floor command '{name}' declares covers_ignored_of = '{name}' — a \
100         command cannot cover its own ignored tests; the accounting would \
101         balance while executing nothing new"
102    )]
103    SelfFloorCoverage { name: String },
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
107#[serde(transparent)]
108pub struct ContractName(String);
109
110impl ContractName {
111    #[must_use]
112    pub fn as_str(&self) -> &str {
113        &self.0
114    }
115}
116
117#[derive(Debug, Deserialize)]
118#[serde(deny_unknown_fields)]
119pub struct Contract {
120    pub name: ContractName,
121    pub source: String,
122    pub emit: Vec<String>,
123}
124
125#[derive(Debug, Deserialize)]
126#[serde(deny_unknown_fields)]
127pub struct Mapping {
128    pub glob: String,
129    pub contracts: Vec<ContractName>,
130    pub require: Option<String>,
131}
132
133#[derive(Debug, Deserialize)]
134#[serde(deny_unknown_fields)]
135pub struct Gates {
136    pub suppression_comments: Option<String>,
137    #[serde(default)]
138    pub protected_paths: Vec<String>,
139    /// Globs whose COMMITTED files are read-only to agents: new files may
140    /// be created (the RED-suite authoring window), files in git HEAD may
141    /// not be modified — N10 ("committed first, read-only hereafter") as a
142    /// product gate. Unwaivable, like `protected_paths`.
143    #[serde(default)]
144    pub read_only_paths: Vec<String>,
145    /// SPIKE — the read contract. Globs whose files an agent must reach
146    /// through `retrieval_tool` rather than an unbounded whole-file read.
147    ///
148    /// A read carrying an explicit range is allowed **on the `Read` surface**,
149    /// where the host supplies `offset`/`limit` as structured fields the gate
150    /// can verify: that is the deliberate shape, and the host's
151    /// read-before-edit gate needs it. A shell reader gets no such allowance,
152    /// because a bound spelled inside a command string can only be inferred
153    /// and `head -999999` is indistinguishable from `head -50`. The asymmetry
154    /// is the verifiability of the bound, not an inconsistency (hook-matcher-gap
155    /// charter, Addendum D, HM-10).
156    #[serde(default)]
157    pub retrieval_paths: Vec<String>,
158    /// The tool a denied read is redirected to. A manifest string, never a
159    /// hard-coded vendor, so a future in-tree Pushkin index can take the
160    /// slot without changing the gate.
161    pub retrieval_tool: Option<String>,
162}
163
164/// `[db]` (spec §5.3, §10): drift-gate configuration. `direction` names
165/// the source of truth — "contract" (generated DDL is desired state) or
166/// "database" (introspected schema is; contracts must follow).
167#[derive(Debug, Deserialize)]
168#[serde(deny_unknown_fields)]
169pub struct Db {
170    pub direction: DbDirection,
171    pub provider: Option<String>,
172    pub rls_tests: Option<String>,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
176#[serde(rename_all = "lowercase")]
177pub enum DbDirection {
178    Contract,
179    Database,
180}
181
182/// `[features]` — repo-level switches for whole enforcement planes,
183/// committed in the manifest so every surface (init, doctor, the
184/// pre-commit floor, CI) reads one truth. The manifest is a protected
185/// path, so the switch is human-owned by construction. Absent table =
186/// every feature enabled: only a positively parsed `false` turns a
187/// plane off, mirroring the N13 principle (act on positive probes,
188/// never on ambiguity).
189#[derive(Debug, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct Features {
192    /// The git-plane floor as one switch: the lefthook pre-commit
193    /// block, the native `.git/hooks` shim, and the staged check they
194    /// both run. `false` = init refuses to install either surface,
195    /// doctor stops checking them, and `check --staged` passes with a
196    /// stderr notice. Deliberately NOT covered: agent-side write gating
197    /// (`hook`, stdin `check`) — the flag turns off commit protection,
198    /// never write-time contract enforcement.
199    #[serde(default = "default_enabled")]
200    pub git_hooks: bool,
201}
202
203impl Default for Features {
204    fn default() -> Self {
205        Self {
206            git_hooks: default_enabled(),
207        }
208    }
209}
210
211fn default_enabled() -> bool {
212    true
213}
214
215/// `[floor]` (spec §8.2 stage 5) — the declared mechanical floor: the one
216/// committed list of commands `pushkin floor`, the `Makefile`, `scripts/floor.sh`
217/// and CI all read, so "mirrors CI commands exactly" is a fact rather than a
218/// promise someone has to remember.
219///
220/// Optional. A repo without a declared floor is a valid manifest; `pushkin
221/// floor` is the surface that refuses to run against one, because pre-empting
222/// that here would break every other verb on a manifest that was never wrong.
223#[derive(Debug, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct Floor {
226    /// Run in declared order. Order is load-bearing: it is the order the verb
227    /// executes and reports in.
228    #[serde(default)]
229    pub commands: Vec<FloorCommand>,
230}
231
232/// How far a command's verdict reaches — **declared, never inferred.**
233///
234/// Nothing in this pass executes differently per scope; everything runs
235/// whole-repo. The field exists because the alternative is a tool guessing at
236/// decomposability, and a wrong guess narrows the check silently. It is the
237/// contract a future warm charter reads, recorded honestly now while the facts
238/// are in front of us: only `cargo fmt --check` is per-file faithful (and only
239/// for a NAMED file — `cargo fmt` discovery skips cfg-gated out-of-line modules,
240/// rustfmt #4034), clippy is a whole-crate rustc driver, and cargo test targets
241/// are crate-level binaries.
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
243#[serde(rename_all = "snake_case")]
244pub enum FloorScope {
245    PerFile,
246    PerCrate,
247    WholeRepo,
248}
249
250/// What a command's verdict depends on BEYOND the repo contents.
251///
252/// `network` is the honest one: `cargo deny`'s `advisories` check consults the
253/// `RustSec` DB, so the floor is not a pure function of the commit — an unchanged
254/// commit can newly fail when an advisory publishes. The verb discloses that in
255/// its output rather than letting it ambush the next unrelated PR (F69 rider).
256///
257/// `machine` is the second one, and it has a different cause: a command that
258/// measures wall-clock time answers about the machine as much as about the
259/// commit. `bench` asserts a latency threshold, so a busy machine fails a commit
260/// that passes quiet — observed as a full floor going RED at 836/1 under
261/// concurrent cargo builds and green at 837/0 on the same tree idle (F76
262/// addendum). Disclosed separately from `network` because the reasons differ and
263/// a reader who cannot tell them apart learns to skip both.
264///
265/// `repo` is the `Default` because it is what an unrecognized value degrades
266/// to (F79, ADR-0007): the most conservative reading, claiming no dependency
267/// the command did not declare, so a typo never adds a command to the network
268/// or machine disclosure and never removes one spelled correctly.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
270#[serde(rename_all = "snake_case")]
271pub enum FloorInputs {
272    #[default]
273    Repo,
274    Toolchain,
275    Network,
276    Machine,
277}
278
279/// The marker for a manifest field whose value PROVABLY reaches no verdict
280/// (F79, ADR-0007 Option A).
281///
282/// Strictness in this file is proportional to consequence. An unknown KEY may
283/// be a gate rule being silently ignored, so it rejects (ADR-0001). An unknown
284/// value in a field that only feeds disclosure text costs one NOTE line, and
285/// charging it the whole-manifest price took every verb down together — in the
286/// fail-open direction (F71). So: a field wrapped in this type degrades an
287/// unrecognized **string** to `T::default()` and keeps the raw text, so the
288/// value carries its own provenance to wherever it is rendered. A value of the
289/// wrong TOML type is a malformed manifest, not a typo, and still rejects.
290///
291/// The leniency is a property of the type, not of a reviewer's per-field call:
292/// `crates/pushkin-core/tests/manifest_display_only.rs` scans this file and
293/// asserts the marker sits on exactly one field. Wrapping another field changes
294/// that list, which is what makes it a reviewed act rather than a silent
295/// widening — Option C of the record (every unknown value warns) is the
296/// N13-forbidden shape, and this guard is what keeps A from drifting into it.
297///
298/// Compares equal to the value it carries, so call sites and committed suites
299/// that compare against the plain enum keep reading naturally. A degradation
300/// is never silent: the consumer that renders the field owns the disclosure.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct DisplayOnly<T> {
303    value: T,
304    degraded_from: Option<String>,
305}
306
307impl<T> DisplayOnly<T> {
308    #[must_use]
309    pub fn value(&self) -> &T {
310        &self.value
311    }
312
313    /// The raw text the parser did not recognize, when the value degraded.
314    #[must_use]
315    pub fn degraded_from(&self) -> Option<&str> {
316        self.degraded_from.as_deref()
317    }
318}
319
320impl<T: PartialEq> PartialEq<T> for DisplayOnly<T> {
321    fn eq(&self, other: &T) -> bool {
322        self.value == *other
323    }
324}
325
326impl<'de, T: DeserializeOwned + Default> Deserialize<'de> for DisplayOnly<T> {
327    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
328        let raw = String::deserialize(deserializer)?;
329        let recognized = T::deserialize(StrDeserializer::<D::Error>::new(&raw));
330        Ok(match recognized {
331            Ok(value) => Self {
332                value,
333                degraded_from: None,
334            },
335            Err(_) => Self {
336                value: T::default(),
337                degraded_from: Some(raw),
338            },
339        })
340    }
341}
342
343#[derive(Debug, Deserialize)]
344#[serde(deny_unknown_fields)]
345pub struct FloorCommand {
346    /// Unique across the table: `--skip` and `covers_ignored_of` both address
347    /// commands by name.
348    pub name: String,
349    /// argv, never a shell string — a shell string is an injection surface and
350    /// a quoting-bug surface, and neither belongs in a gate.
351    pub run: Vec<String>,
352    pub scope: FloorScope,
353    /// Display-only (F79): its three readers are all disclosure text in the
354    /// floor verb, so an unrecognized value degrades to `repo` with a NOTE
355    /// instead of rejecting the manifest.
356    pub inputs: DisplayOnly<FloorInputs>,
357    /// The install hint a missing binary's error carries (the `db.rs`
358    /// `run_tool` pattern: an absent tool is a loud named failure, never a
359    /// silent skip).
360    pub install: Option<String>,
361    /// Whether the Stop sweep runs this command. Default `false` — the
362    /// conservative posture, and the whole Stop integration ships dark until a
363    /// human rules a command in.
364    #[serde(default)]
365    pub on_stop: bool,
366    /// Whether this command runs as a pre-RED lint gate: when a commit stages a
367    /// NEW file under a `read_only_paths` glob, `check --staged` runs this
368    /// command before the file is frozen (F72). Default `false`, mirroring
369    /// `on_stop` — the mechanism ships dark until a human opts a command in.
370    #[serde(default)]
371    pub on_new_read_only: bool,
372    /// Whether this command's output carries cargo-test-shaped `test result:`
373    /// lines whose ignored count must be accounted for.
374    #[serde(default)]
375    pub reconcile_ignored: bool,
376    /// Declares that THIS command executes the tests the named command reported
377    /// as ignored. The link is declared rather than guessed because the
378    /// accounting is only as trustworthy as the claim it checks.
379    pub covers_ignored_of: Option<String>,
380}
381
382#[derive(Debug, Deserialize)]
383#[serde(deny_unknown_fields)]
384struct RawManifest {
385    version: u32,
386    schema_epoch: Option<u32>,
387    canonical: String,
388    authoring: String,
389    #[serde(default)]
390    contracts: Vec<Contract>,
391    #[serde(default)]
392    mappings: Vec<Mapping>,
393    gates: Gates,
394    db: Option<Db>,
395    #[serde(default)]
396    features: Features,
397    floor: Option<Floor>,
398}
399
400/// A parsed, boundary-resolved manifest. Globs are compiled once here.
401pub struct Manifest {
402    pub version: u32,
403    /// R9 (approved 2026-08-13): the workspace-wide schema epoch, owned by
404    /// the manifest and human-incremented. The SOLE source authoring,
405    /// compile, and the daemon probe read. Absent key = 1 (pre-R9
406    /// manifests keep parsing; the repo's own manifest declares it).
407    pub schema_epoch: u32,
408    pub canonical: String,
409    pub authoring: String,
410    pub contracts: Vec<Contract>,
411    pub mappings: Vec<Mapping>,
412    pub gates: Gates,
413    pub db: Option<Db>,
414    pub features: Features,
415    /// `[floor]` — absent when the repo declares no mechanical floor. The verb
416    /// owns that refusal, not the parser.
417    pub floor: Option<Floor>,
418    mapping_globs: GlobSet,
419    protected_globs: GlobSet,
420    read_only_globs: GlobSet,
421    retrieval_globs: GlobSet,
422}
423
424impl std::fmt::Debug for Manifest {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        // GlobSet has no Debug; show the declarative fields only.
427        f.debug_struct("Manifest")
428            .field("version", &self.version)
429            .field("schema_epoch", &self.schema_epoch)
430            .field("canonical", &self.canonical)
431            .field("authoring", &self.authoring)
432            .field("contracts", &self.contracts)
433            .field("mappings", &self.mappings)
434            .field("gates", &self.gates)
435            .field("db", &self.db)
436            .field("features", &self.features)
437            .field("floor", &self.floor)
438            .finish_non_exhaustive()
439    }
440}
441
442impl Manifest {
443    /// Parses and boundary-resolves manifest text.
444    ///
445    /// # Errors
446    /// Returns `ManifestError` on TOML/schema violations (with candidate
447    /// suggestions for unknown keys), unsupported versions, undeclared
448    /// contract references, and invalid globs.
449    pub fn parse(text: &str) -> Result<Self, ManifestError> {
450        let raw: RawManifest = toml::from_str(text).map_err(|e| enrich_unknown_key(&e))?;
451
452        if raw.version != SUPPORTED_VERSION {
453            return Err(ManifestError::UnsupportedVersion {
454                found: raw.version,
455                supported: SUPPORTED_VERSION,
456            });
457        }
458        if let Some(0) = raw.schema_epoch {
459            return Err(ManifestError::NonPositiveEpoch { found: 0 });
460        }
461        resolve_contract_references(&raw)?;
462        if let Some(floor) = raw.floor.as_ref() {
463            validate_floor(floor)?;
464        }
465
466        let mapping_globs = build_globset(raw.mappings.iter().map(|m| m.glob.as_str()))?;
467        let protected_globs = build_globset(raw.gates.protected_paths.iter().map(String::as_str))?;
468        let read_only_globs = build_globset(raw.gates.read_only_paths.iter().map(String::as_str))?;
469        let retrieval_globs = build_globset(raw.gates.retrieval_paths.iter().map(String::as_str))?;
470
471        Ok(Self {
472            version: raw.version,
473            schema_epoch: raw.schema_epoch.unwrap_or(1),
474            canonical: raw.canonical,
475            authoring: raw.authoring,
476            contracts: raw.contracts,
477            mappings: raw.mappings,
478            gates: raw.gates,
479            db: raw.db,
480            features: raw.features,
481            floor: raw.floor,
482            mapping_globs,
483            protected_globs,
484            read_only_globs,
485            retrieval_globs,
486        })
487    }
488
489    /// First mapping whose glob matches `path`, if any.
490    #[must_use]
491    pub fn mapping_for(&self, path: &str) -> Option<&Mapping> {
492        self.mapping_globs
493            .matches(path)
494            .first()
495            .map(|&index| &self.mappings[index])
496    }
497
498    #[must_use]
499    pub fn is_protected(&self, path: &str) -> bool {
500        self.protected_globs.is_match(path)
501    }
502
503    /// Whether `path` falls under a `read_only_paths` glob. Committed-ness
504    /// is the caller's question (it needs git); this is only the glob half.
505    #[must_use]
506    pub fn is_read_only(&self, path: &str) -> bool {
507        self.read_only_globs.is_match(path)
508    }
509
510    /// Whether `path` falls under a `retrieval_paths` glob. Whether the
511    /// READ was bounded is the caller's question; this is only the glob
512    /// half, mirroring `is_read_only`.
513    #[must_use]
514    pub fn is_retrieval_gated(&self, path: &str) -> bool {
515        self.retrieval_globs.is_match(path)
516    }
517
518    /// The declared retrieval destination, if the manifest names one.
519    #[must_use]
520    pub fn retrieval_tool(&self) -> Option<&str> {
521        self.gates.retrieval_tool.as_deref()
522    }
523
524    /// The `[features]` git-plane switch. `true` unless the manifest
525    /// positively declares `git_hooks = false`.
526    #[must_use]
527    pub fn git_hooks_enabled(&self) -> bool {
528        self.features.git_hooks
529    }
530}
531
532fn resolve_contract_references(raw: &RawManifest) -> Result<(), ManifestError> {
533    let declared: Vec<&str> = raw.contracts.iter().map(|c| c.name.as_str()).collect();
534    for mapping in &raw.mappings {
535        for reference in &mapping.contracts {
536            if !declared.contains(&reference.as_str()) {
537                return Err(ManifestError::UnknownContract {
538                    reference: reference.as_str().to_owned(),
539                    candidates: declared.join(", "),
540                });
541            }
542        }
543    }
544    Ok(())
545}
546
547/// The `[floor]` invariants serde cannot express: names unique, every `run`
548/// non-empty, and every `covers_ignored_of` resolving to some OTHER declared
549/// command.
550///
551/// The coverage rules exist because the ignored-test accounting is only as
552/// trustworthy as the claim it checks. A dangling reference makes the accounting
553/// vacuous; self-coverage balances its arithmetic while executing nothing new.
554/// Both are the shape of defect `scripts/floor.sh` was written to prevent — a
555/// floor citation that counts less than it claims (D7(a), F62).
556fn validate_floor(floor: &Floor) -> Result<(), ManifestError> {
557    let mut seen: Vec<&str> = Vec::with_capacity(floor.commands.len());
558    for command in &floor.commands {
559        if seen.contains(&command.name.as_str()) {
560            return Err(ManifestError::DuplicateFloorCommand {
561                name: command.name.clone(),
562            });
563        }
564        seen.push(&command.name);
565        if command.run.is_empty() {
566            return Err(ManifestError::EmptyFloorRun {
567                name: command.name.clone(),
568            });
569        }
570    }
571    // Resolved by name across the WHOLE table, so a coverer may be declared
572    // before the command it covers; a forward-only scan would make the link
573    // order-dependent and the error message a lie.
574    for command in &floor.commands {
575        let Some(reference) = command.covers_ignored_of.as_deref() else {
576            continue;
577        };
578        if reference == command.name {
579            return Err(ManifestError::SelfFloorCoverage {
580                name: command.name.clone(),
581            });
582        }
583        if !seen.contains(&reference) {
584            return Err(ManifestError::UnknownFloorCoverage {
585                name: command.name.clone(),
586                reference: reference.to_owned(),
587                candidates: seen.join(", "),
588            });
589        }
590    }
591    Ok(())
592}
593
594fn build_globset<'a>(globs: impl Iterator<Item = &'a str>) -> Result<GlobSet, ManifestError> {
595    let mut builder = GlobSetBuilder::new();
596    for glob in globs {
597        let compiled = Glob::new(glob).map_err(|error| ManifestError::BadGlob {
598            glob: glob.to_owned(),
599            message: error.to_string(),
600        })?;
601        builder.add(compiled);
602    }
603    builder.build().map_err(|error| ManifestError::BadGlob {
604        glob: "<combined>".to_owned(),
605        message: error.to_string(),
606    })
607}
608
609/// Appends nearest-candidate suggestions to serde's "unknown field" errors so
610/// every rejection is a retry prompt (design principle 5).
611fn enrich_unknown_key(error: &toml::de::Error) -> ManifestError {
612    let message = error.to_string();
613    let Some(unknown) = extract_unknown_field(&message) else {
614        return ManifestError::Invalid { message };
615    };
616    let candidates = nearest_keys(&unknown);
617    if candidates.is_empty() {
618        return ManifestError::Invalid { message };
619    }
620    ManifestError::Invalid {
621        message: format!("{message}; did you mean: {}?", candidates.join(", ")),
622    }
623}
624
625fn extract_unknown_field(message: &str) -> Option<String> {
626    let marker = "unknown field `";
627    let start = message.find(marker)? + marker.len();
628    let rest = &message[start..];
629    let end = rest.find('`')?;
630    Some(rest[..end].to_owned())
631}
632
633fn nearest_keys(unknown: &str) -> Vec<&'static str> {
634    let mut scored: Vec<(usize, &'static str)> = KNOWN_KEYS
635        .iter()
636        .map(|&key| (levenshtein(unknown, key), key))
637        .filter(|&(distance, _)| distance <= 3)
638        .collect();
639    scored.sort_unstable();
640    scored.into_iter().take(3).map(|(_, key)| key).collect()
641}
642
643pub(crate) fn levenshtein(a: &str, b: &str) -> usize {
644    let a_chars: Vec<char> = a.chars().collect();
645    let b_chars: Vec<char> = b.chars().collect();
646    let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
647    let mut current = vec![0usize; b_chars.len() + 1];
648
649    for (i, &a_char) in a_chars.iter().enumerate() {
650        current[0] = i + 1;
651        for (j, &b_char) in b_chars.iter().enumerate() {
652            let substitution = usize::from(a_char != b_char);
653            current[j + 1] = (previous[j] + substitution)
654                .min(previous[j + 1] + 1)
655                .min(current[j] + 1);
656        }
657        std::mem::swap(&mut previous, &mut current);
658    }
659    previous[b_chars.len()]
660}