Skip to main content

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    StaticVars,
154    Exports,
155    PubVars,
156    PrvVars,
157    OnreadyPubVars,
158    OnreadyPrvVars,
159    Others,
160}
161
162impl DeclarationGroup {
163    /// The order the style guide gives, which is what `code-order` checks
164    /// unless a project names one of its own.
165    ///
166    /// Nothing reads the variants' declaration order — there is no `Ord` here
167    /// and no cast to an integer — so this is the single place that says what
168    /// the default order *is*. It had been implicit in the `Bucket` enum over
169    /// in the linter, which left nothing to print and nothing to check a
170    /// document against, and the documented example drifted out of step as a
171    /// result: it showed `staticvars` last, where the guide puts it eighth,
172    /// directly after constants.
173    pub const GUIDE_ORDER: [Self; 14] = [
174        Self::Tools,
175        Self::ClassNames,
176        Self::Extends,
177        Self::Docstrings,
178        Self::Signals,
179        Self::Enums,
180        Self::Consts,
181        Self::StaticVars,
182        Self::Exports,
183        Self::PubVars,
184        Self::PrvVars,
185        Self::OnreadyPubVars,
186        Self::OnreadyPrvVars,
187        Self::Others,
188    ];
189
190    /// The `gdtoolkit` spelling, which is also `gdck`'s.
191    #[must_use]
192    pub fn name(self) -> &'static str {
193        match self {
194            Self::Tools => "tools",
195            Self::ClassNames => "classnames",
196            Self::Extends => "extends",
197            Self::Docstrings => "docstrings",
198            Self::Enums => "enums",
199            Self::Signals => "signals",
200            Self::Consts => "consts",
201            Self::Exports => "exports",
202            Self::PubVars => "pubvars",
203            Self::PrvVars => "prvvars",
204            Self::OnreadyPubVars => "onreadypubvars",
205            Self::OnreadyPrvVars => "onreadyprvvars",
206            Self::StaticVars => "staticvars",
207            Self::Others => "others",
208        }
209    }
210
211    /// Read one from the name `gdtoolkit` uses for it.
212    #[must_use]
213    pub fn from_name(name: &str) -> Option<Self> {
214        [
215            Self::Tools,
216            Self::ClassNames,
217            Self::Extends,
218            Self::Docstrings,
219            Self::Signals,
220            Self::Enums,
221            Self::Consts,
222            Self::Exports,
223            Self::PubVars,
224            Self::PrvVars,
225            Self::OnreadyPubVars,
226            Self::OnreadyPrvVars,
227            Self::StaticVars,
228            Self::Others,
229        ]
230        .into_iter()
231        .find(|group| group.name() == name)
232    }
233}
234
235/// Which convention the `file-name` rule holds a file to.
236///
237/// The style guide says snake_case and that is the default. It is also the one
238/// naming rule where a project's own answer is worth honouring rather than
239/// suppressing: a file name is not an identifier the language sees, so a
240/// project that names files after the classes in them is following a
241/// convention rather than breaking one. Saying which is being followed keeps
242/// the rule working, where disabling it stops it noticing anything at all.
243///
244/// Both settings are a convention by name, not a pattern to match. The
245/// alternative — a regular expression, as `gdtoolkit` takes — makes every
246/// project's spelling of "snake_case" subtly its own. See `docs/DESIGN.md`.
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
248pub enum FileNameCase {
249    /// `player_controller.gd`, what the guide asks for.
250    #[default]
251    SnakeCase,
252    /// `PlayerController.gd`, matching the class the file holds.
253    PascalCase,
254}
255
256/// Lint options.
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub struct LintConfig {
260    pub max_line_length: u16,
261    pub max_file_lines: u32,
262    pub max_public_methods: u32,
263    pub max_returns: u32,
264    pub max_function_arguments: u32,
265    pub code_order: CodeOrderFix,
266    pub file_name: FileNameCase,
267    /// A declaration order of the project's own, if it stated one.
268    ///
269    /// `None` means the style guide's, which is what `gdck` sorts by and what
270    /// its own buckets are named after. A project that pinned
271    /// `class-definitions-order` in a `gdlintrc` gets that order instead.
272    pub declaration_order: Option<Vec<DeclarationGroup>>,
273    /// Rule names switched off for this project.
274    pub disabled: Vec<String>,
275}
276
277impl Default for LintConfig {
278    fn default() -> Self {
279        Self {
280            max_line_length: 100,
281            max_file_lines: 1000,
282            max_public_methods: 20,
283            max_returns: 6,
284            max_function_arguments: 10,
285            code_order: CodeOrderFix::default(),
286            file_name: FileNameCase::default(),
287            declaration_order: None,
288            disabled: Vec::new(),
289        }
290    }
291}
292
293/// Naming patterns from the style guide's conventions table.
294///
295/// Written out as source strings because that is the least ambiguous way to
296/// state a convention, and because it is the form a project would use to
297/// override one. The linter does not compile them: `is_snake_case` and its
298/// two siblings are a dozen lines each and read better than the equivalent
299/// pattern, so these stand as documentation of what those functions accept.
300/// See [`crate::naming`] and `gdck_lint::names`.
301pub mod naming {
302    pub const PASCAL_CASE: &str = r"([A-Z][a-z0-9]*)+";
303    pub const SNAKE_CASE: &str = r"[a-z][a-z0-9]*(_[a-z0-9]+)*";
304    pub const PRIVATE_SNAKE_CASE: &str = r"_?[a-z][a-z0-9]*(_[a-z0-9]+)*";
305    pub const CONSTANT_CASE: &str = r"[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
306    pub const PRIVATE_CONSTANT_CASE: &str = r"_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*";
307}
308
309/// The full configuration for a run.
310#[derive(Debug, Clone, PartialEq, Eq)]
311#[non_exhaustive]
312pub struct Config {
313    pub format: FormatConfig,
314    pub lint: LintConfig,
315    pub excluded_dirs: Vec<String>,
316    /// Skip files a `.gitignore` covers.
317    ///
318    /// On, because a `.gd` file git has been told to ignore is almost always
319    /// something generated or vendored, and reporting on it is noise about
320    /// code nobody is going to edit. A path named directly on the command line
321    /// is still processed: naming a file is a stronger signal than a pattern.
322    pub respect_gitignore: bool,
323}
324
325impl Default for Config {
326    fn default() -> Self {
327        Self {
328            format: FormatConfig::default(),
329            lint: LintConfig::default(),
330            excluded_dirs: Vec::new(),
331            respect_gitignore: true,
332        }
333    }
334}
335
336impl Config {
337    /// Whether a directory name should be skipped when collecting files.
338    #[must_use]
339    pub fn is_excluded_dir(&self, name: &str) -> bool {
340        if self.excluded_dirs.is_empty() {
341            return DEFAULT_EXCLUDED_DIRS.contains(&name);
342        }
343        self.excluded_dirs.iter().any(|dir| dir == name)
344    }
345
346    /// Write these settings out as a `gdck.toml`.
347    ///
348    /// Every setting is written, including the ones left at their default,
349    /// because the question this answers is what a run is actually using.
350    #[must_use]
351    pub fn to_toml(&self) -> String {
352        schema::to_toml(self)
353    }
354
355    /// Write these settings out as a `gdck.toml` for a project to keep.
356    ///
357    /// Every setting appears, but the ones still at their default are
358    /// commented out. The live lines are then what this project decided, and
359    /// the commented ones are a catalogue to uncomment from — and a project
360    /// inherits later changes to a default rather than pinning today's value
361    /// without meaning to.
362    ///
363    /// This is what `gdck init` writes. [`to_toml`](Self::to_toml) is the
364    /// other question — what a run is using — and answers it in full.
365    #[must_use]
366    pub fn to_starter_toml(&self) -> String {
367        schema::to_starter_toml(self)
368    }
369}
370
371// -- errors and notes -------------------------------------------------------
372
373/// Something wrong at a line of a file, before the file is known.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub(crate) struct Problem {
376    pub(crate) line: u32,
377    pub(crate) message: String,
378}
379
380/// A configuration file that could not be used.
381///
382/// A broken configuration file is always fatal rather than a fall back to the
383/// defaults. Settings that quietly do not apply are worse than a run that
384/// stops and says why.
385#[derive(Debug, Clone, PartialEq, Eq)]
386pub struct Error {
387    pub path: PathBuf,
388    /// The line at fault, when the file was read but not understood.
389    pub line: Option<u32>,
390    pub message: String,
391}
392
393impl fmt::Display for Error {
394    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
395        match self.line {
396            Some(line) => write!(f, "{}:{line}: {}", self.path.display(), self.message),
397            None => write!(f, "{}: {}", self.path.display(), self.message),
398        }
399    }
400}
401
402impl std::error::Error for Error {}
403
404/// Something a configuration file asked for that `gdck` cannot honour.
405///
406/// Only the `gdtoolkit` files produce these. `gdck.toml` refuses what it
407/// cannot do, but a foreign file is allowed to hold settings that mean nothing
408/// here — and saying so is the difference between a setting that does not
409/// apply and one that silently does not apply.
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct Note {
412    pub path: PathBuf,
413    pub line: u32,
414    pub message: String,
415}
416
417impl fmt::Display for Note {
418    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
419        write!(f, "{}:{}: {}", self.path.display(), self.line, self.message)
420    }
421}
422
423/// Settings, and where they came from.
424#[derive(Debug, Clone, PartialEq, Eq, Default)]
425pub struct Loaded {
426    pub config: Config,
427    /// The files read, in the order they were applied. Empty when nothing was
428    /// found and the defaults are in force.
429    pub files: Vec<PathBuf>,
430    pub notes: Vec<Note>,
431}
432
433// -- discovery and loading --------------------------------------------------
434
435/// One of the `gdtoolkit` readers: settings in, notes out.
436type Reader = fn(&str, &mut Config) -> Result<Vec<Problem>, Problem>;
437
438/// Find the nearest `gdck.toml`, searching `start` and then each ancestor.
439///
440/// Returns `None` when the search reaches the filesystem root without a match,
441/// which is the normal case for a project that has not configured anything.
442#[must_use]
443pub fn discover(start: &Path) -> Option<PathBuf> {
444    discover_named(start, CONFIG_FILE_NAMES)
445}
446
447/// Find the nearest file with one of `names`, searching `start` and then each
448/// ancestor.
449#[must_use]
450pub fn discover_named(start: &Path, names: &[&str]) -> Option<PathBuf> {
451    for dir in start.ancestors() {
452        for name in names {
453            let candidate = dir.join(name);
454            if candidate.is_file() {
455                return Some(candidate);
456            }
457        }
458    }
459    None
460}
461
462/// The settings in force in a directory.
463///
464/// The nearest `gdck.toml` wins outright: a project that has written one has
465/// said what it wants, and quietly mixing in a `gdlintrc` from three
466/// directories further up would make the result impossible to predict. Only
467/// when there is no `gdck.toml` are `gdformatrc` and `gdlintrc` read, so a
468/// project already set up for `gdtoolkit` keeps its settings.
469pub fn resolve(start: &Path) -> Result<Loaded, Error> {
470    if let Some(path) = discover(start) {
471        let mut loaded = load(&path)?;
472        loaded.notes.extend(shadowed_notes(start, &loaded.config));
473        return Ok(loaded);
474    }
475
476    let mut loaded = Loaded::default();
477    // Formatting first, so a `gdlintrc` naming its own line length has the
478    // last word on what the linter reports.
479    let readers: [(&[&str], Reader); 2] = [
480        (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
481        (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
482    ];
483    for (names, apply) in readers {
484        let Some(path) = discover_named(start, names) else {
485            continue;
486        };
487        let text = read_to_string(&path)?;
488        let problems = apply(&text, &mut loaded.config).map_err(|problem| at(&path, problem))?;
489        loaded.notes.extend(notes_at(&path, problems));
490        loaded.files.push(path);
491    }
492    Ok(loaded)
493}
494
495/// Note any `gdtoolkit` file whose settings a `gdck.toml` is keeping out.
496///
497/// The precedence itself is deliberate and stays: a project that has written a
498/// `gdck.toml` has said what it wants. What is not defensible is doing it
499/// silently, which is how a project ends up governed by rules it thought it had
500/// set — `gdck` says so for a single unknown key in a `gdlintrc`, so passing
501/// over the whole file without a word was the odd one out.
502///
503/// Only a file that would actually *change* something is reported. After
504/// `gdck init` has carried the settings across, both files say the same thing
505/// and there is nothing to warn about, so the warning does not become a
506/// permanent fixture that teaches people to ignore it.
507///
508/// A shadowed file that cannot be read or parsed is passed over in silence. It
509/// is not governing this run, so it cannot mislead anyone about it, and failing
510/// a run over a file it is not using would be worse.
511fn shadowed_notes(start: &Path, active: &Config) -> Vec<Note> {
512    let mut notes = Vec::new();
513    let readers: [(&[&str], Reader); 2] = [
514        (GDFORMAT_FILE_NAMES, compat::apply_gdformatrc),
515        (GDLINT_FILE_NAMES, compat::apply_gdlintrc),
516    ];
517
518    let mut would_be = Config::default();
519    let mut found = Vec::new();
520    for (names, apply) in readers {
521        let Some(path) = discover_named(start, names) else {
522            continue;
523        };
524        let Ok(text) = std::fs::read_to_string(&path) else {
525            continue;
526        };
527        if apply(&text, &mut would_be).is_err() {
528            continue;
529        }
530        found.push(path);
531    }
532    if found.is_empty() {
533        return notes;
534    }
535
536    let changed = differences(active, &would_be);
537    if changed.is_empty() {
538        return notes;
539    }
540
541    for path in found {
542        notes.push(Note {
543            path,
544            line: 1,
545            message: format!(
546                "not applied, because the gdck.toml takes precedence. It disagrees \
547                 about {}. Copy those into the gdck.toml, or delete this file",
548                changed.join(", ")
549            ),
550        });
551    }
552    notes
553}
554
555/// The settings two configurations disagree about, by the name a `gdck.toml`
556/// spells them, so the message names something searchable.
557fn differences(active: &Config, other: &Config) -> Vec<&'static str> {
558    let mut changed = Vec::new();
559    if active.format.line_length != other.format.line_length {
560        changed.push("format.line-length");
561    }
562    if active.format.indent != other.format.indent {
563        changed.push("format.indent");
564    }
565    if active.format.safety_checks != other.format.safety_checks {
566        changed.push("format.safety-checks");
567    }
568    if active.lint.max_line_length != other.lint.max_line_length {
569        changed.push("lint.max-line-length");
570    }
571    if active.lint.max_file_lines != other.lint.max_file_lines {
572        changed.push("lint.max-file-lines");
573    }
574    if active.lint.max_public_methods != other.lint.max_public_methods {
575        changed.push("lint.max-public-methods");
576    }
577    if active.lint.max_returns != other.lint.max_returns {
578        changed.push("lint.max-returns");
579    }
580    if active.lint.max_function_arguments != other.lint.max_function_arguments {
581        changed.push("lint.max-arguments");
582    }
583    if active.lint.declaration_order != other.lint.declaration_order {
584        changed.push("lint.declaration-order");
585    }
586    if active.lint.disabled != other.lint.disabled {
587        changed.push("lint.disable");
588    }
589    if active.excluded_dirs != other.excluded_dirs {
590        changed.push("files.exclude");
591    }
592    changed
593}
594
595/// Read one configuration file, whatever kind it is.
596///
597/// The kind is decided by the file's name, so `--config` can be pointed at a
598/// `gdlintrc` as readily as at a `gdck.toml`. A name matching none of them is
599/// read as a `gdck.toml`.
600pub fn load(path: &Path) -> Result<Loaded, Error> {
601    let text = read_to_string(path)?;
602    let name = path
603        .file_name()
604        .map_or_else(String::new, |name| name.to_string_lossy().into_owned());
605
606    let mut loaded = Loaded {
607        files: vec![path.to_path_buf()],
608        ..Loaded::default()
609    };
610    let problems = if GDLINT_FILE_NAMES.contains(&name.as_str()) {
611        compat::apply_gdlintrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
612    } else if GDFORMAT_FILE_NAMES.contains(&name.as_str()) {
613        compat::apply_gdformatrc(&text, &mut loaded.config).map_err(|problem| at(path, problem))?
614    } else {
615        loaded.config = schema::read(&text).map_err(|problem| at(path, problem))?;
616        Vec::new()
617    };
618    loaded.notes = notes_at(path, problems);
619    Ok(loaded)
620}
621
622/// Pin a problem to the file it was found in.
623fn at(path: &Path, problem: Problem) -> Error {
624    Error {
625        path: path.to_path_buf(),
626        line: Some(problem.line),
627        message: problem.message,
628    }
629}
630
631fn notes_at(path: &Path, problems: Vec<Problem>) -> Vec<Note> {
632    problems
633        .into_iter()
634        .map(|problem| Note {
635            path: path.to_path_buf(),
636            line: problem.line,
637            message: problem.message,
638        })
639        .collect()
640}
641
642fn read_to_string(path: &Path) -> Result<String, Error> {
643    std::fs::read_to_string(path).map_err(|error| Error {
644        path: path.to_path_buf(),
645        line: None,
646        message: error.to_string(),
647    })
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn defaults_match_the_style_guide() {
656        let config = Config::default();
657        assert_eq!(config.format.line_length, 100);
658        assert_eq!(config.format.indent, IndentStyle::Tabs);
659        assert!(config.format.safety_checks);
660        // Reordering must be opt-in until the dependency analysis is proven.
661        assert_eq!(config.lint.code_order, CodeOrderFix::ReportOnly);
662    }
663
664    #[test]
665    fn excluded_dirs_fall_back_to_defaults() {
666        let config = Config::default();
667        assert!(config.is_excluded_dir(".git"));
668        assert!(config.is_excluded_dir(".godot"));
669        assert!(!config.is_excluded_dir("src"));
670    }
671
672    #[test]
673    fn explicit_excluded_dirs_replace_the_defaults() {
674        let config = Config {
675            excluded_dirs: vec!["vendor".to_string()],
676            ..Config::default()
677        };
678        assert!(config.is_excluded_dir("vendor"));
679        assert!(!config.is_excluded_dir(".git"));
680    }
681
682    #[test]
683    fn discover_returns_none_when_nothing_is_configured() {
684        // A directory that certainly holds no gdck.toml above it is hard to
685        // guarantee, so just check the call is total and does not panic.
686        let _ = discover(Path::new("/"));
687    }
688
689    #[test]
690    fn an_error_reads_as_a_place_and_a_reason() {
691        let error = Error {
692            path: PathBuf::from("gdck.toml"),
693            line: Some(4),
694            message: "unknown setting `foo`".to_string(),
695        };
696        assert_eq!(error.to_string(), "gdck.toml:4: unknown setting `foo`");
697        let error = Error {
698            line: None,
699            ..error
700        };
701        assert_eq!(error.to_string(), "gdck.toml: unknown setting `foo`");
702    }
703}