1use std::fs;
2use std::io::{self, Read, Write};
3use std::process::ExitCode;
4
5use anyhow::{Context, Result};
6use clap::{Parser, Subcommand};
7use prost::Message;
8
9use crate::extensions::ExtensionRegistry;
10use crate::{FormatError, OutputOptions, Visibility, format_with_registry, parse_with_registry};
11
12#[derive(Debug)]
17pub enum Outcome {
18 Success,
20 HadFormattingIssues(Vec<FormatError>),
22}
23
24#[derive(Parser)]
25#[command(name = "substrait-explain")]
26#[command(about = "A CLI for parsing and formatting Substrait query plans")]
27#[command(version)]
28pub struct Cli {
29 #[command(subcommand)]
30 pub command: Commands,
31}
32
33impl Cli {
34 pub fn run(self) -> ExitCode {
38 self.run_with_extensions(ExtensionRegistry::default())
39 }
40
41 pub fn run_with_extensions(self, registry: ExtensionRegistry) -> ExitCode {
52 match self.run_inner(®istry) {
53 Ok(Outcome::Success) => ExitCode::SUCCESS,
54 Ok(Outcome::HadFormattingIssues(errors)) => {
55 eprintln!("Formatting issues:");
56 for error in errors {
57 eprintln!(" {error}");
58 }
59 ExitCode::FAILURE
60 }
61 Err(e) => {
62 eprintln!("Error: {e:?}");
63 ExitCode::FAILURE
64 }
65 }
66 }
67
68 fn run_inner(self, registry: &ExtensionRegistry) -> Result<Outcome> {
69 match &self.command {
70 Commands::Convert {
71 input,
72 output,
73 from,
74 to,
75 show_literal_types,
76 verbose,
77 } => {
78 let reader = get_reader(input)
79 .with_context(|| format!("Failed to open input file: {input}"))?;
80 let writer = get_writer(output)
81 .with_context(|| format!("Failed to create output file: {output}"))?;
82 let options = self.create_output_options(*show_literal_types);
83 let from_format = self.resolve_input_format(from, input)?;
84 let to_format = self.resolve_output_format(to, output)?;
85 self.run_convert_with_io(
86 reader,
87 writer,
88 &from_format,
89 &to_format,
90 &options,
91 *verbose,
92 registry,
93 )
94 }
95
96 Commands::Validate {
97 input,
98 output,
99 verbose,
100 } => {
101 let reader = get_reader(input)
102 .with_context(|| format!("Failed to open input file: {input}"))?;
103 let writer = get_writer(output)
104 .with_context(|| format!("Failed to create output file: {output}"))?;
105 self.run_validate_with_io(reader, writer, *verbose, registry)
106 }
107 }
108 }
109
110 pub fn run_with_io<R: Read, W: Write>(
112 &self,
113 reader: R,
114 writer: W,
115 registry: &ExtensionRegistry,
116 ) -> Result<Outcome> {
117 match &self.command {
118 Commands::Convert {
119 input,
120 output,
121 from,
122 to,
123 show_literal_types,
124 verbose,
125 ..
126 } => {
127 let options = self.create_output_options(*show_literal_types);
128 let from_format = self.resolve_input_format(from, input)?;
129 let to_format = self.resolve_output_format(to, output)?;
130 self.run_convert_with_io(
131 reader,
132 writer,
133 &from_format,
134 &to_format,
135 &options,
136 *verbose,
137 registry,
138 )
139 }
140
141 Commands::Validate { verbose, .. } => {
142 self.run_validate_with_io(reader, writer, *verbose, registry)
143 }
144 }
145 }
146
147 fn create_output_options(&self, show_literal_types: bool) -> OutputOptions {
148 let mut options = OutputOptions::default();
149
150 if show_literal_types {
151 options.literal_types = Visibility::Always;
152 }
153
154 options
155 }
156
157 fn resolve_input_format(&self, format: &Option<Format>, input_path: &str) -> Result<Format> {
158 match format {
159 Some(fmt) => Ok(fmt.clone()),
160 None => Format::from_extension(input_path).ok_or_else(|| {
161 anyhow::anyhow!(
162 "Could not auto-detect input format from file extension. \
163 Please specify format explicitly with -f/--from. \
164 Supported formats: text, json, yaml, protobuf/proto/pb"
165 )
166 }),
167 }
168 }
169
170 fn resolve_output_format(&self, format: &Option<Format>, output_path: &str) -> Result<Format> {
171 match format {
172 Some(fmt) => Ok(fmt.clone()),
173 None => Format::from_extension(output_path).ok_or_else(|| {
174 anyhow::anyhow!(
175 "Could not auto-detect output format from file extension. \
176 Please specify format explicitly with -t/--to. \
177 Supported formats: text, json, yaml, protobuf/proto/pb"
178 )
179 }),
180 }
181 }
182
183 #[allow(clippy::too_many_arguments)]
187 fn run_convert_with_io<R: Read, W: Write>(
188 &self,
189 reader: R,
190 writer: W,
191 from: &Format,
192 to: &Format,
193 options: &OutputOptions,
194 verbose: bool,
195 registry: &ExtensionRegistry,
196 ) -> Result<Outcome> {
197 let plan = from.read_plan(reader, registry).with_context(|| {
199 format!(
200 "Failed to parse input as {} format",
201 format!("{from:?}").to_lowercase()
202 )
203 })?;
204
205 let outcome = to
207 .write_plan(writer, &plan, options, registry)
208 .with_context(|| {
209 format!(
210 "Failed to write output as {} format",
211 format!("{to:?}").to_lowercase()
212 )
213 })?;
214
215 if verbose && matches!(outcome, Outcome::Success) {
216 eprintln!("Successfully converted from {from:?} to {to:?}");
217 }
218
219 Ok(outcome)
220 }
221
222 fn run_validate_with_io<R: Read, W: Write>(
223 &self,
224 reader: R,
225 writer: W,
226 verbose: bool,
227 registry: &ExtensionRegistry,
228 ) -> Result<Outcome> {
229 let plan = Format::Text
230 .read_plan(reader, registry)
231 .with_context(|| "Failed to parse input as Substrait text format")?;
232
233 let outcome = Format::Text
234 .write_plan(writer, &plan, &OutputOptions::default(), registry)
235 .with_context(|| "Failed to format plan as Substrait text format")?;
236
237 if verbose && matches!(outcome, Outcome::Success) {
238 eprintln!("Successfully validated plan");
239 }
240
241 Ok(outcome)
242 }
243}
244
245#[derive(Subcommand)]
246pub enum Commands {
247 Convert {
263 #[arg(short, long, default_value = "-")]
265 input: String,
266 #[arg(short, long, default_value = "-")]
268 output: String,
269 #[arg(short = 'f', long)]
271 from: Option<Format>,
272 #[arg(short = 't', long)]
274 to: Option<Format>,
275 #[arg(long)]
277 show_literal_types: bool,
278 #[arg(short, long)]
280 verbose: bool,
281 },
282 Validate {
284 #[arg(short, long, default_value = "-")]
286 input: String,
287 #[arg(short, long, default_value = "-")]
289 output: String,
290 #[arg(short, long)]
292 verbose: bool,
293 },
294}
295
296#[derive(Clone, Debug, PartialEq)]
297pub enum Format {
298 Text,
299 Json,
300 Yaml,
301 Protobuf,
302}
303
304impl std::str::FromStr for Format {
305 type Err = String;
306
307 fn from_str(s: &str) -> Result<Self, Self::Err> {
308 match s.to_lowercase().as_str() {
309 "text" => Ok(Format::Text),
310 "json" => Ok(Format::Json),
311 "yaml" => Ok(Format::Yaml),
312 "protobuf" | "proto" | "pb" => Ok(Format::Protobuf),
313 _ => Err(format!(
314 "Invalid format: '{s}'. Supported formats: text, json, yaml, protobuf/proto/pb"
315 )),
316 }
317 }
318}
319
320impl Format {
321 pub fn from_extension(path: &str) -> Option<Format> {
323 if path == "-" {
324 return None; }
326
327 let extension = std::path::Path::new(path)
328 .extension()
329 .and_then(|ext| ext.to_str())
330 .map(|ext| ext.to_lowercase());
331
332 match extension.as_deref() {
333 Some("substrait") | Some("txt") => Some(Format::Text),
334 Some("json") => Some(Format::Json),
335 Some("yaml") | Some("yml") => Some(Format::Yaml),
336 Some("pb") | Some("proto") | Some("protobuf") => Some(Format::Protobuf),
337 _ => None,
338 }
339 }
340
341 pub fn read_plan<R: Read>(
342 &self,
343 reader: R,
344 registry: &ExtensionRegistry,
345 ) -> Result<substrait::proto::Plan> {
346 match self {
347 Format::Text => {
348 let input_text = read_text_input(reader)?;
349 Ok(parse_with_registry(&input_text, registry)?)
350 }
351 Format::Json => {
352 let input_text = read_text_input(reader)?;
353 let pool = crate::json::build_descriptor_pool(®istry.descriptors())?;
354 crate::json::parse_json(&input_text, &pool)
355 }
356 Format::Yaml => {
357 #[cfg(feature = "serde")]
358 {
359 let input_text = read_text_input(reader)?;
360 Ok(serde_yaml::from_str(&input_text)?)
361 }
362 #[cfg(not(feature = "serde"))]
363 {
364 Err("YAML support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
365 }
366 }
367 Format::Protobuf => {
368 let input_bytes = read_binary_input(reader)?;
369 Ok(substrait::proto::Plan::decode(&input_bytes[..])?)
370 }
371 }
372 }
373
374 pub fn write_plan<W: Write>(
375 &self,
376 writer: W,
377 plan: &substrait::proto::Plan,
378 options: &OutputOptions,
379 registry: &ExtensionRegistry,
380 ) -> Result<Outcome> {
381 match self {
382 Format::Text => {
383 let (text, errors) = format_with_registry(plan, options, registry);
384
385 write_text_output(writer, &text)?;
387
388 if errors.is_empty() {
390 Ok(Outcome::Success)
391 } else {
392 Ok(Outcome::HadFormattingIssues(errors))
393 }
394 }
395 Format::Json => {
396 #[cfg(feature = "serde")]
397 {
398 let json = serde_json::to_string_pretty(plan)?;
399 write_text_output(writer, &json)?;
400 Ok(Outcome::Success)
401 }
402 #[cfg(not(feature = "serde"))]
403 {
404 Err("JSON support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
405 }
406 }
407 Format::Yaml => {
408 #[cfg(feature = "serde")]
409 {
410 let yaml = serde_yaml::to_string(plan)?;
411 write_text_output(writer, &yaml)?;
412 Ok(Outcome::Success)
413 }
414 #[cfg(not(feature = "serde"))]
415 {
416 Err("YAML support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
417 }
418 }
419 Format::Protobuf => {
420 let bytes = plan.encode_to_vec();
421 write_binary_output(writer, &bytes)?;
422 Ok(Outcome::Success)
423 }
424 }
425 }
426}
427
428fn read_text_input<R: Read>(mut reader: R) -> Result<String> {
430 let mut buffer = String::new();
431 reader.read_to_string(&mut buffer)?;
432 Ok(buffer)
433}
434
435fn read_binary_input<R: Read>(mut reader: R) -> Result<Vec<u8>> {
437 let mut buffer = Vec::new();
438 reader.read_to_end(&mut buffer)?;
439 Ok(buffer)
440}
441
442fn write_text_output<W: Write>(mut writer: W, content: &str) -> Result<()> {
444 writer.write_all(content.as_bytes())?;
445 Ok(())
446}
447
448fn write_binary_output<W: Write>(mut writer: W, content: &[u8]) -> Result<()> {
450 writer.write_all(content)?;
451 Ok(())
452}
453
454fn get_reader(path: &str) -> Result<Box<dyn Read>> {
456 if path == "-" {
457 Ok(Box::new(io::stdin()))
458 } else {
459 Ok(Box::new(fs::File::open(path)?))
460 }
461}
462
463fn get_writer(path: &str) -> Result<Box<dyn Write>> {
465 if path == "-" {
466 Ok(Box::new(io::stdout()))
467 } else {
468 Ok(Box::new(fs::File::create(path)?))
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use std::io::Cursor;
475
476 use substrait::proto::expression::RexType;
477 use substrait::proto::plan_rel;
478 use substrait::proto::rel::RelType;
479
480 use super::*;
481 use crate::extensions::{Explainable, ExtensionArgs, ExtensionColumn, ExtensionError};
482 use crate::fixtures::parse_type;
483 use crate::parse;
484
485 const BASIC_PLAN: &str = r#"=== Plan
486Root[result]
487 Project[$0, $1]
488 Read[data => a:i64, b:string]
489"#;
490
491 const PLAN_WITH_EXTENSIONS: &str = r#"=== Extensions
492URNs:
493 @ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_arithmetic.yaml
494Functions:
495 # 10 @ 1: gt
496
497=== Plan
498Root[result]
499 Filter[gt($2, 100):boolean => $0, $1, $2]
500 Project[$0, $1, $2]
501 Read[data => a:i64, b:string, c:i32]
502"#;
503
504 #[test]
505 fn test_convert_text_to_text() {
506 let input = Cursor::new(BASIC_PLAN);
507 let mut output = Vec::new();
508
509 let cli = Cli {
510 command: Commands::Convert {
511 input: "input.substrait".to_string(),
512 output: "output.substrait".to_string(),
513 from: Some(Format::Text),
514 to: Some(Format::Text),
515 show_literal_types: false,
516 verbose: false,
517 },
518 };
519
520 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
521 .unwrap();
522
523 let output_content = String::from_utf8(output).unwrap();
524 assert!(output_content.contains("=== Plan"));
525 assert!(output_content.contains("Root[result]"));
526 assert!(output_content.contains("Project[$0, $1]"));
527 assert!(output_content.contains("Read[data => a:i64, b:string]"));
528 }
529
530 #[test]
531 fn test_convert_text_to_json() {
532 let input = Cursor::new(BASIC_PLAN);
533 let mut output = Vec::new();
534
535 let cli = Cli {
536 command: Commands::Convert {
537 input: "input.substrait".to_string(),
538 output: "output.json".to_string(),
539 from: Some(Format::Text),
540 to: Some(Format::Json),
541 show_literal_types: false,
542 verbose: false,
543 },
544 };
545
546 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
547 .unwrap();
548
549 let output_content = String::from_utf8(output).unwrap();
550 assert!(output_content.contains("\"relations\""));
551 assert!(output_content.contains("\"root\""));
552 assert!(output_content.contains("\"project\""));
553 assert!(output_content.contains("\"read\""));
554 }
555
556 #[test]
557 fn test_convert_json_to_text() {
558 let input = Cursor::new(BASIC_PLAN);
560 let mut json_output = Vec::new();
561
562 let cli_to_json = Cli {
563 command: Commands::Convert {
564 input: "input.substrait".to_string(),
565 output: "output.json".to_string(),
566 from: Some(Format::Text),
567 to: Some(Format::Json),
568 show_literal_types: false,
569 verbose: false,
570 },
571 };
572
573 cli_to_json
574 .run_with_io(input, &mut json_output, &ExtensionRegistry::default())
575 .unwrap();
576
577 let json_input = Cursor::new(json_output);
579 let mut text_output = Vec::new();
580
581 let cli_to_text = Cli {
582 command: Commands::Convert {
583 input: "input.json".to_string(),
584 output: "output.substrait".to_string(),
585 from: Some(Format::Json),
586 to: Some(Format::Text),
587 show_literal_types: false,
588 verbose: false,
589 },
590 };
591
592 cli_to_text
593 .run_with_io(json_input, &mut text_output, &ExtensionRegistry::default())
594 .unwrap();
595
596 let output_content = String::from_utf8(text_output).unwrap();
597 assert!(output_content.contains("=== Plan"));
598 assert!(output_content.contains("Root[result]"));
599 }
600
601 #[test]
602 fn test_convert_with_protobuf_output() {
603 let input = Cursor::new(BASIC_PLAN);
604 let mut output = Vec::new();
605
606 let cli = Cli {
607 command: Commands::Convert {
608 input: "input.substrait".to_string(),
609 output: "output.pb".to_string(),
610 from: Some(Format::Text),
611 to: Some(Format::Protobuf),
612 show_literal_types: false,
613 verbose: false,
614 },
615 };
616
617 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
618 .unwrap();
619
620 assert!(!output.is_empty());
622
623 let output_string = String::from_utf8_lossy(&output);
625 assert!(!output_string.contains("=== Plan"));
626 }
627
628 #[test]
629 fn test_validate_command() {
630 let input = Cursor::new(BASIC_PLAN);
631 let mut output = Vec::new();
632
633 let cli = Cli {
634 command: Commands::Validate {
635 input: String::new(),
636 output: String::new(),
637 verbose: false,
638 },
639 };
640
641 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
642 .unwrap();
643
644 let output_content = String::from_utf8(output).unwrap();
645 assert!(output_content.contains("=== Plan"));
646 assert!(output_content.contains("Root[result]"));
647 assert!(output_content.contains("Project[$0, $1]"));
648 assert!(output_content.contains("Read[data => a:i64, b:string]"));
649 }
650
651 #[test]
652 fn test_validate_with_extensions() {
653 let input = Cursor::new(PLAN_WITH_EXTENSIONS);
654 let mut output = Vec::new();
655
656 let cli = Cli {
657 command: Commands::Validate {
658 input: String::new(),
659 output: String::new(),
660 verbose: false,
661 },
662 };
663
664 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
665 .unwrap();
666
667 let output_content = String::from_utf8(output).unwrap();
668 assert!(output_content.contains("=== Extensions"));
669 assert!(output_content.contains("=== Plan"));
670 assert!(output_content.contains("Root[result]"));
671 assert!(output_content.contains("Filter[gt($2, 100):boolean"));
672 }
673
674 #[test]
675 fn test_convert_with_formatting_options() {
676 let input = Cursor::new(BASIC_PLAN);
677 let mut output = Vec::new();
678
679 let cli = Cli {
680 command: Commands::Convert {
681 input: "input.substrait".to_string(),
682 output: "output.substrait".to_string(),
683 from: Some(Format::Text),
684 to: Some(Format::Text),
685 show_literal_types: true,
686 verbose: false,
687 },
688 };
689
690 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
691 .unwrap();
692
693 let output_content = String::from_utf8(output).unwrap();
694 assert!(output_content.contains("=== Plan"));
695 assert!(output_content.contains("Root[result]"));
696 }
697
698 #[test]
699 fn test_auto_detect_from_extension() {
700 assert_eq!(Format::from_extension("plan.substrait"), Some(Format::Text));
702 assert_eq!(Format::from_extension("plan.txt"), Some(Format::Text));
703
704 assert_eq!(Format::from_extension("plan.json"), Some(Format::Json));
706
707 assert_eq!(Format::from_extension("plan.yaml"), Some(Format::Yaml));
709 assert_eq!(Format::from_extension("plan.yml"), Some(Format::Yaml));
710
711 assert_eq!(Format::from_extension("plan.pb"), Some(Format::Protobuf));
713 assert_eq!(Format::from_extension("plan.proto"), Some(Format::Protobuf));
714 assert_eq!(
715 Format::from_extension("plan.protobuf"),
716 Some(Format::Protobuf)
717 );
718
719 assert_eq!(Format::from_extension("plan.unknown"), None);
721 assert_eq!(Format::from_extension("plan"), None);
722
723 assert_eq!(Format::from_extension("-"), None);
725 }
726
727 #[test]
728 fn test_convert_with_auto_detection() {
729 let input = Cursor::new(BASIC_PLAN);
730 let mut output = Vec::new();
731
732 let cli = Cli {
733 command: Commands::Convert {
734 input: "input.substrait".to_string(),
735 output: "output.json".to_string(),
736 from: None, to: None, show_literal_types: false,
739 verbose: false,
740 },
741 };
742
743 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
744 .unwrap();
745
746 let output_content = String::from_utf8(output).unwrap();
747 assert!(output_content.contains("\"relations\""));
748 assert!(output_content.contains("\"root\""));
749 assert!(output_content.contains("\"project\""));
750 assert!(output_content.contains("\"read\""));
751 }
752
753 #[test]
754 fn test_auto_detection_error_unknown_input_extension() {
755 let input = Cursor::new(BASIC_PLAN);
756 let mut output = Vec::new();
757
758 let cli = Cli {
759 command: Commands::Convert {
760 input: "input.unknown".to_string(),
761 output: "output.json".to_string(),
762 from: None, to: None,
764 show_literal_types: false,
765 verbose: false,
766 },
767 };
768
769 let result = cli.run_with_io(input, &mut output, &ExtensionRegistry::default());
770 assert!(result.is_err());
771 assert!(
772 result
773 .unwrap_err()
774 .to_string()
775 .contains("Could not auto-detect input format")
776 );
777 }
778
779 #[test]
780 fn test_auto_detection_error_unknown_output_extension() {
781 let input = Cursor::new(BASIC_PLAN);
782 let mut output = Vec::new();
783
784 let cli = Cli {
785 command: Commands::Convert {
786 input: "input.substrait".to_string(),
787 output: "output.unknown".to_string(),
788 from: None,
789 to: None, show_literal_types: false,
791 verbose: false,
792 },
793 };
794
795 let result = cli.run_with_io(input, &mut output, &ExtensionRegistry::default());
796 assert!(result.is_err());
797 assert!(
798 result
799 .unwrap_err()
800 .to_string()
801 .contains("Could not auto-detect output format")
802 );
803 }
804
805 #[test]
806 fn test_explicit_format_overrides_auto_detection() {
807 let input = Cursor::new(BASIC_PLAN);
808 let mut output = Vec::new();
809
810 let cli = Cli {
811 command: Commands::Convert {
812 input: "input.json".to_string(), output: "output.pb".to_string(), from: Some(Format::Text), to: Some(Format::Text), show_literal_types: false,
817 verbose: false,
818 },
819 };
820
821 cli.run_with_io(input, &mut output, &ExtensionRegistry::default())
822 .unwrap();
823
824 let output_content = String::from_utf8(output).unwrap();
825 assert!(output_content.contains("=== Plan"));
826 assert!(output_content.contains("Root[result]"));
827 }
828
829 #[test]
830 fn test_protobuf_roundtrip() {
831 let input = Cursor::new(BASIC_PLAN);
833 let mut protobuf_output = Vec::new();
834
835 let cli_to_protobuf = Cli {
836 command: Commands::Convert {
837 input: "input.substrait".to_string(),
838 output: "output.pb".to_string(),
839 from: Some(Format::Text),
840 to: Some(Format::Protobuf),
841 show_literal_types: false,
842 verbose: false,
843 },
844 };
845
846 cli_to_protobuf
847 .run_with_io(input, &mut protobuf_output, &ExtensionRegistry::default())
848 .unwrap();
849
850 let protobuf_input = Cursor::new(protobuf_output);
852 let mut text_output = Vec::new();
853
854 let cli_to_text = Cli {
855 command: Commands::Convert {
856 input: "input.pb".to_string(),
857 output: "output.substrait".to_string(),
858 from: Some(Format::Protobuf),
859 to: Some(Format::Text),
860 show_literal_types: false,
861 verbose: false,
862 },
863 };
864
865 cli_to_text
866 .run_with_io(
867 protobuf_input,
868 &mut text_output,
869 &ExtensionRegistry::default(),
870 )
871 .unwrap();
872
873 let output_content = String::from_utf8(text_output).unwrap();
874 assert!(output_content.contains("=== Plan"));
875 assert!(output_content.contains("Root[result]"));
876 assert!(output_content.contains("Read[data => a:i64, b:string]"));
877 }
878
879 #[derive(Clone, PartialEq, prost::Message)]
886 struct TestSource {
887 #[prost(string, tag = "1")]
888 tag: String,
889 }
890
891 impl prost::Name for TestSource {
892 const NAME: &'static str = "TestSource";
893 const PACKAGE: &'static str = "test";
894 fn full_name() -> String {
895 "test.TestSource".to_string()
896 }
897 fn type_url() -> String {
898 "type.googleapis.com/test.TestSource".to_string()
899 }
900 }
901
902 impl Explainable for TestSource {
903 fn name() -> &'static str {
904 "TestSource"
905 }
906
907 fn from_args(args: &ExtensionArgs) -> Result<Self, ExtensionError> {
908 let mut extractor = args.extractor();
909 let tag: &str = extractor.expect_named_arg("tag")?;
910 extractor.check_exhausted()?;
911 Ok(TestSource {
912 tag: tag.to_string(),
913 })
914 }
915
916 fn to_args(&self) -> Result<ExtensionArgs, ExtensionError> {
917 let mut args = ExtensionArgs::default();
918 args.insert("tag", self.tag.clone());
919 args.output_columns.push(ExtensionColumn::Named {
920 name: "val".to_string(),
921 r#type: parse_type("i64"),
922 });
923 Ok(args)
924 }
925 }
926
927 fn make_extension_registry() -> ExtensionRegistry {
928 let mut registry = ExtensionRegistry::new();
929 registry.register_relation::<TestSource>().unwrap();
930 registry
931 }
932
933 const PLAN_WITH_CUSTOM_EXTENSION: &str = r#"=== Plan
934Root[val]
935 ExtensionLeaf:TestSource[tag='hello' => val:i64]
936"#;
937
938 #[test]
939 fn test_convert_text_to_text_with_extension_registry() {
940 let registry = make_extension_registry();
941 let input = Cursor::new(PLAN_WITH_CUSTOM_EXTENSION);
942 let mut output = Vec::new();
943
944 let cli = Cli {
945 command: Commands::Convert {
946 input: "input.substrait".to_string(),
947 output: "output.substrait".to_string(),
948 from: Some(Format::Text),
949 to: Some(Format::Text),
950 show_literal_types: false,
951 verbose: false,
952 },
953 };
954
955 cli.run_with_io(input, &mut output, ®istry).unwrap();
956
957 let output_content = String::from_utf8(output).unwrap();
958 assert_eq!(output_content, PLAN_WITH_CUSTOM_EXTENSION);
959 }
960
961 #[test]
962 fn test_convert_text_to_json_with_extension_registry() {
963 let registry = make_extension_registry();
964 let input = Cursor::new(PLAN_WITH_CUSTOM_EXTENSION);
965 let mut output = Vec::new();
966
967 let cli = Cli {
968 command: Commands::Convert {
969 input: "input.substrait".to_string(),
970 output: "output.json".to_string(),
971 from: Some(Format::Text),
972 to: Some(Format::Json),
973 show_literal_types: false,
974 verbose: false,
975 },
976 };
977
978 cli.run_with_io(input, &mut output, ®istry).unwrap();
979
980 let output_content = String::from_utf8(output).unwrap();
981 assert!(output_content.contains("\"extensionLeaf\""));
982 }
983
984 #[test]
985 fn test_validate_with_extension_registry() {
986 let registry = make_extension_registry();
987 let input = Cursor::new(PLAN_WITH_CUSTOM_EXTENSION);
988 let mut output = Vec::new();
989
990 let cli = Cli {
991 command: Commands::Validate {
992 input: String::new(),
993 output: String::new(),
994 verbose: false,
995 },
996 };
997
998 cli.run_with_io(input, &mut output, ®istry).unwrap();
999
1000 let output_content = String::from_utf8(output).unwrap();
1001 assert_eq!(output_content, PLAN_WITH_CUSTOM_EXTENSION);
1002 }
1003
1004 #[test]
1005 fn test_convert_text_fails_without_extension_registry() {
1006 let input = Cursor::new(PLAN_WITH_CUSTOM_EXTENSION);
1008 let mut output = Vec::new();
1009
1010 let cli = Cli {
1011 command: Commands::Convert {
1012 input: "input.substrait".to_string(),
1013 output: "output.substrait".to_string(),
1014 from: Some(Format::Text),
1015 to: Some(Format::Text),
1016 show_literal_types: false,
1017 verbose: false,
1018 },
1019 };
1020
1021 let result = cli.run_with_io(input, &mut output, &ExtensionRegistry::default());
1022 assert!(result.is_err());
1023 }
1024
1025 fn make_plan_with_invalid_function_ref() -> substrait::proto::Plan {
1027 const VALID_PLAN: &str = r#"=== Extensions
1028URNs:
1029 @ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_comparison.yaml
1030Functions:
1031 # 10 @ 1: equal
1032
1033=== Plan
1034Root[result]
1035 Filter[equal($0, 42:i32):boolean => $0]
1036 Read[data => a:i32]
1037"#;
1038
1039 let mut plan = parse(VALID_PLAN).expect("Failed to parse valid plan");
1040
1041 let rel_root = plan.relations.first_mut().unwrap();
1043 let plan_rel::RelType::Root(root) = rel_root.rel_type.as_mut().unwrap() else {
1044 panic!("Expected Root relation");
1045 };
1046 let rel = root.input.as_mut().unwrap();
1047 let RelType::Filter(filter) = rel.rel_type.as_mut().unwrap() else {
1048 panic!("Expected Filter relation");
1049 };
1050 let condition = filter.condition.as_mut().unwrap();
1051 let RexType::ScalarFunction(func) = condition.rex_type.as_mut().unwrap() else {
1052 panic!("Expected ScalarFunction");
1053 };
1054 func.function_reference = 999; plan
1057 }
1058
1059 #[test]
1060 fn test_write_plan_reports_formatting_issues() {
1061 let plan = make_plan_with_invalid_function_ref();
1062 let mut output = Vec::new();
1063
1064 let result = Format::Text.write_plan(
1065 &mut output,
1066 &plan,
1067 &OutputOptions::default(),
1068 &ExtensionRegistry::default(),
1069 );
1070
1071 let outcome = result.expect("write_plan should not return hard error");
1073 assert!(
1074 matches!(outcome, Outcome::HadFormattingIssues(ref errors) if !errors.is_empty()),
1075 "Expected HadFormattingIssues with errors, got {outcome:?}"
1076 );
1077 assert!(
1079 !output.is_empty(),
1080 "Output should be written even with issues"
1081 );
1082 }
1083}