Skip to main content

kdl_compose/
lib.rs

1//! Multi-file composition for KDL documents — resolves `(<)file` and
2//! `(<)glob` directives into a single composed [`KdlDocument`].
3//!
4//! `club-kdl` itself is a **pure parser** — `from_str` takes a string, returns
5//! a value, no IO. This crate adds the **composer** layer on top: it reads
6//! files, walks the parsed AST, and splices in the contents of any files
7//! referenced by an include directive — including transitively, with cycle
8//! detection.
9//!
10//! ## Why a separate crate
11//!
12//! Putting include resolution in core would force every `club-kdl` user to
13//! buy into filesystem IO. Keeping it in a companion crate lets users who
14//! want pure-parser behavior keep it (parse a string, deserialize), and lets
15//! users who want multi-file composition opt in with a single `use`.
16//!
17//! ## Directive syntax — `(<)variant`
18//!
19//! KDL has no native include directive — the spec deliberately leaves
20//! composition to the host language. KDL **type annotations** (tags) are by
21//! convention used to type values (`(date)"2026-05-19"`, `(u8)42`), so a word
22//! tag like `(include)` would visually compete with type names. This crate
23//! uses a **symbol tag** `(<)` instead — directional, single character,
24//! unambiguously *not* a type name. The `<` mnemonic is "content flowing
25//! into this position from another file".
26//!
27//! ```kdl
28//! // MVP — inline a single file
29//! (<)file "./types.kdl"
30//!
31//! // Glob — inline every matching file
32//! (<)glob "./protocols/*.kdl"
33//!
34//! // Namespace prefix — top-level nodes' first string arg get `shared.`
35//! (<)file "./types.kdl" as="shared"
36//!
37//! // Children-block options — list-valued filters
38//! (<)file "./types.kdl" as="shared" {
39//!     only "User" "Memory"
40//!     except "Internal"
41//!     rename "User" "Acct"
42//! }
43//! ```
44//!
45//! - **tag** = directive marker (`<` for include; future categories like
46//!   overlay / template would pick their own symbol)
47//! - **node name** = variant (`file`, `glob`; future `remote` / `module`)
48//! - **`as=` property** = single-value namespace prefix
49//! - **children block** = list-valued options (`only` / `except` / `rename`)
50//!
51//! KDL property values are scalars only — array-valued options would not
52//! parse, so list options live in a children block instead. The order of
53//! transformation is **filter (`only` / `except`) → `rename` → `as=` prefix**.
54//!
55//! ## What gets composed
56//!
57//! For each `(<)file/glob` directive node anywhere in the document —
58//! top-level or nested inside any block — the directive node is replaced by
59//! the composed top-level nodes of the referenced file(s), spliced into the
60//! same position. Non-directive nodes are recursively walked: their children
61//! are composed too.
62//!
63//! ## Limits, by design
64//!
65//! - **Paths are resolved relative to the importing file.** No search paths,
66//!   no CWD, no environment lookups. A schema's directory is the only base.
67//! - **`as=` renames the first string argument of each top-level included
68//!   node only.** Cross-references inside an included file (`type="Foo"` in
69//!   a `field`) are not rewritten — `compose` is schema-agnostic. Authors
70//!   who use `as=` should keep included files flat (no cross-refs) or qualify
71//!   refs themselves.
72//! - **No duplicate-name detection.** Two `struct "User"` nodes after
73//!   composition are not flagged here — consumers (e.g., codegen) catch
74//!   that, since "what counts as a duplicate" is schema-specific.
75//!
76//! ## Example
77//!
78//! Each example is an **executable** doc-test — `cargo test --doc` runs it
79//! against a real filesystem (under `tempfile::tempdir`), so an API drift in
80//! `compose` / `from_path` breaks the docs and the test suite at once.
81//!
82//! ```
83//! # fn main() -> Result<(), kdl_compose::ComposeError> {
84//! // Lay out a two-file schema under a scratch directory.
85//! let dir = tempfile::tempdir().unwrap();
86//! std::fs::write(
87//!     dir.path().join("types.kdl"),
88//!     r#"struct "User" { field "id" type="string" }"#,
89//! ).unwrap();
90//! std::fs::write(
91//!     dir.path().join("schema.kdl"),
92//!     "(<)file \"./types.kdl\"\nlocal \"trailing-node\"",
93//! ).unwrap();
94//!
95//! // Resolve all (<) directives, returning a single composed KdlDocument.
96//! let doc = kdl_compose::compose(dir.path().join("schema.kdl"))?;
97//! assert_eq!(doc.nodes().len(), 2);                          // struct + local
98//! assert_eq!(doc.nodes()[0].name().value(), "struct");
99//! assert_eq!(doc.nodes()[1].name().value(), "local");
100//! # Ok(())
101//! # }
102//! ```
103
104#![warn(missing_docs)]
105
106use std::collections::BTreeMap;
107use std::path::{Path, PathBuf};
108
109use kdl::{KdlDocument, KdlNode, KdlValue};
110
111pub mod error;
112pub use error::{ComposeError, Result};
113
114// =============================================================================
115// Public API
116// =============================================================================
117
118/// Compose a KDL document from `path`, resolving every `(<)` directive
119/// recursively. Returns the composed [`KdlDocument`] — equivalent to the file
120/// the user wrote, but with every include spliced in place.
121///
122/// `path` is canonicalized once at entry so cycle detection compares stable
123/// identifiers; child paths from `(<)file/glob` are resolved relative
124/// to the parent file's directory.
125///
126/// See the [crate docs](crate) for directive syntax and design limits.
127pub fn compose(path: impl AsRef<Path>) -> Result<KdlDocument> {
128    let entry = path.as_ref();
129    let canonical = canonicalize(entry)?;
130    let mut stack = Vec::new();
131    let nodes = resolve(&canonical, &mut stack)?;
132    let mut doc = KdlDocument::new();
133    *doc.nodes_mut() = nodes;
134    Ok(doc)
135}
136
137/// Compose then deserialize via [`club_kdl::from_doc`] — a thin convenience
138/// for the common case of "I have a typed schema and a root KDL file".
139pub fn from_path<T>(path: impl AsRef<Path>) -> Result<T>
140where
141    T: for<'de> club_kdl::KdlDeserialize<'de>,
142{
143    let doc = compose(path)?;
144    club_kdl::from_doc(&doc).map_err(|source| ComposeError::Deserialize { source })
145}
146
147// =============================================================================
148// Resolver — walks one file's nodes, recursing into included files
149// =============================================================================
150
151/// Canonicalize a path with a friendly [`ComposeError::Io`] on failure. The
152/// resolver needs canonical paths for cycle detection; relative paths cannot
153/// be compared reliably.
154fn canonicalize(p: &Path) -> Result<PathBuf> {
155    std::fs::canonicalize(p).map_err(|source| ComposeError::Io {
156        path: p.to_path_buf(),
157        source,
158    })
159}
160
161/// Read, parse, and compose the file at `canonical_path`, returning its
162/// composed top-level nodes. `stack` carries the active include chain so the
163/// caller can detect cycles.
164fn resolve(canonical_path: &Path, stack: &mut Vec<PathBuf>) -> Result<Vec<KdlNode>> {
165    if stack.iter().any(|p| p == canonical_path) {
166        let mut cycle = stack.clone();
167        cycle.push(canonical_path.to_path_buf());
168        return Err(ComposeError::Cycle { stack: cycle });
169    }
170    stack.push(canonical_path.to_path_buf());
171
172    let text = std::fs::read_to_string(canonical_path).map_err(|source| ComposeError::Io {
173        path: canonical_path.to_path_buf(),
174        source,
175    })?;
176    let doc: KdlDocument = text.parse().map_err(|source| ComposeError::Parse {
177        path: canonical_path.to_path_buf(),
178        source,
179    })?;
180
181    let base_dir = canonical_path
182        .parent()
183        .map(Path::to_path_buf)
184        .unwrap_or_default();
185    let nodes = process_nodes(doc.nodes(), &base_dir, canonical_path, stack)?;
186
187    stack.pop();
188    Ok(nodes)
189}
190
191/// Walk `nodes`. For each one, if it is a `(<)` directive, splice in the
192/// resolved content; otherwise clone it and recurse into its children so
193/// nested includes (inside a `channel` or `protocol` block) are resolved too.
194fn process_nodes(
195    nodes: &[KdlNode],
196    base_dir: &Path,
197    current_file: &Path,
198    stack: &mut Vec<PathBuf>,
199) -> Result<Vec<KdlNode>> {
200    let mut out = Vec::with_capacity(nodes.len());
201    for node in nodes {
202        if let Some(directive) = parse_directive(node, current_file)? {
203            let included = apply_directive(&directive, base_dir, current_file, stack)?;
204            out.extend(included);
205        } else {
206            let mut new_node = node.clone();
207            if let Some(children) = new_node.children_mut() {
208                let new_children = process_nodes(children.nodes(), base_dir, current_file, stack)?;
209                *children.nodes_mut() = new_children;
210            }
211            out.push(new_node);
212        }
213    }
214    Ok(out)
215}
216
217// =============================================================================
218// Directive — parsed (<) node
219// =============================================================================
220
221/// A parsed `(<)` directive node, with all its options collected.
222#[derive(Debug)]
223struct Directive {
224    kind: DirectiveKind,
225    as_prefix: Option<String>,
226    only: Option<Vec<String>>,
227    except: Vec<String>,
228    rename: BTreeMap<String, String>,
229}
230
231/// The variant — `(<)file` or `(<)glob`.
232#[derive(Debug)]
233enum DirectiveKind {
234    /// A single path, relative to the importing file's directory.
235    File(PathBuf),
236    /// A glob pattern, expanded relative to the importing file's directory.
237    Glob(String),
238}
239
240/// Return `Some(directive)` if `node` is tagged `(<)`, else `None`. Returns
241/// `Err` if the tag is `<` but the node is structurally invalid (unknown
242/// variant, missing path, unsupported property, etc.).
243fn parse_directive(node: &KdlNode, current_file: &Path) -> Result<Option<Directive>> {
244    let Some(tag) = node.ty() else {
245        return Ok(None);
246    };
247    if tag.value() != "<" {
248        return Ok(None);
249    }
250
251    let variant = node.name().value();
252    let kind = match variant {
253        "file" => {
254            let path = first_string_arg(node).ok_or_else(|| ComposeError::InvalidDirective {
255                path: current_file.to_path_buf(),
256                message: "(<)file requires a string path as its first argument".to_string(),
257            })?;
258            DirectiveKind::File(PathBuf::from(path))
259        }
260        "glob" => {
261            let pattern = first_string_arg(node).ok_or_else(|| ComposeError::InvalidDirective {
262                path: current_file.to_path_buf(),
263                message: "(<)glob requires a string pattern as its first argument".to_string(),
264            })?;
265            DirectiveKind::Glob(pattern.to_string())
266        }
267        other => {
268            return Err(ComposeError::InvalidDirective {
269                path: current_file.to_path_buf(),
270                message: format!("unknown (<) variant `{other}`; supported variants: file, glob"),
271            });
272        }
273    };
274
275    // Scalar property: as=
276    let mut as_prefix = None;
277    for entry in node.entries() {
278        let Some(key) = entry.name() else { continue };
279        match key.value() {
280            "as" => {
281                let v =
282                    entry
283                        .value()
284                        .as_string()
285                        .ok_or_else(|| ComposeError::InvalidDirective {
286                            path: current_file.to_path_buf(),
287                            message: "as= must be a string".to_string(),
288                        })?;
289                as_prefix = Some(v.to_string());
290            }
291            other => {
292                return Err(ComposeError::InvalidDirective {
293                    path: current_file.to_path_buf(),
294                    message: format!(
295                        "unknown directive property `{other}`; supported: as. \
296                         (list-valued options like only/except/rename go in a children block.)"
297                    ),
298                });
299            }
300        }
301    }
302
303    // List-valued options in the children block: only, except, rename.
304    let (only, except, rename) = parse_options_block(node, current_file)?;
305
306    Ok(Some(Directive {
307        kind,
308        as_prefix,
309        only,
310        except,
311        rename,
312    }))
313}
314
315/// Parse `only` / `except` / `rename` children of a directive node.
316///
317/// - `only "A" "B"` — keep only top-level included nodes whose first string
318///   arg matches. Multiple `only` nodes accumulate.
319/// - `except "X"` — drop matching nodes. Accumulates similarly.
320/// - `rename "Old" "New"` — rename matching first string args. The mapping
321///   is many-to-one; later entries override earlier ones for the same key.
322#[allow(clippy::type_complexity)]
323fn parse_options_block(
324    node: &KdlNode,
325    current_file: &Path,
326) -> Result<(Option<Vec<String>>, Vec<String>, BTreeMap<String, String>)> {
327    let mut only_acc: Option<Vec<String>> = None;
328    let mut except = Vec::new();
329    let mut rename = BTreeMap::new();
330
331    let Some(children) = node.children() else {
332        return Ok((only_acc, except, rename));
333    };
334
335    for child in children.nodes() {
336        match child.name().value() {
337            "only" => {
338                let entry_only = only_acc.get_or_insert_with(Vec::new);
339                for s in positional_strings(child, "only", current_file)? {
340                    entry_only.push(s);
341                }
342            }
343            "except" => {
344                for s in positional_strings(child, "except", current_file)? {
345                    except.push(s);
346                }
347            }
348            "rename" => {
349                let pair = positional_strings(child, "rename", current_file)?;
350                if pair.len() != 2 {
351                    return Err(ComposeError::InvalidDirective {
352                        path: current_file.to_path_buf(),
353                        message: format!(
354                            "`rename` expects exactly two string arguments \
355                             (`rename \"Old\" \"New\"`), got {}",
356                            pair.len()
357                        ),
358                    });
359                }
360                rename.insert(pair[0].clone(), pair[1].clone());
361            }
362            other => {
363                return Err(ComposeError::InvalidDirective {
364                    path: current_file.to_path_buf(),
365                    message: format!(
366                        "unknown directive option `{other}`; supported: only, except, rename"
367                    ),
368                });
369            }
370        }
371    }
372
373    Ok((only_acc, except, rename))
374}
375
376/// Collect every positional (un-named) string argument of `node` into a Vec.
377fn positional_strings(node: &KdlNode, label: &str, current_file: &Path) -> Result<Vec<String>> {
378    let mut out = Vec::new();
379    for entry in node.entries() {
380        if entry.name().is_some() {
381            continue;
382        }
383        let s = entry
384            .value()
385            .as_string()
386            .ok_or_else(|| ComposeError::InvalidDirective {
387                path: current_file.to_path_buf(),
388                message: format!("`{label}` arguments must be strings"),
389            })?;
390        out.push(s.to_string());
391    }
392    Ok(out)
393}
394
395// =============================================================================
396// Apply a directive — resolve target(s), transform top-level nodes
397// =============================================================================
398
399/// Expand the directive's target paths, recursively compose each, and apply
400/// `only` / `except` / `rename` / `as=` to the resulting top-level nodes.
401fn apply_directive(
402    directive: &Directive,
403    base_dir: &Path,
404    current_file: &Path,
405    stack: &mut Vec<PathBuf>,
406) -> Result<Vec<KdlNode>> {
407    let paths: Vec<PathBuf> = match &directive.kind {
408        DirectiveKind::File(rel) => vec![base_dir.join(rel)],
409        DirectiveKind::Glob(pattern) => expand_glob(base_dir, pattern, current_file)?,
410    };
411
412    let mut out = Vec::new();
413    for path in paths {
414        let canonical = canonicalize(&path)?;
415        let included_nodes = resolve(&canonical, stack)?;
416        for node in included_nodes {
417            if let Some(transformed) = transform_node(&node, directive) {
418                out.push(transformed);
419            }
420        }
421    }
422    Ok(out)
423}
424
425/// Expand a glob pattern relative to `base_dir`. Matches are sorted for
426/// determinism (filesystem iteration order is not portable). A pattern that
427/// matches nothing returns an empty vec — empty is not an error here.
428fn expand_glob(base_dir: &Path, pattern: &str, current_file: &Path) -> Result<Vec<PathBuf>> {
429    let full = base_dir.join(pattern);
430    let pat_str = full.to_string_lossy();
431    let paths = glob::glob(&pat_str).map_err(|source| ComposeError::Glob {
432        path: current_file.to_path_buf(),
433        source,
434    })?;
435    let mut matches: Vec<PathBuf> = paths
436        .filter_map(std::result::Result::ok)
437        .filter(|p| p.is_file())
438        .collect();
439    matches.sort();
440    Ok(matches)
441}
442
443/// Apply this directive's filter / rename / prefix to a single included
444/// top-level node. Returns `None` if the node is filtered out.
445///
446/// Rules apply in order: **filter (`only` / `except`) → `rename` → `as=`
447/// prefix.** Lookup keys for filter and rename are the node's first string
448/// argument — nodes without one are always kept and never renamed.
449fn transform_node(node: &KdlNode, directive: &Directive) -> Option<KdlNode> {
450    let original_name = first_string_arg(node).map(str::to_string);
451
452    // Filter: only / except — operate on the original (pre-rename) name.
453    // Nodes without a first string argument have no name to test against the
454    // filter, so they pass through unconditionally — this lets meta directives
455    // like `kdl-version 2` survive an `only`/`except` clause that targets
456    // sibling type definitions.
457    if let Some(only) = &directive.only
458        && let Some(n) = &original_name
459        && !only.contains(n)
460    {
461        return None;
462    }
463    if let Some(n) = &original_name
464        && directive.except.contains(n)
465    {
466        return None;
467    }
468
469    // Rename, then prefix. Both touch only the first string arg.
470    let mut new_node = node.clone();
471    if let Some(orig) = original_name {
472        let renamed = directive.rename.get(&orig).cloned().unwrap_or(orig);
473        let final_name = match &directive.as_prefix {
474            Some(prefix) => format!("{prefix}.{renamed}"),
475            None => renamed,
476        };
477        set_first_string_arg(&mut new_node, &final_name);
478    }
479    Some(new_node)
480}
481
482// =============================================================================
483// Small helpers on KdlNode
484// =============================================================================
485
486/// The first positional (un-named) string argument of `node`, if any.
487fn first_string_arg(node: &KdlNode) -> Option<&str> {
488    node.entries()
489        .iter()
490        .find(|e| e.name().is_none())
491        .and_then(|e| e.value().as_string())
492}
493
494/// Replace the first positional string argument of `node` with `new_value`.
495/// No-op when the node has no such argument.
496fn set_first_string_arg(node: &mut KdlNode, new_value: &str) {
497    for entry in node.entries_mut() {
498        if entry.name().is_none() && entry.value().is_string() {
499            *entry.value_mut() = KdlValue::String(new_value.to_string());
500            return;
501        }
502    }
503}
504
505// =============================================================================
506// Unit tests — exercise the pure helpers and the directive-parsing /
507// transformation pipeline in isolation. The integration test suite catches
508// the end-to-end IO + path-resolution paths.
509// =============================================================================
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    // -------------------------------------------------------------------------
516    // Test helpers — keep each test focused on its actual assertion.
517    // -------------------------------------------------------------------------
518
519    /// Parse `s` and return its first top-level node, cloned. Lets every test
520    /// build a one-line input without doc/index ceremony.
521    fn first_node(s: &str) -> KdlNode {
522        let doc: KdlDocument = s.parse().expect("kdl parse");
523        doc.nodes()[0].clone()
524    }
525
526    /// A throwaway path used for the `current_file` field of the error
527    /// reporter. Tests only need it to land in error messages; it never has
528    /// to exist on disk.
529    fn dummy_path() -> &'static Path {
530        Path::new("/tmp/dummy.kdl")
531    }
532
533    /// Build a [`Directive`] from the four optional dimensions, so transform
534    /// tests stay one screen high.
535    fn directive(
536        only: Option<&[&str]>,
537        except: &[&str],
538        rename: &[(&str, &str)],
539        as_prefix: Option<&str>,
540    ) -> Directive {
541        Directive {
542            kind: DirectiveKind::File(PathBuf::from("test.kdl")),
543            as_prefix: as_prefix.map(str::to_string),
544            only: only.map(|v| v.iter().map(|s| (*s).to_string()).collect()),
545            except: except.iter().map(|s| (*s).to_string()).collect(),
546            rename: rename
547                .iter()
548                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
549                .collect(),
550        }
551    }
552
553    // -------------------------------------------------------------------------
554    // first_string_arg — read the leading positional string of a node.
555    // -------------------------------------------------------------------------
556
557    #[test]
558    fn first_string_arg_skips_properties() {
559        let node = first_node(r#"node prop="ignored" "the-arg""#);
560        assert_eq!(first_string_arg(&node), Some("the-arg"));
561    }
562
563    #[test]
564    fn first_string_arg_returns_none_when_no_positional_arg() {
565        let node = first_node(r#"node key="value""#);
566        assert_eq!(first_string_arg(&node), None);
567    }
568
569    #[test]
570    fn first_string_arg_returns_none_when_positional_arg_is_integer() {
571        // `kdl-version 2`-style nodes — positional but not a string.
572        let node = first_node(r#"node 42"#);
573        assert_eq!(first_string_arg(&node), None);
574    }
575
576    // -------------------------------------------------------------------------
577    // set_first_string_arg — rewrite the leading positional string in place.
578    // -------------------------------------------------------------------------
579
580    #[test]
581    fn set_first_string_arg_replaces_in_place() {
582        let mut doc: KdlDocument = r#"struct "User" { field "id" type="string" }"#.parse().unwrap();
583        let node = &mut doc.nodes_mut()[0];
584        set_first_string_arg(node, "Renamed");
585        assert_eq!(first_string_arg(node), Some("Renamed"));
586        // Children are untouched — only the first string arg of `struct`.
587        let child = &node.children().unwrap().nodes()[0];
588        assert_eq!(first_string_arg(child), Some("id"));
589    }
590
591    #[test]
592    fn set_first_string_arg_noop_when_no_string_positional_arg() {
593        // `kdl-version 2` has only an integer arg; nothing to rename.
594        let mut node = first_node(r#"node 42 key="value""#);
595        set_first_string_arg(&mut node, "wont-apply");
596        // Integer arg stays an integer (not silently converted to a string).
597        assert!(matches!(node.entries()[0].value(), KdlValue::Integer(_)));
598    }
599
600    // -------------------------------------------------------------------------
601    // parse_directive — recognize a `(<)` directive and reject malformed ones.
602    // -------------------------------------------------------------------------
603
604    #[test]
605    fn parse_directive_returns_none_for_untagged_node() {
606        let node = first_node(r#"file "./x.kdl""#);
607        assert!(parse_directive(&node, dummy_path()).unwrap().is_none());
608    }
609
610    #[test]
611    fn parse_directive_returns_none_for_non_lt_tag() {
612        // `(date)` is the canonical "this is a type" tag — must not be
613        // mistaken for a directive.
614        let node = first_node(r#"(date)"2026-05-19""#);
615        assert!(parse_directive(&node, dummy_path()).unwrap().is_none());
616    }
617
618    #[test]
619    fn parse_directive_rejects_unknown_variant() {
620        let node = first_node(r#"(<)wat "./x.kdl""#);
621        let err = parse_directive(&node, dummy_path()).unwrap_err();
622        let ComposeError::InvalidDirective { message, .. } = err else {
623            panic!("expected InvalidDirective");
624        };
625        assert!(message.contains("unknown (<) variant"));
626        assert!(message.contains("wat"));
627    }
628
629    #[test]
630    fn parse_directive_file_variant_extracts_path() {
631        let node = first_node(r#"(<)file "./types.kdl""#);
632        let d = parse_directive(&node, dummy_path())
633            .unwrap()
634            .expect("directive");
635        let DirectiveKind::File(p) = d.kind else {
636            panic!("expected File");
637        };
638        assert_eq!(p, PathBuf::from("./types.kdl"));
639    }
640
641    #[test]
642    fn parse_directive_glob_variant_extracts_pattern() {
643        let node = first_node(r#"(<)glob "./types/*.kdl""#);
644        let d = parse_directive(&node, dummy_path())
645            .unwrap()
646            .expect("directive");
647        let DirectiveKind::Glob(p) = d.kind else {
648            panic!("expected Glob");
649        };
650        assert_eq!(p, "./types/*.kdl");
651    }
652
653    #[test]
654    fn parse_directive_rejects_file_without_path_arg() {
655        let node = first_node(r#"(<)file"#);
656        let err = parse_directive(&node, dummy_path()).unwrap_err();
657        assert!(matches!(err, ComposeError::InvalidDirective { .. }));
658    }
659
660    #[test]
661    fn parse_directive_rejects_glob_without_pattern_arg() {
662        let node = first_node(r#"(<)glob"#);
663        let err = parse_directive(&node, dummy_path()).unwrap_err();
664        assert!(matches!(err, ComposeError::InvalidDirective { .. }));
665    }
666
667    #[test]
668    fn parse_directive_extracts_as_prefix() {
669        let node = first_node(r#"(<)file "./x.kdl" as="shared""#);
670        let d = parse_directive(&node, dummy_path())
671            .unwrap()
672            .expect("directive");
673        assert_eq!(d.as_prefix.as_deref(), Some("shared"));
674    }
675
676    #[test]
677    fn parse_directive_rejects_non_string_as_value() {
678        let node = first_node(r#"(<)file "./x.kdl" as=42"#);
679        let err = parse_directive(&node, dummy_path()).unwrap_err();
680        let ComposeError::InvalidDirective { message, .. } = err else {
681            panic!("expected InvalidDirective");
682        };
683        assert!(message.contains("as= must be a string"));
684    }
685
686    #[test]
687    fn parse_directive_rejects_unknown_property() {
688        let node = first_node(r#"(<)file "./x.kdl" wat="x""#);
689        let err = parse_directive(&node, dummy_path()).unwrap_err();
690        let ComposeError::InvalidDirective { message, .. } = err else {
691            panic!("expected InvalidDirective");
692        };
693        assert!(message.contains("unknown directive property"));
694        assert!(message.contains("wat"));
695    }
696
697    // -------------------------------------------------------------------------
698    // parse_options_block — list-valued options live in a children block.
699    //
700    // Reached through `parse_directive` since `parse_options_block` is only
701    // called once and is not public to the test module's grandparent. These
702    // tests use directive nodes with children blocks.
703    // -------------------------------------------------------------------------
704
705    fn parse_with_block(node_text: &str) -> Directive {
706        let node = first_node(node_text);
707        parse_directive(&node, dummy_path())
708            .unwrap()
709            .expect("directive")
710    }
711
712    #[test]
713    fn parse_options_block_empty_node_returns_defaults() {
714        let d = parse_with_block(r#"(<)file "./x.kdl""#);
715        assert!(d.only.is_none());
716        assert!(d.except.is_empty());
717        assert!(d.rename.is_empty());
718    }
719
720    #[test]
721    fn parse_options_block_only_single_line_collects_names() {
722        let d = parse_with_block(
723            r#"(<)file "./x.kdl" {
724                only "A" "B"
725            }"#,
726        );
727        assert_eq!(d.only.unwrap(), vec!["A".to_string(), "B".to_string()]);
728    }
729
730    #[test]
731    fn parse_options_block_only_multi_line_accumulates() {
732        // Two `only` nodes — the second should not overwrite the first.
733        let d = parse_with_block(
734            r#"(<)file "./x.kdl" {
735                only "A"
736                only "B"
737            }"#,
738        );
739        assert_eq!(
740            d.only.unwrap(),
741            vec!["A".to_string(), "B".to_string()],
742            "multiple `only` nodes must accumulate, not overwrite"
743        );
744    }
745
746    #[test]
747    fn parse_options_block_except_collects_names() {
748        let d = parse_with_block(
749            r#"(<)file "./x.kdl" {
750                except "A" "B"
751            }"#,
752        );
753        assert_eq!(d.except, vec!["A".to_string(), "B".to_string()]);
754    }
755
756    #[test]
757    fn parse_options_block_rename_single_entry() {
758        let d = parse_with_block(
759            r#"(<)file "./x.kdl" {
760                rename "Old" "New"
761            }"#,
762        );
763        assert_eq!(d.rename.get("Old"), Some(&"New".to_string()));
764    }
765
766    #[test]
767    fn parse_options_block_rename_same_key_later_wins() {
768        // Two `rename` entries for the same key — later overrides earlier.
769        let d = parse_with_block(
770            r#"(<)file "./x.kdl" {
771                rename "User" "First"
772                rename "User" "Second"
773            }"#,
774        );
775        assert_eq!(
776            d.rename.get("User"),
777            Some(&"Second".to_string()),
778            "later rename entry must override earlier for the same key"
779        );
780    }
781
782    #[test]
783    fn parse_options_block_rename_wrong_arity_is_rejected() {
784        let node = first_node(
785            r#"(<)file "./x.kdl" {
786                rename "OnlyOne"
787            }"#,
788        );
789        let err = parse_directive(&node, dummy_path()).unwrap_err();
790        let ComposeError::InvalidDirective { message, .. } = err else {
791            panic!("expected InvalidDirective");
792        };
793        assert!(message.contains("rename"));
794        assert!(message.contains("exactly two"));
795    }
796
797    #[test]
798    fn parse_options_block_unknown_option_is_rejected() {
799        let node = first_node(
800            r#"(<)file "./x.kdl" {
801                weird "x"
802            }"#,
803        );
804        let err = parse_directive(&node, dummy_path()).unwrap_err();
805        let ComposeError::InvalidDirective { message, .. } = err else {
806            panic!("expected InvalidDirective");
807        };
808        assert!(message.contains("unknown directive option"));
809        assert!(message.contains("weird"));
810    }
811
812    #[test]
813    fn parse_options_block_non_string_arg_is_rejected() {
814        let node = first_node(
815            r#"(<)file "./x.kdl" {
816                only 42
817            }"#,
818        );
819        let err = parse_directive(&node, dummy_path()).unwrap_err();
820        let ComposeError::InvalidDirective { message, .. } = err else {
821            panic!("expected InvalidDirective");
822        };
823        assert!(message.contains("only"));
824        assert!(message.contains("string"));
825    }
826
827    // -------------------------------------------------------------------------
828    // transform_node — apply order: filter (only/except) → rename → as= prefix.
829    // -------------------------------------------------------------------------
830
831    #[test]
832    fn transform_node_no_options_returns_node_unchanged() {
833        let node = first_node(r#"struct "User""#);
834        let d = directive(None, &[], &[], None);
835        let out = transform_node(&node, &d).expect("kept");
836        assert_eq!(first_string_arg(&out), Some("User"));
837    }
838
839    #[test]
840    fn transform_node_only_keeps_matching_node() {
841        let node = first_node(r#"struct "User""#);
842        let d = directive(Some(&["User"]), &[], &[], None);
843        assert!(transform_node(&node, &d).is_some());
844    }
845
846    #[test]
847    fn transform_node_only_drops_non_matching_node() {
848        let node = first_node(r#"struct "Other""#);
849        let d = directive(Some(&["User"]), &[], &[], None);
850        assert!(transform_node(&node, &d).is_none());
851    }
852
853    #[test]
854    fn transform_node_only_keeps_no_first_string_arg_node() {
855        // `kdl-version 2` has no string positional arg → kept regardless of
856        // the `only` list. Documented as "nodes without one are always kept".
857        let node = first_node(r#"kdl-version 2"#);
858        let d = directive(Some(&["User"]), &[], &[], None);
859        let out = transform_node(&node, &d).expect("kept despite only filter");
860        assert!(matches!(out.entries()[0].value(), KdlValue::Integer(_)));
861    }
862
863    #[test]
864    fn transform_node_except_drops_matching_node() {
865        let node = first_node(r#"struct "Internal""#);
866        let d = directive(None, &["Internal"], &[], None);
867        assert!(transform_node(&node, &d).is_none());
868    }
869
870    #[test]
871    fn transform_node_except_keeps_no_first_string_arg_node() {
872        // Symmetric to the `only` case — first-string-argless nodes are
873        // also never dropped by `except`.
874        let node = first_node(r#"kdl-version 2"#);
875        let d = directive(None, &["Internal"], &[], None);
876        let out = transform_node(&node, &d).expect("kept");
877        assert!(matches!(out.entries()[0].value(), KdlValue::Integer(_)));
878    }
879
880    #[test]
881    fn transform_node_rename_replaces_first_string_arg() {
882        let node = first_node(r#"struct "User""#);
883        let d = directive(None, &[], &[("User", "Acct")], None);
884        let out = transform_node(&node, &d).expect("kept");
885        assert_eq!(first_string_arg(&out), Some("Acct"));
886    }
887
888    #[test]
889    fn transform_node_rename_for_unknown_key_is_silent_noop() {
890        let node = first_node(r#"struct "User""#);
891        let d = directive(None, &[], &[("Other", "Acct")], None);
892        let out = transform_node(&node, &d).expect("kept");
893        assert_eq!(
894            first_string_arg(&out),
895            Some("User"),
896            "non-matching rename leaves node intact"
897        );
898    }
899
900    #[test]
901    fn transform_node_as_prefix_prepends_dot_separated() {
902        let node = first_node(r#"struct "User""#);
903        let d = directive(None, &[], &[], Some("shared"));
904        let out = transform_node(&node, &d).expect("kept");
905        assert_eq!(first_string_arg(&out), Some("shared.User"));
906    }
907
908    #[test]
909    fn transform_node_apply_order_is_filter_then_rename_then_prefix() {
910        // only=[User] (pre-rename) → keep
911        // rename User→Acct           → first string arg becomes Acct
912        // as="ns"                    → prefix to ns.Acct
913        let node = first_node(r#"struct "User""#);
914        let d = directive(Some(&["User"]), &[], &[("User", "Acct")], Some("ns"));
915        let out = transform_node(&node, &d).expect("kept");
916        assert_eq!(first_string_arg(&out), Some("ns.Acct"));
917    }
918
919    #[test]
920    fn transform_node_only_matches_against_original_name_not_renamed_name() {
921        // `only=["Acct"]` referring to the *renamed* name must NOT match —
922        // filter runs first, against the original.
923        let node = first_node(r#"struct "User""#);
924        let d = directive(Some(&["Acct"]), &[], &[("User", "Acct")], None);
925        assert!(
926            transform_node(&node, &d).is_none(),
927            "only matches pre-rename names, so `Acct` should not match `User`"
928        );
929    }
930
931    #[test]
932    fn transform_node_as_prefix_skips_node_with_no_first_string_arg() {
933        // `as=` only rewrites the first *string* arg; integer-only nodes
934        // pass through unchanged.
935        let node = first_node(r#"kdl-version 2"#);
936        let d = directive(None, &[], &[], Some("shared"));
937        let out = transform_node(&node, &d).expect("kept");
938        assert!(matches!(out.entries()[0].value(), KdlValue::Integer(_)));
939    }
940}