Der handgebaute Testraum ist weg; Etagen entstehen aus Daten. - game/rng.rs: xorshift64*, Seed im Game (Ticket 14) - game/generate.rs: freie Platzierung, Pflichtraeume zuerst, Kette i->i-1 fuer strukturelle Erreichbarkeit, acht Orientierungen - data/: [[prefabs]] und [[floors]] als neunte und zehnte Registry, Ladepruefungen fuer rechteckige Layouts und Pflichtraum-Flaeche - Descend plus Siegbedingung: keine [[floors]] mit der naechsten Nummer heisst gewonnen — die Zahl 5 steht nirgends im Code - GameOver-Screen, mitlaufender Bildausschnitt im Grid Befund: Korridore stanzten 2340 Loecher in gezeichnete Waende, weil sie AUF der Tuer starteten. Erst Schritt nach aussen, dann L-Form, sonst Wegsuche ueber den freien Raum — die kann eine Raumwand nicht beruehren. Der Test zaehlt jetzt null. 21 Tests.
604 lines
21 KiB
Rust
604 lines
21 KiB
Rust
//! Mod-Ladepipeline (Ticket 03) und Fehlerstrategie (Ticket 04).
|
|
//!
|
|
//! Drei Phasen: Manifeste → Einlesen → Aufloesen. In Phase 2 wird nichts
|
|
//! aufgeloest, dadurch gibt es **keine Vorwaertsreferenzen** — ein Mod darf auf
|
|
//! etwas zeigen, das erst ein spaeterer liefert.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use serde::Deserialize;
|
|
use serde::de::DeserializeOwned;
|
|
use toml::Spanned;
|
|
|
|
use super::registries::{Category, CharacterOption, FloorDef, Item, Prefab, Registries, Slot};
|
|
|
|
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
|
|
/// Verwerfen des **einzelnen Eintrags**.
|
|
///
|
|
/// Handgeschrieben statt `thiserror`: drei Varianten, ein Konsument — und in
|
|
/// einem Ticket, das Rust lernen soll, ist dieses `match` der Zweck.
|
|
#[derive(Debug)]
|
|
pub enum LoadError {
|
|
CoreMissing,
|
|
DependencyMissing { module: String, dep: String },
|
|
DependencyOrder { module: String, dep: String },
|
|
}
|
|
|
|
impl fmt::Display for LoadError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
LoadError::CoreMissing => write!(
|
|
f,
|
|
"Das Kernspiel wurde nicht gefunden. Erwartet wird ein Ordner \
|
|
'mods/core' mit einer manifest.toml."
|
|
),
|
|
LoadError::DependencyMissing { module, dep } => {
|
|
write!(f, "'{module}' braucht '{dep}', aber '{dep}' ist nicht installiert.")
|
|
}
|
|
LoadError::DependencyOrder { module, dep } => write!(
|
|
f,
|
|
"'{module}' muss nach '{dep}' geladen werden, steht in der \
|
|
load_order.toml aber davor."
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Manifest und Mod-Erkennung
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Manifest {
|
|
id: String,
|
|
#[allow(dead_code)]
|
|
name: String,
|
|
/// Einziger Leser ist der Save-Header (Ticket 03/09).
|
|
#[allow(dead_code)]
|
|
version: String,
|
|
#[serde(default)]
|
|
depends: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ModInfo {
|
|
id: String,
|
|
depends: Vec<String>,
|
|
path: PathBuf,
|
|
}
|
|
|
|
/// Zwei Suchpfade (Ticket 03): `mods/` neben der Binary und `mods/` im
|
|
/// Benutzerprofil. Bei gleicher Mod-ID **gewinnt das Profil** — es existiert
|
|
/// genau dafuer, dass die Sachen des Spielers eine Neuinstallation ueberleben.
|
|
fn search_paths() -> Vec<PathBuf> {
|
|
let mut paths = Vec::new();
|
|
|
|
if let Ok(exe) = std::env::current_exe()
|
|
&& let Some(dir) = exe.parent()
|
|
{
|
|
paths.push(dir.join("mods"));
|
|
}
|
|
// Entwicklungsfall: `cargo run` legt die Binary nach target/debug/.
|
|
paths.push(PathBuf::from("mods"));
|
|
|
|
if let Some(dir) = profile_dir() {
|
|
paths.push(dir.join("mods"));
|
|
}
|
|
paths
|
|
}
|
|
|
|
/// `data_dir()` ueber die Crate `directories` — Windows, Linux *und* macOS.
|
|
/// Keine Trennung von Daten und Konfiguration: Mods, `load_order.toml`, Save
|
|
/// und Logs liegen in einem Ordner (Ticket 03).
|
|
pub fn profile_dir() -> Option<PathBuf> {
|
|
directories::ProjectDirs::from("", "", "rustkeep").map(|d| d.data_dir().to_path_buf())
|
|
}
|
|
|
|
fn discover(warnings: &mut Vec<String>) -> Vec<ModInfo> {
|
|
let mut found: HashMap<String, ModInfo> = HashMap::new();
|
|
|
|
for base in search_paths() {
|
|
let Ok(entries) = fs::read_dir(&base) else { continue };
|
|
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let manifest_path = path.join("manifest.toml");
|
|
let Ok(raw) = fs::read_to_string(&manifest_path) else {
|
|
// Ordner ohne Manifest ist kein Mod — sonst legt eine vergessene
|
|
// screenshots/-Kopie das Spiel lahm (Ticket 03).
|
|
warnings.push(format!("{}: kein manifest.toml, übersprungen", path.display()));
|
|
continue;
|
|
};
|
|
|
|
match toml::from_str::<Manifest>(&raw) {
|
|
Ok(m) => {
|
|
if let Some(old) = found.get(&m.id) {
|
|
warnings.push(format!(
|
|
"'{}' liegt zweimal: {} gewinnt gegen {}",
|
|
m.id,
|
|
path.display(),
|
|
old.path.display()
|
|
));
|
|
}
|
|
found.insert(
|
|
m.id.clone(),
|
|
ModInfo { id: m.id, depends: m.depends, path },
|
|
);
|
|
}
|
|
Err(e) => warnings.push(format!("{}: {}", manifest_path.display(), e.message())),
|
|
}
|
|
}
|
|
}
|
|
|
|
found.into_values().collect()
|
|
}
|
|
|
|
// ============================================================================
|
|
// Ladereihenfolge — selbstheilend (Ticket 03)
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Default, Deserialize, serde::Serialize)]
|
|
struct LoadOrder {
|
|
#[serde(default)]
|
|
mods: Vec<String>,
|
|
}
|
|
|
|
fn resolve_order(found: &[ModInfo], warnings: &mut Vec<String>) -> Vec<String> {
|
|
let file = profile_dir().map(|d| d.join("load_order.toml"));
|
|
|
|
let mut order: Vec<String> = file
|
|
.as_ref()
|
|
.and_then(|p| fs::read_to_string(p).ok())
|
|
.and_then(|raw| toml::from_str::<LoadOrder>(&raw).ok())
|
|
.map(|lo| lo.mods)
|
|
.unwrap_or_default();
|
|
|
|
// Eintrag ohne Ordner: Warnzeile und ueberspringen. Das ist der Normalfall
|
|
// nach dem Deinstallieren und darf den Start nicht verhindern.
|
|
order.retain(|id| {
|
|
let exists = found.iter().any(|m| &m.id == id);
|
|
if !exists {
|
|
warnings.push(format!("'{id}' steht in load_order.toml, ist aber nicht installiert"));
|
|
}
|
|
exists
|
|
});
|
|
|
|
// Ordner ohne Eintrag: hinten anhaengen. Ein entpackter Mod laeuft damit,
|
|
// ohne dass der Spieler von der Sortierdatei wissen muss.
|
|
let mut fresh: Vec<&str> = found
|
|
.iter()
|
|
.filter(|m| !order.contains(&m.id))
|
|
.map(|m| m.id.as_str())
|
|
.collect();
|
|
fresh.sort_unstable();
|
|
|
|
if order.is_empty() {
|
|
// Erststart: core zuerst, Rest alphabetisch.
|
|
if let Some(pos) = fresh.iter().position(|id| *id == "core") {
|
|
order.push(fresh.remove(pos).to_string());
|
|
}
|
|
}
|
|
order.extend(fresh.into_iter().map(str::to_string));
|
|
|
|
if let Some(path) = file {
|
|
let _ = fs::create_dir_all(path.parent().unwrap());
|
|
if let Ok(text) = toml::to_string(&LoadOrder { mods: order.clone() }) {
|
|
// Schreibfehler wird verschluckt — ein Spiel, das wegen seiner
|
|
// Sortierdatei nicht startet, waere die Karikatur guter
|
|
// Fehlerbehandlung (Ticket 04).
|
|
let _ = fs::write(&path, text);
|
|
}
|
|
}
|
|
order
|
|
}
|
|
|
|
/// `depends` sortiert nicht, es **prueft** — das ist der Preis dafuer, dass ein
|
|
/// Mensch die Reihenfolge pflegt (Ticket 03).
|
|
fn check_depends(found: &[ModInfo], order: &[String]) -> Result<(), LoadError> {
|
|
for (i, id) in order.iter().enumerate() {
|
|
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
|
|
|
for dep in &info.depends {
|
|
match order.iter().position(|o| o == dep) {
|
|
None => {
|
|
return Err(LoadError::DependencyMissing {
|
|
module: id.clone(),
|
|
dep: dep.clone(),
|
|
});
|
|
}
|
|
Some(j) if j > i => {
|
|
return Err(LoadError::DependencyOrder {
|
|
module: id.clone(),
|
|
dep: dep.clone(),
|
|
});
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Phase 2 — Einlesen, zweistufig (Ticket 04)
|
|
// ============================================================================
|
|
|
|
/// Jede Datei darf jede Registry-Tabelle enthalten (Ticket 03). `toml::Value`
|
|
/// als Zwischenstufe ist der Grund, warum ein kaputter Eintrag nur *sich selbst*
|
|
/// mitreisst und nicht die ganze Datei.
|
|
#[derive(Debug, Default, Deserialize)]
|
|
struct ModFile {
|
|
#[serde(default)]
|
|
items: Vec<Spanned<toml::Value>>,
|
|
#[serde(default)]
|
|
slots: Vec<Spanned<toml::Value>>,
|
|
#[serde(default)]
|
|
categories: Vec<Spanned<toml::Value>>,
|
|
#[serde(default)]
|
|
options: Vec<Spanned<toml::Value>>,
|
|
#[serde(default)]
|
|
prefabs: Vec<Spanned<toml::Value>>,
|
|
#[serde(default)]
|
|
floors: Vec<Spanned<toml::Value>>,
|
|
}
|
|
|
|
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
|
|
trait Entry: DeserializeOwned {
|
|
fn id(&self) -> &str;
|
|
fn set_id(&mut self, id: String);
|
|
fn overrides(&self) -> Option<&str>;
|
|
}
|
|
|
|
macro_rules! impl_entry {
|
|
($t:ty) => {
|
|
impl Entry for $t {
|
|
fn id(&self) -> &str {
|
|
&self.id
|
|
}
|
|
fn set_id(&mut self, id: String) {
|
|
self.id = id;
|
|
}
|
|
fn overrides(&self) -> Option<&str> {
|
|
self.overrides.as_deref()
|
|
}
|
|
}
|
|
};
|
|
}
|
|
impl_entry!(Item);
|
|
impl_entry!(Slot);
|
|
impl_entry!(Category);
|
|
impl_entry!(CharacterOption);
|
|
impl_entry!(Prefab);
|
|
impl_entry!(FloorDef);
|
|
|
|
struct FileCtx<'a> {
|
|
module: &'a str,
|
|
/// Pfad relativ zum Mod-Ordner — die Mod-ID steht ohnehin vorn.
|
|
file: String,
|
|
source: &'a str,
|
|
}
|
|
|
|
/// Zeile aus dem Byte-Offset. `toml::de::Error` und `Spanned` liefern nur
|
|
/// Offsets, keine Zeilen (gegen die Doku geprueft).
|
|
fn line_of(source: &str, offset: usize) -> usize {
|
|
source[..offset.min(source.len())].matches('\n').count() + 1
|
|
}
|
|
|
|
fn parse_entries<T: Entry>(
|
|
raw: Vec<Spanned<toml::Value>>,
|
|
ctx: &FileCtx<'_>,
|
|
into: &mut HashMap<String, T>,
|
|
warnings: &mut Vec<String>,
|
|
) {
|
|
for (index, spanned) in raw.into_iter().enumerate() {
|
|
let line = line_of(ctx.source, spanned.span().start);
|
|
let value = spanned.into_inner();
|
|
|
|
let mut entry = match T::deserialize(value) {
|
|
Ok(e) => e,
|
|
Err(e) => {
|
|
// Ein Eintrag ohne lesbare `id` kann sich nicht benennen und
|
|
// heisst nach seiner Position (Ticket 04).
|
|
warnings.push(format!(
|
|
"{}/{}:{}: [{}] — {}",
|
|
ctx.module, ctx.file, line, index, e.message()
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Der Lader stellt manifest.id automatisch voran (Ticket 03).
|
|
let own_id = format!("{}:{}", ctx.module, entry.id());
|
|
|
|
// `overrides` ersetzt den genannten Eintrag und uebernimmt dessen ID —
|
|
// die eigene wird dann gar nicht registriert (Ticket 03).
|
|
let key = match entry.overrides() {
|
|
Some(target) => {
|
|
let target = target.to_string();
|
|
if !into.contains_key(&target) {
|
|
warnings.push(format!(
|
|
"{}/{}:{}: {} überschreibt '{}', das es nicht gibt — verworfen",
|
|
ctx.module, ctx.file, line, own_id, target
|
|
));
|
|
continue;
|
|
}
|
|
warnings.push(format!("{} überschreibt '{}'", ctx.module, target));
|
|
target
|
|
}
|
|
None => {
|
|
if into.contains_key(&own_id) {
|
|
warnings.push(format!("{} überschreibt '{}'", ctx.module, own_id));
|
|
}
|
|
own_id
|
|
}
|
|
};
|
|
|
|
entry.set_id(key.clone());
|
|
into.insert(key, entry);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Phase 3 — Aufloesen
|
|
// ============================================================================
|
|
|
|
fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
|
|
let known_slots: Vec<String> = reg.slots.keys().cloned().collect();
|
|
reg.items.retain(|id, item| match &item.slot {
|
|
Some(slot) if !known_slots.contains(slot) => {
|
|
warnings.push(format!("{id}: Slot '{slot}' existiert nicht — verworfen"));
|
|
false
|
|
}
|
|
_ => true,
|
|
});
|
|
|
|
let known_categories: Vec<String> = reg.categories.keys().cloned().collect();
|
|
reg.options.retain(|id, option| {
|
|
if known_categories.contains(&option.category) {
|
|
true
|
|
} else {
|
|
warnings.push(format!(
|
|
"{id}: Kategorie '{}' existiert nicht — verworfen",
|
|
option.category
|
|
));
|
|
false
|
|
}
|
|
});
|
|
|
|
// Ticket 10: `layout`-Zeilen muessen gleich lang sein, sonst ist der Block
|
|
// kein Rechteck und die Platzierung rechnet mit Muell. Ein Zweizeiler, der
|
|
// eine ganze Klasse von Modder-Frust abfaengt.
|
|
reg.prefabs.retain(|id, prefab| {
|
|
let rows = prefab.rows();
|
|
if rows.is_empty() {
|
|
warnings.push(format!("{id}: layout ist leer — verworfen"));
|
|
return false;
|
|
}
|
|
let width = rows[0].chars().count();
|
|
match rows.iter().position(|r| r.chars().count() != width) {
|
|
Some(i) => {
|
|
warnings.push(format!(
|
|
"{id}: layout-Zeile {} ist {} Zeichen breit, erwartet {width} — verworfen",
|
|
i + 1,
|
|
rows[i].chars().count()
|
|
));
|
|
false
|
|
}
|
|
None => true,
|
|
}
|
|
});
|
|
|
|
// Ticket 10: Passen die Pflicht-Tags grundsaetzlich nicht in `size`, ist das
|
|
// ein **Datenfehler und beim Laden erkennbar**. Zur Laufzeit gibt es dafuer
|
|
// dann keinen Wiederholungszaehler und keinen Rueckfallpfad — die Pruefung
|
|
// sitzt dort, wo der Fehler gemacht wurde.
|
|
let prefabs = ®.prefabs;
|
|
reg.floors.retain(|id, floor| {
|
|
let mut needed = 0u32;
|
|
|
|
for tag in &floor.required {
|
|
let smallest = prefabs
|
|
.values()
|
|
.filter(|p| p.tags.iter().any(|t| t == tag))
|
|
.map(|p| {
|
|
let (w, h) = p.size();
|
|
(w as u32 + 1) * (h as u32 + 1)
|
|
})
|
|
.min();
|
|
|
|
match smallest {
|
|
None => {
|
|
warnings.push(format!("{id}: kein Prefab mit dem Pflicht-Tag '{tag}'"));
|
|
return false;
|
|
}
|
|
Some(area) => needed += area,
|
|
}
|
|
}
|
|
|
|
let available = floor.size.0 as u32 * floor.size.1 as u32;
|
|
if needed > available {
|
|
warnings.push(format!(
|
|
"{id}: Pflichträume brauchen mindestens {needed} Felder, size bietet {available}"
|
|
));
|
|
return false;
|
|
}
|
|
true
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Einstiegspunkt
|
|
// ============================================================================
|
|
|
|
pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
|
|
let mut warnings = Vec::new();
|
|
|
|
// Phase 1 — Manifeste, Reihenfolge, depends
|
|
let found = discover(&mut warnings);
|
|
if !found.iter().any(|m| m.id == "core") {
|
|
return Err(LoadError::CoreMissing);
|
|
}
|
|
let order = resolve_order(&found, &mut warnings);
|
|
check_depends(&found, &order)?;
|
|
|
|
// Phase 2 — Einlesen in Ladereihenfolge
|
|
let mut reg = Registries::default();
|
|
for id in &order {
|
|
let Some(info) = found.iter().find(|m| &m.id == id) else { continue };
|
|
|
|
for file in toml_files(&info.path) {
|
|
let name = file
|
|
.strip_prefix(&info.path)
|
|
.unwrap_or(&file)
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
|
|
let Ok(source) = fs::read_to_string(&file) else { continue };
|
|
|
|
// Kaputte Syntax reisst die ganze Datei mit — da kommt der Parser
|
|
// schlicht nicht weiter. Das ist keine Entscheidung, sondern eine
|
|
// Tatsache (Ticket 04).
|
|
let parsed: ModFile = match toml::from_str(&source) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
let line = e.span().map(|s| line_of(&source, s.start)).unwrap_or(0);
|
|
warnings.push(format!("{}/{}:{}: {}", info.id, name, line, e.message()));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let ctx = FileCtx { module: &info.id, file: name, source: &source };
|
|
parse_entries(parsed.items, &ctx, &mut reg.items, &mut warnings);
|
|
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
|
|
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
|
|
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
|
|
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
|
|
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
|
|
}
|
|
}
|
|
|
|
// Phase 3 — Referenzen aufloesen
|
|
resolve(&mut reg, &mut warnings);
|
|
|
|
Ok((reg, warnings))
|
|
}
|
|
|
|
fn toml_files(dir: &Path) -> Vec<PathBuf> {
|
|
let mut files: Vec<PathBuf> = fs::read_dir(dir)
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().is_some_and(|e| e == "toml"))
|
|
.filter(|p| p.file_name().is_some_and(|n| n != "manifest.toml"))
|
|
.collect();
|
|
files.sort();
|
|
files
|
|
}
|
|
|
|
// ============================================================================
|
|
// Warnungen ins Log (Ticket 04)
|
|
// ============================================================================
|
|
|
|
/// Schreibt die Warnliste, behaelt die letzten zehn Laeufe und gibt den
|
|
/// Dateinamen zurueck. Ein sauberer Lauf legt keine Datei an.
|
|
pub fn write_log(warnings: &[String]) -> Option<String> {
|
|
if warnings.is_empty() {
|
|
return None;
|
|
}
|
|
let dir = profile_dir()?.join("logs");
|
|
fs::create_dir_all(&dir).ok()?;
|
|
|
|
// ponytail: Sekunden seit Epoch statt JJJJMMTT-HHMMSS. Sortierbar, und die
|
|
// Alternative waere Datumsarithmetik von Hand oder eine Crate dafuer.
|
|
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
|
let name = format!("load-{stamp}.log");
|
|
fs::write(dir.join(&name), warnings.join("\n")).ok()?;
|
|
|
|
let mut old: Vec<PathBuf> = fs::read_dir(&dir)
|
|
.into_iter()
|
|
.flatten()
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.filter(|p| {
|
|
p.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with("load-") && n.ends_with(".log"))
|
|
})
|
|
.collect();
|
|
old.sort();
|
|
for path in old.iter().rev().skip(10) {
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
|
|
Some(name)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn zeilennummer_aus_offset() {
|
|
let src = "a\nb\nc";
|
|
assert_eq!(line_of(src, 0), 1);
|
|
assert_eq!(line_of(src, 2), 2);
|
|
assert_eq!(line_of(src, 4), 3);
|
|
}
|
|
|
|
/// Der Check aus Ticket 04: Ein kaputtes Kernspiel faellt beim `cargo test`
|
|
/// auf, nicht beim Spieler — und die Ladelogik bleibt frei von Sonderregeln.
|
|
#[test]
|
|
fn core_laedt_ohne_warnungen() {
|
|
let (reg, warnings) = load_mods().expect("core muss laden");
|
|
let relevant: Vec<&String> =
|
|
warnings.iter().filter(|w| !w.contains("load_order.toml")).collect();
|
|
assert!(relevant.is_empty(), "core lädt nicht sauber: {relevant:#?}");
|
|
assert!(reg.items.contains_key("core:iron_sword"));
|
|
assert!(reg.slots.contains_key("core:hand"));
|
|
assert!(reg.options.contains_key("core:dwarf"));
|
|
}
|
|
|
|
/// Ticket 04 versprach, dass ein kaputter Eintrag nur *sich selbst*
|
|
/// mitreisst. Das ist mit serde nicht gratis — hier wird es geprueft.
|
|
#[test]
|
|
fn kaputter_eintrag_reisst_die_nachbarn_nicht_mit() {
|
|
let source = r#"
|
|
[[items]]
|
|
id = "gut"
|
|
name = "Gut"
|
|
|
|
[[items]]
|
|
id = "kaputt"
|
|
name = 42
|
|
|
|
[[items]]
|
|
id = "auch_gut"
|
|
name = "Auch gut"
|
|
"#;
|
|
let parsed: ModFile = toml::from_str(source).expect("Syntax ist in Ordnung");
|
|
let ctx = FileCtx { module: "test", file: "items.toml".into(), source };
|
|
let mut items: HashMap<String, Item> = HashMap::new();
|
|
let mut warnings = Vec::new();
|
|
parse_entries(parsed.items, &ctx, &mut items, &mut warnings);
|
|
|
|
assert_eq!(items.len(), 2, "die beiden heilen Eintraege muessen ueberleben");
|
|
assert!(items.contains_key("test:gut"));
|
|
assert!(items.contains_key("test:auch_gut"));
|
|
assert_eq!(warnings.len(), 1);
|
|
// `Spanned` zeigt auf den Anfang des Eintrags (`[[items]]`, Zeile 6),
|
|
// nicht auf das schuldige Feld — genau der Zeilenbereich, den Ticket 04
|
|
// zugesagt hat. Der Feldname steht dafuer in `message()`.
|
|
assert!(warnings[0].contains("items.toml:6"), "Zeile fehlt: {}", warnings[0]);
|
|
assert!(warnings[0].contains("expected a string"));
|
|
}
|
|
}
|