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}