diff --git a/cmd/manifest/diff.go b/cmd/manifest/diff.go new file mode 100644 index 00000000..9dde22f2 --- /dev/null +++ b/cmd/manifest/diff.go @@ -0,0 +1,83 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/cmdutil" + "github.com/slackapi/slack-cli/internal/manifest" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +// NewDiffCommand implements the "manifest diff" command, which prints the +// differences between the project manifest and the app settings on Slack. +func NewDiffCommand(clients *shared.ClientFactory) *cobra.Command { + return &cobra.Command{ + Use: "diff", + Short: "Show differences between the project manifest and app settings", + Long: "Compare the project manifest with app settings and print any differences.", + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "manifest diff", Meaning: "Show differences between project manifest and app settings"}, + {Command: "manifest diff --app A0123456789 --token xoxp-...", Meaning: "Show manifest differences without prompts"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return cmdutil.IsValidProjectDirectory(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.manifest.diff") + defer span.Finish() + + selection, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err != nil { + return err + } + + clients.Config.ManifestEnv = app.SetManifestEnvTeamVars(clients.Config.ManifestEnv, selection.App.TeamDomain, selection.App.IsDev) + + localManifest, err := clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return err + } + + remoteManifest, err := clients.AppClient().Manifest.GetManifestRemote(ctx, selection.Auth.Token, selection.App.AppID) + if err != nil { + return err + } + + diffs, err := manifest.Diff(localManifest.AppManifest, remoteManifest.AppManifest) + if err != nil { + return err + } + + if !diffs.HasDifferences() { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "App Manifest", + Secondary: []string{"Project manifest and app settings are in sync"}, + })) + return nil + } + + manifest.DisplayDiffs(ctx, clients.IO, diffs) + return nil + }, + } +} diff --git a/cmd/manifest/diff_test.go b/cmd/manifest/diff_test.go new file mode 100644 index 00000000..cbfd8470 --- /dev/null +++ b/cmd/manifest/diff_test.go @@ -0,0 +1,90 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/hooks" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func TestDiffCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "prints in-sync message when manifests match": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return( + prompts.SelectedApp{ + App: types.App{AppID: "A001"}, + Auth: types.SlackAuth{Token: "xapp"}, + }, nil) + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + }, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + }, nil) + cf.AppClient().Manifest = manifestMock + cf.SDKConfig = hooks.NewSDKConfigMock() + }, + ExpectedOutputs: []string{"in sync"}, + }, + "prints differences when project and app settings disagree": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly).Return( + prompts.SelectedApp{ + App: types.App{AppID: "A002"}, + Auth: types.SlackAuth{Token: "xapp"}, + }, nil) + manifestMock := &app.ManifestMockObject{} + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Project Name"}, + }, + }, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Remote Name"}, + }, + }, nil) + cf.AppClient().Manifest = manifestMock + cf.SDKConfig = hooks.NewSDKConfigMock() + }, + ExpectedOutputs: []string{ + "display_information.name", + "Project Name", + "Remote Name", + }, + }, + }, func(clients *shared.ClientFactory) *cobra.Command { + return NewDiffCommand(clients) + }) +} diff --git a/cmd/manifest/manifest.go b/cmd/manifest/manifest.go index c2c2f3d7..e96df539 100644 --- a/cmd/manifest/manifest.go +++ b/cmd/manifest/manifest.go @@ -80,6 +80,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { } // Add child commands + cmd.AddCommand(NewDiffCommand(clients)) cmd.AddCommand(NewInfoCommand(clients)) cmd.AddCommand(NewValidateCommand(clients)) diff --git a/internal/manifest/diff.go b/internal/manifest/diff.go new file mode 100644 index 00000000..2b5d38b9 --- /dev/null +++ b/internal/manifest/diff.go @@ -0,0 +1,234 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/shared/types" +) + +// ignoredDiffPaths are top-level manifest fields that the project may declare +// but Slack's apps.manifest.export does not echo back. Diffs at or under these +// paths are dropped to avoid spurious "only in project" entries on every run. +var ignoredDiffPaths = []string{ + "_metadata", // SDK-side schema annotations; not stored in app settings +} + +// devLocalSuffixPaths are flattened paths where Slack's apps.manifest.export +// appends " (local)" for dev-installed apps. Diffs at these paths are +// dropped only when removing the suffix would make the values equal; real +// renames still surface. +var devLocalSuffixPaths = []string{ + "display_information.name", + "features.bot_user.display_name", +} + +const devLocalSuffix = " (local)" + +// remoteFalseDefaultPaths are flattened paths where Slack's +// apps.manifest.export emits a default `false` for every app, even when the +// project has not declared the field. Remote-only diffs at these paths are +// dropped when the value is false so users do not see a phantom entry on +// every run; a real disagreement (e.g. local sets the field to true) still +// surfaces as a Modified diff. +var remoteFalseDefaultPaths = []string{ + "settings.is_mcp_enabled", +} + +// DiffType describes how a field differs between local and remote. +type DiffType int + +const ( + DiffModified DiffType = iota // Both sides have the field but with different values + DiffLocalOnly // Field exists only in local (added locally or deleted remotely) + DiffRemoteOnly // Field exists only in remote (added remotely or deleted locally) +) + +// FieldDiff represents a single difference between local and remote manifests. +type FieldDiff struct { + Path string + Type DiffType + LocalValue any + RemoteValue any +} + +// DiffResult holds all differences found between two manifests. +type DiffResult struct { + Diffs []FieldDiff +} + +// HasDifferences returns true if any differences were found. +func (dr *DiffResult) HasDifferences() bool { + return len(dr.Diffs) > 0 +} + +// Diff performs a two-way comparison between local and remote manifests, +// returning all fields that differ between them. Paths under ignoredDiffPaths +// are excluded. +func Diff(local, remote types.AppManifest) (*DiffResult, error) { + localFlat, err := Flatten(local) + if err != nil { + return nil, fmt.Errorf("failed to flatten local manifest: %w", err) + } + remoteFlat, err := Flatten(remote) + if err != nil { + return nil, fmt.Errorf("failed to flatten remote manifest: %w", err) + } + result, err := diffFlat(localFlat, remoteFlat) + if err != nil { + return nil, err + } + filtered := result.Diffs[:0] + for _, d := range result.Diffs { + if isIgnoredPath(d.Path) { + continue + } + if isDevLocalSuffixDiff(d) { + continue + } + if isRemoteFalseDefaultDiff(d) { + continue + } + filtered = append(filtered, d) + } + result.Diffs = filtered + return result, nil +} + +// isIgnoredPath reports whether a flattened manifest path is at or under any +// entry in ignoredDiffPaths. +func isIgnoredPath(path string) bool { + for _, prefix := range ignoredDiffPaths { + if path == prefix || strings.HasPrefix(path, prefix+".") { + return true + } + } + return false +} + +// isRemoteFalseDefaultDiff reports whether a remote-only diff is purely the +// result of Slack's apps.manifest.export emitting a default `false` for a +// field the project did not declare. Real disagreements (e.g. local sets +// the field to true) are not suppressed because they would surface as a +// Modified diff, not a remote-only diff. +func isRemoteFalseDefaultDiff(d FieldDiff) bool { + if d.Type != DiffRemoteOnly { + return false + } + matched := false + for _, p := range remoteFalseDefaultPaths { + if d.Path == p { + matched = true + break + } + } + if !matched { + return false + } + v, ok := d.RemoteValue.(bool) + return ok && !v +} + +// isDevLocalSuffixDiff reports whether a Modified diff is purely the result +// of Slack's apps.manifest.export appending " (local)" to a name field for a +// dev-installed app. Real renames are not suppressed because trimming the +// suffix from RemoteValue would not produce LocalValue. +func isDevLocalSuffixDiff(d FieldDiff) bool { + if d.Type != DiffModified { + return false + } + matched := false + for _, p := range devLocalSuffixPaths { + if d.Path == p { + matched = true + break + } + } + if !matched { + return false + } + local, ok := d.LocalValue.(string) + if !ok { + return false + } + remote, ok := d.RemoteValue.(string) + if !ok { + return false + } + return strings.TrimSuffix(remote, devLocalSuffix) == local +} + +// diffFlat compares two flattened manifests and returns one FieldDiff per +// path that differs (modified, local-only, or remote-only). +func diffFlat(local, remote map[string]any) (*DiffResult, error) { + result := &DiffResult{} + seen := make(map[string]bool) + + for path, localVal := range local { + seen[path] = true + remoteVal, exists := remote[path] + if !exists { + result.Diffs = append(result.Diffs, FieldDiff{ + Path: path, + Type: DiffLocalOnly, + LocalValue: localVal, + }) + continue + } + equal, err := valuesEqual(localVal, remoteVal) + if err != nil { + return nil, fmt.Errorf("failed to compare manifest values at %q: %w", path, err) + } + if !equal { + result.Diffs = append(result.Diffs, FieldDiff{ + Path: path, + Type: DiffModified, + LocalValue: localVal, + RemoteValue: remoteVal, + }) + } + } + + for path, remoteVal := range remote { + if seen[path] { + continue + } + result.Diffs = append(result.Diffs, FieldDiff{ + Path: path, + Type: DiffRemoteOnly, + RemoteValue: remoteVal, + }) + } + + return result, nil +} + +// valuesEqual reports whether two leaf values from a flattened manifest are +// equivalent. It compares their JSON encodings so type-equivalent values +// (e.g. matching arrays or nested objects) compare equal. +func valuesEqual(a, b any) (bool, error) { + aJSON, err := json.Marshal(a) + if err != nil { + return false, err + } + bJSON, err := json.Marshal(b) + if err != nil { + return false, err + } + return string(aJSON) == string(bJSON), nil +} diff --git a/internal/manifest/diff_test.go b/internal/manifest/diff_test.go new file mode 100644 index 00000000..b9622e95 --- /dev/null +++ b/internal/manifest/diff_test.go @@ -0,0 +1,291 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Diff(t *testing.T) { + tests := map[string]struct { + local types.AppManifest + remote types.AppManifest + expected []FieldDiff + }{ + "identical manifests produce no diffs": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + expected: nil, + }, + "modified field detected": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App", Description: "Local desc"}, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App", Description: "Remote desc"}, + }, + expected: []FieldDiff{ + {Path: "display_information.description", Type: DiffModified, LocalValue: "Local desc", RemoteValue: "Remote desc"}, + }, + }, + "local-only field detected": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App", Description: "Has desc"}, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + expected: []FieldDiff{ + {Path: "display_information.description", Type: DiffLocalOnly, LocalValue: "Has desc"}, + }, + }, + "remote-only field detected": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App", Description: "Remote only"}, + }, + expected: []FieldDiff{ + {Path: "display_information.description", Type: DiffRemoteOnly, RemoteValue: "Remote only"}, + }, + }, + "function added locally": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Functions: map[string]types.ManifestFunction{ + "greet": {Title: "Greet", Description: "Hello"}, + }, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + }, + expected: []FieldDiff{ + {Path: "functions.greet.description", Type: DiffLocalOnly, LocalValue: "Hello"}, + {Path: "functions.greet.title", Type: DiffLocalOnly, LocalValue: "Greet"}, + // ManifestFunction.InputParameters/OutputParameters lack + // `omitempty`, so an added function flattens with nil values + // for both — they show as local-only too. + {Path: "functions.greet.input_parameters", Type: DiffLocalOnly, LocalValue: nil}, + {Path: "functions.greet.output_parameters", Type: DiffLocalOnly, LocalValue: nil}, + }, + }, + "array values compared as wholes": { + local: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + OAuthConfig: &types.OAuthConfig{ + Scopes: &types.ManifestScopes{ + Bot: []string{"chat:write", "users:read"}, + }, + }, + }, + remote: types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + OAuthConfig: &types.OAuthConfig{ + Scopes: &types.ManifestScopes{ + Bot: []string{"chat:write", "files:read"}, + }, + }, + }, + expected: []FieldDiff{ + { + Path: "oauth_config.scopes.bot", + Type: DiffModified, + LocalValue: []any{"chat:write", "users:read"}, + RemoteValue: []any{"chat:write", "files:read"}, + }, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := Diff(tc.local, tc.remote) + require.NoError(t, err) + if tc.expected == nil { + assert.False(t, result.HasDifferences()) + return + } + assert.True(t, result.HasDifferences()) + assert.Len(t, result.Diffs, len(tc.expected), "unexpected number of diffs: got %+v", result.Diffs) + for _, expectedDiff := range tc.expected { + found := false + for _, actualDiff := range result.Diffs { + if actualDiff.Path == expectedDiff.Path { + found = true + assert.Equal(t, expectedDiff.Type, actualDiff.Type, "diff type mismatch for path %s", expectedDiff.Path) + if expectedDiff.LocalValue != nil { + assert.Equal(t, expectedDiff.LocalValue, actualDiff.LocalValue, "local value mismatch for path %s", expectedDiff.Path) + } + if expectedDiff.RemoteValue != nil { + assert.Equal(t, expectedDiff.RemoteValue, actualDiff.RemoteValue, "remote value mismatch for path %s", expectedDiff.Path) + } + break + } + } + assert.True(t, found, "expected diff not found for path %s", expectedDiff.Path) + } + }) + } +} + +func Test_Diff_IgnoresMetadataPaths(t *testing.T) { + // _metadata is project-side annotation that apps.manifest.export does not + // echo back. Without filtering, projects using _metadata see noisy + // "(only in project)" entries on every diff run. + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Metadata: &types.ManifestMetadata{ + MajorVersion: 1, + MinorVersion: 2, + }, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + } + + result, err := Diff(local, remote) + require.NoError(t, err) + assert.False(t, result.HasDifferences(), "metadata-only differences should be filtered, got %+v", result.Diffs) +} + +func Test_Diff_FiltersMetadataButKeepsOtherDiffs(t *testing.T) { + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Project"}, + Metadata: &types.ManifestMetadata{MajorVersion: 1}, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "Remote"}, + } + + result, err := Diff(local, remote) + require.NoError(t, err) + require.True(t, result.HasDifferences()) + for _, d := range result.Diffs { + assert.False(t, isIgnoredPath(d.Path), "unexpected ignored path in result: %s", d.Path) + } + // The display_information.name diff must still be reported. + var sawNameDiff bool + for _, d := range result.Diffs { + if d.Path == "display_information.name" { + sawNameDiff = true + } + } + assert.True(t, sawNameDiff, "display_information.name diff was unexpectedly filtered") +} + +func Test_Diff_SuppressesDevLocalSuffix(t *testing.T) { + // apps.manifest.export appends " (local)" to display_information.name and + // features.bot_user.display_name for dev-installed apps. The diff command + // suppresses these so users don't see noise on every run. + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "romantic-dolphin-526"}, + Features: &types.AppFeatures{ + BotUser: types.BotUser{DisplayName: "romantic-dolphin-526"}, + }, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "romantic-dolphin-526 (local)"}, + Features: &types.AppFeatures{ + BotUser: types.BotUser{DisplayName: "romantic-dolphin-526 (local)"}, + }, + } + result, err := Diff(local, remote) + require.NoError(t, err) + assert.False(t, result.HasDifferences(), "dev-local suffix differences should be suppressed, got %+v", result.Diffs) +} + +func Test_Diff_PreservesRealRenames(t *testing.T) { + // A genuine rename (suffix-trimmed remote does not equal local) must + // continue to surface as a diff. + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "new-name"}, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "old-name (local)"}, + } + result, err := Diff(local, remote) + require.NoError(t, err) + require.True(t, result.HasDifferences()) + require.Len(t, result.Diffs, 1) + assert.Equal(t, "display_information.name", result.Diffs[0].Path) + assert.Equal(t, DiffModified, result.Diffs[0].Type) + assert.Equal(t, "new-name", result.Diffs[0].LocalValue) + assert.Equal(t, "old-name (local)", result.Diffs[0].RemoteValue) +} + +func Test_Diff_SuppressesRemoteFalseDefaultIsMcpEnabled(t *testing.T) { + // apps.manifest.export emits settings.is_mcp_enabled: false for every app + // even when the project never declared the field. Suppress the + // remote-only diff so users do not see a phantom entry on every run. + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Settings: &types.AppSettings{ + IsMCPEnabled: ptrBool(false), + }, + } + result, err := Diff(local, remote) + require.NoError(t, err) + assert.False(t, result.HasDifferences(), "remote-only is_mcp_enabled=false should be suppressed, got %+v", result.Diffs) +} + +func Test_Diff_SurfacesRealIsMcpEnabledDisagreement(t *testing.T) { + // A genuine disagreement — local sets is_mcp_enabled to true while + // remote returns false — must still surface as a Modified diff. + local := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Settings: &types.AppSettings{ + IsMCPEnabled: ptrBool(true), + }, + } + remote := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Settings: &types.AppSettings{ + IsMCPEnabled: ptrBool(false), + }, + } + result, err := Diff(local, remote) + require.NoError(t, err) + require.True(t, result.HasDifferences()) + require.Len(t, result.Diffs, 1) + assert.Equal(t, "settings.is_mcp_enabled", result.Diffs[0].Path) + assert.Equal(t, DiffModified, result.Diffs[0].Type) +} + +func ptrBool(b bool) *bool { return &b } + +func Test_DiffResult_HasDifferences(t *testing.T) { + t.Run("empty result has no differences", func(t *testing.T) { + result := &DiffResult{} + assert.False(t, result.HasDifferences()) + }) + + t.Run("result with diffs has differences", func(t *testing.T) { + result := &DiffResult{ + Diffs: []FieldDiff{{Path: "test", Type: DiffModified}}, + } + assert.True(t, result.HasDifferences()) + }) +} diff --git a/internal/manifest/display.go b/internal/manifest/display.go new file mode 100644 index 00000000..f9b2a2b9 --- /dev/null +++ b/internal/manifest/display.go @@ -0,0 +1,98 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "context" + "encoding/json" + "fmt" + "sort" + + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/style" +) + +// DisplayDiffs prints the differences to the terminal. +func DisplayDiffs(ctx context.Context, io iostreams.IOStreamer, diffs *DiffResult) { + if !diffs.HasDifferences() { + return + } + + sorted := make([]FieldDiff, len(diffs.Diffs)) + copy(sorted, diffs.Diffs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Path < sorted[j].Path + }) + + io.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "books", + Text: "App Manifest", + Secondary: []string{ + fmt.Sprintf("Found %d difference(s) between project and app settings", len(sorted)), + }, + })) + + for i, d := range sorted { + if i > 0 { + io.PrintInfo(ctx, false, "") + } + switch d.Type { + case DiffModified: + io.PrintInfo(ctx, false, " %s", style.Bold(d.Path)) + io.PrintInfo(ctx, false, " Project: %s", formatValue(d.LocalValue)) + io.PrintInfo(ctx, false, " App settings: %s", formatValue(d.RemoteValue)) + case DiffLocalOnly: + io.PrintInfo(ctx, false, " %s %s", style.Bold(d.Path), "(only in project)") + io.PrintInfo(ctx, false, " Value: %s", formatValue(d.LocalValue)) + case DiffRemoteOnly: + io.PrintInfo(ctx, false, " %s %s", style.Bold(d.Path), "(only in app settings)") + io.PrintInfo(ctx, false, " Value: %s", formatValue(d.RemoteValue)) + } + } + io.PrintInfo(ctx, false, "") +} + +// formatValue renders a leaf value for display. Strings are quoted, other +// values are JSON-encoded, and any value longer than 80 runes is +// truncated with an ellipsis. +func formatValue(v any) string { + if v == nil { + return "(not present)" + } + switch val := v.(type) { + case string: + return fmt.Sprintf("%q", val) + default: + data, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return truncateRunes(string(data), 80) + } +} + +// truncateRunes returns s unchanged if it is at most max runes, otherwise it +// returns the first max-3 runes followed by "...". Splitting on runes (rather +// than bytes) avoids cutting through a multi-byte UTF-8 character. +func truncateRunes(s string, max int) string { + if max <= 3 { + return s + } + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max-3]) + "..." +} diff --git a/internal/manifest/display_test.go b/internal/manifest/display_test.go new file mode 100644 index 00000000..232e8c84 --- /dev/null +++ b/internal/manifest/display_test.go @@ -0,0 +1,61 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "testing" + "unicode/utf8" + + "github.com/stretchr/testify/assert" +) + +func Test_truncateRunes(t *testing.T) { + tests := map[string]struct { + input string + max int + expected string + }{ + "shorter than max returns unchanged": { + input: "hello", + max: 80, + expected: "hello", + }, + "exactly max runes returns unchanged": { + input: "abcdefghij", + max: 10, + expected: "abcdefghij", + }, + "longer than max truncates with ellipsis": { + input: "abcdefghijklmno", + max: 10, + expected: "abcdefg...", + }, + "multi-byte runes are not cut mid-character": { + // Each emoji is 4 bytes in UTF-8 but one rune. Byte-based + // slicing would split the middle emoji. + input: "🐶🐱🐭🐹🐰🦊🐻🐼🐨🐯🦁🐮", + max: 6, + expected: "🐶🐱🐭...", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := truncateRunes(tc.input, tc.max) + assert.Equal(t, tc.expected, got) + // In every case the result must remain valid UTF-8. + assert.True(t, utf8.ValidString(got), "result is not valid UTF-8: %q", got) + }) + } +} diff --git a/internal/manifest/flatten.go b/internal/manifest/flatten.go new file mode 100644 index 00000000..049351f4 --- /dev/null +++ b/internal/manifest/flatten.go @@ -0,0 +1,80 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// Flatten converts a manifest (as JSON-serializable struct) into a flat map +// where keys are dot-notation paths and values are the leaf values. +// Arrays are treated as leaf values (not recursed into individually) because +// array element identity is ambiguous without a key field. +// +// Keys that contain literal dots (e.g. function IDs like "slack.users.lookup") +// have those dots backslash-escaped in the path so flatten/unflatten round- +// trip cleanly. splitPath honors the same escape sequence. +func Flatten(manifest any) (map[string]any, error) { + data, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("failed to marshal manifest: %w", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + result := make(map[string]any) + flattenRecursive("", raw, result) + return result, nil +} + +// flattenRecursive walks a map produced by json.Unmarshal and writes one +// dot-notation entry per leaf into result. Nested maps recurse; arrays and +// scalars are leaves. +func flattenRecursive(prefix string, data map[string]any, result map[string]any) { + for key, value := range data { + fullKey := escapePathSegment(key) + if prefix != "" { + fullKey = prefix + "." + fullKey + } + switch v := value.(type) { + case map[string]any: + flattenRecursive(fullKey, v, result) + default: + result[fullKey] = value + } + } +} + +// escapePathSegment backslash-escapes the path delimiter and the escape +// character so a key containing a literal dot survives flatten/splitPath. +func escapePathSegment(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `.`, `\.`) + return s +} + +// SortedKeys returns the keys of a flat map in sorted order for deterministic output. +func SortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/manifest/flatten_test.go b/internal/manifest/flatten_test.go new file mode 100644 index 00000000..f2236ce8 --- /dev/null +++ b/internal/manifest/flatten_test.go @@ -0,0 +1,144 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package manifest + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Flatten(t *testing.T) { + tests := map[string]struct { + manifest types.AppManifest + expected map[string]any + }{ + "flattens display_information fields": { + manifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "My App", + Description: "A test app", + }, + }, + expected: map[string]any{ + "display_information.name": "My App", + "display_information.description": "A test app", + }, + }, + "flattens nested settings": { + manifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "App", + }, + Settings: &types.AppSettings{ + FunctionRuntime: types.LocallyRun, + }, + }, + expected: map[string]any{ + "display_information.name": "App", + "settings.function_runtime": "local", + }, + }, + "flattens functions map": { + manifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "App", + }, + Functions: map[string]types.ManifestFunction{ + "greet": { + Title: "Greet", + Description: "Greets a user", + }, + }, + }, + expected: map[string]any{ + "display_information.name": "App", + "functions.greet.title": "Greet", + "functions.greet.description": "Greets a user", + // ManifestFunction.InputParameters/OutputParameters lack `omitempty` + // so they always serialize as null. Flatten captures the nulls. + "functions.greet.input_parameters": nil, + "functions.greet.output_parameters": nil, + }, + }, + "treats arrays as leaf values": { + manifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "App", + }, + OAuthConfig: &types.OAuthConfig{ + Scopes: &types.ManifestScopes{ + Bot: []string{"chat:write", "channels:read"}, + }, + }, + }, + expected: map[string]any{ + "display_information.name": "App", + "oauth_config.scopes.bot": []any{"chat:write", "channels:read"}, + }, + }, + "empty manifest has only display_information.name": { + manifest: types.AppManifest{ + DisplayInformation: types.DisplayInformation{ + Name: "App", + }, + }, + expected: map[string]any{ + "display_information.name": "App", + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + result, err := Flatten(tc.manifest) + require.NoError(t, err) + assert.Len(t, result, len(tc.expected), "unexpected number of flattened keys: got %+v", result) + for key, expectedVal := range tc.expected { + assert.Contains(t, result, key) + assert.Equal(t, expectedVal, result[key], "mismatch at key %s", key) + } + }) + } +} + +func Test_Flatten_EscapesDotsInKeys(t *testing.T) { + // Manifest function IDs may contain literal dots (e.g. "slack.users.lookup"). + // Flatten must backslash-escape those dots so the path remains parseable. + manifest := types.AppManifest{ + DisplayInformation: types.DisplayInformation{Name: "App"}, + Functions: map[string]types.ManifestFunction{ + "slack.users.lookup": {Title: "Lookup", Description: "Lookup a user"}, + }, + } + + flat, err := Flatten(manifest) + require.NoError(t, err) + + assert.Contains(t, flat, `functions.slack\.users\.lookup.title`) + assert.Equal(t, "Lookup", flat[`functions.slack\.users\.lookup.title`]) + assert.Equal(t, "Lookup a user", flat[`functions.slack\.users\.lookup.description`]) +} + +func Test_SortedKeys(t *testing.T) { + m := map[string]any{ + "z.field": "val", + "a.field": "val", + "m.field": "val", + } + keys := SortedKeys(m) + assert.Equal(t, []string{"a.field", "m.field", "z.field"}, keys) +}