@@ -37,6 +37,7 @@ import (
3737 "sigs.k8s.io/yaml"
3838
3939 "github.com/conforma/cli/internal/applicationsnapshot"
40+ "github.com/conforma/cli/internal/format"
4041 "github.com/conforma/cli/internal/image"
4142 "github.com/conforma/cli/internal/output"
4243 "github.com/conforma/cli/internal/policy"
@@ -215,7 +216,14 @@ func addVSAFlags(cmd *cobra.Command, data *validateVSAData) {
215216 cmd .Flags ().BoolVar (& data .noFallback , "no-fallback" , false , "Disable fallback to image validation when VSA validation fails (fallback is enabled by default)" )
216217 cmd .Flags ().StringVar (& data .fallbackPublicKey , "fallback-public-key" , "" , "Public key to use for fallback image validation (different from VSA verification key)" )
217218 // Output options
218- cmd .Flags ().StringSliceVar (& data .output , "output" , []string {}, "Output formats (e.g., json, yaml, text)" )
219+ validOutputFormats := []string {"json" , "yaml" , "text" }
220+ cmd .Flags ().StringSliceVar (& data .output , "output" , []string {}, hd .Doc (`
221+ write output to a file in a specific format. Use empty string path for stdout.
222+ May be used multiple times. Possible formats are:
223+ ` + strings .Join (validOutputFormats , ", " )+ `. In following format and file path
224+ additional options can be provided in key=value form following the question
225+ mark (?) sign, for example: --output text=output.txt?show-successes=false
226+ ` ))
219227 cmd .Flags ().BoolVar (& data .strict , "strict" , DefaultStrictMode , "Exit with non-zero code if validation fails" )
220228
221229 // Parallel processing options
@@ -600,6 +608,16 @@ type VSASectionsReport struct {
600608 PolicyDiff * PolicyDiffReport `json:"policy_diff,omitempty" yaml:"policy_diff,omitempty"`
601609}
602610
611+ // VSAReport holds the combined VSA sections and fallback report
612+ // VSA sections are at the root level, with fallback as an additional field
613+ type VSAReport struct {
614+ Header HeaderReport `json:"header" yaml:"header"`
615+ Result ResultReport `json:"result" yaml:"result"`
616+ VSASummary VSASummaryReport `json:"vsa_summary" yaml:"vsa_summary"`
617+ PolicyDiff * PolicyDiffReport `json:"policy_diff,omitempty" yaml:"policy_diff,omitempty"`
618+ Fallback * applicationsnapshot.Report `json:"fallback,omitempty" yaml:"fallback,omitempty"`
619+ }
620+
603621// HeaderReport is the serializable version of HeaderDisplay
604622type HeaderReport struct {
605623 Title string `json:"title" yaml:"title"`
@@ -1073,6 +1091,31 @@ func buildFallbackReportData(fallbackResults []validate_utils.Result, vsaData *v
10731091 }, nil
10741092}
10751093
1094+ // createFallbackReport creates a fallback report without writing it
1095+ // This is used when we need to combine VSA and fallback output into a single file
1096+ func createFallbackReport (allData AllSectionsData , vsaData * validateVSAData ) (* applicationsnapshot.Report , error ) {
1097+ reportData , err := buildFallbackReportData (allData .FallbackResults , vsaData )
1098+ if err != nil {
1099+ return nil , err
1100+ }
1101+
1102+ // Create the report without writing it (same logic as WriteReport but without the WriteAll call)
1103+ report , err := applicationsnapshot .NewReport (
1104+ reportData .Snapshot ,
1105+ reportData .Components ,
1106+ reportData .Policy ,
1107+ reportData .PolicyInputs ,
1108+ reportData .ShowSuccesses ,
1109+ reportData .ShowWarnings ,
1110+ reportData .Expansion ,
1111+ )
1112+ if err != nil {
1113+ return nil , err
1114+ }
1115+
1116+ return & report , nil
1117+ }
1118+
10761119// displayFallbackImageSection - Displays fallback validate image output using WriteReport
10771120func displayFallbackImageSection (allData AllSectionsData , vsaData * validateVSAData , cmd * cobra.Command ) error {
10781121 // Only print header for text output format (or when no format is specified, which defaults to text)
@@ -1194,42 +1237,131 @@ func (d ComponentResultsDisplay) WriteAll(outputFormats []string, fs afero.Fs, c
11941237
11951238 for _ , outputSpec := range outputFormats {
11961239 parts := strings .SplitN (outputSpec , "=" , 2 )
1197- format := parts [0 ]
1240+ formatName := parts [0 ]
11981241 file := ""
11991242 if len (parts ) > 1 {
12001243 file = parts [1 ]
12011244 }
12021245
1203- data , err := d .toFormat (format )
1246+ data , err := d .toFormat (formatName )
12041247 if err != nil {
1205- return fmt .Errorf ("failed to convert to %s: %w" , format , err )
1248+ return fmt .Errorf ("failed to convert to %s: %w" , formatName , err )
1249+ }
1250+
1251+ // Use shared file writing logic
1252+ if err := writeDataToOutput (data , formatName , file , fs , cmd ); err != nil {
1253+ return err
1254+ }
1255+ }
1256+
1257+ return nil
1258+ }
1259+
1260+ // writeVSAReport writes a combined VSAReport to all specified output formats
1261+ // display is used for text format to avoid reconstructing ComponentResultsDisplay
1262+ func writeVSAReport (report VSAReport , display ComponentResultsDisplay , outputFormats []string , fs afero.Fs , cmd * cobra.Command ) error {
1263+ if len (outputFormats ) == 0 {
1264+ outputFormats = []string {"text" }
1265+ }
1266+
1267+ for _ , outputSpec := range outputFormats {
1268+ parts := strings .SplitN (outputSpec , "=" , 2 )
1269+ formatName := parts [0 ]
1270+ file := ""
1271+ if len (parts ) > 1 {
1272+ file = parts [1 ]
12061273 }
12071274
1208- var writer io.Writer = cmd .OutOrStdout ()
1209- if file != "" {
1210- f , err := fs .Create (file )
1275+ var data []byte
1276+ var err error
1277+
1278+ switch formatName {
1279+ case "json" :
1280+ data , err = json .MarshalIndent (& report , "" , " " )
1281+ if err != nil {
1282+ return fmt .Errorf ("failed to marshal VSA report to JSON: %w" , err )
1283+ }
1284+ case "yaml" :
1285+ data , err = yaml .Marshal (& report )
12111286 if err != nil {
1212- return fmt .Errorf ("failed to create output file %s: %w" , file , err )
1287+ return fmt .Errorf ("failed to marshal VSA report to YAML: %w" , err )
1288+ }
1289+ case "text" :
1290+ // For text format, write VSA sections first, then fallback if present
1291+ var b strings.Builder
1292+ b .Write (display .toText ())
1293+
1294+ // Write fallback section if present
1295+ if report .Fallback != nil {
1296+ b .WriteString ("\n === FALLBACK: VALIDATE IMAGE ===\n " )
1297+ // Capture fallback text output using WriteAll with a string writer
1298+ formatOpts := format.Options {
1299+ ShowSuccesses : report .Fallback .ShowSuccesses ,
1300+ ShowWarnings : report .Fallback .ShowWarnings ,
1301+ }
1302+ var fallbackBuf strings.Builder
1303+ fallbackWriter := & stringWriter {& fallbackBuf }
1304+ fallbackParser := format .NewTargetParser (
1305+ applicationsnapshot .Text ,
1306+ formatOpts ,
1307+ fallbackWriter ,
1308+ fs ,
1309+ )
1310+ if err := report .Fallback .WriteAll ([]string {"text" }, fallbackParser ); err != nil {
1311+ return fmt .Errorf ("failed to write fallback text: %w" , err )
1312+ }
1313+ b .WriteString (fallbackBuf .String ())
12131314 }
1214- defer f .Close ()
1215- writer = f
1315+ data = []byte (b .String ())
1316+ default :
1317+ return fmt .Errorf ("unsupported format: %s (supported: json, yaml, text)" , formatName )
12161318 }
12171319
1218- if _ , err := writer .Write (data ); err != nil {
1219- return fmt .Errorf ("failed to write %s output: %w" , format , err )
1320+ // Use shared file writing logic
1321+ if err := writeDataToOutput (data , formatName , file , fs , cmd ); err != nil {
1322+ return err
12201323 }
1324+ }
12211325
1222- // Add newline if not present
1223- if ! strings .HasSuffix (string (data ), "\n " ) {
1224- if _ , err := writer .Write ([]byte ("\n " )); err != nil {
1225- return fmt .Errorf ("failed to write newline: %w" , err )
1226- }
1326+ return nil
1327+ }
1328+
1329+ // writeDataToOutput writes data to stdout or a file, adding a newline if needed
1330+ // This is shared between ComponentResultsDisplay.WriteAll and writeVSAReport
1331+ func writeDataToOutput (data []byte , formatName , file string , fs afero.Fs , cmd * cobra.Command ) error {
1332+ var writer io.Writer = cmd .OutOrStdout ()
1333+ if file != "" {
1334+ f , err := fs .Create (file )
1335+ if err != nil {
1336+ return fmt .Errorf ("failed to create output file %s: %w" , file , err )
1337+ }
1338+ defer f .Close ()
1339+ writer = f
1340+ }
1341+
1342+ if _ , err := writer .Write (data ); err != nil {
1343+ return fmt .Errorf ("failed to write %s output: %w" , formatName , err )
1344+ }
1345+
1346+ // Add newline if not present
1347+ if ! strings .HasSuffix (string (data ), "\n " ) {
1348+ if _ , err := writer .Write ([]byte ("\n " )); err != nil {
1349+ return fmt .Errorf ("failed to write newline: %w" , err )
12271350 }
12281351 }
12291352
12301353 return nil
12311354}
12321355
1356+ // stringWriter is a helper to capture text output from WriteAll
1357+ type stringWriter struct {
1358+ buf * strings.Builder
1359+ }
1360+
1361+ func (w * stringWriter ) Write (p []byte ) (n int , err error ) {
1362+ return w .buf .Write (p )
1363+ }
1364+
12331365// displayComponentResults displays the validation results for each component
12341366// Uses the new modular section-based approach
12351367// Supports yaml, json, and text output formats based on the output flag
@@ -1249,36 +1381,65 @@ func displayComponentResults(allResults []vsa.ComponentResult, data *validateVSA
12491381 // Filter output formats to only json, yaml, text (exclude other formats like appstudio, etc.)
12501382 vsaOutputFormats := filterVSAOutputFormats (data .output )
12511383
1252- if len (vsaOutputFormats ) > 0 {
1253- // Use format system for structured output (json/yaml/text)
1384+ // Check if we need combined output (file output specified AND fallback will be used)
1385+ hasFileOutput := hasFileOutputInFormats (vsaOutputFormats )
1386+
1387+ // If file output is specified and fallback will be used, combine them
1388+ if hasFileOutput && allData .FallbackUsed {
1389+ // Create fallback report without writing
1390+ fallbackReport , err := createFallbackReport (allData , data )
1391+ if err != nil {
1392+ return fmt .Errorf ("failed to create fallback report: %w" , err )
1393+ }
1394+
1395+ // Build combined VSAReport (call toVSASectionsReport once and reuse)
1396+ vsaSections := display .toVSASectionsReport ()
1397+ vsaReport := VSAReport {
1398+ Header : vsaSections .Header ,
1399+ Result : vsaSections .Result ,
1400+ VSASummary : vsaSections .VSASummary ,
1401+ PolicyDiff : vsaSections .PolicyDiff ,
1402+ Fallback : fallbackReport ,
1403+ }
1404+
1405+ // Write combined report to file(s)
12541406 fs := utils .FS (cmd .Context ())
1255- if err := display . WriteAll ( vsaOutputFormats , fs , cmd ); err != nil {
1256- return fmt .Errorf ("failed to write VSA sections output : %w" , err )
1407+ if err := writeVSAReport ( vsaReport , display , vsaOutputFormats , fs , cmd ); err != nil {
1408+ return fmt .Errorf ("failed to write combined VSA report : %w" , err )
12571409 }
12581410 } else {
1259- // Default: display as text to stdout using existing String() methods
1260- // This reuses the same formatting logic as the structured output
1261- fmt .Print (display .Header .String ())
1262- fmt .Println ()
1263-
1264- fmt .Print (display .Result .String ())
1265- fmt .Println ()
1411+ // Use separate output (current behavior)
1412+ if len (vsaOutputFormats ) > 0 {
1413+ // Use format system for structured output (json/yaml/text)
1414+ fs := utils .FS (cmd .Context ())
1415+ if err := display .WriteAll (vsaOutputFormats , fs , cmd ); err != nil {
1416+ return fmt .Errorf ("failed to write VSA sections output: %w" , err )
1417+ }
1418+ } else {
1419+ // Default: display as text to stdout using existing String() methods
1420+ // This reuses the same formatting logic as the structured output
1421+ fmt .Print (display .Header .String ())
1422+ fmt .Println ()
12661423
1267- fmt .Print (display .VSASummary .String ())
1268- fmt .Println ()
1424+ fmt .Print (display .Result .String ())
1425+ fmt .Println ()
12691426
1270- // Conditional sections
1271- if display .PolicyDiff != nil {
1272- fmt .Print (display .PolicyDiff .String ())
1427+ fmt .Print (display .VSASummary .String ())
12731428 fmt .Println ()
1429+
1430+ // Conditional sections
1431+ if display .PolicyDiff != nil {
1432+ fmt .Print (display .PolicyDiff .String ())
1433+ fmt .Println ()
1434+ }
12741435 }
1275- }
12761436
1277- if allData .FallbackUsed {
1278- if err := displayFallbackImageSection (allData , data , cmd ); err != nil {
1279- return err
1437+ if allData .FallbackUsed {
1438+ if err := displayFallbackImageSection (allData , data , cmd ); err != nil {
1439+ return err
1440+ }
1441+ fmt .Println ()
12801442 }
1281- fmt .Println ()
12821443 }
12831444
12841445 return nil
@@ -1298,6 +1459,16 @@ func filterVSAOutputFormats(outputFormats []string) []string {
12981459 return filtered
12991460}
13001461
1462+ // hasFileOutputInFormats checks if any format in the list specifies a file path
1463+ func hasFileOutputInFormats (outputFormats []string ) bool {
1464+ for _ , format := range outputFormats {
1465+ if strings .Contains (format , "=" ) {
1466+ return true
1467+ }
1468+ }
1469+ return false
1470+ }
1471+
13011472// isFailureResult determines if a result represents a failure
13021473func isFailureResult (result vsa.ComponentResult ) bool {
13031474 resultType := classifyResult (result )
0 commit comments