Skip to main content

skif_config/
model.rs

1use std::path::{Path, PathBuf};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use skif_core::{FileSystem, OsFs};
6use skif_specifier::CONFIG_IDENTIFIERS;
7
8use crate::dependency_types::{DependencyType, DependencyTypes};
9use crate::error::ConfigError;
10use crate::overrides::ConfigOverrides;
11use crate::rules::{Level, RuleConfig, rules_map};
12use crate::selector::{Selector, anchor_patterns};
13
14#[cfg(feature = "schema")]
15use crate::schema::RuleConfigSchema;
16
17/// The complete `skif.toml` model.
18#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
20#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
21#[non_exhaustive]
22pub struct Config {
23    /// Stops the upward search for configuration files: no file above this one contributes. Only valid in a file
24    /// found by discovery, not in one reached through `extends`.
25    #[serde(skip_serializing_if = "is_false")]
26    pub root: bool,
27    /// Configuration files to inherit, relative to this file. They apply before this file does, in the order listed.
28    #[serde(skip_serializing_if = "Vec::is_empty")]
29    pub extends: Vec<String>,
30    /// Inherited lists to discard before this file applies.
31    #[serde(skip_serializing_if = "Vec::is_empty")]
32    pub reset: Vec<ResetKey>,
33    /// `[workspace]`: package-manager detection and which packages are skipped.
34    pub workspace: Workspace,
35    /// `[lint]`: how diagnostics are reported.
36    pub lint: Lint,
37    /// `[rules]`: the level and options of every configured rule, in file order.
38    #[serde(with = "rules_map")]
39    #[cfg_attr(feature = "schema", schemars(with = "IndexMap<String, RuleConfigSchema>"))]
40    pub rules: IndexMap<String, RuleConfig>,
41    /// `[[ignore]]`: suppressions for diagnostics.
42    pub ignore: Vec<IgnoreEntry>,
43    /// `[policy]`: defaults for the version policies.
44    pub policy: Policy,
45    /// `[[version-groups]]`: how dependency versions must relate, first match wins.
46    pub version_groups: Vec<VersionGroup>,
47    /// `[[semver-groups]]`: the range type dependencies must use, first match wins.
48    pub semver_groups: Vec<SemverGroup>,
49    /// `[[custom-types]]`: manifest fields that hold versions and take part in the version policies.
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub custom_types: Vec<CustomType>,
52    /// `[catalog]`: when catalog entries are suggested and reported.
53    pub catalog: Catalog,
54    /// `[workspace-protocol]`: how dependencies on workspace packages are written.
55    pub workspace_protocol: WorkspaceProtocol,
56    /// `[[dependency-families]]`: packages that must share a version.
57    pub dependency_families: Vec<DependencyFamily>,
58    /// `[update]`: how `skif update` reads the package registry. Linting never reads it.
59    pub update: Update,
60    /// `[[update-groups]]`: how far `skif update` may move the dependencies it matches, first match wins.
61    #[serde(skip_serializing_if = "Vec::is_empty")]
62    pub update_groups: Vec<UpdateGroup>,
63    /// `[js]`: the JavaScript and TypeScript analysis.
64    pub js: Js,
65    /// `[format]`: how `package-json-format` and `skif format` order and lay out `package.json` files.
66    pub format: Format,
67    /// Scoped rules for packages and files; only package-scoped keys are allowed in them.
68    #[serde(skip_serializing_if = "Vec::is_empty")]
69    pub overrides: Vec<Override>,
70    /// Rule levels set on the command line. They also sit in `rules`; this copy tells the engine to let them beat
71    /// the levels of overrides. Set by [`apply`](Self::apply), never read from a file.
72    #[serde(skip)]
73    #[cfg_attr(feature = "schema", schemars(skip))]
74    pub cli_levels: IndexMap<String, Level>,
75    /// The instances the version-group policies see, narrowed on the command line (`--dependency`,
76    /// `--dependency-types`, `--specifier-types`, `--source`). Never read from a file.
77    #[serde(skip)]
78    #[cfg_attr(feature = "schema", schemars(skip))]
79    pub filter: InstanceFilter,
80}
81
82/// Narrows the dependency instances the version-group policies see, as syncpack's command-line filters do: an
83/// instance that does not pass is left out before it is assigned to a group, so it is neither reported nor aligned
84/// with. Empty selects everything.
85#[derive(Debug, Clone, PartialEq, Eq, Default)]
86#[non_exhaustive]
87pub struct InstanceFilter {
88    /// Dependency names or globs.
89    pub dependencies: Selector,
90    /// Dependency types; a non-empty list also replaces `[policy] dependency-types` as the scope of the default
91    /// group, so `["peer"]` governs peer dependencies.
92    pub dependency_types: DependencyTypes,
93    /// Specifier types (`exact`, `range`, ...), optionally negated with `!`.
94    pub specifier_types: Vec<String>,
95    /// Globs of the files the instances are declared in, relative to the workspace root (`packages/*/package.json`).
96    pub sources: Selector,
97}
98
99impl InstanceFilter {
100    /// Builds a filter from command-line values.
101    ///
102    /// # Errors
103    /// [`ConfigError::Invalid`] for a glob that does not compile, an empty dependency type or an unknown specifier
104    /// type.
105    pub fn new(
106        dependencies: &[String],
107        dependency_types: &[String],
108        specifier_types: &[String],
109        sources: &[String],
110    ) -> Result<Self, ConfigError> {
111        if let Some(message) = unknown_specifier_type(specifier_types) {
112            return Err(ConfigError::invalid(None, format!("--specifier-types: {message}")));
113        }
114        Ok(Self {
115            dependencies: Selector::new(dependencies.iter().cloned())?,
116            dependency_types: DependencyTypes::new(dependency_types)?,
117            specifier_types: specifier_types.to_vec(),
118            sources: Selector::new(sources.iter().cloned())?,
119        })
120    }
121
122    /// Whether the filter selects everything.
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.dependencies.is_empty()
126            && self.dependency_types.is_empty()
127            && self.specifier_types.is_empty()
128            && self.sources.is_empty()
129    }
130
131    /// The first custom type the filter names that `config` does not define.
132    #[must_use]
133    pub fn unknown_custom_type<'a>(&'a self, config: &Config) -> Option<&'a str> {
134        self.dependency_types
135            .custom_names()
136            .find(|name| config.custom_types.iter().all(|custom| custom.name != *name))
137    }
138}
139
140#[allow(clippy::trivially_copy_pass_by_ref)]
141const fn is_false(value: &bool) -> bool {
142    !*value
143}
144
145/// The inherited lists that `reset` can discard.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148#[serde(rename_all = "kebab-case")]
149#[non_exhaustive]
150pub enum ResetKey {
151    /// Discard the inherited `version-groups`.
152    VersionGroups,
153    /// Discard the inherited `semver-groups`.
154    SemverGroups,
155    /// Discard the inherited `ignore` entries.
156    Ignore,
157    /// Discard the inherited `dependency-families`.
158    DependencyFamilies,
159    /// Discard the inherited `custom-types`.
160    CustomTypes,
161    /// Discard the inherited `overrides`.
162    Overrides,
163    /// Discard the inherited `update-groups`.
164    UpdateGroups,
165}
166
167impl ResetKey {
168    /// The key as written in `skif.toml`.
169    #[must_use]
170    pub const fn name(self) -> &'static str {
171        match self {
172            Self::VersionGroups => "version-groups",
173            Self::SemverGroups => "semver-groups",
174            Self::Ignore => "ignore",
175            Self::DependencyFamilies => "dependency-families",
176            Self::CustomTypes => "custom-types",
177            Self::Overrides => "overrides",
178            Self::UpdateGroups => "update-groups",
179        }
180    }
181}
182
183/// Why `patterns` are not a valid selector, when they are not.
184fn selector_problem(patterns: &[String]) -> Option<String> {
185    match Selector::new(patterns.iter().cloned()) {
186        Err(ConfigError::Invalid { message, .. }) => Some(message),
187        _ => None,
188    }
189}
190
191impl Config {
192    /// Parses `skif.toml` contents.
193    ///
194    /// # Errors
195    /// [`ConfigError::Parse`] for malformed TOML, unknown keys or wrong types, and
196    /// [`ConfigError::Invalid`] for inconsistent group definitions.
197    pub fn from_toml_str(source: &str) -> Result<Self, ConfigError> {
198        Self::parse(source, None)
199    }
200
201    /// Reads and parses a configuration file; errors carry `path`.
202    ///
203    /// # Errors
204    /// [`ConfigError::Io`] when the file cannot be read, otherwise as [`Config::from_toml_str`].
205    pub fn from_toml_file(path: &Path) -> Result<Self, ConfigError> {
206        Self::from_toml_file_with_fs(path, &OsFs)
207    }
208
209    /// Reads and parses a configuration file through `fs`; errors carry `path`.
210    ///
211    /// # Errors
212    /// [`ConfigError::Io`] when the file cannot be read, otherwise as [`Config::from_toml_str`].
213    pub fn from_toml_file_with_fs(path: &Path, fs: &dyn FileSystem) -> Result<Self, ConfigError> {
214        let source = fs.read_to_string(path).map_err(|source| ConfigError::Io {
215            path: path.to_path_buf(),
216            source,
217        })?;
218        Self::parse(&source, Some(path))
219    }
220
221    /// Parses and validates the typed form of an already parsed document, leaving `extend-*` keys as written.
222    ///
223    /// `resolve_references` is whether a `dependency-types` list may only name the custom types this document
224    /// defines. A layer of a chain leaves that to the merged configuration, where the types of every layer are known.
225    pub(crate) fn from_document(
226        document: toml::Spanned<toml::de::DeTable<'_>>,
227        source: &str,
228        path: Option<&Path>,
229        resolve_references: bool,
230    ) -> Result<Self, ConfigError> {
231        let config = Self::deserialize(toml::de::Deserializer::from(document))
232            .map_err(|err| ConfigError::from_toml(&err, source, path))?;
233        config.validate_with(path, resolve_references)?;
234        Ok(config)
235    }
236
237    /// Parses a standalone file: the document is parsed once, its `extend-*` keys are folded into the lists they
238    /// extend.
239    fn parse(source: &str, path: Option<&Path>) -> Result<Self, ConfigError> {
240        let from_toml = |err: &toml::de::Error| ConfigError::from_toml(err, source, path);
241        let document = toml::de::DeTable::parse(source).map_err(|err| from_toml(&err))?;
242        let config = Self::from_document(document.clone(), source, path, true)?;
243        if !config.has_extend_keys() {
244            return Ok(config);
245        }
246        let table = toml::Table::deserialize(toml::de::Deserializer::from(document)).map_err(|err| from_toml(&err))?;
247        let mut merger = crate::merge::Merger::default();
248        merger.apply(0, &table, &std::collections::HashMap::new());
249        let folded = merger.into_table();
250        toml::Value::Table(folded)
251            .try_into()
252            .map_err(|err: toml::de::Error| ConfigError::invalid(path, err.message()))
253    }
254
255    fn has_extend_keys(&self) -> bool {
256        !self.workspace.extend_ignore_packages.is_empty()
257            || !self.js.extend_ignore.is_empty()
258            || !self.js.extend_ignore_dependencies.is_empty()
259            || !self.js.extend_ignore_binaries.is_empty()
260            || !self.js.extend_ignore_unresolved.is_empty()
261            || !self.catalog.extend_exclude.is_empty()
262    }
263
264    /// Serializes back to TOML.
265    ///
266    /// # Errors
267    /// Fails only when a rule option holds a value TOML cannot represent.
268    pub fn to_toml_string(&self) -> Result<String, toml::ser::Error> {
269        toml::to_string(self)
270    }
271
272    /// Checks cross-field consistency of groups, catalog and families.
273    ///
274    /// # Errors
275    /// [`ConfigError::Invalid`] naming the offending entry.
276    pub fn validate(&self) -> Result<(), ConfigError> {
277        self.validate_with(None, true)
278    }
279
280    fn validate_with(&self, path: Option<&Path>, resolve_references: bool) -> Result<(), ConfigError> {
281        let invalid = |message: String| Err(ConfigError::invalid(path, message));
282        for (index, group) in self.version_groups.iter().enumerate() {
283            if let Some(message) = group.inconsistency() {
284                return invalid(format!("version-groups[{index}]: {message}"));
285            }
286            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
287                return invalid(format!("version-groups[{index}]: {message}"));
288            }
289        }
290        for (index, group) in self.semver_groups.iter().enumerate() {
291            if let Some(message) = group.inconsistency() {
292                return invalid(format!("semver-groups[{index}]: {message}"));
293            }
294            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
295                return invalid(format!("semver-groups[{index}]: {message}"));
296            }
297        }
298        for (index, group) in self.update_groups.iter().enumerate() {
299            if let Some(message) = group.inconsistency() {
300                return invalid(format!("update-groups[{index}]: {message}"));
301            }
302            if let Some(message) = unknown_specifier_type(&group.specifier_types) {
303                return invalid(format!("update-groups[{index}]: {message}"));
304            }
305        }
306        if self.update.max_concurrent_requests == 0 {
307            return invalid("update.max-concurrent-requests must be at least 1".to_string());
308        }
309        if let Some(message) = self.custom_type_problem(resolve_references) {
310            return invalid(message);
311        }
312        for (index, entry) in self.ignore.iter().enumerate() {
313            if entry.rules.is_empty() && entry.packages.is_empty() && entry.dependencies.is_empty() {
314                return invalid(format!(
315                    "ignore[{index}]: set at least one of `rules`, `packages` or `dependencies`; an entry without filters would suppress every diagnostic"
316                ));
317            }
318            if entry.reason.as_deref().is_some_and(|reason| reason.trim().is_empty()) {
319                return invalid(format!("ignore[{index}]: `reason` is empty; say why or remove it"));
320            }
321        }
322        if let Some(message) = self.format.inconsistency() {
323            return invalid(format!("format.{message}"));
324        }
325        for (index, scoped) in self.overrides.iter().enumerate() {
326            if let Some(message) = scoped.inconsistency() {
327                return invalid(format!("overrides[{index}]: {message}"));
328            }
329        }
330        if self.catalog.min_packages == 0 {
331            return invalid("catalog.min-packages must be at least 1".to_string());
332        }
333        if let Some(message) = selector_problem(&self.catalog.exclude) {
334            return invalid(format!("catalog.exclude: {message}"));
335        }
336        for (index, pattern) in self.workspace.discover_ignore.iter().enumerate() {
337            // The gitignore parser reads an unclosed class such as `[bad` literally; a typo like that is reported.
338            let glob = pattern.strip_prefix('!').unwrap_or(pattern).trim_start_matches('/');
339            let mut builder = ignore::gitignore::GitignoreBuilder::new("");
340            let checked = builder
341                .add_line(None, pattern)
342                .map(|_| ())
343                .map_err(|err| err.to_string())
344                .and_then(|()| globset::Glob::new(glob).map(|_| ()).map_err(|err| err.to_string()));
345            if let Err(err) = checked {
346                return invalid(format!("workspace.discover-ignore[{index}]: {err}"));
347            }
348        }
349        for (index, family) in self.dependency_families.iter().enumerate() {
350            if family.name.is_empty() {
351                return invalid(format!("dependency-families[{index}]: `name` must not be empty"));
352            }
353            if family.enabled && family.packages.is_empty() {
354                return invalid(format!("dependency-families[{index}]: `packages` must not be empty"));
355            }
356            if let Some(message) = selector_problem(&family.packages) {
357                return invalid(format!("dependency-families[{index}]: {message}"));
358            }
359        }
360        Ok(())
361    }
362
363    /// The first problem of `[[custom-types]]`, or, with `resolve_references`, of a `dependency-types` list naming a
364    /// custom type that is not defined.
365    fn custom_type_problem(&self, resolve_references: bool) -> Option<String> {
366        for (index, custom) in self.custom_types.iter().enumerate() {
367            if let Some(message) = custom.inconsistency() {
368                return Some(format!("custom-types[{index}]: {message}"));
369            }
370            if self.custom_types[..index]
371                .iter()
372                .any(|earlier| earlier.name == custom.name)
373            {
374                return Some(format!(
375                    "custom-types[{index}]: the name \"{}\" is defined twice",
376                    custom.name
377                ));
378            }
379        }
380        if !resolve_references {
381            return None;
382        }
383        let lists = std::iter::once(("policy.dependency-types".to_owned(), &self.policy.dependency_types))
384            .chain(self.version_groups.iter().enumerate().map(|(index, group)| {
385                (
386                    format!("version-groups[{index}].dependency-types"),
387                    &group.dependency_types,
388                )
389            }))
390            .chain(self.semver_groups.iter().enumerate().map(|(index, group)| {
391                (
392                    format!("semver-groups[{index}].dependency-types"),
393                    &group.dependency_types,
394                )
395            }))
396            .chain(self.update_groups.iter().enumerate().map(|(index, group)| {
397                (
398                    format!("update-groups[{index}].dependency-types"),
399                    &group.dependency_types,
400                )
401            }));
402        for (place, types) in lists {
403            if let Some(name) = types
404                .custom_names()
405                .find(|name| self.custom_types.iter().all(|custom| custom.name != *name))
406            {
407                let hint = closest(
408                    name,
409                    &self
410                        .custom_types
411                        .iter()
412                        .map(|custom| custom.name.as_str())
413                        .chain(BUILTIN_TYPE_TOKENS)
414                        .collect::<Vec<_>>(),
415                )
416                .map_or_else(String::new, |s| format!("; did you mean \"{s}\"?"));
417                return Some(format!(
418                    "{place}: unknown dependency type \"{name}\"{hint} (expected {}, or the name of a `[[custom-types]]` entry)",
419                    BUILTIN_TYPE_TOKENS.join(", ")
420                ));
421            }
422        }
423        None
424    }
425
426    /// Fails on the first rule ID (in `[rules]`, `[[ignore]]` or an override) that is not in `known`, suggesting the closest ID.
427    ///
428    /// # Errors
429    /// [`ConfigError::UnknownRule`].
430    pub fn validate_rules(&self, known: &[&str]) -> Result<(), ConfigError> {
431        let configured = self.rules.keys();
432        let ignored = self.ignore.iter().flat_map(|entry| entry.rules.iter());
433        let scoped = self.overrides.iter().flat_map(|scoped| {
434            scoped
435                .rules
436                .keys()
437                .chain(scoped.ignore.iter().flat_map(|entry| entry.rules.iter()))
438        });
439        for id in configured.chain(ignored).chain(scoped) {
440            if !known.contains(&id.as_str()) {
441                return Err(ConfigError::UnknownRule {
442                    rule: id.clone(),
443                    suggestion: closest(id, known),
444                });
445            }
446        }
447        Ok(())
448    }
449
450    /// Fails on the first plugin id (in `[js.plugins]`, a `[js.workspaces]` table or an override) that is not in `known`,
451    /// suggesting the closest id.
452    ///
453    /// # Errors
454    /// [`ConfigError::Invalid`] naming the key.
455    pub fn validate_plugins(&self, known: &[&str]) -> Result<(), ConfigError> {
456        let workspaces = self
457            .js
458            .workspaces
459            .iter()
460            .map(|(dir, workspace)| (format!("js.workspaces.\"{dir}\".plugins"), &workspace.plugins));
461        let overrides = self
462            .overrides
463            .iter()
464            .enumerate()
465            .map(|(index, scoped)| (format!("overrides[{index}].js.plugins"), &scoped.js.plugins));
466        let tables = std::iter::once(("js.plugins".to_owned(), &self.js.plugins))
467            .chain(workspaces)
468            .chain(overrides);
469        for (key, table) in tables {
470            if let Some(id) = table.keys().find(|id| !known.contains(&id.as_str())) {
471                let hint = closest(id, known).map_or_else(String::new, |close| format!("; did you mean `{close}`?"));
472                return Err(ConfigError::invalid(
473                    None,
474                    format!("{key}.{id}: unknown plugin `{id}`{hint}"),
475                ));
476            }
477        }
478        Ok(())
479    }
480
481    /// The configuration of rule `id`, if it appears in `[rules]`.
482    #[must_use]
483    pub fn rule(&self, id: &str) -> Option<&RuleConfig> {
484        self.rules.get(id)
485    }
486
487    /// Layers CLI overrides over this configuration (defaults < file < CLI).
488    ///
489    /// # Errors
490    /// [`ConfigError::Invalid`] when an ignore pattern in `overrides` is not a valid selector; the configuration is
491    /// left unchanged.
492    pub fn apply(&mut self, overrides: &ConfigOverrides) -> Result<(), ConfigError> {
493        let ignore_packages = Selector::new(overrides.ignore_packages.iter().cloned())?;
494        let ignore_dependencies = Selector::new(overrides.ignore_dependencies.iter().cloned())?;
495        for (id, level) in &overrides.rules {
496            self.rules.entry(id.clone()).or_default().severity = Some(*level);
497            self.cli_levels.insert(id.clone(), *level);
498        }
499        for id in &overrides.ignore_rules {
500            self.rules.entry(id.clone()).or_default().severity = Some(Level::Off);
501            self.cli_levels.insert(id.clone(), Level::Off);
502        }
503        if !ignore_packages.is_empty() {
504            self.workspace.ignore_packages = ignore_packages;
505        }
506        if !ignore_dependencies.is_empty() {
507            self.ignore.push(IgnoreEntry {
508                dependencies: ignore_dependencies,
509                reason: Some("--ignore-dependency".to_owned()),
510                ..IgnoreEntry::default()
511            });
512        }
513        if let Some(value) = overrides.fail_on_warnings {
514            self.lint.fail_on_warnings = value;
515        }
516        if let Some(value) = overrides.production {
517            self.js.production = value;
518        }
519        if overrides.no_js {
520            self.js.enabled = false;
521        }
522        Ok(())
523    }
524}
525
526/// The built-in dependency types, as `dependency-types` names them.
527const BUILTIN_TYPE_TOKENS: [&str; 8] = [
528    "prod",
529    "dev",
530    "peer",
531    "optional",
532    "local",
533    "resolutions",
534    "overrides",
535    "pnpm-overrides",
536];
537
538/// Describes the first entry of `specifier_types` (optionally negated with `!`) that is not a specifier type.
539fn unknown_specifier_type(specifier_types: &[String]) -> Option<String> {
540    specifier_types.iter().find_map(|raw| {
541        let name = raw.strip_prefix('!').unwrap_or(raw);
542        if CONFIG_IDENTIFIERS.contains(&name) {
543            return None;
544        }
545        let hint = closest(name, &CONFIG_IDENTIFIERS).map_or_else(String::new, |s| format!("; did you mean \"{s}\"?"));
546        Some(format!(
547            "unknown specifier type \"{name}\"{hint} (expected one of: {})",
548            CONFIG_IDENTIFIERS.join(", ")
549        ))
550    })
551}
552
553fn closest(id: &str, known: &[&str]) -> Option<String> {
554    let limit = (id.chars().count() / 3).clamp(2, 4);
555    known
556        .iter()
557        .map(|candidate| (strsim::levenshtein(id, candidate), candidate))
558        .filter(|(distance, _)| *distance <= limit)
559        .min_by_key(|(distance, _)| *distance)
560        .map(|(_, candidate)| (*candidate).to_string())
561}
562
563/// Package managers that can be forced with `[workspace] package-manager`.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
565#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
566#[serde(rename_all = "kebab-case")]
567#[non_exhaustive]
568pub enum PackageManager {
569    /// npm.
570    Npm,
571    /// pnpm.
572    Pnpm,
573    /// Yarn.
574    Yarn,
575    /// Bun.
576    Bun,
577}
578
579/// Names of the directories that hold test fixtures. Recursive root discovery skips them by default (they lead
580/// [`DEFAULT_DISCOVER_IGNORE`]), and the JavaScript analysis reports nothing about their files that would only hold
581/// for production code: their imports are not unresolved or unlisted, and, unless `[js] include-fixtures` is set,
582/// their files and exports are not unused.
583pub const FIXTURE_DIRECTORIES: [&str; 2] = ["__fixtures__", "fixtures"];
584
585/// Directory names that recursive root discovery skips by default: they hold fixtures and test doubles, not projects.
586/// The first entries are [`FIXTURE_DIRECTORIES`].
587pub const DEFAULT_DISCOVER_IGNORE: [&str; 4] = ["__fixtures__", "fixtures", "__mocks__", "__snapshots__"];
588
589fn default_discover_ignore() -> Vec<String> {
590    DEFAULT_DISCOVER_IGNORE.iter().map(|name| (*name).to_owned()).collect()
591}
592
593/// `[workspace]`
594#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
595#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
596#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
597#[non_exhaustive]
598pub struct Workspace {
599    /// Package names or globs excluded from every check.
600    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
601    pub ignore_packages: Selector,
602    /// Overrides package-manager detection.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub package_manager: Option<PackageManager>,
605    /// Gitignore-style patterns, relative to the directory a recursive run starts in, of directories that root
606    /// discovery (`-r`) does not enter, so no project below them is found. A bare name matches at any depth; a
607    /// pattern with a slash is anchored at the start directory. Replaces the default list
608    /// (`__fixtures__`, `fixtures`, `__mocks__`, `__snapshots__`); `[]` skips nothing. Read from the configuration
609    /// file that governs the start directory.
610    pub discover_ignore: Vec<String>,
611    /// Appended to `ignore-packages` instead of replacing it. Folded away when the configuration is resolved.
612    #[serde(skip_serializing_if = "Selector::is_empty")]
613    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
614    pub extend_ignore_packages: Selector,
615}
616
617impl Default for Workspace {
618    fn default() -> Self {
619        Self {
620            ignore_packages: Selector::default(),
621            extend_ignore_packages: Selector::default(),
622            package_manager: None,
623            discover_ignore: default_discover_ignore(),
624        }
625    }
626}
627
628impl Workspace {
629    /// Sets `ignore-packages`.
630    #[must_use]
631    pub fn with_ignore_packages(mut self, ignore_packages: Selector) -> Self {
632        self.ignore_packages = ignore_packages;
633        self
634    }
635
636    /// Forces the package manager instead of detecting it.
637    #[must_use]
638    pub const fn with_package_manager(mut self, package_manager: PackageManager) -> Self {
639        self.package_manager = Some(package_manager);
640        self
641    }
642
643    /// Sets `discover-ignore`, replacing the default list.
644    #[must_use]
645    pub fn with_discover_ignore(mut self, discover_ignore: Vec<String>) -> Self {
646        self.discover_ignore = discover_ignore;
647        self
648    }
649}
650
651/// `[lint]`
652#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
653#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
654#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
655#[non_exhaustive]
656pub struct Lint {
657    /// Exit non-zero when only warnings were reported.
658    pub fail_on_warnings: bool,
659    /// Caps the severity of every diagnostic: a rule configured above this level reports at it.
660    #[serde(skip_serializing_if = "Option::is_none")]
661    pub max_level: Option<Level>,
662}
663
664impl Lint {
665    /// Sets `fail-on-warnings`.
666    #[must_use]
667    pub const fn with_fail_on_warnings(mut self, fail_on_warnings: bool) -> Self {
668        self.fail_on_warnings = fail_on_warnings;
669        self
670    }
671
672    /// Caps the severity of every diagnostic at `max_level`.
673    #[must_use]
674    pub const fn with_max_level(mut self, max_level: Level) -> Self {
675        self.max_level = Some(max_level);
676        self
677    }
678}
679
680/// One `[[ignore]]` suppression. An empty list does not narrow that dimension, but at least one list must be set:
681/// an entry without any filter is rejected as it would suppress everything.
682#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
683#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
684#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
685#[non_exhaustive]
686pub struct IgnoreEntry {
687    /// Rule IDs to suppress; an empty list does not narrow the entry by rule.
688    pub rules: Vec<String>,
689    /// Package names or directories; an empty selector matches every package.
690    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
691    pub packages: Selector,
692    /// Dependency names; an empty selector matches every dependency.
693    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
694    pub dependencies: Selector,
695    /// Why the suppression exists. `--require-ignore-reasons` fails a run on an entry without one.
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub reason: Option<String>,
698}
699
700impl IgnoreEntry {
701    /// Limits the entry to the rule IDs `rules`.
702    #[must_use]
703    pub fn with_rules<I, S>(mut self, rules: I) -> Self
704    where
705        I: IntoIterator<Item = S>,
706        S: Into<String>,
707    {
708        self.rules = rules.into_iter().map(Into::into).collect();
709        self
710    }
711
712    /// Limits the entry to the packages `packages` selects.
713    #[must_use]
714    pub fn with_packages(mut self, packages: Selector) -> Self {
715        self.packages = packages;
716        self
717    }
718
719    /// Limits the entry to the dependencies `dependencies` selects.
720    #[must_use]
721    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
722        self.dependencies = dependencies;
723        self
724    }
725
726    /// Records why the suppression exists.
727    #[must_use]
728    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
729        self.reason = Some(reason.into());
730        self
731    }
732}
733
734/// Which version `preferred-semver` favours.
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
736#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
737#[serde(rename_all = "kebab-case")]
738#[non_exhaustive]
739pub enum Prefer {
740    /// Prefer the highest version in use.
741    #[default]
742    Highest,
743    /// Prefer the lowest version in use.
744    Lowest,
745}
746
747/// How the override sections (`resolutions`, `overrides`, `pnpm-overrides`) are read as dependency instances.
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
749#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
750#[serde(rename_all = "kebab-case")]
751#[non_exhaustive]
752pub enum OverrideReading {
753    /// The dependency of an entry is the package its selector replaces (`parent>child` and `child@<2` are `child`),
754    /// npm's nested objects are read, and `pnpm.overrides` of every `package.json` is read.
755    #[default]
756    Skif,
757    /// As syncpack 15 reads them: the key of an entry is the dependency name, so `parent>child` and `child@<2` are
758    /// dependencies of their own; only string values count (nested objects are skipped); and `pnpm-overrides` is the
759    /// `overrides` of `pnpm-workspace.yaml` only.
760    Syncpack,
761}
762
763/// `[policy]`
764#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
765#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
766#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
767#[non_exhaustive]
768pub struct Policy {
769    /// Which version `preferred-semver` favours.
770    pub prefer: Prefer,
771    /// Dependency types the policy rules apply to. Empty means production and development dependencies (`dependencies` and `devDependencies`); name `peer` or `optional` to include them.
772    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
773    pub dependency_types: DependencyTypes,
774    /// How `resolutions`, `overrides` and `pnpm-overrides` entries become dependency instances: `skif` (default) names
775    /// the package a selector replaces, `syncpack` reads them as syncpack 15 does.
776    pub override_reading: OverrideReading,
777}
778
779impl Policy {
780    /// Sets which version `preferred-semver` favours.
781    #[must_use]
782    pub const fn with_prefer(mut self, prefer: Prefer) -> Self {
783        self.prefer = prefer;
784        self
785    }
786
787    /// Sets the dependency types the policy rules apply to.
788    #[must_use]
789    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
790        self.dependency_types = dependency_types;
791        self
792    }
793
794    /// Sets how the override sections are read.
795    #[must_use]
796    pub const fn with_override_reading(mut self, override_reading: OverrideReading) -> Self {
797        self.override_reading = override_reading;
798        self
799    }
800}
801
802/// The policy of a version group.
803#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
804#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
805#[serde(rename_all = "kebab-case")]
806#[non_exhaustive]
807pub enum VersionPolicy {
808    /// Every matched dependency must use the `pin` version.
809    Pinned,
810    /// The matched dependencies must not be used.
811    Banned,
812    /// The matched dependencies are not checked.
813    Ignored,
814    /// Every range must overlap every other range.
815    SameRange,
816    /// Every matched dependency must share a major and minor version.
817    SameMinor,
818    /// Versions must match those declared by the `snap-to` packages.
819    SnappedTo,
820    /// Versions must match the highest or lowest one in use.
821    #[default]
822    PreferredSemver,
823    /// Versions may differ; only the range the semver groups require is checked (syncpack's `semverRangeOnly`).
824    SemverRangeOnly,
825}
826
827/// One `[[version-groups]]` entry; first match wins. Selectors accept `!` negation and `$LOCAL`.
828#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
829#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
830#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
831#[non_exhaustive]
832pub struct VersionGroup {
833    /// A name shown in diagnostics.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    pub label: Option<String>,
836    /// Dependency names the group covers; empty covers every dependency.
837    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
838    pub dependencies: Selector,
839    /// Package names the group covers; empty covers every package.
840    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
841    pub packages: Selector,
842    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs (production and development dependencies and a package's own version by default). Peer and optional dependencies, the override sections and custom types are covered only when named.
843    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
844    pub dependency_types: DependencyTypes,
845    /// Specifier types the group covers (`exact`, `range`, `tag`, ...), optionally negated with `!`; empty covers every type.
846    pub specifier_types: Vec<String>,
847    /// The policy the group enforces.
848    pub policy: VersionPolicy,
849    /// Required by `pinned`.
850    #[serde(skip_serializing_if = "Option::is_none")]
851    pub pin: Option<String>,
852    /// Required by `snapped-to`: packages whose version the group follows.
853    pub snap_to: Vec<String>,
854    /// Allowed with `preferred-semver`; overrides `[policy] prefer`.
855    #[serde(skip_serializing_if = "Option::is_none")]
856    pub prefer: Option<Prefer>,
857}
858
859impl VersionGroup {
860    /// Sets the label shown in diagnostics.
861    #[must_use]
862    pub fn with_label(mut self, label: impl Into<String>) -> Self {
863        self.label = Some(label.into());
864        self
865    }
866
867    /// Selects the dependencies the group covers.
868    #[must_use]
869    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
870        self.dependencies = dependencies;
871        self
872    }
873
874    /// Selects the packages the group covers.
875    #[must_use]
876    pub fn with_packages(mut self, packages: Selector) -> Self {
877        self.packages = packages;
878        self
879    }
880
881    /// Selects the dependency types the group covers.
882    #[must_use]
883    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
884        self.dependency_types = dependency_types;
885        self
886    }
887
888    /// Selects the specifier types the group covers.
889    #[must_use]
890    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
891    where
892        I: IntoIterator<Item = S>,
893        S: Into<String>,
894    {
895        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
896        self
897    }
898
899    /// Sets the policy.
900    #[must_use]
901    pub const fn with_policy(mut self, policy: VersionPolicy) -> Self {
902        self.policy = policy;
903        self
904    }
905
906    /// Sets the version `pinned` requires.
907    #[must_use]
908    pub fn with_pin(mut self, pin: impl Into<String>) -> Self {
909        self.pin = Some(pin.into());
910        self
911    }
912
913    /// Sets the packages `snapped-to` follows.
914    #[must_use]
915    pub fn with_snap_to<I, S>(mut self, snap_to: I) -> Self
916    where
917        I: IntoIterator<Item = S>,
918        S: Into<String>,
919    {
920        self.snap_to = snap_to.into_iter().map(Into::into).collect();
921        self
922    }
923
924    /// Overrides `[policy] prefer` for this group.
925    #[must_use]
926    pub const fn with_prefer(mut self, prefer: Prefer) -> Self {
927        self.prefer = Some(prefer);
928        self
929    }
930
931    fn inconsistency(&self) -> Option<&'static str> {
932        match self.policy {
933            VersionPolicy::Pinned if self.pin.is_none() => return Some("policy `pinned` requires `pin`"),
934            VersionPolicy::SnappedTo if self.snap_to.is_empty() => {
935                return Some("policy `snapped-to` requires `snap-to`");
936            }
937            _ => {}
938        }
939        if self.pin.is_some() && self.policy != VersionPolicy::Pinned {
940            return Some("`pin` is only valid with policy `pinned`");
941        }
942        if !self.snap_to.is_empty() && self.policy != VersionPolicy::SnappedTo {
943            return Some("`snap-to` is only valid with policy `snapped-to`");
944        }
945        if self.prefer.is_some() && self.policy != VersionPolicy::PreferredSemver {
946            return Some("`prefer` is only valid with policy `preferred-semver`");
947        }
948        None
949    }
950}
951
952/// The range type a semver group enforces.
953#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
954#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
955#[non_exhaustive]
956pub enum SemverRange {
957    /// No range operator: an exact version.
958    #[serde(rename = "")]
959    Exact,
960    /// Any version (`*`).
961    #[serde(rename = "*")]
962    Any,
963    /// Compatible versions (`^`).
964    #[serde(rename = "^")]
965    Caret,
966    /// Patch-level changes (`~`).
967    #[serde(rename = "~")]
968    Tilde,
969    /// Greater than (`>`).
970    #[serde(rename = ">")]
971    Gt,
972    /// Greater than or equal (`>=`).
973    #[serde(rename = ">=")]
974    Gte,
975    /// Less than (`<`).
976    #[serde(rename = "<")]
977    Lt,
978    /// Less than or equal (`<=`).
979    #[serde(rename = "<=")]
980    Lte,
981}
982
983/// One `[[semver-groups]]` entry; first match wins. Set `range`, or `ignored = true` to leave the matched dependencies
984/// without a range requirement (syncpack's `isIgnored` and `isDisabled`).
985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
986#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
987#[serde(rename_all = "kebab-case", deny_unknown_fields)]
988#[non_exhaustive]
989pub struct SemverGroup {
990    /// A name shown in diagnostics.
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub label: Option<String>,
993    /// Dependency names the group covers; empty covers every dependency.
994    #[serde(default)]
995    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
996    pub dependencies: Selector,
997    /// Package names the group covers; empty covers every package.
998    #[serde(default)]
999    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1000    pub packages: Selector,
1001    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs (production and development dependencies and a package's own version by default). Peer and optional dependencies, the override sections and custom types are covered only when named.
1002    #[serde(default)]
1003    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1004    pub dependency_types: DependencyTypes,
1005    /// Specifier types the group covers, optionally negated with `!`; empty covers every type.
1006    #[serde(default)]
1007    pub specifier_types: Vec<String>,
1008    /// The range type the matched dependencies must use. Required unless `ignored` is set.
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub range: Option<SemverRange>,
1011    /// The matched dependencies have no range requirement and no later semver group applies to them. Exclusive with
1012    /// `range`.
1013    #[serde(default, skip_serializing_if = "is_false")]
1014    pub ignored: bool,
1015}
1016
1017impl SemverGroup {
1018    /// A group enforcing `range` on every dependency.
1019    #[must_use]
1020    pub fn new(range: SemverRange) -> Self {
1021        Self {
1022            label: None,
1023            dependencies: Selector::default(),
1024            packages: Selector::default(),
1025            dependency_types: DependencyTypes::default(),
1026            specifier_types: Vec::new(),
1027            range: Some(range),
1028            ignored: false,
1029        }
1030    }
1031
1032    /// A group that exempts the dependencies it covers from range enforcement.
1033    #[must_use]
1034    pub fn ignored() -> Self {
1035        Self {
1036            range: None,
1037            ignored: true,
1038            ..Self::new(SemverRange::Exact)
1039        }
1040    }
1041
1042    fn inconsistency(&self) -> Option<&'static str> {
1043        match (self.range, self.ignored) {
1044            (None, false) => Some("set `range`, or `ignored = true` to exempt the dependencies from a range"),
1045            (Some(_), true) => Some("`range` and `ignored = true` are mutually exclusive"),
1046            _ => None,
1047        }
1048    }
1049
1050    /// Sets the label shown in diagnostics.
1051    #[must_use]
1052    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1053        self.label = Some(label.into());
1054        self
1055    }
1056
1057    /// Selects the dependencies the group covers.
1058    #[must_use]
1059    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
1060        self.dependencies = dependencies;
1061        self
1062    }
1063
1064    /// Selects the packages the group covers.
1065    #[must_use]
1066    pub fn with_packages(mut self, packages: Selector) -> Self {
1067        self.packages = packages;
1068        self
1069    }
1070
1071    /// Selects the dependency types the group covers.
1072    #[must_use]
1073    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1074        self.dependency_types = dependency_types;
1075        self
1076    }
1077
1078    /// Selects the specifier types the group covers.
1079    #[must_use]
1080    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
1081    where
1082        I: IntoIterator<Item = S>,
1083        S: Into<String>,
1084    {
1085        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
1086        self
1087    }
1088}
1089
1090/// How far `skif update` may move a version: syncpack's `--target` and an update group's `target`.
1091#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1092#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1093#[serde(rename_all = "kebab-case")]
1094#[non_exhaustive]
1095pub enum UpdateTarget {
1096    /// The highest published version, across majors.
1097    #[default]
1098    Latest,
1099    /// The highest version with the same major.
1100    Minor,
1101    /// The highest version with the same major and minor.
1102    Patch,
1103}
1104
1105impl UpdateTarget {
1106    /// The stricter of two targets: `patch` over `minor` over `latest`. The command line's `--target` and an update
1107    /// group's `target` combine this way, so a group can only narrow what the run allows.
1108    #[must_use]
1109    pub const fn stricter(self, other: Self) -> Self {
1110        match (self, other) {
1111            (Self::Patch, _) | (_, Self::Patch) => Self::Patch,
1112            (Self::Minor, _) | (_, Self::Minor) => Self::Minor,
1113            _ => Self::Latest,
1114        }
1115    }
1116
1117    /// The name as `--target` and `target` write it.
1118    #[must_use]
1119    pub const fn name(self) -> &'static str {
1120        match self {
1121            Self::Latest => "latest",
1122            Self::Minor => "minor",
1123            Self::Patch => "patch",
1124        }
1125    }
1126}
1127
1128/// The default of `[update] max-concurrent-requests`, syncpack's `maxConcurrentRequests`.
1129pub const DEFAULT_MAX_CONCURRENT_REQUESTS: usize = 12;
1130
1131/// `[update]`: how `skif update` reads the package registry (ADR 0058). Linting never reads it.
1132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1134#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1135#[non_exhaustive]
1136pub struct Update {
1137    /// Minutes a version must have been published before `skif update` proposes it; `0` turns the delay off. Unset,
1138    /// the `minimumReleaseAge` of `pnpm-workspace.yaml` applies, else one day (1440), as in syncpack.
1139    #[serde(skip_serializing_if = "Option::is_none")]
1140    pub minimum_release_age: Option<u64>,
1141    /// How many registry requests run at once.
1142    pub max_concurrent_requests: usize,
1143}
1144
1145impl Default for Update {
1146    fn default() -> Self {
1147        Self {
1148            minimum_release_age: None,
1149            max_concurrent_requests: DEFAULT_MAX_CONCURRENT_REQUESTS,
1150        }
1151    }
1152}
1153
1154impl Update {
1155    /// Sets the minimum release age in minutes.
1156    #[must_use]
1157    pub const fn with_minimum_release_age(mut self, minutes: Option<u64>) -> Self {
1158        self.minimum_release_age = minutes;
1159        self
1160    }
1161
1162    /// Sets how many registry requests run at once.
1163    #[must_use]
1164    pub const fn with_max_concurrent_requests(mut self, requests: usize) -> Self {
1165        self.max_concurrent_requests = requests;
1166        self
1167    }
1168}
1169
1170/// One `[[update-groups]]` entry; first match wins. Set `target` to cap how far `skif update` moves the matched
1171/// dependencies, or `ignored = true` to leave them alone (syncpack's `updateGroups`).
1172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1174#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1175#[non_exhaustive]
1176pub struct UpdateGroup {
1177    /// A name for the group.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub label: Option<String>,
1180    /// Dependency names the group covers; empty covers every dependency.
1181    #[serde(default)]
1182    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1183    pub dependencies: Selector,
1184    /// Package names the group covers; empty covers every package. A group that names packages does not cover catalog
1185    /// entries, which belong to no package.
1186    #[serde(default)]
1187    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1188    pub packages: Selector,
1189    /// Dependency types the group covers; empty covers the types `[policy] dependency-types` governs. A group that
1190    /// names types does not cover catalog entries.
1191    #[serde(default)]
1192    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
1193    pub dependency_types: DependencyTypes,
1194    /// Specifier types the group covers, optionally negated with `!`; empty covers every type.
1195    #[serde(default)]
1196    pub specifier_types: Vec<String>,
1197    /// The furthest the matched dependencies may move; combined with `--target`, the stricter wins. Required unless
1198    /// `ignored` is set.
1199    #[serde(default, skip_serializing_if = "Option::is_none")]
1200    pub target: Option<UpdateTarget>,
1201    /// `skif update` never changes the matched dependencies. Exclusive with `target`.
1202    #[serde(default, skip_serializing_if = "is_false")]
1203    pub ignored: bool,
1204}
1205
1206impl UpdateGroup {
1207    /// A group capping every dependency at `target`.
1208    #[must_use]
1209    pub fn new(target: UpdateTarget) -> Self {
1210        Self {
1211            label: None,
1212            dependencies: Selector::default(),
1213            packages: Selector::default(),
1214            dependency_types: DependencyTypes::default(),
1215            specifier_types: Vec::new(),
1216            target: Some(target),
1217            ignored: false,
1218        }
1219    }
1220
1221    /// A group whose dependencies `skif update` leaves alone.
1222    #[must_use]
1223    pub fn ignored() -> Self {
1224        Self {
1225            target: None,
1226            ignored: true,
1227            ..Self::new(UpdateTarget::Latest)
1228        }
1229    }
1230
1231    fn inconsistency(&self) -> Option<&'static str> {
1232        match (self.target, self.ignored) {
1233            (None, false) => Some("set `target`, or `ignored = true` to leave the dependencies alone"),
1234            (Some(_), true) => Some("`target` and `ignored = true` are mutually exclusive"),
1235            _ => None,
1236        }
1237    }
1238
1239    /// Sets the label.
1240    #[must_use]
1241    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1242        self.label = Some(label.into());
1243        self
1244    }
1245
1246    /// Selects the dependencies the group covers.
1247    #[must_use]
1248    pub fn with_dependencies(mut self, dependencies: Selector) -> Self {
1249        self.dependencies = dependencies;
1250        self
1251    }
1252
1253    /// Selects the packages the group covers.
1254    #[must_use]
1255    pub fn with_packages(mut self, packages: Selector) -> Self {
1256        self.packages = packages;
1257        self
1258    }
1259
1260    /// Selects the dependency types the group covers.
1261    #[must_use]
1262    pub fn with_dependency_types(mut self, dependency_types: DependencyTypes) -> Self {
1263        self.dependency_types = dependency_types;
1264        self
1265    }
1266
1267    /// Selects the specifier types the group covers.
1268    #[must_use]
1269    pub fn with_specifier_types<I, S>(mut self, specifier_types: I) -> Self
1270    where
1271        I: IntoIterator<Item = S>,
1272        S: Into<String>,
1273    {
1274        self.specifier_types = specifier_types.into_iter().map(Into::into).collect();
1275        self
1276    }
1277}
1278
1279/// How a [`CustomType`] reads versions from its `path`.
1280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1282#[non_exhaustive]
1283pub enum CustomStrategy {
1284    /// The value is a string `name@version` (`"packageManager": "pnpm@9.0.0"`): the dependency is the name.
1285    #[serde(rename = "name@version")]
1286    NameAtVersion,
1287    /// The value is a version string (`engines.node`): the dependency is named after the custom type.
1288    #[serde(rename = "version")]
1289    Version,
1290    /// The value is an object of `name: version` (`pnpm.peerDependencyRules.allowedVersions`).
1291    #[serde(rename = "versions-by-name", alias = "versionsByName")]
1292    VersionsByName,
1293    /// The value is a version string and the dependency name is the string at `name-path` (how syncpack reads a
1294    /// package's own `name` and `version`).
1295    #[serde(rename = "name~version")]
1296    NameTildeVersion,
1297}
1298
1299/// One `[[custom-types]]` entry: a field of `package.json` that holds versions. Name it in a `dependency-types` list to
1300/// select its instances, like a dependency section (syncpack's `customTypes`).
1301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1303#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1304#[non_exhaustive]
1305pub struct CustomType {
1306    /// The token that selects this type in `dependency-types`. It must not be the name of a built-in type
1307    /// (`prod`, `dev`, `peer`, `optional`, `local`, `resolutions`, `overrides`, `pnpm-overrides`). With the `version`
1308    /// strategy it is also the dependency name.
1309    pub name: String,
1310    /// Dotted path of the field in `package.json`, e.g. `engines.node` or `packageManager`.
1311    pub path: String,
1312    /// How the value at `path` holds versions.
1313    pub strategy: CustomStrategy,
1314    /// With the `name~version` strategy: dotted path of the field that holds the dependency name.
1315    #[serde(default, skip_serializing_if = "Option::is_none")]
1316    pub name_path: Option<String>,
1317}
1318
1319impl CustomType {
1320    /// A custom type reading `strategy` from `path`.
1321    #[must_use]
1322    pub fn new(name: impl Into<String>, path: impl Into<String>, strategy: CustomStrategy) -> Self {
1323        Self {
1324            name: name.into(),
1325            path: path.into(),
1326            strategy,
1327            name_path: None,
1328        }
1329    }
1330
1331    /// Sets the field that holds the dependency name, for the `name~version` strategy.
1332    #[must_use]
1333    pub fn with_name_path(mut self, name_path: impl Into<String>) -> Self {
1334        self.name_path = Some(name_path.into());
1335        self
1336    }
1337
1338    /// The `name-path` split into its keys; empty when there is none.
1339    pub fn name_segments(&self) -> impl Iterator<Item = &str> {
1340        self.name_path.iter().flat_map(|path| path.split('.'))
1341    }
1342
1343    /// The `path` split into its keys.
1344    pub fn segments(&self) -> impl Iterator<Item = &str> {
1345        self.path.split('.')
1346    }
1347
1348    fn inconsistency(&self) -> Option<String> {
1349        if self.name.is_empty() || self.name.starts_with('!') {
1350            return Some("`name` must not be empty or start with `!`".to_owned());
1351        }
1352        if DependencyType::is_builtin_token(&self.name) {
1353            return Some(format!(
1354                "`name` \"{}\" is the name of a built-in dependency type",
1355                self.name
1356            ));
1357        }
1358        if self.segments().any(str::is_empty) {
1359            return Some(format!(
1360                "`path` \"{}\" must be a dotted path of non-empty keys",
1361                self.path
1362            ));
1363        }
1364        let takes_name_path = self.strategy == CustomStrategy::NameTildeVersion;
1365        match (takes_name_path, self.name_path.is_some()) {
1366            (true, false) => return Some("strategy `name~version` requires `name-path`".to_owned()),
1367            (false, true) => return Some("`name-path` is only valid with strategy `name~version`".to_owned()),
1368            _ => {}
1369        }
1370        if self.name_segments().any(str::is_empty) {
1371            return Some("`name-path` must be a dotted path of non-empty keys".to_owned());
1372        }
1373        None
1374    }
1375}
1376
1377/// `[catalog] mode`
1378#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1380#[serde(rename_all = "kebab-case")]
1381#[non_exhaustive]
1382pub enum CatalogMode {
1383    /// Catalog rules suggest nothing.
1384    #[default]
1385    Off,
1386    /// Suggest moving shared dependencies into a catalog.
1387    Prefer,
1388    /// Require shared dependencies to come from a catalog.
1389    Enforce,
1390}
1391
1392/// `[catalog]`
1393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1394#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1395#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1396#[non_exhaustive]
1397pub struct Catalog {
1398    /// How strongly catalogs are preferred.
1399    pub mode: CatalogMode,
1400    /// Minimum number of packages using a dependency before a catalog entry is suggested.
1401    pub min_packages: usize,
1402    /// Dependencies never suggested for a catalog.
1403    pub exclude: Vec<String>,
1404    /// Report catalog entries no package references.
1405    pub report_unused_entries: bool,
1406    /// Appended to `exclude` instead of replacing it. Folded away when the configuration is resolved.
1407    #[serde(skip_serializing_if = "Vec::is_empty")]
1408    pub extend_exclude: Vec<String>,
1409}
1410
1411impl Default for Catalog {
1412    fn default() -> Self {
1413        Self {
1414            mode: CatalogMode::Off,
1415            min_packages: 2,
1416            exclude: Vec::new(),
1417            report_unused_entries: false,
1418            extend_exclude: Vec::new(),
1419        }
1420    }
1421}
1422
1423impl Catalog {
1424    /// Sets the catalog mode.
1425    #[must_use]
1426    pub const fn with_mode(mut self, mode: CatalogMode) -> Self {
1427        self.mode = mode;
1428        self
1429    }
1430
1431    /// Sets the minimum number of packages using a dependency before an entry is suggested.
1432    #[must_use]
1433    pub const fn with_min_packages(mut self, min_packages: usize) -> Self {
1434        self.min_packages = min_packages;
1435        self
1436    }
1437
1438    /// Sets the dependencies excluded from catalog suggestions.
1439    #[must_use]
1440    pub fn with_exclude<I, S>(mut self, exclude: I) -> Self
1441    where
1442        I: IntoIterator<Item = S>,
1443        S: Into<String>,
1444    {
1445        self.exclude = exclude.into_iter().map(Into::into).collect();
1446        self
1447    }
1448
1449    /// Sets whether unused catalog entries are reported.
1450    #[must_use]
1451    pub const fn with_report_unused_entries(mut self, report_unused_entries: bool) -> Self {
1452        self.report_unused_entries = report_unused_entries;
1453        self
1454    }
1455}
1456
1457/// `[workspace-protocol]`
1458#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1460#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1461#[non_exhaustive]
1462pub struct WorkspaceProtocol {
1463    /// Require `workspace:` for dependencies on other workspace packages.
1464    pub internal: bool,
1465    /// Accept `file:` / `link:` paths for internal dependencies.
1466    pub allow_local_paths: bool,
1467}
1468
1469impl WorkspaceProtocol {
1470    /// Sets whether `workspace:` is required for internal dependencies.
1471    #[must_use]
1472    pub const fn with_internal(mut self, internal: bool) -> Self {
1473        self.internal = internal;
1474        self
1475    }
1476
1477    /// Sets whether `file:` / `link:` paths are accepted for internal dependencies.
1478    #[must_use]
1479    pub const fn with_allow_local_paths(mut self, allow_local_paths: bool) -> Self {
1480        self.allow_local_paths = allow_local_paths;
1481        self
1482    }
1483}
1484
1485/// `[format]`: what the `package-json-format` rule and `skif format` check, with syncpack's defaults (ADR 0055).
1486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1488#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1489#[non_exhaustive]
1490pub struct Format {
1491    /// Top-level keys that come first, in this order.
1492    pub sort_first: Vec<String>,
1493    /// Fields sorted alphabetically one level deep: an object by key, an array of strings by value. `a/b` names a
1494    /// nested field. For fields whose order means nothing: an array with a `!`-prefixed entry keeps its order.
1495    pub sort_az: Vec<String>,
1496    /// Keys that come first, in this order, in every object of `exports`; `default` is always last. The default moves
1497    /// only `types` and `default`; a key that reorders other conditions makes the fix unsafe.
1498    pub sort_exports: Vec<String>,
1499    /// Sort the top-level keys `sort-first` does not name.
1500    pub sort_packages: bool,
1501    /// Write a `bugs` object that holds only `url` as that string.
1502    pub format_bugs: bool,
1503    /// Write a `repository` object that holds only `url` and a `type` of `git` (or no `type`) as its shorthand.
1504    pub format_repository: bool,
1505    /// Check the layout against `JSON.stringify(manifest, null, indent)`: spaces or tabs, for example `"  "` or
1506    /// `"\t"`. Unset keeps each file's layout.
1507    #[serde(skip_serializing_if = "Option::is_none")]
1508    pub indent: Option<String>,
1509}
1510
1511fn strings(items: &[&str]) -> Vec<String> {
1512    items.iter().map(|item| (*item).to_owned()).collect()
1513}
1514
1515impl Default for Format {
1516    fn default() -> Self {
1517        Self {
1518            sort_first: strings(&["name", "description", "version", "author"]),
1519            sort_az: strings(&[
1520                "bin",
1521                "contributors",
1522                "dependencies",
1523                "devDependencies",
1524                "keywords",
1525                "peerDependencies",
1526                "resolutions",
1527                "scripts",
1528            ]),
1529            sort_exports: strings(&["types", "default"]),
1530            sort_packages: true,
1531            format_bugs: false,
1532            format_repository: false,
1533            indent: None,
1534        }
1535    }
1536}
1537
1538impl Format {
1539    /// Sets the keys that come first.
1540    #[must_use]
1541    pub fn with_sort_first<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1542        self.sort_first = keys.into_iter().map(Into::into).collect();
1543        self
1544    }
1545
1546    /// Sets the fields sorted alphabetically.
1547    #[must_use]
1548    pub fn with_sort_az<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1549        self.sort_az = keys.into_iter().map(Into::into).collect();
1550        self
1551    }
1552
1553    /// Sets the order of the `exports` conditions.
1554    #[must_use]
1555    pub fn with_sort_exports<I: IntoIterator<Item = S>, S: Into<String>>(mut self, keys: I) -> Self {
1556        self.sort_exports = keys.into_iter().map(Into::into).collect();
1557        self
1558    }
1559
1560    /// Sets whether the remaining top-level keys are sorted.
1561    #[must_use]
1562    pub const fn with_sort_packages(mut self, sort: bool) -> Self {
1563        self.sort_packages = sort;
1564        self
1565    }
1566
1567    /// Sets whether `bugs` is collapsed to its URL.
1568    #[must_use]
1569    pub const fn with_format_bugs(mut self, format: bool) -> Self {
1570        self.format_bugs = format;
1571        self
1572    }
1573
1574    /// Sets whether `repository` is collapsed to its shorthand.
1575    #[must_use]
1576    pub const fn with_format_repository(mut self, format: bool) -> Self {
1577        self.format_repository = format;
1578        self
1579    }
1580
1581    /// Sets the indent the layout is checked against.
1582    #[must_use]
1583    pub fn with_indent(mut self, indent: impl Into<String>) -> Self {
1584        self.indent = Some(indent.into());
1585        self
1586    }
1587
1588    /// The indent with a written `\t` read as a tab, as syncpack reads it.
1589    #[must_use]
1590    pub fn indent_text(&self) -> Option<String> {
1591        self.indent.as_ref().map(|indent| indent.replace("\\t", "\t"))
1592    }
1593
1594    fn inconsistency(&self) -> Option<String> {
1595        let indent = self.indent_text()?;
1596        (indent.is_empty() || indent.chars().any(|c| c != ' ' && c != '\t')).then(|| {
1597            format!(
1598                "indent: {:?} is not a run of spaces or tabs",
1599                self.indent.as_deref().unwrap_or_default()
1600            )
1601        })
1602    }
1603}
1604
1605/// One `[[dependency-families]]` entry: packages that must share a version.
1606#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1607#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1608#[serde(rename_all = "kebab-case", deny_unknown_fields)]
1609#[non_exhaustive]
1610pub struct DependencyFamily {
1611    /// The family name; a built-in family of the same name is replaced.
1612    pub name: String,
1613    /// Package names or globs that must share a version.
1614    #[serde(default)]
1615    pub packages: Vec<String>,
1616    /// `false` disables a built-in family of the same name.
1617    #[serde(default = "default_true")]
1618    pub enabled: bool,
1619}
1620
1621impl DependencyFamily {
1622    /// An enabled family called `name` whose members are `packages`.
1623    #[must_use]
1624    pub fn new<I, S>(name: impl Into<String>, packages: I) -> Self
1625    where
1626        I: IntoIterator<Item = S>,
1627        S: Into<String>,
1628    {
1629        Self {
1630            name: name.into(),
1631            packages: packages.into_iter().map(Into::into).collect(),
1632            enabled: true,
1633        }
1634    }
1635
1636    /// Enables or disables the family; `false` disables a built-in family of the same name.
1637    #[must_use]
1638    pub const fn with_enabled(mut self, enabled: bool) -> Self {
1639        self.enabled = enabled;
1640        self
1641    }
1642}
1643
1644const fn default_true() -> bool {
1645    true
1646}
1647
1648/// `[js] discovery`
1649#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1650#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1651#[serde(rename_all = "lowercase")]
1652#[non_exhaustive]
1653pub enum JsDiscovery {
1654    /// The git index, checked against the file system: a directory modified since the index was written is listed
1655    /// from disk, so untracked and deleted files are found as the walk finds them. The directory walk when the
1656    /// workspace is not in a git work tree with a usable index.
1657    #[default]
1658    Auto,
1659    /// The git index alone, without checking the file system: faster, but blind to files created or deleted since the
1660    /// last `git add`. The directory walk when there is no usable index.
1661    Git,
1662    /// Always walk the directories.
1663    Walk,
1664}
1665
1666/// `[js.cycles]`: knip's `cycles` options of the `circular-imports` rule.
1667#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1668#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1669#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1670#[non_exhaustive]
1671pub struct JsCycles {
1672    /// Cycles that are not reported, each the list of its files relative to the workspace root. A cycle is allowed
1673    /// when its files are exactly the files of one entry, in any order.
1674    pub allow: Vec<Vec<String>>,
1675    /// Count dynamic `import()` as an edge of a cycle. Off by default: a dynamic import runs after the module graph
1676    /// is loaded, so it cannot cause an initialisation-order problem.
1677    pub dynamic_imports: bool,
1678}
1679
1680/// `[js]`
1681#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1682#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1683#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1684#[non_exhaustive]
1685#[allow(
1686    clippy::struct_excessive_bools,
1687    reason = "each bool is an independent switch of the analysis"
1688)]
1689pub struct Js {
1690    /// Run the JavaScript analysis.
1691    pub enabled: bool,
1692    /// Entry-point globs, added to the ones plugins and `package.json` provide.
1693    pub entry: Vec<String>,
1694    /// Globs of the files that belong to the project.
1695    pub project: Vec<String>,
1696    /// Globs of files that are not analysed.
1697    pub ignore: Vec<String>,
1698    /// Globs of files, relative to the workspace root, never reported by `unused-files`; other rules still analyse
1699    /// them (knip's `ignoreFiles`).
1700    pub ignore_files: Vec<String>,
1701    /// Dependencies never reported as unused or unlisted.
1702    pub ignore_dependencies: Vec<String>,
1703    /// Binaries never reported as unlisted.
1704    pub ignore_binaries: Vec<String>,
1705    /// Never report the system and shell tools scripts run (`rm`, `curl`, `git`, `docker`) as unlisted binaries,
1706    /// since they are installed globally rather than from npm (knip's `ignoreGlobalBinaries`). On by default; turn it
1707    /// off to have `unlisted-binaries` report them unless a dependency provides them.
1708    pub ignore_global_binaries: bool,
1709    /// Import specifiers never reported as unresolved. Entries are exact specifiers, globs or regular expressions
1710    /// (`/^@org\/.+$/`), see the `[js]` documentation.
1711    pub ignore_unresolved: Vec<String>,
1712    /// Workspace directories (globs) the JavaScript analysis skips. Manifest and policy rules still check them.
1713    pub ignore_workspaces: Vec<String>,
1714    /// Exports that other code in the same file uses are not reported: `true` for every kind, or a table of kinds.
1715    pub ignore_exports_used_in_file: IgnoreExportsUsedInFile,
1716    /// Enum and namespace member names (globs or regular expressions) never reported as unused.
1717    pub ignore_members: Vec<String>,
1718    /// Analyse production code only.
1719    pub production: bool,
1720    /// knip's strict mode. Each workspace package uses only the dependencies its own manifest lists: an import is
1721    /// not satisfied by a containing package or the root. Production code must import from `dependencies` or
1722    /// `peerDependencies`, type-only imports use no package, and required peer dependencies are checked like
1723    /// `dependencies`. Set by `skif --strict`; not a configuration key, so a file never sets it.
1724    #[serde(skip)]
1725    #[cfg_attr(feature = "schema", schemars(skip))]
1726    pub isolate_workspaces: bool,
1727    /// Find source files that `.gitignore` files hide (the git index is not used then). Set by
1728    /// `skif --no-gitignore`; not a configuration key.
1729    #[serde(skip)]
1730    #[cfg_attr(feature = "schema", schemars(skip))]
1731    pub no_gitignore: bool,
1732    /// Report unused exports of entry files too.
1733    pub include_entry_exports: bool,
1734    /// The TypeScript configuration of the root package, relative to the workspace root and inside it, used instead
1735    /// of `tsconfig.json` for its path aliases and compiler options (knip's `--tsConfig`). Other packages keep their
1736    /// own `tsconfig.json`.
1737    #[serde(skip_serializing_if = "Option::is_none")]
1738    pub tsconfig: Option<String>,
1739    /// The project files of a package with a TypeScript configuration are the files its `files` names and those its
1740    /// `include` selects minus its `exclude`, instead of the `project` globs (knip's `--use-tsconfig-files`).
1741    pub use_tsconfig_files: bool,
1742    /// Report files in `fixtures` and `__fixtures__` directories as unused, and their unused exports, types and
1743    /// members, like any other file. Off by default: those directories hold test data that tests load by path.
1744    pub include_fixtures: bool,
1745    /// How source files are found: `auto` lists them from the git index and re-lists from disk every directory
1746    /// modified since the index was written, which gives the walk's result without walking every directory; `git`
1747    /// trusts the index alone (faster, blind to untracked and deleted files); `walk` always walks. Both index modes
1748    /// fall back to the walk when the workspace is not in a git work tree with a usable SHA-1 index.
1749    pub discovery: JsDiscovery,
1750    /// What `circular-imports` reports.
1751    pub cycles: JsCycles,
1752    /// Per-workspace overrides keyed by directory. The key `"."` is the root workspace.
1753    pub workspaces: IndexMap<String, JsWorkspace>,
1754    /// Plugin switches keyed by plugin name.
1755    pub plugins: IndexMap<String, PluginConfig>,
1756    /// Appended to `ignore` instead of replacing it. Folded away when the configuration is resolved.
1757    #[serde(skip_serializing_if = "Vec::is_empty")]
1758    pub extend_ignore: Vec<String>,
1759    /// Appended to `ignore-dependencies` instead of replacing it. Folded away when the configuration is resolved.
1760    #[serde(skip_serializing_if = "Vec::is_empty")]
1761    pub extend_ignore_dependencies: Vec<String>,
1762    /// Appended to `ignore-binaries` instead of replacing it. Folded away when the configuration is resolved.
1763    #[serde(skip_serializing_if = "Vec::is_empty")]
1764    pub extend_ignore_binaries: Vec<String>,
1765    /// Appended to `ignore-unresolved` instead of replacing it. Folded away when the configuration is resolved.
1766    #[serde(skip_serializing_if = "Vec::is_empty")]
1767    pub extend_ignore_unresolved: Vec<String>,
1768}
1769
1770impl Js {
1771    /// Sets the entry globs.
1772    #[must_use]
1773    pub fn with_entry<I, S>(mut self, entry: I) -> Self
1774    where
1775        I: IntoIterator<Item = S>,
1776        S: Into<String>,
1777    {
1778        self.entry = entry.into_iter().map(Into::into).collect();
1779        self
1780    }
1781
1782    /// Sets the project globs.
1783    #[must_use]
1784    pub fn with_project<I, S>(mut self, project: I) -> Self
1785    where
1786        I: IntoIterator<Item = S>,
1787        S: Into<String>,
1788    {
1789        self.project = project.into_iter().map(Into::into).collect();
1790        self
1791    }
1792
1793    /// Enables or disables the JavaScript analysis.
1794    #[must_use]
1795    pub const fn with_enabled(mut self, enabled: bool) -> Self {
1796        self.enabled = enabled;
1797        self
1798    }
1799
1800    /// Restricts the analysis to production code.
1801    #[must_use]
1802    pub const fn with_production(mut self, production: bool) -> Self {
1803        self.production = production;
1804        self
1805    }
1806
1807    /// Makes each workspace package use only the dependencies its own manifest lists, as knip's `--strict` does.
1808    #[must_use]
1809    pub const fn with_isolate_workspaces(mut self, isolate: bool) -> Self {
1810        self.isolate_workspaces = isolate;
1811        self
1812    }
1813
1814    /// Uses `path`, relative to the workspace root, as the TypeScript configuration of the root package.
1815    #[must_use]
1816    pub fn with_tsconfig(mut self, path: impl Into<String>) -> Self {
1817        self.tsconfig = Some(path.into());
1818        self
1819    }
1820
1821    /// Takes the project files of each package with a TypeScript configuration from its `files`, `include` and
1822    /// `exclude`.
1823    #[must_use]
1824    pub const fn with_use_tsconfig_files(mut self, enabled: bool) -> Self {
1825        self.use_tsconfig_files = enabled;
1826        self
1827    }
1828
1829    /// Switches a plugin on or off, or configures it.
1830    #[must_use]
1831    pub fn with_plugin(mut self, name: impl Into<String>, plugin: PluginConfig) -> Self {
1832        self.plugins.insert(name.into(), plugin);
1833        self
1834    }
1835}
1836
1837impl Default for Js {
1838    fn default() -> Self {
1839        Self {
1840            enabled: true,
1841            entry: Vec::new(),
1842            project: Vec::new(),
1843            ignore: Vec::new(),
1844            ignore_files: Vec::new(),
1845            ignore_dependencies: Vec::new(),
1846            ignore_binaries: Vec::new(),
1847            ignore_global_binaries: true,
1848            ignore_unresolved: Vec::new(),
1849            ignore_workspaces: Vec::new(),
1850            ignore_exports_used_in_file: IgnoreExportsUsedInFile::default(),
1851            ignore_members: Vec::new(),
1852            production: false,
1853            isolate_workspaces: false,
1854            no_gitignore: false,
1855            include_entry_exports: false,
1856            tsconfig: None,
1857            use_tsconfig_files: false,
1858            include_fixtures: false,
1859            discovery: JsDiscovery::Auto,
1860            cycles: JsCycles::default(),
1861            workspaces: IndexMap::new(),
1862            plugins: IndexMap::new(),
1863            extend_ignore: Vec::new(),
1864            extend_ignore_dependencies: Vec::new(),
1865            extend_ignore_binaries: Vec::new(),
1866            extend_ignore_unresolved: Vec::new(),
1867        }
1868    }
1869}
1870
1871/// `ignore-exports-used-in-file`: `true`, or a table naming the kinds of export it applies to.
1872///
1873/// It is read by hand rather than as an untagged enum, so that a mistyped kind is reported by name (`unknown field
1874/// "fucntion"`) instead of serde's "data did not match any variant".
1875#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1876#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1877#[serde(untagged)]
1878#[non_exhaustive]
1879pub enum IgnoreExportsUsedInFile {
1880    /// `true` ignores every kind of export, `false` none.
1881    All(bool),
1882    /// Ignores the kinds set to `true`.
1883    Kinds(ExportKinds),
1884}
1885
1886impl<'de> Deserialize<'de> for IgnoreExportsUsedInFile {
1887    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1888        struct Visitor;
1889
1890        impl<'de> serde::de::Visitor<'de> for Visitor {
1891            type Value = IgnoreExportsUsedInFile;
1892
1893            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1894                f.write_str("`true`, `false` or a table of export kinds")
1895            }
1896
1897            fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
1898                Ok(IgnoreExportsUsedInFile::All(value))
1899            }
1900
1901            fn visit_map<A: serde::de::MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
1902                ExportKinds::deserialize(serde::de::value::MapAccessDeserializer::new(map))
1903                    .map(IgnoreExportsUsedInFile::Kinds)
1904            }
1905        }
1906
1907        deserializer.deserialize_any(Visitor)
1908    }
1909}
1910
1911impl Default for IgnoreExportsUsedInFile {
1912    fn default() -> Self {
1913        Self::All(false)
1914    }
1915}
1916
1917/// The kinds of export `ignore-exports-used-in-file` can name, as in knip.
1918#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1919#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1920#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1921#[non_exhaustive]
1922#[allow(clippy::struct_excessive_bools)]
1923pub struct ExportKinds {
1924    /// `const`, `let` and `var` exports.
1925    pub variable: bool,
1926    /// `function` exports.
1927    pub function: bool,
1928    /// `class` exports.
1929    pub class: bool,
1930    /// `enum` exports.
1931    #[serde(rename = "enum")]
1932    pub enumeration: bool,
1933    /// TypeScript `namespace` exports.
1934    pub namespace: bool,
1935    /// Type alias exports. skif does not tell type aliases from interfaces, so this and `interface` are the same.
1936    #[serde(rename = "type")]
1937    pub type_alias: bool,
1938    /// Interface exports, see `type`.
1939    pub interface: bool,
1940    /// Enum and namespace members. skif always counts a member read in its own file as used, so this key is
1941    /// accepted for compatibility and has no further effect.
1942    pub member: bool,
1943}
1944
1945/// `[js.workspaces."<dir>"]`
1946#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1947#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1948#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1949#[non_exhaustive]
1950pub struct JsWorkspace {
1951    /// Entry-point globs relative to the workspace directory.
1952    pub entry: Vec<String>,
1953    /// Project file globs relative to the workspace directory.
1954    pub project: Vec<String>,
1955    /// Globs of files that are not analysed.
1956    pub ignore: Vec<String>,
1957    /// Globs of files, relative to the workspace directory, never reported by `unused-files`.
1958    pub ignore_files: Vec<String>,
1959    /// Dependencies never reported as unused or unlisted.
1960    pub ignore_dependencies: Vec<String>,
1961    /// Binaries never reported as unlisted.
1962    pub ignore_binaries: Vec<String>,
1963    /// Import specifiers never reported as unresolved in this workspace.
1964    pub ignore_unresolved: Vec<String>,
1965    /// Report unused exports of entry files of this workspace; unset inherits `[js] include-entry-exports`.
1966    #[serde(skip_serializing_if = "Option::is_none")]
1967    pub include_entry_exports: Option<bool>,
1968    /// Exports used in their own file that are not reported in this workspace; unset inherits `[js]`.
1969    #[serde(skip_serializing_if = "Option::is_none")]
1970    pub ignore_exports_used_in_file: Option<IgnoreExportsUsedInFile>,
1971    /// Enum and namespace member names never reported as unused in this workspace.
1972    pub ignore_members: Vec<String>,
1973    /// Module aliases as in a `tsconfig` `paths` map: `{ "alias/*" = ["./target/*"] }`, targets relative to the workspace.
1974    pub paths: IndexMap<String, Vec<String>>,
1975    /// Plugin switches and options for this workspace, applied over `[js.plugins]`.
1976    pub plugins: IndexMap<String, PluginConfig>,
1977}
1978
1979/// `[js.plugins] <name> = true | { ... }`
1980#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1981#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1982#[serde(untagged)]
1983#[non_exhaustive]
1984pub enum PluginConfig {
1985    /// A bare switch: `true` forces the plugin on, `false` off.
1986    Enabled(bool),
1987    /// The table form with options.
1988    Options(PluginOptions),
1989}
1990
1991/// Table form of a plugin entry.
1992#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
1993#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1994#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
1995#[non_exhaustive]
1996pub struct PluginOptions {
1997    /// Forces the plugin on or off; unset keeps its detection.
1998    #[serde(skip_serializing_if = "Option::is_none")]
1999    pub enabled: Option<bool>,
2000    /// Config-file globs the plugin reads.
2001    pub config: Vec<String>,
2002    /// Entry-point globs the plugin contributes.
2003    pub entry: Vec<String>,
2004    /// Project file globs the plugin contributes.
2005    pub project: Vec<String>,
2006}
2007
2008impl PluginOptions {
2009    /// Options with nothing set.
2010    #[must_use]
2011    pub fn new() -> Self {
2012        Self::default()
2013    }
2014
2015    /// Switches the plugin on or off explicitly.
2016    #[must_use]
2017    pub const fn with_enabled(mut self, enabled: bool) -> Self {
2018        self.enabled = Some(enabled);
2019        self
2020    }
2021
2022    /// Sets the config-file globs.
2023    #[must_use]
2024    pub fn with_config<I, S>(mut self, config: I) -> Self
2025    where
2026        I: IntoIterator<Item = S>,
2027        S: Into<String>,
2028    {
2029        self.config = config.into_iter().map(Into::into).collect();
2030        self
2031    }
2032
2033    /// Sets the entry globs.
2034    #[must_use]
2035    pub fn with_entry<I, S>(mut self, entry: I) -> Self
2036    where
2037        I: IntoIterator<Item = S>,
2038        S: Into<String>,
2039    {
2040        self.entry = entry.into_iter().map(Into::into).collect();
2041        self
2042    }
2043
2044    /// Sets the project globs.
2045    #[must_use]
2046    pub fn with_project<I, S>(mut self, project: I) -> Self
2047    where
2048        I: IntoIterator<Item = S>,
2049        S: Into<String>,
2050    {
2051        self.project = project.into_iter().map(Into::into).collect();
2052        self
2053    }
2054}
2055
2056/// `[js]` keys an override can set for the packages it selects.
2057///
2058/// Globs are relative to each selected package. The `extend-*` keys append to what the package inherits; the others
2059/// replace it, and set to an empty list they clear it.
2060#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2061#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2062#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2063#[non_exhaustive]
2064pub struct PackageJs {
2065    /// Replaces the entry globs; an empty list clears what the package inherits.
2066    #[serde(skip_serializing_if = "Option::is_none")]
2067    pub entry: Option<Vec<String>>,
2068    /// Replaces the project globs; an empty list clears what the package inherits.
2069    #[serde(skip_serializing_if = "Option::is_none")]
2070    pub project: Option<Vec<String>>,
2071    /// Replaces the ignore globs; an empty list clears what the package inherits.
2072    #[serde(skip_serializing_if = "Option::is_none")]
2073    pub ignore: Option<Vec<String>>,
2074    /// Replaces the ignored dependencies; an empty list clears what the package inherits.
2075    #[serde(skip_serializing_if = "Option::is_none")]
2076    pub ignore_dependencies: Option<Vec<String>>,
2077    /// Replaces the ignored binaries; an empty list clears what the package inherits.
2078    #[serde(skip_serializing_if = "Option::is_none")]
2079    pub ignore_binaries: Option<Vec<String>>,
2080    /// Appended to the ignore globs.
2081    #[serde(skip_serializing_if = "Vec::is_empty")]
2082    pub extend_ignore: Vec<String>,
2083    /// Appended to the ignored dependencies.
2084    #[serde(skip_serializing_if = "Vec::is_empty")]
2085    pub extend_ignore_dependencies: Vec<String>,
2086    /// Appended to the ignored binaries.
2087    #[serde(skip_serializing_if = "Vec::is_empty")]
2088    pub extend_ignore_binaries: Vec<String>,
2089    /// Replaces the ignored unresolved specifiers; an empty list clears what the package inherits.
2090    #[serde(skip_serializing_if = "Option::is_none")]
2091    pub ignore_unresolved: Option<Vec<String>>,
2092    /// Appended to the ignored unresolved specifiers.
2093    #[serde(skip_serializing_if = "Vec::is_empty")]
2094    pub extend_ignore_unresolved: Vec<String>,
2095    /// Plugin switches and options for the selected packages.
2096    #[serde(skip_serializing_if = "IndexMap::is_empty")]
2097    pub plugins: IndexMap<String, PluginConfig>,
2098}
2099
2100impl PackageJs {
2101    /// Whether no key is set.
2102    #[must_use]
2103    pub fn is_empty(&self) -> bool {
2104        self == &Self::default()
2105    }
2106}
2107
2108/// One `[[overrides]]` entry: settings for the packages and files it selects.
2109///
2110/// Patterns are anchored at the directory of the file that declares them: a package pattern starting with `./` is a
2111/// directory below it, any other is a package name (or a `$LOCAL`-style token); a file pattern is always a path
2112/// below it. A `$ROOT/` prefix anchors a pattern at the root being linted instead, and a path that would leave the
2113/// anchor never matches.
2114///
2115/// Only package-scoped keys are allowed. An override never changes what a workspace-wide analysis computes; it
2116/// changes how the diagnostics located in the selected packages or files are reported, and the JavaScript inputs of
2117/// the selected packages.
2118#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
2119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
2120#[serde(rename_all = "kebab-case", deny_unknown_fields, default)]
2121#[non_exhaustive]
2122pub struct Override {
2123    /// Package names, or directories (`./apps/old/**`). At least one of `packages` and `files` must be set.
2124    #[serde(skip_serializing_if = "Selector::is_empty")]
2125    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
2126    pub packages: Selector,
2127    /// File globs. When both lists are set, a diagnostic must match both.
2128    #[serde(skip_serializing_if = "Selector::is_empty")]
2129    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
2130    pub files: Selector,
2131    /// Caps the severity of every matched diagnostic.
2132    #[serde(skip_serializing_if = "Option::is_none")]
2133    pub max_level: Option<Level>,
2134    /// Rule levels; options are root-scoped and not allowed here.
2135    #[serde(with = "rules_map", skip_serializing_if = "IndexMap::is_empty")]
2136    #[cfg_attr(feature = "schema", schemars(with = "IndexMap<String, RuleConfigSchema>"))]
2137    pub rules: IndexMap<String, RuleConfig>,
2138    /// Suppressions limited to the selected packages and files; an entry may omit every filter.
2139    #[serde(skip_serializing_if = "Vec::is_empty")]
2140    pub ignore: Vec<IgnoreEntry>,
2141    /// JavaScript inputs for the selected packages; needs `packages` and no `files`.
2142    #[serde(skip_serializing_if = "PackageJs::is_empty")]
2143    pub js: PackageJs,
2144    /// Where the override was declared. Set on a resolved configuration only.
2145    #[serde(skip)]
2146    pub scope: Option<OverrideScope>,
2147}
2148
2149impl Override {
2150    pub(crate) fn inconsistency(&self) -> Option<&'static str> {
2151        if self.packages.is_empty() && self.files.is_empty() {
2152            return Some(
2153                "set at least one of `packages` and `files`; an override without a selector would apply everywhere",
2154            );
2155        }
2156        if !self.js.is_empty() && (self.packages.is_empty() || !self.files.is_empty()) {
2157            return Some("`js` needs `packages` and cannot be combined with `files`");
2158        }
2159        if self.rules.values().any(|rule| !rule.options.is_empty()) {
2160            return Some("rule options are root-scoped; an override can only set rule levels");
2161        }
2162        None
2163    }
2164
2165    /// Whether this override covers a diagnostic located in `package` (its name and absolute directory) and/or in
2166    /// `file` (absolute). When both selectors are set both must match; a selector that is set but has nothing to
2167    /// match against (a diagnostic without a package or file) does not match.
2168    ///
2169    /// False for an override without a [`scope`](Self::scope), which only a resolved configuration has.
2170    #[must_use]
2171    pub fn applies_to(&self, package: Option<(&str, &Path)>, file: Option<&Path>) -> bool {
2172        self.scope.as_ref().is_some_and(|scope| scope.applies_to(package, file))
2173    }
2174
2175    /// As [`applies_to`](Self::applies_to) for directories and files already written with `/` separators (see
2176    /// `skif_core::slash`), which a caller checking many overrides converts once instead of once per override.
2177    #[must_use]
2178    pub fn applies_to_slashed(&self, package: Option<(&str, &str)>, file: Option<&str>) -> bool {
2179        self.scope
2180            .as_ref()
2181            .is_some_and(|scope| scope.applies_to_slashed(package, file))
2182    }
2183}
2184
2185/// Where an [`Override`] was declared, with its patterns resolved to absolute ones.
2186#[derive(Debug, Clone, PartialEq)]
2187#[non_exhaustive]
2188pub struct OverrideScope {
2189    /// The directory of the declaring file.
2190    pub declared_in: PathBuf,
2191    /// Index of the declaring file in the resolved configuration's sources.
2192    pub source: usize,
2193    has_packages: bool,
2194    has_files: bool,
2195    packages: Selector,
2196    files: Selector,
2197}
2198
2199impl OverrideScope {
2200    /// Anchors the patterns of `scoped`, declared by the file `source` in `declared_in`, for the root `root`.
2201    ///
2202    /// # Errors
2203    /// [`ConfigError::Invalid`] when an anchored pattern is not a valid glob.
2204    pub fn new(scoped: &Override, declared_in: &Path, source: usize, root: &Path) -> Result<Self, ConfigError> {
2205        Ok(Self {
2206            declared_in: declared_in.to_path_buf(),
2207            source,
2208            has_packages: !scoped.packages.is_empty(),
2209            has_files: !scoped.files.is_empty(),
2210            packages: anchor_patterns(scoped.packages.patterns(), declared_in, root, false)?,
2211            files: anchor_patterns(scoped.files.patterns(), declared_in, root, true)?,
2212        })
2213    }
2214
2215    fn applies_to(&self, package: Option<(&str, &Path)>, file: Option<&Path>) -> bool {
2216        let package = package.map(|(name, dir)| (name, skif_core::slash(dir)));
2217        let file = file.map(skif_core::slash);
2218        self.applies_to_slashed(
2219            package.as_ref().map(|(name, dir)| (*name, dir.as_str())),
2220            file.as_deref(),
2221        )
2222    }
2223
2224    fn applies_to_slashed(&self, package: Option<(&str, &str)>, file: Option<&str>) -> bool {
2225        let package_ok =
2226            !self.has_packages || package.is_some_and(|(name, dir)| self.packages.matches_any([name, dir], true));
2227        let file_ok = !self.has_files || file.is_some_and(|file| self.files.matches(file, false));
2228        package_ok && file_ok
2229    }
2230}