Skip to main content

opys_engine/
project_config.rs

1//! The universal typed-document config (`opys.toml`) — the sole
2//! source of truth for the engine.
3//!
4//! A project declares its own document **types** (each with a prefix, dir,
5//! statuses, fields, and required sections) plus a list of conditional
6//! **validation rules**. `Project::open` loads this; every command reads it, and
7//! `verify` enforces it via [`crate::rules`]. Reuses
8//! `config::FieldSpec`/`FieldType`/`TestRefCheck`.
9
10use std::collections::{BTreeMap, HashSet};
11use std::path::{Path, PathBuf};
12
13use regex::Regex;
14use serde::Deserialize;
15
16use crate::config::{FieldSpec, FieldType};
17use crate::error::{usage, OpysError, Result};
18use crate::palette::{parse_color, PaletteEntry};
19use crate::refs;
20
21fn default_pad() -> usize {
22    4
23}
24fn default_base() -> String {
25    DEFAULT_BASE.to_string()
26}
27fn default_roots() -> Vec<String> {
28    vec![".".to_string()]
29}
30fn default_min() -> usize {
31    1
32}
33fn default_true() -> bool {
34    true
35}
36fn default_layout_path() -> String {
37    DEFAULT_LAYOUT_PATH.to_string()
38}
39
40/// Placeholder names in a layout template that aren't one of `type`/`status`/`id`.
41fn unknown_placeholders(template: &str) -> Vec<&str> {
42    let known = ["type", "status", "id"];
43    placeholder_names(template)
44        .into_iter()
45        .filter(|name| !known.contains(name))
46        .collect()
47}
48
49/// Every distinct `{name}` placeholder in `template`, in order of first
50/// appearance.
51fn placeholder_names(template: &str) -> Vec<&str> {
52    let mut out = Vec::new();
53    let mut rest = template;
54    while let Some(open) = rest.find('{') {
55        rest = &rest[open + 1..];
56        if let Some(close) = rest.find('}') {
57            let name = &rest[..close];
58            if !out.contains(&name) {
59                out.push(name);
60            }
61            rest = &rest[close + 1..];
62        } else {
63            break;
64        }
65    }
66    out
67}
68
69/// Inventory base directory (the documents and `_retired.txt`), relative to the
70/// project root that holds `opys.toml`; the config `base` default.
71pub const DEFAULT_BASE: &str = "opys";
72
73/// Directory (under the inventory base) for a type that declares no explicit
74/// `dir`. Empty by default → documents live flat at the base.
75pub const DEFAULT_DOC_DIR: &str = "";
76
77/// Default file-path template (relative to the base). Both the `{type}` and
78/// `{status}` segments are empty by default, so this collapses to a flat
79/// `PREFIX-NNNN.md` at the base.
80pub const DEFAULT_LAYOUT_PATH: &str = "{type}/{status}/{id}.md";
81
82/// The on-disk layout: a single path template rendered per document. The
83/// `{type}` placeholder resolves to the type's `dir`, `{status}` to the type's
84/// `status_dirs[status]` (both empty by default), and `{id}` to `PREFIX-NNNN`.
85/// Empty segments collapse, so the order of segments is freely configurable.
86#[derive(Debug, Clone, Deserialize)]
87pub struct Layout {
88    #[serde(default = "default_layout_path")]
89    pub path: String,
90}
91
92impl Default for Layout {
93    fn default() -> Self {
94        Layout {
95            path: default_layout_path(),
96        }
97    }
98}
99
100/// A built-in section behavior a type's section opts into. The validator and
101/// scaffold for each kind are compiled code (closed set, not extensible from
102/// config) — this is the guardrail that keeps the engine opinionated. The
103/// `structured` kind is the configurable one: its content shape is declared by
104/// the section's `structure` (an [`mdprism`] schema; see
105/// `docs/structure-dsl-spec.md`).
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "kebab-case")]
108pub enum SectionKind {
109    Prose,
110    Log,
111    Checklist,
112    Structured,
113}
114
115impl SectionKind {
116    /// Whether "≥1 checked item" is meaningful for this kind.
117    pub fn is_checkable(self) -> bool {
118        matches!(self, SectionKind::Checklist)
119    }
120}
121
122/// Which lines of a section a [`SectionCheck`] validates.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum CheckScope {
126    /// Every line in the section; lines not matching `pattern` are skipped.
127    #[default]
128    All,
129    /// Only checked checklist items; a checked item with no `pattern` match is
130    /// itself an error.
131    Checked,
132}
133
134/// A universal, config-driven content check attached to a section. `pattern`
135/// parses a line into named capture groups; `file` (a group name) and/or
136/// `must_match` (a regex built from `${group}` substitutions) then assert the
137/// captured reference points at something real.
138#[derive(Debug, Clone, Deserialize)]
139pub struct SectionCheck {
140    /// Regex parsing one line into named groups. A line that does not match is
141    /// prose (skipped); a match is validated.
142    pub pattern: String,
143    /// Optional capture-group name holding a file path to open (resolved against
144    /// `roots`). Its presence is what makes the check file-scoped vs corpus-wide.
145    #[serde(default)]
146    pub file: Option<String>,
147    /// Directories the `file` path / corpus resolve against (project-root
148    /// relative). Defaults to `["."]`.
149    #[serde(default = "default_roots")]
150    pub roots: Vec<String>,
151    /// Optional regex that must match in the opened file (or, when `file` is
152    /// unset, in the concatenated corpus under `roots`). `${group}` is replaced
153    /// by the regex-escaped capture from `pattern`.
154    #[serde(default)]
155    pub must_match: Option<String>,
156    /// Which lines to validate. Defaults to `all`.
157    #[serde(default)]
158    pub scope: CheckScope,
159    /// Optional custom failure message; `${group}` is replaced by the raw
160    /// capture from `pattern`.
161    #[serde(default)]
162    pub message: Option<String>,
163}
164
165#[derive(Debug, Clone, Deserialize)]
166pub struct SectionSpec {
167    pub heading: String,
168    pub kind: SectionKind,
169    /// Whether the section must be present (verify enforces it; `new` scaffolds it).
170    #[serde(default)]
171    pub required: bool,
172    /// Universal content checks run over this section at `verify` time.
173    #[serde(default)]
174    pub checks: Vec<SectionCheck>,
175    /// The content shape for a `structured` section: an [`mdprism`] schema (the
176    /// body portion of the DSL). Required for `structured`, rejected otherwise.
177    #[serde(default)]
178    pub structure: Option<String>,
179}
180
181/// `requires_link = { to = "feature", min = 1 }` — a type must reference ≥`min`
182/// docs of type `to`.
183#[derive(Debug, Clone, Deserialize)]
184pub struct LinkReq {
185    pub to: String,
186    #[serde(default = "default_min")]
187    pub min: usize,
188}
189
190#[derive(Debug, Clone, Deserialize)]
191pub struct DocType {
192    pub prefix: String,
193    /// Directory under the inventory base holding this type's files (defaults to
194    /// the type name). The legacy adapter sets `features`/`work-items`.
195    #[serde(default)]
196    pub dir: Option<String>,
197    #[serde(default)]
198    pub statuses: Vec<String>,
199    /// Per-status subdirectory (the `{status}` layout segment). A status absent
200    /// from the map contributes an empty segment. E.g. `archived = "_archived"`.
201    #[serde(default)]
202    pub status_dirs: BTreeMap<String, String>,
203    #[serde(default)]
204    pub default_status: String,
205    #[serde(default)]
206    pub terminal_statuses: Vec<String>,
207    #[serde(default)]
208    pub tags_required: bool,
209    #[serde(default)]
210    pub requires_link: Option<LinkReq>,
211    #[serde(default)]
212    pub fields: BTreeMap<String, FieldSpec>,
213    #[serde(default)]
214    pub sections: Vec<SectionSpec>,
215}
216
217impl DocType {
218    /// The `{type}` layout segment for this type: its `dir`, or the default
219    /// (empty → flat at the base).
220    pub fn resolved_dir(&self) -> &str {
221        self.dir.as_deref().unwrap_or(DEFAULT_DOC_DIR)
222    }
223
224    /// The `{status}` layout segment for the given status (empty if unmapped).
225    pub fn status_dir(&self, status: &str) -> &str {
226        self.status_dirs.get(status).map_or("", String::as_str)
227    }
228}
229
230/// A rule's match guard. Both fields optional: omitting both means "always".
231#[derive(Debug, Clone, Default, Deserialize)]
232pub struct When {
233    #[serde(default, rename = "type")]
234    pub doc_type: Option<String>,
235    #[serde(default)]
236    pub status: Option<String>,
237    /// Applies only to documents carrying this tag (exact tag or tag key — see
238    /// [`Frontmatter::has_tag`](crate::frontmatter::Frontmatter::has_tag)).
239    #[serde(default)]
240    pub tag: Option<String>,
241}
242
243/// One term of a `require_any` (exactly one of the three is set).
244#[derive(Debug, Clone, Deserialize)]
245pub struct AnyTerm {
246    #[serde(default)]
247    pub field: Option<String>,
248    #[serde(default)]
249    pub link: Option<String>,
250    #[serde(default)]
251    pub section: Option<String>,
252    /// Holds when the document carries this tag (exact tag or tag key).
253    #[serde(default)]
254    pub tag: Option<String>,
255}
256
257#[derive(Debug, Clone, Deserialize)]
258pub struct FieldMatch {
259    pub field: String,
260    pub pattern: String,
261}
262
263/// A conditional validation rule: a `when` guard plus exactly one assertion
264/// from the closed set below.
265#[derive(Debug, Clone, Default, Deserialize)]
266pub struct Rule {
267    #[serde(default)]
268    pub when: When,
269    #[serde(default)]
270    pub require_field: Option<String>,
271    #[serde(default)]
272    pub require_section: Option<String>,
273    #[serde(default)]
274    pub require_checked_section: Option<String>,
275    #[serde(default)]
276    pub require_link: Option<LinkReq>,
277    #[serde(default)]
278    pub require_any: Option<Vec<AnyTerm>>,
279    #[serde(default)]
280    pub field_matches: Option<FieldMatch>,
281}
282
283impl Rule {
284    /// How many assertions this rule sets (must be exactly one).
285    fn assertion_count(&self) -> usize {
286        [
287            self.require_field.is_some(),
288            self.require_section.is_some(),
289            self.require_checked_section.is_some(),
290            self.require_link.is_some(),
291            self.require_any.is_some(),
292            self.field_matches.is_some(),
293        ]
294        .iter()
295        .filter(|b| **b)
296        .count()
297    }
298}
299
300/// Built-in column keys for the TUI list (everything else is a custom field).
301pub const BUILTIN_COLUMNS: [&str; 7] = [
302    "id", "type", "title", "status", "tags", "created", "updated",
303];
304
305fn default_columns() -> Vec<String> {
306    ["id", "title", "status", "tags"]
307        .iter()
308        .map(|s| s.to_string())
309        .collect()
310}
311
312/// `[tui]` — presentation knobs for the `opys tui` board (ignored by the core
313/// engine, validated here so `config validate` catches mistakes).
314#[derive(Debug, Clone, Deserialize)]
315pub struct TuiConfig {
316    /// The list columns, left to right. Each is a [`BUILTIN_COLUMNS`] key or the
317    /// name of a custom frontmatter field (shown as that field's value).
318    #[serde(default = "default_columns")]
319    pub columns: Vec<String>,
320}
321
322impl Default for TuiConfig {
323    fn default() -> Self {
324        TuiConfig {
325            columns: default_columns(),
326        }
327    }
328}
329
330/// `[file_refs]` — how document ids are mentioned in code, for `opys show <id>
331/// --refs` and the post-`renumber` reference warning. This is about textual
332/// mentions of an id (e.g. `FEAT-0123`) in source files, distinct from the
333/// `references` relation maps *between documents* (see [`crate::refs`]).
334#[derive(Debug, Clone, Deserialize)]
335pub struct FileRefs {
336    /// Directories to scan, project-root relative. Defaults to the whole project
337    /// (`["."]`). The inventory `base`, `.git`, `target`, `node_modules`, and
338    /// hidden directories are always skipped.
339    #[serde(default = "default_roots")]
340    pub roots: Vec<String>,
341    /// The textual forms an id may take in code. When empty, a single canonical
342    /// `{id}` word-boundary format is assumed (see [`FileRefs::effective_formats`]).
343    #[serde(default)]
344    pub formats: Vec<RefFormat>,
345}
346
347impl Default for FileRefs {
348    fn default() -> Self {
349        FileRefs {
350            roots: default_roots(),
351            formats: Vec::new(),
352        }
353    }
354}
355
356impl FileRefs {
357    /// The configured formats, or a single canonical `{id}` word-boundary format
358    /// when none are configured (so the feature works without any config).
359    pub fn effective_formats(&self) -> Vec<RefFormat> {
360        if self.formats.is_empty() {
361            vec![RefFormat {
362                template: "{id}".to_string(),
363                word: true,
364            }]
365        } else {
366            self.formats.clone()
367        }
368    }
369}
370
371/// One way a document id may appear in code. `template` is rendered for a given
372/// id with these placeholders: `{id}` (the full `PREFIX-NNNN`), `{prefix}` /
373/// `{prefix_lower}`, `{num}` (the unpadded number), and `{padded}` (zero-padded
374/// to `pad`). With `word` (the default) the match must fall on word boundaries;
375/// set `word = false` to match anywhere (substring).
376#[derive(Debug, Clone, Deserialize)]
377pub struct RefFormat {
378    pub template: String,
379    #[serde(default = "default_true")]
380    pub word: bool,
381}
382
383/// The placeholder names `{id}` / `{prefix}` / `{prefix_lower}` / `{num}` /
384/// `{padded}` that a [`RefFormat`] template may use.
385pub const REF_PLACEHOLDERS: [&str; 5] = ["id", "prefix", "prefix_lower", "num", "padded"];
386
387/// One custom stats section for `opys stats`. `sql` is a single SQL query run
388/// against an in-memory relational view of the corpus (tables `docs`, `tags`,
389/// `sections`, `fields` — see [`crate::commands::stats`]). Without `template`
390/// the result set renders as a markdown table headed by `name`; with `template`,
391/// each result row is rendered through it (`{column}` placeholders, `{{`/`}}` for
392/// literal braces) and the rendered rows are joined under the `name` heading.
393#[derive(Debug, Clone, Deserialize)]
394pub struct StatSpec {
395    pub name: String,
396    pub sql: String,
397    #[serde(default)]
398    pub template: Option<String>,
399}
400
401#[derive(Debug, Clone, Deserialize)]
402pub struct ProjectConfig {
403    /// Inventory base directory, relative to the project root (the dir holding
404    /// `opys.toml`). Defaults to `opys`.
405    #[serde(default = "default_base")]
406    pub base: String,
407    #[serde(default = "default_pad")]
408    pub pad: usize,
409    #[serde(default)]
410    pub layout: Layout,
411    #[serde(default)]
412    pub types: BTreeMap<String, DocType>,
413    #[serde(default)]
414    pub rules: Vec<Rule>,
415    /// Custom stats sections rendered by `opys stats` (and the TUI stats
416    /// screen). Each is a single `sql` query over the corpus view, rendered as
417    /// a markdown table.
418    #[serde(default)]
419    pub stats: Vec<StatSpec>,
420    /// Presentation rules for the TUI. Ignored by the core engine; parsed and
421    /// validated here so `config validate` catches mistakes regardless of the
422    /// `tui` feature. See [`crate::palette`].
423    #[serde(default)]
424    pub palette: BTreeMap<String, PaletteEntry>,
425    /// TUI list presentation. Ignored by the core engine; see [`TuiConfig`].
426    #[serde(default)]
427    pub tui: TuiConfig,
428    /// How document ids are mentioned in code, for `show --refs` and the
429    /// post-`renumber` reference warning. See [`FileRefs`].
430    #[serde(default)]
431    pub file_refs: FileRefs,
432}
433
434impl ProjectConfig {
435    /// Load `opys.toml`, or a usage error pointing at `config init` when absent.
436    pub fn load(path: &Path) -> Result<ProjectConfig> {
437        if !path.exists() {
438            return Err(usage(format!(
439                "no opys.toml at {} — run `opys config init`",
440                path.display()
441            )));
442        }
443        let text = std::fs::read_to_string(path)?;
444        toml::from_str(&text).map_err(|source| OpysError::Toml {
445            path: path.to_path_buf(),
446            source,
447        })
448    }
449
450    /// The distinct directories (under the base) that hold documents — the set
451    /// generic discovery scans. Multiple types may share a dir (assigned by id
452    /// prefix at load).
453    pub fn doc_dirs(&self) -> Vec<&str> {
454        let mut dirs: Vec<&str> = self.types.values().map(DocType::resolved_dir).collect();
455        dirs.sort_unstable();
456        dirs.dedup();
457        dirs
458    }
459
460    /// The directory holding the type whose prefix matches `id` (the default dir
461    /// when the prefix matches no type).
462    pub fn dir_for_id(&self, id: &str) -> &str {
463        self.type_name_for_id(id)
464            .and_then(|n| self.types.get(n))
465            .map(DocType::resolved_dir)
466            .unwrap_or(DEFAULT_DOC_DIR)
467    }
468
469    /// The canonical file path for a document, relative to the inventory base:
470    /// the `layout.path` template with `{type}`/`{status}`/`{id}` substituted and
471    /// empty path segments collapsed. The `{type}`/`{status}` segments come from
472    /// the type matching `id`'s prefix (empty for an unknown prefix).
473    pub fn doc_relpath(&self, id: &str, status: &str) -> PathBuf {
474        let t = self.type_name_for_id(id).and_then(|n| self.types.get(n));
475        let type_seg = t.map_or(DEFAULT_DOC_DIR, DocType::resolved_dir);
476        let status_seg = t.map_or("", |t| t.status_dir(status));
477        let rendered = self
478            .layout
479            .path
480            .replace("{type}", type_seg)
481            .replace("{status}", status_seg)
482            .replace("{id}", id);
483        // Collapse empty segments so unset `{type}`/`{status}` don't leave `//`.
484        rendered.split('/').filter(|seg| !seg.is_empty()).collect()
485    }
486
487    /// The name of the type whose prefix matches `id`'s prefix, if any. (A doc's
488    /// type is derived from its ID prefix — the ID is the single source of truth.)
489    pub fn type_name_for_id(&self, id: &str) -> Option<&str> {
490        let prefix = id.split_once('-')?.0;
491        self.types
492            .iter()
493            .find(|(_, t)| t.prefix == prefix)
494            .map(|(name, _)| name.as_str())
495    }
496
497    /// Check the config is well-formed, returning all problems (empty == OK).
498    /// These are content problems (verify-style), not hard errors.
499    pub fn validate(&self) -> Vec<String> {
500        let mut errs = Vec::new();
501        if self.types.is_empty() {
502            errs.push("no document types defined ([types.<name>])".into());
503        }
504
505        // The layout template must place each document at a unique path: it must
506        // contain `{id}` and use only the known placeholders.
507        if !self.layout.path.contains("{id}") {
508            errs.push("layout.path must contain the {id} placeholder".into());
509        }
510        for unknown in unknown_placeholders(&self.layout.path) {
511            errs.push(format!(
512                "layout.path: unknown placeholder '{{{unknown}}}' (use type/status/id)"
513            ));
514        }
515
516        let prefix_re = Regex::new(r"^[A-Z][A-Z0-9]*$").unwrap();
517        let type_names: HashSet<&str> = self.types.keys().map(String::as_str).collect();
518        let mut seen_prefix: BTreeMap<&str, &str> = BTreeMap::new();
519
520        for (name, t) in &self.types {
521            if !prefix_re.is_match(&t.prefix) {
522                errs.push(format!(
523                    "type '{name}': prefix '{}' must match ^[A-Z][A-Z0-9]*$",
524                    t.prefix
525                ));
526            }
527            if let Some(prev) = seen_prefix.insert(&t.prefix, name) {
528                errs.push(format!(
529                    "type '{name}': prefix '{}' already used by type '{prev}'",
530                    t.prefix
531                ));
532            }
533            if t.statuses.is_empty() {
534                errs.push(format!("type '{name}': statuses must be non-empty"));
535            }
536            if t.default_status.is_empty() {
537                errs.push(format!("type '{name}': default_status is required"));
538            } else if !t.statuses.contains(&t.default_status) {
539                errs.push(format!(
540                    "type '{name}': default_status '{}' not in statuses",
541                    t.default_status
542                ));
543            }
544            for s in &t.terminal_statuses {
545                if !t.statuses.contains(s) {
546                    errs.push(format!(
547                        "type '{name}': terminal_status '{s}' not in statuses"
548                    ));
549                }
550            }
551            for s in t.status_dirs.keys() {
552                if !t.statuses.contains(s) {
553                    errs.push(format!(
554                        "type '{name}': status_dirs key '{s}' is not a status"
555                    ));
556                }
557            }
558            if let Some(lr) = &t.requires_link {
559                if !type_names.contains(lr.to.as_str()) {
560                    errs.push(format!(
561                        "type '{name}': requires_link.to '{}' is not a defined type",
562                        lr.to
563                    ));
564                }
565            }
566            for (fname, spec) in &t.fields {
567                if spec.field_type == FieldType::Enum && spec.values.is_empty() {
568                    errs.push(format!(
569                        "type '{name}' field '{fname}': enum declares no values"
570                    ));
571                }
572                if let Some(p) = &spec.pattern {
573                    if Regex::new(p).is_err() {
574                        errs.push(format!(
575                            "type '{name}' field '{fname}': pattern is not a valid regex"
576                        ));
577                    }
578                }
579            }
580            let mut seen_section: HashSet<&str> = HashSet::new();
581            for sec in &t.sections {
582                if !seen_section.insert(sec.heading.as_str()) {
583                    errs.push(format!(
584                        "type '{name}': duplicate section heading '{}'",
585                        sec.heading
586                    ));
587                }
588                for (ci, chk) in sec.checks.iter().enumerate() {
589                    validate_check(name, &sec.heading, sec.kind, ci + 1, chk, &mut errs);
590                }
591                validate_structure(name, sec, &mut errs);
592            }
593        }
594
595        for (i, rule) in self.rules.iter().enumerate() {
596            self.validate_rule(i + 1, rule, &type_names, &mut errs);
597        }
598
599        self.validate_palette(&type_names, &mut errs);
600        self.validate_tui(&mut errs);
601        self.validate_file_refs(&mut errs);
602        self.validate_stats(&mut errs);
603        errs
604    }
605
606    /// Validate `[[stats]]`: each `sql` must run against the corpus schema. We
607    /// execute it over an empty corpus, which surfaces parse errors and unknown
608    /// columns/tables at config time (a valid query just returns no rows).
609    fn validate_stats(&self, errs: &mut Vec<String>) {
610        let empty = serde_json::json!([]);
611        for s in &self.stats {
612            if let Err(e) = crate::commands::stats::render_stat(s, &empty) {
613                errs.push(e);
614            }
615        }
616    }
617
618    /// Validate `[file_refs].formats`: every placeholder must be known, and each
619    /// template must include a number placeholder (`{id}`/`{num}`/`{padded}`) so
620    /// two ids sharing a prefix can't render to the same text.
621    fn validate_file_refs(&self, errs: &mut Vec<String>) {
622        for (i, f) in self.file_refs.formats.iter().enumerate() {
623            for ph in placeholder_names(&f.template) {
624                if !REF_PLACEHOLDERS.contains(&ph) {
625                    errs.push(format!(
626                        "file_refs.formats[{i}]: unknown placeholder '{{{ph}}}' (use {})",
627                        REF_PLACEHOLDERS.join("/")
628                    ));
629                }
630            }
631            let numbered = ["{id}", "{num}", "{padded}"]
632                .iter()
633                .any(|p| f.template.contains(p));
634            if !numbered {
635                errs.push(format!(
636                    "file_refs.formats[{i}]: template '{}' must include a number placeholder ({{id}}, {{num}}, or {{padded}})",
637                    f.template
638                ));
639            }
640        }
641    }
642
643    /// Validate `[tui].columns`: each must be a built-in key or a custom field
644    /// declared on some type.
645    fn validate_tui(&self, errs: &mut Vec<String>) {
646        let known_field = |key: &str| self.types.values().any(|t| t.fields.contains_key(key));
647        for col in &self.tui.columns {
648            if !BUILTIN_COLUMNS.contains(&col.as_str()) && !known_field(col) {
649                errs.push(format!(
650                    "tui.columns: '{col}' is not a built-in column or a declared field"
651                ));
652            }
653        }
654    }
655
656    /// Validate the `[palette]` rules: every matcher must reference a real type
657    /// and/or a real status, the colors must parse, and each entry needs ≥1
658    /// matcher.
659    fn validate_palette(&self, types: &HashSet<&str>, errs: &mut Vec<String>) {
660        for (name, entry) in &self.palette {
661            if entry.matchers.is_empty() {
662                errs.push(format!("palette '{name}': needs at least one matcher"));
663            }
664            for m in &entry.matchers {
665                if let Some(t) = &m.doc_type {
666                    if !types.contains(t.as_str()) {
667                        errs.push(format!(
668                            "palette '{name}': matcher type '{t}' is not a defined type"
669                        ));
670                    }
671                }
672                if let Some(s) = &m.status {
673                    let ok = match &m.doc_type {
674                        // When the matcher also fixes a type, the status must be
675                        // one of that type's statuses; otherwise it must be a
676                        // status of some type.
677                        Some(t) => self.types.get(t).is_some_and(|dt| dt.statuses.contains(s)),
678                        None => self.types.values().any(|dt| dt.statuses.contains(s)),
679                    };
680                    if !ok {
681                        let scope = m
682                            .doc_type
683                            .as_ref()
684                            .map(|t| format!(" of type '{t}'"))
685                            .unwrap_or_default();
686                        errs.push(format!(
687                            "palette '{name}': matcher status '{s}' is not a status{scope}"
688                        ));
689                    }
690                }
691            }
692            for (label, color) in [
693                ("fg_color", &entry.style.fg_color),
694                ("bg_color", &entry.style.bg_color),
695            ] {
696                if let Some(c) = color {
697                    if parse_color(c).is_none() {
698                        errs.push(format!(
699                            "palette '{name}': {label} '{c}' is not a valid color (a name, #rrggbb, or 0-255)"
700                        ));
701                    }
702                }
703            }
704        }
705    }
706
707    fn validate_rule(&self, n: usize, r: &Rule, types: &HashSet<&str>, errs: &mut Vec<String>) {
708        let tag = format!("rule #{n}");
709        match r.assertion_count() {
710            1 => {}
711            0 => errs.push(format!("{tag}: has no assertion")),
712            _ => errs.push(format!("{tag}: has more than one assertion")),
713        }
714
715        // `when` guard resolves.
716        if let Some(t) = &r.when.doc_type {
717            if !types.contains(t.as_str()) {
718                errs.push(format!("{tag}: when.type '{t}' is not a defined type"));
719            } else if let Some(s) = &r.when.status {
720                if !self.types[t].statuses.contains(s) {
721                    errs.push(format!(
722                        "{tag}: when.status '{s}' is not a status of type '{t}'"
723                    ));
724                }
725            }
726        }
727
728        // `require_link.to` is a type.
729        if let Some(lr) = &r.require_link {
730            if !types.contains(lr.to.as_str()) {
731                errs.push(format!(
732                    "{tag}: require_link.to '{}' is not a defined type",
733                    lr.to
734                ));
735            }
736        }
737
738        // `require_any` terms: exactly one key each; a `link` is a relation field.
739        if let Some(terms) = &r.require_any {
740            if terms.is_empty() {
741                errs.push(format!("{tag}: require_any is empty"));
742            }
743            for term in terms {
744                let count = [
745                    term.field.is_some(),
746                    term.link.is_some(),
747                    term.section.is_some(),
748                    term.tag.is_some(),
749                ]
750                .iter()
751                .filter(|b| **b)
752                .count();
753                if count != 1 {
754                    errs.push(format!(
755                        "{tag}: each require_any term needs exactly one of field/link/section/tag"
756                    ));
757                }
758                if let Some(l) = &term.link {
759                    if !refs::RELATION_FIELDS.contains(&l.as_str()) {
760                        errs.push(format!(
761                            "{tag}: require_any link '{l}' is not a relation field (references/blocked_by/blocks)"
762                        ));
763                    }
764                }
765            }
766        }
767
768        // Field/section assertions are resolvable against the named type.
769        if let Some(t) = &r.when.doc_type {
770            if let Some(dt) = self.types.get(t) {
771                if let Some(f) = &r.require_field {
772                    if !dt.fields.contains_key(f) {
773                        errs.push(format!(
774                            "{tag}: require_field '{f}' is not a field of type '{t}'"
775                        ));
776                    }
777                }
778                if let Some(sec) = &r.require_section {
779                    if !dt.sections.iter().any(|s| &s.heading == sec) {
780                        errs.push(format!(
781                            "{tag}: require_section '{sec}' is not a section of type '{t}'"
782                        ));
783                    }
784                }
785                if let Some(sec) = &r.require_checked_section {
786                    match dt.sections.iter().find(|s| &s.heading == sec) {
787                        None => errs.push(format!(
788                            "{tag}: require_checked_section '{sec}' is not a section of type '{t}'"
789                        )),
790                        Some(s) if !s.kind.is_checkable() => errs.push(format!(
791                            "{tag}: require_checked_section '{sec}' targets a non-checklist section"
792                        )),
793                        _ => {}
794                    }
795                }
796            }
797        }
798
799        // A `field_matches.pattern` must always compile.
800        if let Some(fm) = &r.field_matches {
801            if Regex::new(&fm.pattern).is_err() {
802                errs.push(format!("{tag}: field_matches.pattern is not a valid regex"));
803            }
804            if let Some(t) = &r.when.doc_type {
805                if let Some(dt) = self.types.get(t) {
806                    if !dt.fields.contains_key(&fm.field) {
807                        errs.push(format!(
808                            "{tag}: field_matches.field '{}' is not a field of type '{t}'",
809                            fm.field
810                        ));
811                    }
812                }
813            }
814        }
815    }
816}
817
818/// Validate a section's `structure`: only a `structured` section may declare it,
819/// and then it is required and must parse as an [`mdprism`] schema.
820fn validate_structure(type_name: &str, sec: &SectionSpec, errs: &mut Vec<String>) {
821    let tag = format!("type '{type_name}' section '{}'", sec.heading);
822    if sec.kind != SectionKind::Structured {
823        if sec.structure.is_some() {
824            errs.push(format!(
825                "{tag}: 'structure' is only allowed on a 'structured' section"
826            ));
827        }
828        return;
829    }
830    match &sec.structure {
831        None => errs.push(format!("{tag}: a 'structured' section needs a 'structure'")),
832        Some(src) => {
833            if let Err(e) = crate::mdprism::parse_schema(src) {
834                errs.push(format!("{tag}: invalid structure ({e})"));
835            }
836        }
837    }
838}
839
840/// Validate one [`SectionCheck`]: its `pattern` compiles, `file` / every
841/// `${group}` reference name real capture groups, at least one of `file` /
842/// `must_match` is set, `must_match` compiles, and `scope = "checked"` only
843/// targets a checklist section.
844fn validate_check(
845    type_name: &str,
846    heading: &str,
847    kind: SectionKind,
848    n: usize,
849    chk: &SectionCheck,
850    errs: &mut Vec<String>,
851) {
852    let tag = format!("type '{type_name}' section '{heading}' check #{n}");
853    let names: HashSet<String> = match Regex::new(&chk.pattern) {
854        Ok(re) => re
855            .capture_names()
856            .flatten()
857            .map(|s| s.to_string())
858            .collect(),
859        Err(_) => {
860            errs.push(format!("{tag}: pattern is not a valid regex"));
861            return;
862        }
863    };
864
865    if let Some(f) = &chk.file {
866        if !names.contains(f) {
867            errs.push(format!(
868                "{tag}: file '{f}' is not a named capture group of pattern"
869            ));
870        }
871    }
872    if chk.file.is_none() && chk.must_match.is_none() {
873        errs.push(format!("{tag}: needs at least one of file / must_match"));
874    }
875
876    let group_re = Regex::new(r"\$\{(\w+)\}").unwrap();
877    for tmpl in [chk.must_match.as_deref(), chk.message.as_deref()]
878        .into_iter()
879        .flatten()
880    {
881        for c in group_re.captures_iter(tmpl) {
882            let g = &c[1];
883            if !names.contains(g) {
884                errs.push(format!(
885                    "{tag}: ${{{g}}} is not a named capture group of pattern"
886                ));
887            }
888        }
889    }
890    if let Some(mm) = &chk.must_match {
891        let probe = group_re.replace_all(mm, "x");
892        if Regex::new(&probe).is_err() {
893            errs.push(format!("{tag}: must_match is not a valid regex"));
894        }
895    }
896    if chk.scope == CheckScope::Checked && !kind.is_checkable() {
897        errs.push(format!(
898            "{tag}: scope = \"checked\" requires a checklist section"
899        ));
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::templates::DEFAULT_OPYS_CONFIG;
907
908    #[test]
909    fn default_config_validates_clean() {
910        let cfg: ProjectConfig = toml::from_str(DEFAULT_OPYS_CONFIG).unwrap();
911        let problems = cfg.validate();
912        assert!(
913            problems.is_empty(),
914            "default config has problems: {problems:?}"
915        );
916        assert_eq!(cfg.types.len(), 4);
917    }
918
919    #[test]
920    fn flags_structured_structure_problems() {
921        // A structured section with no structure, a structured section with an
922        // invalid structure, and a `structure` on a non-structured section are
923        // all rejected.
924        let cfg: ProjectConfig = toml::from_str(
925            r#"
926[types.feature]
927prefix = "FEAT"
928statuses = ["planned"]
929default_status = "planned"
930
931[[types.feature.sections]]
932heading = "Empty"
933kind = "structured"
934
935[[types.feature.sections]]
936heading = "Bad"
937kind = "structured"
938structure = "this is not a marker"
939
940[[types.feature.sections]]
941heading = "Notes"
942kind = "prose"
943structure = "- @x"
944"#,
945        )
946        .unwrap();
947        let joined = cfg.validate().join("\n");
948        assert!(
949            joined.contains("section 'Empty': a 'structured' section needs a 'structure'"),
950            "{joined}"
951        );
952        assert!(
953            joined.contains("section 'Bad': invalid structure"),
954            "{joined}"
955        );
956        assert!(
957            joined
958                .contains("section 'Notes': 'structure' is only allowed on a 'structured' section"),
959            "{joined}"
960        );
961    }
962
963    #[test]
964    fn flags_unknown_tui_column() {
965        let cfg: ProjectConfig = toml::from_str(
966            r#"
967[types.feature]
968prefix = "FEAT"
969statuses = ["planned"]
970default_status = "planned"
971[types.feature.fields.priority]
972type = "string"
973
974[tui]
975columns = ["id", "title", "priority", "bogus"]
976"#,
977        )
978        .unwrap();
979        let joined = cfg.validate().join("\n");
980        assert!(joined.contains("tui.columns: 'bogus'"), "{joined}");
981        // built-ins and declared fields are accepted
982        assert!(!joined.contains("'priority'"), "{joined}");
983        assert!(!joined.contains("'id'"), "{joined}");
984    }
985
986    #[test]
987    fn flags_palette_unknown_type_status_and_bad_color() {
988        let cfg: ProjectConfig = toml::from_str(
989            r#"
990[types.feature]
991prefix = "FEAT"
992statuses = ["planned", "done"]
993default_status = "planned"
994
995[palette.ghost]
996matchers = [ { type = "ghost" } ]
997
998[palette.badstatus]
999matchers = [ { status = "nope" } ]
1000
1001[palette.typedstatus]
1002matchers = [ { type = "feature", status = "nope" } ]
1003
1004[palette.badcolor]
1005matchers = [ { status = "done" } ]
1006[palette.badcolor.style]
1007fg_color = "rainbow"
1008
1009[palette.empty]
1010matchers = []
1011"#,
1012        )
1013        .unwrap();
1014        let joined = cfg.validate().join("\n");
1015        assert!(
1016            joined.contains("matcher type 'ghost' is not a defined type"),
1017            "{joined}"
1018        );
1019        assert!(
1020            joined.contains("matcher status 'nope' is not a status\n")
1021                || joined.contains("matcher status 'nope' is not a status of"),
1022            "{joined}"
1023        );
1024        assert!(
1025            joined.contains("matcher status 'nope' is not a status of type 'feature'"),
1026            "{joined}"
1027        );
1028        assert!(
1029            joined.contains("fg_color 'rainbow' is not a valid color"),
1030            "{joined}"
1031        );
1032        assert!(
1033            joined.contains("palette 'empty': needs at least one matcher"),
1034            "{joined}"
1035        );
1036    }
1037
1038    #[test]
1039    fn flags_bad_default_status_and_duplicate_prefix_and_unknown_rule_type() {
1040        let cfg: ProjectConfig = toml::from_str(
1041            r#"
1042[types.feature]
1043prefix = "FEAT"
1044statuses = ["planned"]
1045default_status = "nope"
1046
1047[types.bug]
1048prefix = "FEAT"
1049statuses = ["todo"]
1050default_status = "todo"
1051
1052[[rules]]
1053when = { type = "ghost" }
1054require_field = "x"
1055"#,
1056        )
1057        .unwrap();
1058        let problems = cfg.validate();
1059        let joined = problems.join("\n");
1060        assert!(
1061            joined.contains("default_status 'nope' not in statuses"),
1062            "{joined}"
1063        );
1064        assert!(joined.contains("already used by type"), "{joined}");
1065        assert!(
1066            joined.contains("when.type 'ghost' is not a defined type"),
1067            "{joined}"
1068        );
1069    }
1070}