gdck_config/lib.rs
1//! Configuration for `gdck`.
2//!
3//! Defaults come from the [GDScript style guide][guide], so a project with no
4//! configuration file at all gets style-guide behaviour.
5//!
6//! [guide]: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html
7//!
8//! # Reading a project's settings
9//!
10//! [`resolve`] walks up from a directory and returns the settings in force
11//! there, along with the files they came from:
12//!
13//! ```no_run
14//! let loaded = gdck_config::resolve(std::path::Path::new("."))?;
15//! println!("{} columns", loaded.config.format.line_length);
16//! # Ok::<(), gdck_config::Error>(())
17//! ```
18//!
19//! The nearest `gdck.toml` wins outright. Failing that, `gdtoolkit`'s own
20//! `gdformatrc` and `gdlintrc` are read, so a project already using `gdformat`
21//! and `gdlint` keeps its line length and its disabled rules without writing
22//! anything new. See `docs/CONFIG.md` for the schema and the precedence.
23//!
24//! # Two file formats, two approaches
25//!
26//! `gdck.toml` is read by the [`toml`] crate, and validated here for the
27//! things a deserialiser cannot know — ranges, and settings that only mean
28//! something alongside another.
29//!
30//! The `gdtoolkit` files are read by [`yaml_serde`] into an untyped mapping
31//! rather than into a struct, because a `gdlintrc` holds dozens of settings
32//! `gdck` has no equivalent for and each should be skipped with a note rather
33//! than failing the file. A file that cannot be parsed at all *is* an error:
34//! none of its settings would apply, and a project formatted by rules it had
35//! written down and rejected is the outcome worse than not running.
36
37mod compat;
38mod schema;
39
40use std::fmt;
41use std::path::{Path, PathBuf};
42
43/// Config file names searched for, in priority order, walking up from the
44/// working directory.
45pub const CONFIG_FILE_NAMES: &[&str] = &["gdck.toml", ".gdck.toml"];
46
47/// `gdformat`'s configuration file, read for compatibility.
48pub const GDFORMAT_FILE_NAMES: &[&str] = &["gdformatrc", ".gdformatrc"];
49
50/// `gdlint`'s configuration file, read for compatibility.
51pub const GDLINT_FILE_NAMES: &[&str] = &["gdlintrc", ".gdlintrc"];
52
53/// Directories skipped when collecting `.gd` files.
54///
55/// `.godot` holds the editor's generated import cache and `addons` is usually
56/// third-party code a project does not want reformatted.
57pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &[".git", ".godot", ".import", "addons"];
58
59/// Indentation style. The style guide mandates tabs; spaces are available
60/// because some existing projects have already committed to them.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum IndentStyle {
63 #[default]
64 Tabs,
65 Spaces(u8),
66}
67
68/// How a file-level `class_name` and its `extends` are laid out.
69///
70/// The style guide asks for two lines, and says so three ways: the prose
71/// introduces them in sequence ("Follow with the optional `@icon` then the
72/// `class_name`... Then, add the `extends` keyword"), every example writes
73/// them apart, and inner classes are singled out for the opposite treatment —
74/// "For inner classes, use single-line declarations". That contrast only means
75/// something if file-level declarations are not single-line, so
76/// [`MultiLine`](Self::MultiLine) is the default.
77///
78/// `gdformat` enforces neither: its grammar has a separate rule for the joined
79/// form and it preserves whichever the author wrote. So a project arriving from
80/// `gdformat` can be consistently on the one-line form without ever having
81/// chosen it, and the first `gdck format` would rewrite most of its files.
82/// [`SingleLine`](Self::SingleLine) exists for a project that has looked at
83/// that diff and decided it prefers what it already had.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum ClassDeclaration {
86 /// `class_name Player` and `extends Node` on their own lines.
87 #[default]
88 MultiLine,
89 /// `class_name Player extends Node`, as `gdformat` leaves it.
90 SingleLine,
91}
92
93/// Formatting options.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub struct FormatConfig {
97 /// Hard wrap width. The style guide says keep lines under 100 characters.
98 pub line_length: u16,
99 pub indent: IndentStyle,
100 /// Whether a file-level `class_name` keeps its `extends` on the same line.
101 pub class_declaration: ClassDeclaration,
102 /// Re-run the formatter on its own output and reject the result if it is
103 /// not stable, and check that no comments were dropped. Cheap insurance
104 /// against a formatter bug silently eating code.
105 pub safety_checks: bool,
106}
107
108impl Default for FormatConfig {
109 fn default() -> Self {
110 Self {
111 line_length: 100,
112 indent: IndentStyle::Tabs,
113 class_declaration: ClassDeclaration::MultiLine,
114 safety_checks: true,
115 }
116 }
117}
118
119/// How aggressively `gdck fix` may reorder class members.
120///
121/// Reordering is not purely cosmetic: class-level initialisers run in
122/// declaration order, so moving a public variable above a private one it reads
123/// changes behaviour. See `docs/DESIGN.md` for the full analysis.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub enum CodeOrderFix {
126 /// Report violations; only reorder when `--fix-order` is passed.
127 #[default]
128 ReportOnly,
129 /// Reorder a file when every required move is provably safe, and leave the
130 /// file completely untouched otherwise.
131 WholeFileWhenSafe,
132 /// Never reorder, and do not report ordering problems either.
133 Off,
134}
135
136/// The groups a project may put in an order of its own.
137///
138/// This is `gdtoolkit`'s vocabulary rather than `gdck`'s, deliberately: it
139/// exists so a project that pinned `class-definitions-order` in a `gdlintrc`
140/// keeps the order it chose. `gdck` sorts more finely than these fourteen
141/// names can express — it knows `_ready()` from `_process()` from a static
142/// function, where `gdtoolkit` has only `others` — and that finer order is
143/// kept *within* whichever position `Others` is given.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum DeclarationGroup {
146 Tools,
147 ClassNames,
148 Extends,
149 Docstrings,
150 Signals,
151 Enums,
152 Consts,
153 Exports,
154 PubVars,
155 PrvVars,
156 OnreadyPubVars,
157 OnreadyPrvVars,
158 StaticVars,
159 Others,
160}
161
162impl DeclarationGroup {
163 /// The `gdtoolkit` spelling, which is also `gdck`'s.
164 #[must_use]
165 pub fn name(self) -> &'static str {
166 match self {
167 Self::Tools => "tools",
168 Self::ClassNames => "classnames",
169 Self::Extends => "extends",
170 Self::Docstrings => "docstrings",
171 Self::Enums => "enums",
172 Self::Signals => "signals",
173 Self::Consts => "consts",
174 Self::Exports => "exports",
175 Self::PubVars => "pubvars",
176 Self::PrvVars => "prvvars",
177 Self::OnreadyPubVars => "onreadypubvars",
178 Self::OnreadyPrvVars => "onreadyprvvars",
179 Self::StaticVars => "staticvars",
180 Self::Others => "others",
181 }
182 }
183
184 /// Read one from the name `gdtoolkit` uses for it.
185 #[must_use]
186 pub fn from_name(name: &str) -> Option<Self> {
187 [
188 Self::Tools,
189 Self::ClassNames,
190 Self::Extends,
191 Self::Docstrings,
192 Self::Signals,
193 Self::Enums,
194 Self::Consts,
195 Self::Exports,
196 Self::PubVars,
197 Self::PrvVars,
198 Self::OnreadyPubVars,
199 Self::OnreadyPrvVars,
200 Self::StaticVars,
201 Self::Others,
202 ]
203 .into_iter()
204 .find(|group| group.name() == name)
205 }
206}
207
208/// Which convention the `file-name` rule holds a file to.
209///
210/// The style guide says snake_case and that is the default. It is also the one
211/// naming rule where a project's own answer is worth honouring rather than
212/// suppressing: a file name is not an identifier the language sees, so a
213/// project that names files after the classes in them is following a
214/// convention rather than breaking one. Saying which is being followed keeps
215/// the rule working, where disabling it stops it noticing anything at all.
216///
217/// Both settings are a convention by name, not a pattern to match. The
218/// alternative — a regular expression, as `gdtoolkit` takes — makes every
219/// project's spelling of "snake_case" subtly its own. See `docs/DESIGN.md`.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
221pub enum FileNameCase {
222 /// `player_controller.gd`, what the guide asks for.
223 #[default]
224 SnakeCase,
225 /// `PlayerController.gd`, matching the class the file holds.
226 PascalCase,
227}
228
229/// Lint options.
230#[derive(Debug, Clone, PartialEq, Eq)]
231#[non_exhaustive]
232pub struct LintConfig {
233 pub max_line_length: u16,
234 pub max_file_lines: u32,
235 pub max_public_methods: u32,
236 pub max_returns: u32,
237 pub max_function_arguments: u32,
238 pub code_order: CodeOrderFix,
239 pub file_name: FileNameCase,
240 /// A declaration order of the project's own, if it stated one.
241 ///
242 /// `None` means the style guide's, which is what `gdck` sorts by and what
243 /// its own buckets are named after. A project that pinned
244 /// `class-definitions-order` in a `gdlintrc` gets that order instead.
245 pub declaration_order: Option<Vec<DeclarationGroup>>,
246 /// Rule names switched off for this project.
247 pub disabled: Vec<String>,
248}
249
250impl Default for LintConfig {
251 fn default() -> Self {
252 Self {
253 max_line_length: 100,
254 max_file_lines: 1000,
255 max_public_methods: 20,
256 max_returns: 6,
257 max_function_arguments: 10,
258 code_order: CodeOrderFix::default(),
259 file_name: FileNameCase::default(),
260 declaration_order: None,
261 disabled: Vec::new(),
262 }
263 }
264}
265
266/// Naming patterns from the style guide's conventions table.
267///
268/// Written out as source strings because that is the least ambiguous way to
269/// state a convention, and because it is the form a project would use to
270/// override one. The linter does not compile them: `is_snake_case` and its
271/// two siblings are a dozen lines each and read better than the equivalent
272/// pattern, so these stand as documentation of what those functions accept.
273/// See [`crate::naming`] and `gdck_lint::names`.
274pub mod naming {
275 pub const PASCAL_CASE: &str = r"([A-Z][a-z0-9]*)+";
276 pub const SNAKE_CASE: &str = r"[a-z][a-z0-9]*(_[a-z0-9]+)*";
277 pub const PRIVATE_SNAKE_CASE: &str = r"_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
278 pub const CONSTANT_CASE: &str = r"[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
279 pub const PRIVATE_CONSTANT_CASE: &str = r"_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
280}
281
282/// The full configuration for a run.
283#[derive(Debug, Clone, PartialEq, Eq)]
284#[non_exhaustive]
285pub struct Config {
286 pub format: FormatConfig,
287 pub lint: LintConfig,
288 pub excluded_dirs: Vec<String>,
289 /// Skip files a `.gitignore` covers.
290 ///
291 /// On, because a `.gd` file git has been told to ignore is almost always
292 /// something generated or vendored, and reporting on it is noise about
293 /// code nobody is going to edit. A path named directly on the command line
294 /// is still processed: naming a file is a stronger signal than a pattern.
295 pub respect_gitignore: bool,
296}
297
298impl Default for Config {
299 fn default() -> Self {
300 Self {
301 format: FormatConfig::default(),
302 lint: LintConfig::default(),
303 excluded_dirs: Vec::new(),
304 respect_gitignore: true,
305 }
306 }
307}
308
309impl Config {
310 /// Whether a directory name should be skipped when collecting files.
311 #[must_use]
312 pub fn is_excluded_dir(&self, name: &str) -> bool {
313 if self.excluded_dirs.is_empty() {
314 return DEFAULT_EXCLUDED_DIRS.contains(&name);
315 }
316 self.excluded_dirs.iter().any(|dir| dir == name)
317 }
318
319 /// Write these settings out as a `gdck.toml`.
320 ///
321 /// Every setting is written, including the ones left at their default,
322 /// because the question this answers is what a run is actually using.
323 #[must_use]
324 pub fn to_toml(&self) -> String {
325 schema::to_toml(self)
326 }
327
328 /// Write these settings out as a `gdck.toml` for a project to keep.
329 ///
330 /// Every setting appears, but the ones still at their default are
331 /// commented out. The live lines are then what this project decided, and
332 /// the commented ones are a catalogue to uncomment from — and a project
333 /// inherits later changes to a default rather than pinning today's value
334 /// without meaning to.
335 ///
336 /// This is what `gdck init` writes. [`to_toml`](Self::to_toml) is the
337 /// other question — what a run is using — and answers it in full.
338 #[must_use]
339 pub fn to_starter_toml(&self) -> String {
340 schema::to_starter_toml(self)
341 }
342}
343
344// -- errors and notes -------------------------------------------------------
345
346/// Something wrong at a line of a file, before the file is known.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub(crate) struct Problem {
349 pub(crate) line: u32,
350 pub(crate) message: String,
351}
352
353/// A configuration file that could not be used.
354///
355/// A broken configuration file is always fatal rather than a fall back to the
356/// defaults. Settings that quietly do not apply are worse than a run that
357/// stops and says why.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct Error {
360 pub path: PathBuf,
361 /// The line at fault, when the file was read but not understood.
362 pub line: Option<u32>,
363 pub message: String,
364}
365
366impl fmt::Display for Error {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 match self.line {
369 Some(line) => write!(f, "{}:{line}: {}", self.path.display(), self.message),
370 None => write!(f, "{}: {}", self.path.display(), self.message),
371 }
372 }
373}
374
375impl std::error::Error for Error {}
376
377/// Something a configuration file asked for that `gdck` cannot honour.
378///
379/// Only the `gdtoolkit` files produce these. `gdck.toml` refuses what it
380/// cannot do, but a foreign file is allowed to hold settings that mean nothing
381/// here — and saying so is the difference between a setting that does not
382/// apply and one that silently does not apply.
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct Note {
385 pub path: PathBuf,
386 pub line: u32,
387 pub message: String,
388}
389
390impl fmt::Display for Note {
391 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
392 write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
393 }
394}
395
396/// Settings, and where they came from.
397#[derive(Debug, Clone, PartialEq, Eq, Default)]
398pub struct Loaded {
399 pub config: Config,
400 /// The files read, in the order they were applied. Empty when nothing was
401 /// found and the defaults are in force.
402 pub files: Vec<PathBuf>,
403 pub notes: Vec<Note>,
404}
405
406// -- discovery and loading --------------------------------------------------
407
408/// One of the `gdtoolkit` readers: settings in, notes out.
409type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
410
411/// Find the nearest `gdck.toml`, searching `start` and then each ancestor.
412///
413/// Returns `None` when the search reaches the filesystem root without a match,
414/// which is the normal case for a project that has not configured anything.
415#[must_use]
416pub fn discover(start: &Path) -> Option<PathBuf> {
417 discover_named(start, CONFIG_FILE_NAMES)
418}
419
420/// Find the nearest file with one of `names`, searching `start` and then each
421/// ancestor.
422#[must_use]
423pub fn discover_named(start: &Path, names: &[&str]) -> Option<PathBuf> {
424 for dir in start.ancestors() {
425 for name in names {
426 let candidate = dir.join(name);
427 if candidate.is_file() {
428 return Some(candidate);
429 }
430 }
431 }
432 None
433}
434
435/// The settings in force in a directory.
436///
437/// The nearest `gdck.toml` wins outright: a project that has written one has
438/// said what it wants, and quietly mixing in a `gdlintrc` from three
439/// directories further up would make the result impossible to predict. Only
440/// when there is no `gdck.toml` are `gdformatrc` and `gdlintrc` read, so a
441/// project already set up for `gdtoolkit` keeps its settings.
442pub fn resolve(start: &Path) -> Result<Loaded, Error> {
443 if let Some(path) = discover(start) {
444 let mut loaded = load(&path)?;
445 loaded.notes.extend(shadowed_notes(start, &loaded.config));
446 return Ok(loaded);
447 }
448
449 let mut loaded = Loaded::default();
450 // Formatting first, so a `gdlintrc` naming its own line length has the
451 // last word on what the linter reports.
452 let readers: [(&[&str], Reader); 2] = [
453 (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
454 (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
455 ];
456 for (names, apply) in readers {
457 let Some(path) = discover_named(start, names) else {
458 continue;
459 };
460 let text = read_to_string(&path)?;
461 let problems = apply(&text, &mut loaded.config).map_err(|problem| at(&path, problem))?;
462 loaded.notes.extend(notes_at(&path, problems));
463 loaded.files.push(path);
464 }
465 Ok(loaded)
466}
467
468/// Note any `gdtoolkit` file whose settings a `gdck.toml` is keeping out.
469///
470/// The precedence itself is deliberate and stays: a project that has written a
471/// `gdck.toml` has said what it wants. What is not defensible is doing it
472/// silently, which is how a project ends up governed by rules it thought it had
473/// set — `gdck` says so for a single unknown key in a `gdlintrc`, so passing
474/// over the whole file without a word was the odd one out.
475///
476/// Only a file that would actually *change* something is reported. After
477/// `gdck init` has carried the settings across, both files say the same thing
478/// and there is nothing to warn about, so the warning does not become a
479/// permanent fixture that teaches people to ignore it.
480///
481/// A shadowed file that cannot be read or parsed is passed over in silence. It
482/// is not governing this run, so it cannot mislead anyone about it, and failing
483/// a run over a file it is not using would be worse.
484fn shadowed_notes(start: &Path, active: &Config) -> Vec<Note> {
485 let mut notes = Vec::new();
486 let readers: [(&[&str], Reader); 2] = [
487 (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
488 (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
489 ];
490
491 let mut would_be = Config::default();
492 let mut found = Vec::new();
493 for (names, apply) in readers {
494 let Some(path) = discover_named(start, names) else {
495 continue;
496 };
497 let Ok(text) = std::fs::read_to_string(&path) else {
498 continue;
499 };
500 if apply(&text, &mut would_be).is_err() {
501 continue;
502 }
503 found.push(path);
504 }
505 if found.is_empty() {
506 return notes;
507 }
508
509 let changed = differences(active, &would_be);
510 if changed.is_empty() {
511 return notes;
512 }
513
514 for path in found {
515 notes.push(Note {
516 path,
517 line: 1,
518 message: format!(
519 "not applied, because the gdck.toml takes precedence. It disagrees \
520 about {}. Copy those into the gdck.toml, or delete this file",
521 changed.join(", ")
522 ),
523 });
524 }
525 notes
526}
527
528/// The settings two configurations disagree about, by the name a `gdck.toml`
529/// spells them, so the message names something searchable.
530fn differences(active: &Config, other: &Config) -> Vec<&'static str> {
531 let mut changed = Vec::new();
532 if active.format.line_length != other.format.line_length {
533 changed.push("format.line-length");
534 }
535 if active.format.indent != other.format.indent {
536 changed.push("format.indent");
537 }
538 if active.format.safety_checks != other.format.safety_checks {
539 changed.push("format.safety-checks");
540 }
541 if active.lint.max_line_length != other.lint.max_line_length {
542 changed.push("lint.max-line-length");
543 }
544 if active.lint.max_file_lines != other.lint.max_file_lines {
545 changed.push("lint.max-file-lines");
546 }
547 if active.lint.max_public_methods != other.lint.max_public_methods {
548 changed.push("lint.max-public-methods");
549 }
550 if active.lint.max_returns != other.lint.max_returns {
551 changed.push("lint.max-returns");
552 }
553 if active.lint.max_function_arguments != other.lint.max_function_arguments {
554 changed.push("lint.max-arguments");
555 }
556 if active.lint.declaration_order != other.lint.declaration_order {
557 changed.push("lint.declaration-order");
558 }
559 if active.lint.disabled != other.lint.disabled {
560 changed.push("lint.disable");
561 }
562 if active.excluded_dirs != other.excluded_dirs {
563 changed.push("files.exclude");
564 }
565 changed
566}
567
568/// Read one configuration file, whatever kind it is.
569///
570/// The kind is decided by the file's name, so `--config` can be pointed at a
571/// `gdlintrc` as readily as at a `gdck.toml`. A name matching none of them is
572/// read as a `gdck.toml`.
573pub fn load(path: &Path) -> Result<Loaded, Error> {
574 let text = read_to_string(path)?;
575 let name = path
576 .file_name()
577 .map_or_else(String::new, |name| name.to_string_lossy().into_owned());
578
579 let mut loaded = Loaded {
580 files: vec![path.to_path_buf()],
581 ..Loaded::default()
582 };
583 let problems = if GDLINT_FILE_NAMES.contains(&name.as_str()) {
584 compat::apply_gdlintrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
585 } else if GDFORMAT_FILE_NAMES.contains(&name.as_str()) {
586 compat::apply_gdformatrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
587 } else {
588 loaded.config = schema::read(&text).map_err(|problem| at(path, problem))?;
589 Vec::new()
590 };
591 loaded.notes = notes_at(path, problems);
592 Ok(loaded)
593}
594
595/// Pin a problem to the file it was found in.
596fn at(path: &Path, problem: Problem) -> Error {
597 Error {
598 path: path.to_path_buf(),
599 line: Some(problem.line),
600 message: problem.message,
601 }
602}
603
604fn notes_at(path: &Path, problems: Vec<Problem>) -> Vec<Note> {
605 problems
606 .into_iter()
607 .map(|problem| Note {
608 path: path.to_path_buf(),
609 line: problem.line,
610 message: problem.message,
611 })
612 .collect()
613}
614
615fn read_to_string(path: &Path) -> Result<String, Error> {
616 std::fs::read_to_string(path).map_err(|error| Error {
617 path: path.to_path_buf(),
618 line: None,
619 message: error.to_string(),
620 })
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn defaults_match_the_style_guide() {
629 let config = Config::default();
630 assert_eq!(config.format.line_length, 100);
631 assert_eq!(config.format.indent, IndentStyle::Tabs);
632 assert!(config.format.safety_checks);
633 // Reordering must be opt-in until the dependency analysis is proven.
634 assert_eq!(config.lint.code_order, CodeOrderFix::ReportOnly);
635 }
636
637 #[test]
638 fn excluded_dirs_fall_back_to_defaults() {
639 let config = Config::default();
640 assert!(config.is_excluded_dir(".git"));
641 assert!(config.is_excluded_dir(".godot"));
642 assert!(!config.is_excluded_dir("src"));
643 }
644
645 #[test]
646 fn explicit_excluded_dirs_replace_the_defaults() {
647 let config = Config {
648 excluded_dirs: vec!["vendor".to_string()],
649 ..Config::default()
650 };
651 assert!(config.is_excluded_dir("vendor"));
652 assert!(!config.is_excluded_dir(".git"));
653 }
654
655 #[test]
656 fn discover_returns_none_when_nothing_is_configured() {
657 // A directory that certainly holds no gdck.toml above it is hard to
658 // guarantee, so just check the call is total and does not panic.
659 let _ = discover(Path::new("/"));
660 }
661
662 #[test]
663 fn an_error_reads_as_a_place_and_a_reason() {
664 let error = Error {
665 path: PathBuf::from("gdck.toml"),
666 line: Some(4),
667 message: "unknown setting `foo`".to_string(),
668 };
669 assert_eq!(error.to_string(), "gdck.toml:4: unknown setting `foo`");
670 let error = Error {
671 line: None,
672 ..error
673 };
674 assert_eq!(error.to_string(), "gdck.toml: unknown setting `foo`");
675 }
676}