1#![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
114pub 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
137pub 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
147fn 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
161fn 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
191fn 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#[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#[derive(Debug)]
233enum DirectiveKind {
234 File(PathBuf),
236 Glob(String),
238}
239
240fn 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 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 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#[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
376fn 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
395fn 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
425fn 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
443fn transform_node(node: &KdlNode, directive: &Directive) -> Option<KdlNode> {
450 let original_name = first_string_arg(node).map(str::to_string);
451
452 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 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
482fn 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
494fn 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#[cfg(test)]
512mod tests {
513 use super::*;
514
515 fn first_node(s: &str) -> KdlNode {
522 let doc: KdlDocument = s.parse().expect("kdl parse");
523 doc.nodes()[0].clone()
524 }
525
526 fn dummy_path() -> &'static Path {
530 Path::new("/tmp/dummy.kdl")
531 }
532
533 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 #[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 let node = first_node(r#"node 42"#);
573 assert_eq!(first_string_arg(&node), None);
574 }
575
576 #[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 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 let mut node = first_node(r#"node 42 key="value""#);
595 set_first_string_arg(&mut node, "wont-apply");
596 assert!(matches!(node.entries()[0].value(), KdlValue::Integer(_)));
598 }
599
600 #[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 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 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 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 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 #[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 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 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 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 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 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}