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::{FormatError, OutputOptions, Visibility, format, format_with_options, parse};
10
11#[derive(Debug)]
16pub enum Outcome {
17 Success,
19 HadFormattingIssues(Vec<FormatError>),
21}
22
23#[derive(Parser)]
24#[command(name = "substrait-explain")]
25#[command(about = "A CLI for parsing and formatting Substrait query plans")]
26#[command(version)]
27pub struct Cli {
28 #[command(subcommand)]
29 pub command: Commands,
30}
31
32impl Cli {
33 pub fn run(self) -> ExitCode {
37 match self.run_inner() {
38 Ok(Outcome::Success) => ExitCode::SUCCESS,
39 Ok(Outcome::HadFormattingIssues(errors)) => {
40 eprintln!("Formatting issues:");
41 for error in errors {
42 eprintln!(" {error}");
43 }
44 ExitCode::FAILURE
45 }
46 Err(e) => {
47 eprintln!("Error: {e:?}");
48 ExitCode::FAILURE
49 }
50 }
51 }
52
53 fn run_inner(self) -> Result<Outcome> {
54 match &self.command {
55 Commands::Convert {
56 input,
57 output,
58 from,
59 to,
60 show_literal_types,
61 show_expression_types,
62 verbose,
63 } => {
64 let reader = get_reader(input)
65 .with_context(|| format!("Failed to open input file: {input}"))?;
66 let writer = get_writer(output)
67 .with_context(|| format!("Failed to create output file: {output}"))?;
68 let options =
69 self.create_output_options(*show_literal_types, *show_expression_types);
70 let from_format = self.resolve_input_format(from, input)?;
71 let to_format = self.resolve_output_format(to, output)?;
72 self.run_convert_with_io(
73 reader,
74 writer,
75 &from_format,
76 &to_format,
77 &options,
78 *verbose,
79 )
80 }
81
82 Commands::Validate {
83 input,
84 output,
85 verbose,
86 } => {
87 let reader = get_reader(input)
88 .with_context(|| format!("Failed to open input file: {input}"))?;
89 let writer = get_writer(output)
90 .with_context(|| format!("Failed to create output file: {output}"))?;
91 self.run_validate_with_io(reader, writer, *verbose)
92 }
93 }
94 }
95
96 pub fn run_with_io<R: Read, W: Write>(&self, reader: R, writer: W) -> Result<Outcome> {
98 match &self.command {
99 Commands::Convert {
100 input,
101 output,
102 from,
103 to,
104 show_literal_types,
105 show_expression_types,
106 verbose,
107 ..
108 } => {
109 let options =
110 self.create_output_options(*show_literal_types, *show_expression_types);
111 let from_format = self.resolve_input_format(from, input)?;
112 let to_format = self.resolve_output_format(to, output)?;
113 self.run_convert_with_io(
114 reader,
115 writer,
116 &from_format,
117 &to_format,
118 &options,
119 *verbose,
120 )
121 }
122
123 Commands::Validate { verbose, .. } => {
124 self.run_validate_with_io(reader, writer, *verbose)
125 }
126 }
127 }
128
129 fn create_output_options(
130 &self,
131 show_literal_types: bool,
132 show_expression_types: bool,
133 ) -> OutputOptions {
134 let mut options = OutputOptions::default();
135
136 if show_literal_types {
137 options.literal_types = Visibility::Always;
138 }
139
140 if show_expression_types {
141 options.fn_types = true;
142 }
143
144 options
145 }
146
147 fn resolve_input_format(&self, format: &Option<Format>, input_path: &str) -> Result<Format> {
148 match format {
149 Some(fmt) => Ok(fmt.clone()),
150 None => Format::from_extension(input_path).ok_or_else(|| {
151 anyhow::anyhow!(
152 "Could not auto-detect input format from file extension. \
153 Please specify format explicitly with -f/--from. \
154 Supported formats: text, json, yaml, protobuf/proto/pb"
155 )
156 }),
157 }
158 }
159
160 fn resolve_output_format(&self, format: &Option<Format>, output_path: &str) -> Result<Format> {
161 match format {
162 Some(fmt) => Ok(fmt.clone()),
163 None => Format::from_extension(output_path).ok_or_else(|| {
164 anyhow::anyhow!(
165 "Could not auto-detect output format from file extension. \
166 Please specify format explicitly with -t/--to. \
167 Supported formats: text, json, yaml, protobuf/proto/pb"
168 )
169 }),
170 }
171 }
172
173 fn run_convert_with_io<R: Read, W: Write>(
174 &self,
175 reader: R,
176 writer: W,
177 from: &Format,
178 to: &Format,
179 options: &OutputOptions,
180 verbose: bool,
181 ) -> Result<Outcome> {
182 let plan = from.read_plan(reader).with_context(|| {
184 format!(
185 "Failed to parse input as {} format",
186 format!("{from:?}").to_lowercase()
187 )
188 })?;
189
190 let outcome = to.write_plan(writer, &plan, options).with_context(|| {
192 format!(
193 "Failed to write output as {} format",
194 format!("{to:?}").to_lowercase()
195 )
196 })?;
197
198 if verbose && matches!(outcome, Outcome::Success) {
199 eprintln!("Successfully converted from {from:?} to {to:?}");
200 }
201
202 Ok(outcome)
203 }
204
205 fn run_validate_with_io<R: Read, W: Write>(
206 &self,
207 reader: R,
208 writer: W,
209 verbose: bool,
210 ) -> Result<Outcome> {
211 let input_text = read_text_input(reader)?;
212
213 let plan =
215 parse(&input_text).with_context(|| "Failed to parse input as Substrait text format")?;
216
217 let (output_text, errors) = format(&plan);
219
220 write_text_output(writer, &output_text)?;
222
223 if verbose && errors.is_empty() {
224 eprintln!("Successfully validated plan");
225 }
226
227 if errors.is_empty() {
229 Ok(Outcome::Success)
230 } else {
231 Ok(Outcome::HadFormattingIssues(errors))
232 }
233 }
234}
235
236#[derive(Subcommand)]
237pub enum Commands {
238 Convert {
254 #[arg(short, long, default_value = "-")]
256 input: String,
257 #[arg(short, long, default_value = "-")]
259 output: String,
260 #[arg(short = 'f', long)]
262 from: Option<Format>,
263 #[arg(short = 't', long)]
265 to: Option<Format>,
266 #[arg(long)]
268 show_literal_types: bool,
269 #[arg(long)]
271 show_expression_types: bool,
272 #[arg(short, long)]
274 verbose: bool,
275 },
276 Validate {
278 #[arg(short, long, default_value = "-")]
280 input: String,
281 #[arg(short, long, default_value = "-")]
283 output: String,
284 #[arg(short, long)]
286 verbose: bool,
287 },
288}
289
290#[derive(Clone, Debug, PartialEq)]
291pub enum Format {
292 Text,
293 Json,
294 Yaml,
295 Protobuf,
296}
297
298impl std::str::FromStr for Format {
299 type Err = String;
300
301 fn from_str(s: &str) -> Result<Self, Self::Err> {
302 match s.to_lowercase().as_str() {
303 "text" => Ok(Format::Text),
304 "json" => Ok(Format::Json),
305 "yaml" => Ok(Format::Yaml),
306 "protobuf" | "proto" | "pb" => Ok(Format::Protobuf),
307 _ => Err(format!(
308 "Invalid format: '{s}'. Supported formats: text, json, yaml, protobuf/proto/pb"
309 )),
310 }
311 }
312}
313
314impl Format {
315 pub fn from_extension(path: &str) -> Option<Format> {
317 if path == "-" {
318 return None; }
320
321 let extension = std::path::Path::new(path)
322 .extension()
323 .and_then(|ext| ext.to_str())
324 .map(|ext| ext.to_lowercase());
325
326 match extension.as_deref() {
327 Some("substrait") | Some("txt") => Some(Format::Text),
328 Some("json") => Some(Format::Json),
329 Some("yaml") | Some("yml") => Some(Format::Yaml),
330 Some("pb") | Some("proto") | Some("protobuf") => Some(Format::Protobuf),
331 _ => None,
332 }
333 }
334
335 pub fn read_plan<R: Read>(&self, reader: R) -> Result<substrait::proto::Plan> {
336 match self {
337 Format::Text => {
338 let input_text = read_text_input(reader)?;
339 Ok(parse(&input_text)?)
340 }
341 Format::Json => {
342 #[cfg(feature = "serde")]
343 {
344 let input_text = read_text_input(reader)?;
345 Ok(serde_json::from_str(&input_text)?)
346 }
347 #[cfg(not(feature = "serde"))]
348 {
349 Err("JSON support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
350 }
351 }
352 Format::Yaml => {
353 #[cfg(feature = "serde")]
354 {
355 let input_text = read_text_input(reader)?;
356 Ok(serde_yaml::from_str(&input_text)?)
357 }
358 #[cfg(not(feature = "serde"))]
359 {
360 Err("YAML support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
361 }
362 }
363 Format::Protobuf => {
364 let input_bytes = read_binary_input(reader)?;
365 Ok(substrait::proto::Plan::decode(&input_bytes[..])?)
366 }
367 }
368 }
369
370 pub fn write_plan<W: Write>(
371 &self,
372 writer: W,
373 plan: &substrait::proto::Plan,
374 options: &OutputOptions,
375 ) -> Result<Outcome> {
376 match self {
377 Format::Text => {
378 let (text, errors) = format_with_options(plan, options);
379
380 write_text_output(writer, &text)?;
382
383 if errors.is_empty() {
385 Ok(Outcome::Success)
386 } else {
387 Ok(Outcome::HadFormattingIssues(errors))
388 }
389 }
390 Format::Json => {
391 #[cfg(feature = "serde")]
392 {
393 let json = serde_json::to_string_pretty(plan)?;
394 write_text_output(writer, &json)?;
395 Ok(Outcome::Success)
396 }
397 #[cfg(not(feature = "serde"))]
398 {
399 Err("JSON support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
400 }
401 }
402 Format::Yaml => {
403 #[cfg(feature = "serde")]
404 {
405 let yaml = serde_yaml::to_string(plan)?;
406 write_text_output(writer, &yaml)?;
407 Ok(Outcome::Success)
408 }
409 #[cfg(not(feature = "serde"))]
410 {
411 Err("YAML support requires the 'serde' feature. Install with: cargo install substrait-explain --features cli,serde".into())
412 }
413 }
414 Format::Protobuf => {
415 let bytes = plan.encode_to_vec();
416 write_binary_output(writer, &bytes)?;
417 Ok(Outcome::Success)
418 }
419 }
420 }
421}
422
423fn read_text_input<R: Read>(mut reader: R) -> Result<String> {
425 let mut buffer = String::new();
426 reader.read_to_string(&mut buffer)?;
427 Ok(buffer)
428}
429
430fn read_binary_input<R: Read>(mut reader: R) -> Result<Vec<u8>> {
432 let mut buffer = Vec::new();
433 reader.read_to_end(&mut buffer)?;
434 Ok(buffer)
435}
436
437fn write_text_output<W: Write>(mut writer: W, content: &str) -> Result<()> {
439 writer.write_all(content.as_bytes())?;
440 Ok(())
441}
442
443fn write_binary_output<W: Write>(mut writer: W, content: &[u8]) -> Result<()> {
445 writer.write_all(content)?;
446 Ok(())
447}
448
449fn get_reader(path: &str) -> Result<Box<dyn Read>> {
451 if path == "-" {
452 Ok(Box::new(io::stdin()))
453 } else {
454 Ok(Box::new(fs::File::open(path)?))
455 }
456}
457
458fn get_writer(path: &str) -> Result<Box<dyn Write>> {
460 if path == "-" {
461 Ok(Box::new(io::stdout()))
462 } else {
463 Ok(Box::new(fs::File::create(path)?))
464 }
465}
466
467#[cfg(test)]
468mod tests {
469 use std::io::Cursor;
470
471 use substrait::proto::expression::RexType;
472 use substrait::proto::plan_rel;
473 use substrait::proto::rel::RelType;
474
475 use super::*;
476
477 const BASIC_PLAN: &str = r#"=== Plan
478Root[result]
479 Project[$0, $1]
480 Read[data => a:i64, b:string]
481"#;
482
483 const PLAN_WITH_EXTENSIONS: &str = r#"=== Extensions
484URNs:
485 @ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_arithmetic.yaml
486Functions:
487 # 10 @ 1: gt
488
489=== Plan
490Root[result]
491 Filter[gt($2, 100) => $0, $1, $2]
492 Project[$0, $1, $2]
493 Read[data => a:i64, b:string, c:i32]
494"#;
495
496 #[test]
497 fn test_convert_text_to_text() {
498 let input = Cursor::new(BASIC_PLAN);
499 let mut output = Vec::new();
500
501 let cli = Cli {
502 command: Commands::Convert {
503 input: "input.substrait".to_string(),
504 output: "output.substrait".to_string(),
505 from: Some(Format::Text),
506 to: Some(Format::Text),
507 show_literal_types: false,
508 show_expression_types: false,
509 verbose: false,
510 },
511 };
512
513 cli.run_with_io(input, &mut output).unwrap();
514
515 let output_content = String::from_utf8(output).unwrap();
516 assert!(output_content.contains("=== Plan"));
517 assert!(output_content.contains("Root[result]"));
518 assert!(output_content.contains("Project[$0, $1]"));
519 assert!(output_content.contains("Read[data => a:i64, b:string]"));
520 }
521
522 #[test]
523 fn test_convert_text_to_json() {
524 let input = Cursor::new(BASIC_PLAN);
525 let mut output = Vec::new();
526
527 let cli = Cli {
528 command: Commands::Convert {
529 input: "input.substrait".to_string(),
530 output: "output.json".to_string(),
531 from: Some(Format::Text),
532 to: Some(Format::Json),
533 show_literal_types: false,
534 show_expression_types: false,
535 verbose: false,
536 },
537 };
538
539 cli.run_with_io(input, &mut output).unwrap();
540
541 let output_content = String::from_utf8(output).unwrap();
542 assert!(output_content.contains("\"relations\""));
543 assert!(output_content.contains("\"root\""));
544 assert!(output_content.contains("\"project\""));
545 assert!(output_content.contains("\"read\""));
546 }
547
548 #[test]
549 fn test_convert_json_to_text() {
550 let input = Cursor::new(BASIC_PLAN);
552 let mut json_output = Vec::new();
553
554 let cli_to_json = Cli {
555 command: Commands::Convert {
556 input: "input.substrait".to_string(),
557 output: "output.json".to_string(),
558 from: Some(Format::Text),
559 to: Some(Format::Json),
560 show_literal_types: false,
561 show_expression_types: false,
562 verbose: false,
563 },
564 };
565
566 cli_to_json.run_with_io(input, &mut json_output).unwrap();
567
568 let json_input = Cursor::new(json_output);
570 let mut text_output = Vec::new();
571
572 let cli_to_text = Cli {
573 command: Commands::Convert {
574 input: "input.json".to_string(),
575 output: "output.substrait".to_string(),
576 from: Some(Format::Json),
577 to: Some(Format::Text),
578 show_literal_types: false,
579 show_expression_types: false,
580 verbose: false,
581 },
582 };
583
584 cli_to_text
585 .run_with_io(json_input, &mut text_output)
586 .unwrap();
587
588 let output_content = String::from_utf8(text_output).unwrap();
589 assert!(output_content.contains("=== Plan"));
590 assert!(output_content.contains("Root[result]"));
591 }
592
593 #[test]
594 fn test_convert_with_protobuf_output() {
595 let input = Cursor::new(BASIC_PLAN);
596 let mut output = Vec::new();
597
598 let cli = Cli {
599 command: Commands::Convert {
600 input: "input.substrait".to_string(),
601 output: "output.pb".to_string(),
602 from: Some(Format::Text),
603 to: Some(Format::Protobuf),
604 show_literal_types: false,
605 show_expression_types: false,
606 verbose: false,
607 },
608 };
609
610 cli.run_with_io(input, &mut output).unwrap();
611
612 assert!(!output.is_empty());
614
615 let output_string = String::from_utf8_lossy(&output);
617 assert!(!output_string.contains("=== Plan"));
618 }
619
620 #[test]
621 fn test_validate_command() {
622 let input = Cursor::new(BASIC_PLAN);
623 let mut output = Vec::new();
624
625 let cli = Cli {
626 command: Commands::Validate {
627 input: String::new(),
628 output: String::new(),
629 verbose: false,
630 },
631 };
632
633 cli.run_with_io(input, &mut output).unwrap();
634
635 let output_content = String::from_utf8(output).unwrap();
636 assert!(output_content.contains("=== Plan"));
637 assert!(output_content.contains("Root[result]"));
638 assert!(output_content.contains("Project[$0, $1]"));
639 assert!(output_content.contains("Read[data => a:i64, b:string]"));
640 }
641
642 #[test]
643 fn test_validate_with_extensions() {
644 let input = Cursor::new(PLAN_WITH_EXTENSIONS);
645 let mut output = Vec::new();
646
647 let cli = Cli {
648 command: Commands::Validate {
649 input: String::new(),
650 output: String::new(),
651 verbose: false,
652 },
653 };
654
655 cli.run_with_io(input, &mut output).unwrap();
656
657 let output_content = String::from_utf8(output).unwrap();
658 assert!(output_content.contains("=== Extensions"));
659 assert!(output_content.contains("=== Plan"));
660 assert!(output_content.contains("Root[result]"));
661 assert!(output_content.contains("Filter[gt($2, 100)"));
662 }
663
664 #[test]
665 fn test_convert_with_formatting_options() {
666 let input = Cursor::new(BASIC_PLAN);
667 let mut output = Vec::new();
668
669 let cli = Cli {
670 command: Commands::Convert {
671 input: "input.substrait".to_string(),
672 output: "output.substrait".to_string(),
673 from: Some(Format::Text),
674 to: Some(Format::Text),
675 show_literal_types: true,
676 show_expression_types: true,
677 verbose: false,
678 },
679 };
680
681 cli.run_with_io(input, &mut output).unwrap();
682
683 let output_content = String::from_utf8(output).unwrap();
684 assert!(output_content.contains("=== Plan"));
685 assert!(output_content.contains("Root[result]"));
686 }
687
688 #[test]
689 fn test_auto_detect_from_extension() {
690 assert_eq!(Format::from_extension("plan.substrait"), Some(Format::Text));
692 assert_eq!(Format::from_extension("plan.txt"), Some(Format::Text));
693
694 assert_eq!(Format::from_extension("plan.json"), Some(Format::Json));
696
697 assert_eq!(Format::from_extension("plan.yaml"), Some(Format::Yaml));
699 assert_eq!(Format::from_extension("plan.yml"), Some(Format::Yaml));
700
701 assert_eq!(Format::from_extension("plan.pb"), Some(Format::Protobuf));
703 assert_eq!(Format::from_extension("plan.proto"), Some(Format::Protobuf));
704 assert_eq!(
705 Format::from_extension("plan.protobuf"),
706 Some(Format::Protobuf)
707 );
708
709 assert_eq!(Format::from_extension("plan.unknown"), None);
711 assert_eq!(Format::from_extension("plan"), None);
712
713 assert_eq!(Format::from_extension("-"), None);
715 }
716
717 #[test]
718 fn test_convert_with_auto_detection() {
719 let input = Cursor::new(BASIC_PLAN);
720 let mut output = Vec::new();
721
722 let cli = Cli {
723 command: Commands::Convert {
724 input: "input.substrait".to_string(),
725 output: "output.json".to_string(),
726 from: None, to: None, show_literal_types: false,
729 show_expression_types: false,
730 verbose: false,
731 },
732 };
733
734 cli.run_with_io(input, &mut output).unwrap();
735
736 let output_content = String::from_utf8(output).unwrap();
737 assert!(output_content.contains("\"relations\""));
738 assert!(output_content.contains("\"root\""));
739 assert!(output_content.contains("\"project\""));
740 assert!(output_content.contains("\"read\""));
741 }
742
743 #[test]
744 fn test_auto_detection_error_unknown_input_extension() {
745 let input = Cursor::new(BASIC_PLAN);
746 let mut output = Vec::new();
747
748 let cli = Cli {
749 command: Commands::Convert {
750 input: "input.unknown".to_string(),
751 output: "output.json".to_string(),
752 from: None, to: None,
754 show_literal_types: false,
755 show_expression_types: false,
756 verbose: false,
757 },
758 };
759
760 let result = cli.run_with_io(input, &mut output);
761 assert!(result.is_err());
762 assert!(
763 result
764 .unwrap_err()
765 .to_string()
766 .contains("Could not auto-detect input format")
767 );
768 }
769
770 #[test]
771 fn test_auto_detection_error_unknown_output_extension() {
772 let input = Cursor::new(BASIC_PLAN);
773 let mut output = Vec::new();
774
775 let cli = Cli {
776 command: Commands::Convert {
777 input: "input.substrait".to_string(),
778 output: "output.unknown".to_string(),
779 from: None,
780 to: None, show_literal_types: false,
782 show_expression_types: false,
783 verbose: false,
784 },
785 };
786
787 let result = cli.run_with_io(input, &mut output);
788 assert!(result.is_err());
789 assert!(
790 result
791 .unwrap_err()
792 .to_string()
793 .contains("Could not auto-detect output format")
794 );
795 }
796
797 #[test]
798 fn test_explicit_format_overrides_auto_detection() {
799 let input = Cursor::new(BASIC_PLAN);
800 let mut output = Vec::new();
801
802 let cli = Cli {
803 command: Commands::Convert {
804 input: "input.json".to_string(), output: "output.pb".to_string(), from: Some(Format::Text), to: Some(Format::Text), show_literal_types: false,
809 show_expression_types: false,
810 verbose: false,
811 },
812 };
813
814 cli.run_with_io(input, &mut output).unwrap();
815
816 let output_content = String::from_utf8(output).unwrap();
817 assert!(output_content.contains("=== Plan"));
818 assert!(output_content.contains("Root[result]"));
819 }
820
821 #[test]
822 fn test_protobuf_roundtrip() {
823 let input = Cursor::new(BASIC_PLAN);
825 let mut protobuf_output = Vec::new();
826
827 let cli_to_protobuf = Cli {
828 command: Commands::Convert {
829 input: "input.substrait".to_string(),
830 output: "output.pb".to_string(),
831 from: Some(Format::Text),
832 to: Some(Format::Protobuf),
833 show_literal_types: false,
834 show_expression_types: false,
835 verbose: false,
836 },
837 };
838
839 cli_to_protobuf
840 .run_with_io(input, &mut protobuf_output)
841 .unwrap();
842
843 let protobuf_input = Cursor::new(protobuf_output);
845 let mut text_output = Vec::new();
846
847 let cli_to_text = Cli {
848 command: Commands::Convert {
849 input: "input.pb".to_string(),
850 output: "output.substrait".to_string(),
851 from: Some(Format::Protobuf),
852 to: Some(Format::Text),
853 show_literal_types: false,
854 show_expression_types: false,
855 verbose: false,
856 },
857 };
858
859 cli_to_text
860 .run_with_io(protobuf_input, &mut text_output)
861 .unwrap();
862
863 let output_content = String::from_utf8(text_output).unwrap();
864 assert!(output_content.contains("=== Plan"));
865 assert!(output_content.contains("Root[result]"));
866 assert!(output_content.contains("Read[data => a:i64, b:string]"));
867 }
868
869 fn make_plan_with_invalid_function_ref() -> substrait::proto::Plan {
871 const VALID_PLAN: &str = r#"=== Extensions
872URNs:
873 @ 1: https://github.com/substrait-io/substrait/blob/main/extensions/functions_comparison.yaml
874Functions:
875 # 10 @ 1: equal
876
877=== Plan
878Root[result]
879 Filter[equal($0, 42:i32) => $0]
880 Read[data => a:i32]
881"#;
882
883 let mut plan = parse(VALID_PLAN).expect("Failed to parse valid plan");
884
885 let rel_root = plan.relations.first_mut().unwrap();
887 let plan_rel::RelType::Root(root) = rel_root.rel_type.as_mut().unwrap() else {
888 panic!("Expected Root relation");
889 };
890 let rel = root.input.as_mut().unwrap();
891 let RelType::Filter(filter) = rel.rel_type.as_mut().unwrap() else {
892 panic!("Expected Filter relation");
893 };
894 let condition = filter.condition.as_mut().unwrap();
895 let RexType::ScalarFunction(func) = condition.rex_type.as_mut().unwrap() else {
896 panic!("Expected ScalarFunction");
897 };
898 func.function_reference = 999; plan
901 }
902
903 #[test]
904 fn test_write_plan_reports_formatting_issues() {
905 let plan = make_plan_with_invalid_function_ref();
906 let mut output = Vec::new();
907
908 let result = Format::Text.write_plan(&mut output, &plan, &OutputOptions::default());
909
910 let outcome = result.expect("write_plan should not return hard error");
912 assert!(
913 matches!(outcome, Outcome::HadFormattingIssues(ref errors) if !errors.is_empty()),
914 "Expected HadFormattingIssues with errors, got {outcome:?}"
915 );
916 assert!(
918 !output.is_empty(),
919 "Output should be written even with issues"
920 );
921 }
922}