diff --git a/pkg/github/__toolsnaps__/compare_file_contents.snap b/pkg/github/__toolsnaps__/compare_file_contents.snap new file mode 100644 index 000000000..889f098b4 --- /dev/null +++ b/pkg/github/__toolsnaps__/compare_file_contents.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Compare file contents" + }, + "description": "Compare a file between two git refs, with semantic diffs for structured formats (JSON, YAML)", + "inputSchema": { + "properties": { + "base": { + "description": "Base git ref to compare from (commit SHA, branch name, or tag)", + "type": "string" + }, + "head": { + "description": "Head git ref to compare to (commit SHA, branch name, or tag)", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "Path to the file to compare", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "path", + "base", + "head" + ], + "type": "object" + }, + "name": "compare_file_contents" +} \ No newline at end of file diff --git a/pkg/github/compare.go b/pkg/github/compare.go new file mode 100644 index 000000000..26454975c --- /dev/null +++ b/pkg/github/compare.go @@ -0,0 +1,138 @@ +package github + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// CompareFileContents creates a tool to compare a file between two git refs, +// producing semantic diffs for structured formats (JSON, YAML) and falling back +// to unified diffs for unsupported formats. +func CompareFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "compare_file_contents", + Description: t("TOOL_COMPARE_FILE_CONTENTS_DESCRIPTION", "Compare a file between two git refs, with semantic diffs for structured formats (JSON, YAML)"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_COMPARE_FILE_CONTENTS_USER_TITLE", "Compare file contents"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file to compare", + }, + "base": { + Type: "string", + Description: "Base git ref to compare from (commit SHA, branch name, or tag)", + }, + "head": { + Type: "string", + Description: "Head git ref to compare to (commit SHA, branch name, or tag)", + }, + }, + Required: []string{"owner", "repo", "path", "base", "head"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + base, err := RequiredParam[string](args, "base") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + head, err := RequiredParam[string](args, "head") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + rawClient, err := deps.GetRawClient(ctx) + if err != nil { + return utils.NewToolResultError("failed to get raw content client"), nil, nil + } + + baseContent, err := fetchFileContent(ctx, rawClient, owner, repo, path, base) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to fetch base file: %s", err)), nil, nil + } + + headContent, err := fetchFileContent(ctx, rawClient, owner, repo, path, head) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to fetch head file: %s", err)), nil, nil + } + + diff, format, isFallback, err := SemanticDiff(baseContent, headContent, path) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to compute diff: %s", err)), nil, nil + } + + var header string + if isFallback { + header = fmt.Sprintf("Format: %s (unified diff — no semantic diff available for this format)", format) + } else { + header = fmt.Sprintf("Format: %s (semantic diff)", format) + } + + result := fmt.Sprintf("%s\n\n%s", header, diff) + + return utils.NewToolResultText(result), nil, nil + }, + ) + st.FeatureFlagEnable = "semantic_diff" + return st +} + +// fetchFileContent retrieves the raw content of a file at a given ref. +func fetchFileContent(ctx context.Context, client *raw.Client, owner, repo, path, ref string) ([]byte, error) { + resp, err := client.GetRawContent(ctx, owner, repo, path, &raw.ContentOpts{Ref: ref}) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("file %q not found at ref %q", path, ref) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d for %q at ref %q", resp.StatusCode, path, ref) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + return body, nil +} diff --git a/pkg/github/compare_test.go b/pkg/github/compare_test.go new file mode 100644 index 000000000..06012f8fa --- /dev/null +++ b/pkg/github/compare_test.go @@ -0,0 +1,202 @@ +package github + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v82/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CompareFileContents(t *testing.T) { + // Verify tool definition and snapshot + toolDef := CompareFileContents(translations.NullTranslationHelper) + assert.Equal(t, "compare_file_contents", toolDef.Tool.Name) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + assert.Equal(t, "semantic_diff", toolDef.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectContains []string + }{ + { + name: "successful JSON semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "": func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case containsRef(path, "abc123"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name":"Bob","age":30}`)) + case containsRef(path, "def456"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name":"Bobby","age":30}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "abc123", + "head": "def456", + }, + expectError: false, + expectContains: []string{ + "Format: json (semantic diff)", + `name: "Bob" → "Bobby"`, + }, + }, + { + name: "successful YAML semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "": func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case containsRef(path, "v1.0"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("name: Alice\nage: 30\n")) + case containsRef(path, "v2.0"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("name: Alice\nage: 31\n")) + default: + w.WriteHeader(http.StatusNotFound) + } + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "config.yaml", + "base": "v1.0", + "head": "v2.0", + }, + expectError: false, + expectContains: []string{ + "Format: yaml (semantic diff)", + "age: 30 → 31", + }, + }, + { + name: "fallback to unified diff for txt", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "": func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + switch { + case containsRef(path, "old"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("line1\nline2\nline3\n")) + case containsRef(path, "new"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("line1\nmodified\nline3\n")) + default: + w.WriteHeader(http.StatusNotFound) + } + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "readme.txt", + "base": "old", + "head": "new", + }, + expectError: false, + expectContains: []string{ + "unified diff", + "-line2", + "+modified", + }, + }, + { + name: "base file not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "missing.json", + "base": "abc123", + "head": "def456", + }, + expectError: true, + expectedErrMsg: "failed to fetch base file", + }, + { + name: "missing required param owner", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "repo": "repo", + "path": "file.json", + "base": "abc", + "head": "def", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required param base", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "file.json", + "head": "def", + }, + expectError: true, + expectedErrMsg: "missing required parameter: base", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + deps := BaseDeps{ + Client: client, + RawClient: mockRawClient, + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + textContent := getErrorResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + for _, expected := range tc.expectContains { + assert.Contains(t, textContent.Text, expected) + } + }) + } +} + +// containsRef checks if a URL path contains a specific ref segment. +func containsRef(path, ref string) bool { + return strings.Contains(path, "/"+ref+"/") +} diff --git a/pkg/github/semantic_diff.go b/pkg/github/semantic_diff.go new file mode 100644 index 000000000..1d8cd8e24 --- /dev/null +++ b/pkg/github/semantic_diff.go @@ -0,0 +1,314 @@ +package github + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/pmezard/go-difflib/difflib" + "gopkg.in/yaml.v3" +) + +// Change represents a single semantic difference between two structured values. +type Change struct { + Path string // dot/bracket notation path, e.g. "users[1].name" + Type string // "modified", "added", "removed", "type_changed" + Old string // formatted old value (empty for additions) + New string // formatted new value (empty for removals) +} + +// SemanticDiff compares two byte slices and returns a human-readable diff. +// For supported formats (JSON, YAML), it produces a semantic diff showing only value changes. +// For unsupported formats, it falls back to unified diff. +// Returns: diff output, format name, whether fallback was used, and any error. +func SemanticDiff(base, head []byte, filePath string) (string, string, bool, error) { + format := detectFormat(filePath) + + var changes []Change + var err error + + switch format { + case "json": + changes, err = compareJSON(base, head) + case "yaml": + changes, err = compareYAML(base, head) + default: + diff, uerr := unifiedDiff(base, head, filePath) + if uerr != nil { + return "", "", false, uerr + } + ext := filepath.Ext(filePath) + if ext != "" { + ext = ext[1:] // strip leading dot + } else { + ext = "txt" + } + return diff, ext, true, nil + } + + if err != nil { + return "", format, false, err + } + + return formatChanges(changes), format, false, nil +} + +// detectFormat returns the semantic diff format based on file extension. +func detectFormat(path string) string { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + return "json" + case ".yaml", ".yml": + return "yaml" + default: + return "" + } +} + +// compareJSON parses two JSON byte slices and computes semantic differences. +func compareJSON(base, head []byte) ([]Change, error) { + var baseVal, headVal any + if err := json.Unmarshal(base, &baseVal); err != nil { + return nil, fmt.Errorf("failed to parse base JSON: %w", err) + } + if err := json.Unmarshal(head, &headVal); err != nil { + return nil, fmt.Errorf("failed to parse head JSON: %w", err) + } + return deepCompare("", baseVal, headVal), nil +} + +// compareYAML parses two YAML byte slices and computes semantic differences. +func compareYAML(base, head []byte) ([]Change, error) { + var baseVal, headVal any + if err := yaml.Unmarshal(base, &baseVal); err != nil { + return nil, fmt.Errorf("failed to parse base YAML: %w", err) + } + if err := yaml.Unmarshal(head, &headVal); err != nil { + return nil, fmt.Errorf("failed to parse head YAML: %w", err) + } + // Normalize YAML maps (map[string]any vs map[any]any) + baseVal = normalizeYAML(baseVal) + headVal = normalizeYAML(headVal) + return deepCompare("", baseVal, headVal), nil +} + +// normalizeYAML converts map[any]any (from yaml.v3) to map[string]any for consistent comparison. +func normalizeYAML(v any) any { + switch val := v.(type) { + case map[string]any: + normalized := make(map[string]any, len(val)) + for k, v := range val { + normalized[k] = normalizeYAML(v) + } + return normalized + case map[any]any: + normalized := make(map[string]any, len(val)) + for k, v := range val { + normalized[fmt.Sprintf("%v", k)] = normalizeYAML(v) + } + return normalized + case []any: + normalized := make([]any, len(val)) + for i, v := range val { + normalized[i] = normalizeYAML(v) + } + return normalized + default: + return v + } +} + +// deepCompare recursively compares two values and returns a list of changes. +func deepCompare(path string, base, head any) []Change { + if base == nil && head == nil { + return nil + } + + if base == nil { + return []Change{{Path: path, Type: "added", New: formatValue(head)}} + } + if head == nil { + return []Change{{Path: path, Type: "removed", Old: formatValue(base)}} + } + + // Check for type mismatch + baseMap, baseIsMap := base.(map[string]any) + headMap, headIsMap := head.(map[string]any) + baseSlice, baseIsSlice := base.([]any) + headSlice, headIsSlice := head.([]any) + + if baseIsMap && headIsMap { + return compareMaps(path, baseMap, headMap) + } + if baseIsSlice && headIsSlice { + return compareSlices(path, baseSlice, headSlice) + } + + // If types differ between map/slice/scalar, it's a type change + if baseIsMap != headIsMap || baseIsSlice != headIsSlice { + return []Change{{Path: path, Type: "type_changed", Old: formatValue(base), New: formatValue(head)}} + } + + // Scalar comparison — normalize numeric types for comparison + if !scalarEqual(base, head) { + return []Change{{Path: path, Type: "modified", Old: formatValue(base), New: formatValue(head)}} + } + + return nil +} + +// compareMaps compares two maps and returns changes with sorted keys for deterministic output. +func compareMaps(path string, base, head map[string]any) []Change { + var changes []Change + + // Collect all keys + allKeys := make(map[string]bool) + for k := range base { + allKeys[k] = true + } + for k := range head { + allKeys[k] = true + } + + // Sort for deterministic output + keys := make([]string, 0, len(allKeys)) + for k := range allKeys { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + childPath := joinPath(path, k) + baseVal, inBase := base[k] + headVal, inHead := head[k] + + switch { + case inBase && !inHead: + changes = append(changes, Change{Path: childPath, Type: "removed", Old: formatValue(baseVal)}) + case !inBase && inHead: + changes = append(changes, Change{Path: childPath, Type: "added", New: formatValue(headVal)}) + default: + changes = append(changes, deepCompare(childPath, baseVal, headVal)...) + } + } + + return changes +} + +// compareSlices compares two slices element by element. +func compareSlices(path string, base, head []any) []Change { + var changes []Change + maxLen := len(base) + if len(head) > maxLen { + maxLen = len(head) + } + + for i := 0; i < maxLen; i++ { + childPath := fmt.Sprintf("%s[%d]", path, i) + switch { + case i >= len(base): + changes = append(changes, Change{Path: childPath, Type: "added", New: formatValue(head[i])}) + case i >= len(head): + changes = append(changes, Change{Path: childPath, Type: "removed", Old: formatValue(base[i])}) + default: + changes = append(changes, deepCompare(childPath, base[i], head[i])...) + } + } + + return changes +} + +// scalarEqual compares two scalar values, normalizing numeric types. +func scalarEqual(a, b any) bool { + // Normalize floats that are whole numbers to int for comparison + // JSON unmarshals all numbers as float64, YAML may use int + af, aIsFloat := a.(float64) + bf, bIsFloat := b.(float64) + ai, aIsInt := a.(int) + bi, bIsInt := b.(int) + + switch { + case aIsFloat && bIsInt: + return af == float64(bi) + case aIsInt && bIsFloat: + return float64(ai) == bf + case aIsFloat && bIsFloat: + return af == bf + case aIsInt && bIsInt: + return ai == bi + default: + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) + } +} + +// formatValue formats a value for display in the diff output. +func formatValue(v any) string { + switch val := v.(type) { + case string: + return fmt.Sprintf("%q", val) + case nil: + return "null" + case bool: + return fmt.Sprintf("%t", val) + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case int: + return fmt.Sprintf("%d", val) + case map[string]any, []any: + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(b) + default: + return fmt.Sprintf("%v", val) + } +} + +// joinPath builds a dot-notation path, handling the root case. +func joinPath(base, key string) string { + if base == "" { + return key + } + return base + "." + key +} + +// formatChanges renders a list of changes as human-readable text. +func formatChanges(changes []Change) string { + if len(changes) == 0 { + return "No changes detected" + } + + var sb strings.Builder + for _, c := range changes { + switch c.Type { + case "modified": + fmt.Fprintf(&sb, "%s: %s → %s\n", c.Path, c.Old, c.New) + case "type_changed": + fmt.Fprintf(&sb, "%s: %s → %s (type changed)\n", c.Path, c.Old, c.New) + case "added": + fmt.Fprintf(&sb, "+ %s: %s\n", c.Path, c.New) + case "removed": + fmt.Fprintf(&sb, "- %s: %s\n", c.Path, c.Old) + } + } + return strings.TrimRight(sb.String(), "\n") +} + +// unifiedDiff produces a standard unified diff between two byte slices. +func unifiedDiff(base, head []byte, filePath string) (string, error) { + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(string(base)), + B: difflib.SplitLines(string(head)), + FromFile: filePath + " (base)", + ToFile: filePath + " (head)", + Context: 3, + } + return difflib.GetUnifiedDiffString(diff) +} diff --git a/pkg/github/semantic_diff_test.go b/pkg/github/semantic_diff_test.go new file mode 100644 index 000000000..321358bf5 --- /dev/null +++ b/pkg/github/semantic_diff_test.go @@ -0,0 +1,232 @@ +package github + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectFormat(t *testing.T) { + tests := []struct { + path string + expected string + }{ + {"config.json", "json"}, + {"config.JSON", "json"}, + {"data.yaml", "yaml"}, + {"data.yml", "yaml"}, + {"data.YML", "yaml"}, + {"readme.txt", ""}, + {"readme.md", ""}, + {"Makefile", ""}, + {"script.py", ""}, + } + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + assert.Equal(t, tc.expected, detectFormat(tc.path)) + }) + } +} + +func TestSemanticDiff_JSON_ValueChanges(t *testing.T) { + base := []byte(`{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}`) + head := []byte(`{ + "users": [ + {"id": 1, "name": "Alice"}, + {"id": 2, "name": "Bobby"} + ] +}`) + diff, format, isFallback, err := SemanticDiff(base, head, "data.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, `users[1].name: "Bob" → "Bobby"`) +} + +func TestSemanticDiff_JSON_NoChanges(t *testing.T) { + base := []byte(`{"a":1,"b":"hello"}`) + head := []byte(`{ + "a": 1, + "b": "hello" +}`) + diff, format, isFallback, err := SemanticDiff(base, head, "config.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Equal(t, "No changes detected", diff) +} + +func TestSemanticDiff_JSON_Additions(t *testing.T) { + base := []byte(`{"a":1}`) + head := []byte(`{"a":1,"b":2}`) + diff, format, isFallback, err := SemanticDiff(base, head, "data.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, "+ b: 2") +} + +func TestSemanticDiff_JSON_Removals(t *testing.T) { + base := []byte(`{"a":1,"b":2}`) + head := []byte(`{"a":1}`) + diff, format, isFallback, err := SemanticDiff(base, head, "data.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, "- b: 2") +} + +func TestSemanticDiff_JSON_NestedChanges(t *testing.T) { + base := []byte(`{"config":{"db":{"host":"localhost","port":5432}}}`) + head := []byte(`{"config":{"db":{"host":"prod-server","port":5432}}}`) + diff, format, isFallback, err := SemanticDiff(base, head, "config.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, `config.db.host: "localhost" → "prod-server"`) +} + +func TestSemanticDiff_JSON_TypeChange(t *testing.T) { + base := []byte(`{"value":"hello"}`) + head := []byte(`{"value":["hello"]}`) + diff, format, isFallback, err := SemanticDiff(base, head, "data.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, "value:") + assert.Contains(t, diff, "type changed") +} + +func TestSemanticDiff_JSON_ArrayChanges(t *testing.T) { + base := []byte(`{"items":["a","b","c"]}`) + head := []byte(`{"items":["a","x","c","d"]}`) + diff, format, isFallback, err := SemanticDiff(base, head, "data.json") + require.NoError(t, err) + assert.Equal(t, "json", format) + assert.False(t, isFallback) + assert.Contains(t, diff, `items[1]: "b" → "x"`) + assert.Contains(t, diff, `+ items[3]: "d"`) +} + +func TestSemanticDiff_JSON_InvalidBase(t *testing.T) { + base := []byte(`not json`) + head := []byte(`{"a":1}`) + _, _, _, err := SemanticDiff(base, head, "data.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse base JSON") +} + +func TestSemanticDiff_JSON_InvalidHead(t *testing.T) { + base := []byte(`{"a":1}`) + head := []byte(`not json`) + _, _, _, err := SemanticDiff(base, head, "data.json") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse head JSON") +} + +func TestSemanticDiff_YAML_ValueChanges(t *testing.T) { + base := []byte("name: Alice\nage: 30\n") + head := []byte("name: Alice\nage: 31\n") + diff, format, isFallback, err := SemanticDiff(base, head, "config.yaml") + require.NoError(t, err) + assert.Equal(t, "yaml", format) + assert.False(t, isFallback) + assert.Contains(t, diff, "age: 30 → 31") +} + +func TestSemanticDiff_YAML_NestedChanges(t *testing.T) { + base := []byte("db:\n host: localhost\n port: 5432\n") + head := []byte("db:\n host: prod-server\n port: 5432\n") + diff, format, isFallback, err := SemanticDiff(base, head, "config.yml") + require.NoError(t, err) + assert.Equal(t, "yaml", format) + assert.False(t, isFallback) + assert.Contains(t, diff, `db.host: "localhost" → "prod-server"`) +} + +func TestSemanticDiff_YAML_NoChanges(t *testing.T) { + base := []byte("a: 1\nb: hello\n") + head := []byte("a: 1\nb: hello\n") + diff, format, isFallback, err := SemanticDiff(base, head, "config.yaml") + require.NoError(t, err) + assert.Equal(t, "yaml", format) + assert.False(t, isFallback) + assert.Equal(t, "No changes detected", diff) +} + +func TestSemanticDiff_YAML_InvalidBase(t *testing.T) { + base := []byte(":\n :\n - :\n invalid: [") + head := []byte("a: 1\n") + _, _, _, err := SemanticDiff(base, head, "config.yaml") + // YAML is more lenient than JSON, so only truly broken YAML errors + if err != nil { + assert.Contains(t, err.Error(), "failed to parse base YAML") + } +} + +func TestSemanticDiff_UnifiedFallback(t *testing.T) { + base := []byte("line1\nline2\nline3\n") + head := []byte("line1\nmodified\nline3\n") + diff, format, isFallback, err := SemanticDiff(base, head, "readme.txt") + require.NoError(t, err) + assert.Equal(t, "txt", format) + assert.True(t, isFallback) + assert.Contains(t, diff, "-line2") + assert.Contains(t, diff, "+modified") +} + +func TestSemanticDiff_UnifiedFallback_NoExtension(t *testing.T) { + base := []byte("hello\n") + head := []byte("world\n") + diff, format, isFallback, err := SemanticDiff(base, head, "Makefile") + require.NoError(t, err) + assert.Equal(t, "txt", format) + assert.True(t, isFallback) + assert.Contains(t, diff, "-hello") + assert.Contains(t, diff, "+world") +} + +func TestSemanticDiff_IdenticalFiles(t *testing.T) { + content := []byte("same content\n") + diff, _, isFallback, err := SemanticDiff(content, content, "file.txt") + require.NoError(t, err) + assert.True(t, isFallback) + assert.Empty(t, diff) // unified diff of identical content is empty +} + +func TestDeepCompare_NilValues(t *testing.T) { + changes := deepCompare("root", nil, nil) + assert.Empty(t, changes) + + changes = deepCompare("root", nil, "value") + require.Len(t, changes, 1) + assert.Equal(t, "added", changes[0].Type) + + changes = deepCompare("root", "value", nil) + require.Len(t, changes, 1) + assert.Equal(t, "removed", changes[0].Type) +} + +func TestFormatValue(t *testing.T) { + assert.Equal(t, `"hello"`, formatValue("hello")) + assert.Equal(t, "null", formatValue(nil)) + assert.Equal(t, "true", formatValue(true)) + assert.Equal(t, "42", formatValue(float64(42))) + assert.Equal(t, "3.14", formatValue(float64(3.14))) + assert.Equal(t, "42", formatValue(42)) +} + +func TestJoinPath(t *testing.T) { + assert.Equal(t, "key", joinPath("", "key")) + assert.Equal(t, "parent.key", joinPath("parent", "key")) +} + +func TestScalarEqual(t *testing.T) { + assert.True(t, scalarEqual(float64(42), 42)) + assert.True(t, scalarEqual(42, float64(42))) + assert.True(t, scalarEqual(float64(1.5), float64(1.5))) + assert.True(t, scalarEqual("a", "a")) + assert.False(t, scalarEqual("a", "b")) + assert.False(t, scalarEqual(float64(1), float64(2))) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0164b48e5..b5b42a0cd 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -183,6 +183,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + CompareFileContents(t), // Git tools GetRepositoryTree(t),