2026-08-30 15:10:38 +00:00
package app
import (
"context"
"errors"
"flag"
"fmt"
"io"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
type configDiffFlags struct {
pipelinePath singletonStringFlag
campaignPath singletonStringFlag
campaignFilePath singletonStringFlag
2026-09-07 04:13:58 +00:00
secretsDir singletonStringFlag
2026-08-30 15:10:38 +00:00
}
type configDiffRequest struct {
2026-09-25 15:24:03 +00:00
leftOverlay string
rightOverlay string
2026-08-30 15:10:38 +00:00
flags configDiffFlags
}
type configDiffResolved struct {
left * inspectionConfig
right * inspectionConfig
}
type configDiffRecord struct {
Kind string
Path string
Left string
Right string
}
2026-09-25 15:24:03 +00:00
// ConfigDiff compares two explicitly selected overlays through the same
2026-08-30 15:10:38 +00:00
// read-only configuration resolution boundary used by config validate, show,
// and sources. Differences describe normalized effective values rather than
// the layout of root, import, or overlay files.
2026-09-07 04:13:58 +00:00
func ConfigDiff ( ctx context . Context , args [ ] string , out io . Writer ) error {
return configDiffWithDependencies ( ctx , args , out , commandDependencies { } )
}
func configDiffWithDependencies ( _ context . Context , args [ ] string , out io . Writer , deps commandDependencies ) error {
2026-08-30 15:10:38 +00:00
request , err := parseConfigDiffRequest ( args , out )
if err != nil {
if errors . Is ( err , flag . ErrHelp ) {
return nil
}
return err
}
2026-09-07 04:13:58 +00:00
resolved , err := resolveConfigDiff ( request , deps )
2026-08-30 15:10:38 +00:00
if err != nil {
return fmt . Errorf ( "config diff: %w" , err )
}
leftValues , err := config . EffectivePipelineValues ( resolved . left . Pipeline )
if err != nil {
return fmt . Errorf ( "config diff: project left effective configuration: %w" , err )
}
rightValues , err := config . EffectivePipelineValues ( resolved . right . Pipeline )
if err != nil {
return fmt . Errorf ( "config diff: project right effective configuration: %w" , err )
}
records , err := diffEffectivePipelineValues ( leftValues , rightValues )
if err != nil {
return fmt . Errorf ( "config diff: %w" , err )
}
leftDigest := config . EffectivePipelineDigest ( resolved . left . Pipeline )
rightDigest := config . EffectivePipelineDigest ( resolved . right . Pipeline )
if len ( records ) == 0 {
if leftDigest != rightDigest {
return fmt . Errorf ( "internal error: effective pipeline digests differ without a semantic difference" )
}
_ , err := fmt . Fprintln ( out , "no differences" )
return err
}
for _ , record := range records {
switch record . Kind {
case "added" :
if _ , err := fmt . Fprintf ( out , "added\t%s\t%s\n" , record . Path , record . Right ) ; err != nil {
return err
}
case "removed" :
if _ , err := fmt . Fprintf ( out , "removed\t%s\t%s\n" , record . Path , record . Left ) ; err != nil {
return err
}
case "changed" :
if _ , err := fmt . Fprintf ( out , "changed\t%s\t%s\t%s\n" , record . Path , record . Left , record . Right ) ; err != nil {
return err
}
default :
return fmt . Errorf ( "internal error: unsupported difference kind %q" , record . Kind )
}
}
return nil
}
func parseConfigDiffRequest ( args [ ] string , out io . Writer ) ( configDiffRequest , error ) {
2026-09-25 15:24:03 +00:00
overlays , flagArgs := splitConfigDiffArguments ( args )
2026-08-30 15:10:38 +00:00
fs := flag . NewFlagSet ( "config diff" , flag . ContinueOnError )
fs . SetOutput ( out )
var request configDiffRequest
request . flags . pipelinePath . name = "config"
request . flags . campaignPath . name = "campaign"
request . flags . campaignFilePath . name = "campaign-file"
2026-09-07 04:13:58 +00:00
request . flags . secretsDir . name = "secrets-dir"
2026-08-30 15:10:38 +00:00
fs . Var ( & request . flags . pipelinePath , "config" , "path to pipeline.yml (optional; defaults searched)" )
fs . Var ( & request . flags . campaignPath , "campaign" , "campaign ID" )
fs . Var ( & request . flags . campaignFilePath , "campaign-file" , "path to campaign.yml" )
2026-09-07 04:13:58 +00:00
fs . Var ( & request . flags . secretsDir , "secrets-dir" , "secrets directory (optional; defaults beside pipeline.yml)" )
2026-08-30 15:10:38 +00:00
fs . Usage = func ( ) {
2026-09-25 15:24:03 +00:00
fmt . Fprintln ( out , "Usage: narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]" )
2026-08-30 15:10:38 +00:00
fmt . Fprintln ( out )
2026-09-25 15:24:03 +00:00
fmt . Fprintln ( out , "Compares fully resolved overlays without a session, workspace, manifest, adapters, or external services." )
2026-08-30 15:10:38 +00:00
fmt . Fprintln ( out )
fmt . Fprintln ( out , "Flags:" )
fs . PrintDefaults ( )
}
if err := fs . Parse ( flagArgs ) ; err != nil {
return configDiffRequest { } , fmt . Errorf ( "config diff: invalid flags: %w" , err )
}
2026-09-25 15:24:03 +00:00
overlays = append ( overlays , fs . Args ( ) ... )
if len ( overlays ) != 2 || strings . TrimSpace ( overlays [ 0 ] ) == "" || strings . TrimSpace ( overlays [ 1 ] ) == "" {
return configDiffRequest { } , fmt . Errorf ( "config diff: exactly two non-empty overlay names are required" )
2026-08-30 15:10:38 +00:00
}
2026-09-25 15:24:03 +00:00
if overlays [ 0 ] == overlays [ 1 ] {
return configDiffRequest { } , fmt . Errorf ( "config diff: overlay names must differ" )
2026-09-07 04:13:58 +00:00
}
2026-08-30 15:10:38 +00:00
if request . flags . campaignPath . set && request . flags . campaignFilePath . set {
return configDiffRequest { } , fmt . Errorf ( "config diff: --campaign and --campaign-file are mutually exclusive" )
}
2026-09-25 15:24:03 +00:00
request . leftOverlay = overlays [ 0 ]
request . rightOverlay = overlays [ 1 ]
2026-08-30 15:10:38 +00:00
return request , nil
}
// splitConfigDiffArguments accepts the documented positional-first syntax and
2026-09-25 15:24:03 +00:00
// also permits flags before or between overlay names. Values belonging to the
2026-08-30 15:10:38 +00:00
// supported string flags stay with their flag so they are not mistaken for
2026-09-25 15:24:03 +00:00
// overlay names.
func splitConfigDiffArguments ( args [ ] string ) ( overlays , flagArgs [ ] string ) {
2026-08-30 15:10:38 +00:00
for index := 0 ; index < len ( args ) ; index ++ {
argument := args [ index ]
if ! strings . HasPrefix ( argument , "-" ) || argument == "-" {
2026-09-25 15:24:03 +00:00
overlays = append ( overlays , argument )
2026-08-30 15:10:38 +00:00
continue
}
flagArgs = append ( flagArgs , argument )
name , hasValue := strings . CutPrefix ( argument , "--" )
if ! hasValue {
continue
}
name , _ , hasInlineValue := strings . Cut ( name , "=" )
if hasInlineValue || ! configDiffStringFlag ( name ) || index + 1 >= len ( args ) {
continue
}
index ++
flagArgs = append ( flagArgs , args [ index ] )
}
2026-09-25 15:24:03 +00:00
return overlays , flagArgs
2026-08-30 15:10:38 +00:00
}
func configDiffStringFlag ( name string ) bool {
switch name {
2026-09-25 15:24:03 +00:00
case "config" , "campaign" , "campaign-file" , "secrets-dir" :
2026-08-30 15:10:38 +00:00
return true
default :
return false
}
}
2026-09-07 04:13:58 +00:00
func resolveConfigDiff ( request configDiffRequest , deps commandDependencies ) ( * configDiffResolved , error ) {
deps = normalizeCommandDependencies ( deps )
2026-09-25 15:24:03 +00:00
pipelinePath , leftPipeline , err := loadPipelineConfigWithDependencies ( request . flags . pipelinePath . value , config . PipelineLoadOptions { Overlay : & request . leftOverlay } , request . flags . secretsDir . pointer ( ) , deps )
2026-08-30 15:10:38 +00:00
if err != nil {
2026-09-25 15:24:03 +00:00
return nil , fmt . Errorf ( "load left pipeline overlay: %w" , err )
2026-08-30 15:10:38 +00:00
}
2026-09-25 15:24:03 +00:00
_ , rightPipeline , err := loadPipelineConfigWithDependencies ( request . flags . pipelinePath . value , config . PipelineLoadOptions { Overlay : & request . rightOverlay } , request . flags . secretsDir . pointer ( ) , deps )
2026-08-30 15:10:38 +00:00
if err != nil {
2026-09-25 15:24:03 +00:00
return nil , fmt . Errorf ( "load right pipeline overlay: %w" , err )
2026-08-30 15:10:38 +00:00
}
needsCampaign := pipelineHasArtifactFamilies ( leftPipeline ) || pipelineHasArtifactFamilies ( rightPipeline )
hasCampaignSelection := request . flags . campaignPath . set || request . flags . campaignFilePath . set
if ! needsCampaign && ! hasCampaignSelection {
if err := validateInspectionPipeline ( pipelinePath , leftPipeline ) ; err != nil {
return nil , err
}
if err := validateInspectionPipeline ( pipelinePath , rightPipeline ) ; err != nil {
return nil , err
}
return & configDiffResolved {
left : & inspectionConfig { PipelinePath : pipelinePath , Pipeline : leftPipeline } ,
right : & inspectionConfig { PipelinePath : pipelinePath , Pipeline : rightPipeline } ,
} , nil
}
campaignPath , err := resolveSharedDiffCampaignPath ( leftPipeline , rightPipeline , request . flags )
if err != nil {
if needsCampaign {
return nil , fmt . Errorf ( "pipeline.scriptorium.artifact_families requires one shared campaign: %w" , err )
}
return nil , err
}
campaign , err := config . LoadCampaign ( campaignPath )
if err != nil {
return nil , err
}
if request . flags . campaignPath . set && ! request . flags . campaignFilePath . set {
if got := config . CampaignID ( campaign ) ; got != request . flags . campaignPath . value {
return nil , fmt . Errorf ( "campaign config %q invalid: campaign_id %q does not match selected campaign %q" , campaignPath , got , request . flags . campaignPath . value )
}
}
leftLoaded , err := config . LoadPipelineCampaign ( pipelinePath , leftPipeline , campaignPath , campaign )
if err != nil {
return nil , err
}
rightLoaded , err := config . LoadPipelineCampaignWithParty ( pipelinePath , rightPipeline , campaignPath , campaign , leftLoaded . Party )
if err != nil {
return nil , err
}
if err := validateInspectionPipeline ( leftLoaded . PipelinePath , leftLoaded . Pipeline ) ; err != nil {
return nil , err
}
if err := validateInspectionPipeline ( rightLoaded . PipelinePath , rightLoaded . Pipeline ) ; err != nil {
return nil , err
}
if err := validateInspectionCampaign ( campaignPath , campaign ) ; err != nil {
return nil , err
}
return & configDiffResolved {
left : & inspectionConfig {
PipelinePath : leftLoaded . PipelinePath ,
Pipeline : leftLoaded . Pipeline ,
CampaignPath : leftLoaded . CampaignPath ,
Campaign : campaign ,
Party : leftLoaded . Party ,
} ,
right : & inspectionConfig {
PipelinePath : rightLoaded . PipelinePath ,
Pipeline : rightLoaded . Pipeline ,
CampaignPath : rightLoaded . CampaignPath ,
Campaign : campaign ,
Party : rightLoaded . Party ,
} ,
} , nil
}
func resolveSharedDiffCampaignPath ( left , right * config . PipelineConfig , flags configDiffFlags ) ( string , error ) {
leftPath , leftErr := resolveCampaignConfigPath ( left , flags . campaignPath . value , flags . campaignFilePath . value )
if leftErr != nil {
return "" , leftErr
}
rightPath , rightErr := resolveCampaignConfigPath ( right , flags . campaignPath . value , flags . campaignFilePath . value )
if rightErr != nil {
return "" , rightErr
}
if leftPath != rightPath {
2026-09-25 15:24:03 +00:00
return "" , fmt . Errorf ( "overlay selections resolve different campaign files; specify --campaign-file to compare one campaign" )
2026-08-30 15:10:38 +00:00
}
return leftPath , nil
}
func diffEffectivePipelineValues ( left , right [ ] config . EffectivePipelineValueRecord ) ( [ ] configDiffRecord , error ) {
leftByPath , err := effectivePipelineValueMap ( "left" , left )
if err != nil {
return nil , err
}
rightByPath , err := effectivePipelineValueMap ( "right" , right )
if err != nil {
return nil , err
}
paths := make ( [ ] string , 0 , len ( leftByPath ) + len ( rightByPath ) )
seen := make ( map [ string ] struct { } , len ( leftByPath ) + len ( rightByPath ) )
for path := range leftByPath {
seen [ path ] = struct { } { }
paths = append ( paths , path )
}
for path := range rightByPath {
if _ , exists := seen [ path ] ; ! exists {
paths = append ( paths , path )
}
}
sort . Strings ( paths )
records := make ( [ ] configDiffRecord , 0 )
for _ , path := range paths {
leftValue , leftExists := leftByPath [ path ]
rightValue , rightExists := rightByPath [ path ]
switch {
case ! leftExists :
records = append ( records , configDiffRecord { Kind : "added" , Path : path , Right : rightValue } )
case ! rightExists :
records = append ( records , configDiffRecord { Kind : "removed" , Path : path , Left : leftValue } )
case leftValue != rightValue :
records = append ( records , configDiffRecord { Kind : "changed" , Path : path , Left : leftValue , Right : rightValue } )
}
}
return records , nil
}
func effectivePipelineValueMap ( side string , records [ ] config . EffectivePipelineValueRecord ) ( map [ string ] string , error ) {
values := make ( map [ string ] string , len ( records ) )
for _ , record := range records {
if _ , exists := values [ record . Path ] ; exists {
return nil , fmt . Errorf ( "internal error: %s effective configuration has duplicate path %q" , side , record . Path )
}
values [ record . Path ] = record . Value
}
return values , nil
}