substrait_explain/
cli.rs

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/// The outcome of a CLI operation.
12///
13/// Distinguishes between complete success and "soft failures" like formatting
14/// issues where output was still written but there were problems.
15#[derive(Debug)]
16pub enum Outcome {
17    /// Operation completed successfully with no issues.
18    Success,
19    /// Output was written, but there were formatting issues.
20    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    /// Run the CLI and return an exit code.
34    ///
35    /// Errors are printed to stderr.
36    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    /// Run CLI with provided readers and writers for testing
97    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        // Read input based on format
183        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        // Write output based on format
191        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        // Parse text to protobuf
214        let plan =
215            parse(&input_text).with_context(|| "Failed to parse input as Substrait text format")?;
216
217        // Format back to text
218        let (output_text, errors) = format(&plan);
219
220        // Write output first (best-effort)
221        write_text_output(writer, &output_text)?;
222
223        if verbose && errors.is_empty() {
224            eprintln!("Successfully validated plan");
225        }
226
227        // Return outcome based on whether there were formatting issues
228        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 between different Substrait plan formats
239    ///
240    /// Format auto-detection:
241    ///   If -f/--from or -t/--to are not specified, formats will be auto-detected
242    ///   from file extensions:
243    ///     .substrait, .txt    -> text format
244    ///     .json               -> json format
245    ///     .yaml, .yml         -> yaml format
246    ///     .pb, .proto, .protobuf -> protobuf format
247    ///
248    /// Plan formats:
249    ///   text     - Human-readable Substrait text format
250    ///   json     - JSON serialized protobuf
251    ///   yaml     - YAML serialized protobuf
252    ///   protobuf - Binary protobuf format
253    Convert {
254        /// Input file (use - for stdin)
255        #[arg(short, long, default_value = "-")]
256        input: String,
257        /// Output file (use - for stdout)
258        #[arg(short, long, default_value = "-")]
259        output: String,
260        /// Input format: text, json, yaml, protobuf/proto/pb (auto-detected from file extension if not specified)
261        #[arg(short = 'f', long)]
262        from: Option<Format>,
263        /// Output format: text, json, yaml, protobuf/proto/pb (auto-detected from file extension if not specified)
264        #[arg(short = 't', long)]
265        to: Option<Format>,
266        /// Show literal types (text output only)
267        #[arg(long)]
268        show_literal_types: bool,
269        /// Show expression types (text output only)
270        #[arg(long)]
271        show_expression_types: bool,
272        /// Verbose output
273        #[arg(short, long)]
274        verbose: bool,
275    },
276    /// Validate text format by parsing and formatting (roundtrip test)
277    Validate {
278        /// Input file (use - for stdin)
279        #[arg(short, long, default_value = "-")]
280        input: String,
281        /// Output file (use - for stdout)
282        #[arg(short, long, default_value = "-")]
283        output: String,
284        /// Verbose output
285        #[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    /// Detect format from file extension
316    pub fn from_extension(path: &str) -> Option<Format> {
317        if path == "-" {
318            return None; // stdin/stdout - no extension
319        }
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 output first (best-effort)
381                write_text_output(writer, &text)?;
382
383                // Return outcome based on whether there were formatting issues
384                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
423/// Read text input from reader
424fn 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
430/// Read binary input from reader
431fn 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
437/// Write text output to writer
438fn write_text_output<W: Write>(mut writer: W, content: &str) -> Result<()> {
439    writer.write_all(content.as_bytes())?;
440    Ok(())
441}
442
443/// Write binary output to writer
444fn write_binary_output<W: Write>(mut writer: W, content: &[u8]) -> Result<()> {
445    writer.write_all(content)?;
446    Ok(())
447}
448
449/// Helper function to get reader from file path (or stdin if "-")
450fn 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
458/// Helper function to get writer from file path (or stdout if "-")
459fn 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        // First convert text to JSON
551        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        // Now convert JSON back to text
569        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        // Protobuf output should be binary, so we just check that it's not empty
613        assert!(!output.is_empty());
614
615        // Should not contain readable text
616        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        // Test auto-detection of text format
691        assert_eq!(Format::from_extension("plan.substrait"), Some(Format::Text));
692        assert_eq!(Format::from_extension("plan.txt"), Some(Format::Text));
693
694        // Test auto-detection of JSON format
695        assert_eq!(Format::from_extension("plan.json"), Some(Format::Json));
696
697        // Test auto-detection of YAML format
698        assert_eq!(Format::from_extension("plan.yaml"), Some(Format::Yaml));
699        assert_eq!(Format::from_extension("plan.yml"), Some(Format::Yaml));
700
701        // Test auto-detection of protobuf format
702        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        // Test unknown extensions
710        assert_eq!(Format::from_extension("plan.unknown"), None);
711        assert_eq!(Format::from_extension("plan"), None);
712
713        // Test stdin/stdout
714        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, // Auto-detect from extension
727                to: None,   // Auto-detect from extension
728                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, // Should fail auto-detection
753                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, // Should fail auto-detection
781                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(), // Would auto-detect as JSON
805                output: "output.pb".to_string(), // Would auto-detect as Protobuf
806                from: Some(Format::Text),        // Explicit override
807                to: Some(Format::Text),          // Explicit override
808                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        // Convert text to protobuf
824        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        // Convert protobuf back to text
844        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    /// Creates a plan with an invalid function reference that will cause formatting errors.
870    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        // Navigate to the function and corrupt its reference
886        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; // Invalid - doesn't exist in extensions
899
900        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        // Should succeed but report formatting issues
911        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        // Output should still be written (best-effort formatting)
917        assert!(
918            !output.is_empty(),
919            "Output should be written even with issues"
920        );
921    }
922}