package discovery_test

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"regexp"
	"strings"
	"syscall"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/stretchr/testify/require"

	"github.com/cloudflare/pint/internal/diags"
	"github.com/cloudflare/pint/internal/discovery"
	"github.com/cloudflare/pint/internal/git"
	"github.com/cloudflare/pint/internal/parser"
)

func gitCommit(t *testing.T, message string) {
	t.Setenv("GIT_AUTHOR_NAME", "pint")
	t.Setenv("GIT_AUTHOR_EMAIL", "pint@example.com")
	t.Setenv("GIT_COMMITTER_NAME", "pint")
	t.Setenv("GIT_COMMITTER_EMAIL", "pint")
	_, err := git.RunGit(t.Context(), "commit", "-am", "commit "+message)
	require.NoError(t, err, "git commit %s", message)
}

func commitFile(t *testing.T, path, content, message string) {
	err := os.WriteFile(path, []byte(content), 0o644)
	require.NoError(t, err, "write %s", path)
	_, err = git.RunGit(t.Context(), "add", path)
	require.NoError(t, err, "git add")
	gitCommit(t, message)
}

func TestGitBranchFinder(t *testing.T) {
	includeAll := []*regexp.Regexp{regexp.MustCompile(".*")}

	mustParse := func(offset int, s string) parser.Rule {
		p := parser.NewParser(parser.DefaultOptions)
		file := p.Parse(strings.NewReader(strings.Repeat("\n", offset) + s))
		if file.Error.Err != nil {
			panic(fmt.Sprintf("failed to parse rule:\n---\n%s\n---\nerror: %s", s, file.Error))
		}
		if len(file.Groups) != 1 {
			panic(fmt.Sprintf("wrong number of groups returned: %d\n---\n%s\n---", len(file.Groups), s))
		}
		if len(file.Groups[0].Rules) != 1 {
			panic(fmt.Sprintf("wrong number of rules returned: %d\n---\n%s\n---", len(file.Groups[0].Rules), s))
		}
		return file.Groups[0].Rules[0]
	}

	type setupFn func(t *testing.T)

	type testCaseT struct {
		setup      setupFn
		title      string
		err        string
		entries    []*discovery.Entry
		allEntries []*discovery.Entry
		finder     discovery.GitBranchFinder
	}

	testCases := []testCaseT{
		{
			title: "git list PR commits error - main",
			setup: func(_ *testing.T) {},
			finder: discovery.NewGitBranchFinder(
				func(_ context.Context, args ...string) ([]byte, error) {
					return nil, fmt.Errorf("mock git error: %v", args)
				},
				git.NewPathFilter(includeAll, nil, nil),
				"main",
				50,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
			err:     "failed to get the list of modified files from git: mock git error: [log --reverse --no-merges --first-parent --format=%H --name-status main..HEAD]",
		},
		{
			title: "git list PR commits error - master",
			setup: func(_ *testing.T) {},
			finder: discovery.NewGitBranchFinder(
				func(_ context.Context, args ...string) ([]byte, error) {
					return nil, fmt.Errorf("mock git error: %v", args)
				},
				git.NewPathFilter(includeAll, nil, nil),
				"master",
				50,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
			err:     "failed to get the list of modified files from git: mock git error: [log --reverse --no-merges --first-parent --format=%H --name-status master..HEAD]",
		},
		{
			title: "too many commits",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", "# v1\n", "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", "# v2-1\n", "v2-1")
				commitFile(t, "rules.yml", "# v2-2\n", "v2-2")
				commitFile(t, "rules.yml", "# v2-3\n", "v2-3")
				commitFile(t, "rules.yml", "# v2-4\n", "v2-4")
			},
			finder:  discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 3, parser.DefaultOptions, nil),
			entries: nil,
			err:     "number of commits to check (4) is higher than maxCommits (3), exiting",
		},
		{
			title: "git list modified files error",
			setup: func(_ *testing.T) {},
			finder: discovery.NewGitBranchFinder(
				func(_ context.Context, args ...string) ([]byte, error) {
					switch strings.Join(args, " ") {
					case "log --format=%H --no-abbrev-commit --reverse main..HEAD":
						return []byte("c1\nc2\nc3\nc4\n"), nil
					default:
						return nil, fmt.Errorf("mock git error: %v", args)
					}
				},
				git.NewPathFilter(includeAll, nil, nil),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
			err:     "failed to get the list of modified files from git: mock git error: [log --reverse --no-merges --first-parent --format=%H --name-status main..HEAD]",
		},
		{
			title: "git get commit message error",
			setup: func(_ *testing.T) {},
			finder: discovery.NewGitBranchFinder(
				func(_ context.Context, args ...string) ([]byte, error) {
					switch strings.Join(args, " ") {
					case "log --reverse --no-merges --first-parent --format=%H --name-status main..HEAD":
						return []byte("c1\nA\trules.yml\n"), nil
					default:
						return nil, fmt.Errorf("mock git error: %v", args)
					}
				},
				git.NewPathFilter(includeAll, nil, nil),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
			err:     "failed to get commit message for c1: mock git error: [show -s --format=%B c1]",
		},
		{
			title: "git diff error",
			setup: func(_ *testing.T) {},
			finder: discovery.NewGitBranchFinder(
				func(_ context.Context, args ...string) ([]byte, error) {
					switch strings.Join(args, " ") {
					case "log --reverse --no-merges --first-parent --format=%H --name-status main..HEAD":
						return []byte("c1\nA\trules.yml\n"), nil
					case "ls-tree c1^ rules.yml":
						return []byte("100644 blob c0\trules.yml"), nil
					case "ls-tree c1 rules.yml":
						return []byte("100644 blob c1\trules.yml"), nil
					case "show -s --format=%B c1":
						return []byte(""), nil
					case "cat-file blob c0":
						return []byte("# old\n"), nil
					case "cat-file blob c1":
						return []byte("# new\n"), nil
					default:
						return nil, fmt.Errorf("mock git error: %v", args)
					}
				},
				git.NewPathFilter(includeAll, nil, nil),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
			err:     "failed to run git diff for rules.yml: git diff for rules.yml: mock git error: [diff -M c1^..c1 -- rules.yml rules.yml]",
		},
		{
			title: "no rules in file",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", "# v1\n", "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", "# v2\n", "v2")
			},
			finder:  discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: nil,
		},
		{
			title: "no rule changes",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: git.LineNumbers{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:count\n    expr: count(up == 1)\n"),
				},
			},
		},
		{
			title: "rule changed - strict",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:count\n    expr: count(up == 1)\n"),
				},
			},
		},
		{
			title: "rule changed - relaxed",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: up:count
  expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- record: up:count
  expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: up:count\n  expr: count(up == 1)\n"),
				},
			},
		},
		{
			title: "rule changed - empty include list",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: up:count
  expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- record: up:count
  expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(nil, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: up:count\n  expr: count(up == 1)\n"),
				},
			},
		},
		{
			title: "rule changed but file excluded",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(
				git.RunGit,
				git.NewPathFilter([]*regexp.Regexp{regexp.MustCompile("^foo#")}, nil, nil),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: nil,
		},
		{
			title: "rule changed - [skip ci]",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v2\nskip this commit\n[skip ci]\n")
			},
			finder:  discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: nil,
		},
		{
			title: "rule changed - [no ci]",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up == 1)
`, "v2\nskip this commit\n[no ci]\n")
			},
			finder:  discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: nil,
		},
		{
			title: "rule symlinked",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: up:count
  expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				err = os.Symlink("rules.yml", "symlink.yml")
				require.NoError(t, err, "symlink")
				_, err = git.RunGit(t.Context(), "add", "symlink.yml")
				require.NoError(t, err, "git add")
				gitCommit(t, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "symlink.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: git.MakeLineRangeFromTo(1, 3, git.LinesAfter),
					},
					Rule: mustParse(1, "- record: up:count\n  expr: count(up)\n"),
				},
			},
		},
		{
			title: "rule changed - multiple rules",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count:1
    expr: count(up)
  - record: up:count:2
    expr: count(up)
  - record: up:count:3
    expr: count(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count:1
    expr: count(up == 1)
  - record: up:count:2a
    expr: count(up)
  - record: up:count:3
    expr: count(up)
  - record: up:count:4
    expr: count(up)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:count:1\n    expr: count(up == 1)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(6, "  - record: up:count:2a\n    expr: count(up)\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(8, "  - record: up:count:3\n    expr: count(up)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(10, "  - record: up:count:4\n    expr: count(up)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(6, "  - record: up:count:2\n    expr: count(up)\n"),
				},
			},
		},
		{
			title: "rule changed - added extra line",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
  for: 0s
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
  for: 0s
- alert: rule2
  expr: sum(foo) by(job)
  for: 0s
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 4, Modified: true},
							{Before: 4, After: 5, Modified: false},
							{Before: 5, After: 6, Modified: false},
							{Before: 6, After: 7, Modified: false},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n  for: 0s\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 4, Modified: true},
							{Before: 4, After: 5, Modified: false},
							{Before: 5, After: 6, Modified: false},
							{Before: 6, After: 7, Modified: false},
						},
					},
					Rule: mustParse(4, "- alert: rule2\n  expr: sum(foo) by(job)\n  for: 0s\n"),
				},
			},
		},
		{
			title: "rule removed - head",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule2
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 2, After: 0},
							{Before: 3, After: 0},
							{Before: 4, After: 2},
							{Before: 5, After: 3},
						},
					},
					Rule: mustParse(1, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 2, After: 0},
							{Before: 3, After: 0},
							{Before: 4, After: 2},
							{Before: 5, After: 3},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rule removed - tail",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: git.MakeLineRangeFromTo(4, 5, git.LinesBefore),
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: git.MakeLineRangeFromTo(4, 5, git.LinesBefore),
					},
					Rule: mustParse(3, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rule removed - middle",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 4, After: 0},
							{Before: 5, After: 0},
							{Before: 6, After: 4},
							{Before: 7, After: 5},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 4, After: 0},
							{Before: 5, After: 0},
							{Before: 6, After: 4},
							{Before: 7, After: 5},
						},
					},
					Rule: mustParse(3, "- alert: rule3\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 4, After: 0},
							{Before: 5, After: 0},
							{Before: 6, After: 4},
							{Before: 7, After: 5},
						},
					},
					Rule: mustParse(3, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rule fixed",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: count(up)
    expr: sum(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    expr: count(up)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},

					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 7, After: 0},
						},
					},
					Rule: mustParse(4, "  - record: up:count\n    expr: count(up)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 7, After: 0},
						},
					},
					Rule: parser.Rule{
						Lines: diags.LineRange{First: 5, Last: 7},
						Error: parser.ParseError{
							Line: 7,
							Err:  errors.New("duplicated expr key"),
						},
					},
				},
			},
		},
		{
			title: "rules duplicated",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule1
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
						},
					},
					Rule: mustParse(3, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
						},
					},
					Rule: mustParse(5, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
						},
					},
					Rule: mustParse(7, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rules duplicated with different query",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: up == 0
- alert: rule1
  expr: up == 1
- alert: rule1
  expr: up != 0
- alert: rule2
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 3, Modified: true},
							{Before: 0, After: 4, Modified: true},
							{Before: 0, After: 5, Modified: true},
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 3, After: 9},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: up == 0\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 3, Modified: true},
							{Before: 0, After: 4, Modified: true},
							{Before: 0, After: 5, Modified: true},
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 3, After: 9},
						},
					},
					Rule: mustParse(3, "- alert: rule1\n  expr: up == 1\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 3, Modified: true},
							{Before: 0, After: 4, Modified: true},
							{Before: 0, After: 5, Modified: true},
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 3, After: 9},
						},
					},
					Rule: mustParse(5, "- alert: rule1\n  expr: up != 0\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 3, Modified: true},
							{Before: 0, After: 4, Modified: true},
							{Before: 0, After: 5, Modified: true},
							{Before: 0, After: 6, Modified: true},
							{Before: 0, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 3, After: 9},
						},
					},
					Rule: mustParse(7, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rule changed - modified for and added extra lines",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
  for: 1s
- alert: rule2
  expr: sum(foo) by(job)
  for: 1s
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
  for: 1s
- alert: rule2
  expr: sum(foo) by(job)
  keep_firing_for: 5m
  for: 0s
  annotations:
    foo: bar
  labels:
    foo: bar
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n  for: 1s\n"),
				},
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 7, After: 7, Modified: true},
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
						},
					},
					Rule: mustParse(4, "- alert: rule2\n  expr: sum(foo) by(job)\n  keep_firing_for: 5m\n  for: 0s\n  annotations:\n    foo: bar\n  labels:\n    foo: bar\n"),
				},
			},
		},
		{
			title: "rule file moved",
			setup: func(t *testing.T) {
				commitFile(t, "a.yml", `
- alert: rule
  expr: up == 0
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				_, err = git.RunGit(t.Context(), "mv", "a.yml", "b.yml")
				require.NoError(t, err, "git mv")

				gitCommit(t, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Moved,
					Path: discovery.Path{
						Name:          "b.yml",
						SymlinkTarget: "b.yml",
					},
					Changes: &discovery.Changes{
						OldPath: "a.yml",
						// Renamed without content changes.
						Lines: git.LineNumbers{},
					},
					Rule: mustParse(1, "- alert: rule\n  expr: up == 0\n"),
				},
			},
		},
		{
			title: "rule modified then the file renamed",
			setup: func(t *testing.T) {
				commitFile(t, "a.yml", `
- alert: rule
  # pint disable promql/series
  expr: up == 0
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "a.yml", `
- alert: rule
  expr: up == 0
`, "v2")

				_, err = git.RunGit(t.Context(), "mv", "a.yml", "b.yml")
				require.NoError(t, err, "git mv")

				gitCommit(t, "v3")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Moved,
					Path: discovery.Path{
						Name:          "b.yml",
						SymlinkTarget: "b.yml",
					},
					Changes: &discovery.Changes{
						OldPath: "a.yml",
						Lines: []git.LineNumber{
							{Before: 3, After: 0},
							{Before: 4, After: 3},
						},
					},
					Rule: mustParse(1, "- alert: rule\n  expr: up == 0\n"),
				},
			},
		},
		{
			title: "rule file with symlink moved",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule
  expr: up == 0
`, "v1")

				err := os.Symlink("rules.yml", "symlink.yml")
				require.NoError(t, err, "symlink")
				_, err = git.RunGit(t.Context(), "add", "symlink.yml")
				require.NoError(t, err, "git add")
				gitCommit(t, "v1")

				_, err = git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				_, err = git.RunGit(t.Context(), "mv", "rules.yml", "new.yml")
				require.NoError(t, err, "git mv")

				_, err = git.RunGit(t.Context(), "rm", "-f", "symlink.yml")
				require.NoError(t, err, "git rm symlink")

				gitCommit(t, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Moved,
					Path: discovery.Path{
						Name:          "new.yml",
						SymlinkTarget: "new.yml",
					},
					Changes: &discovery.Changes{
						OldPath: "rules.yml",
						// Renamed without content changes.
						Lines: git.LineNumbers{},
					},
					Rule: mustParse(1, "- alert: rule\n  expr: up == 0\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "symlink.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: git.MakeLineRangeFromTo(1, 3, git.LinesBefore),
					},
					Rule: mustParse(1, "- alert: rule\n  expr: up == 0\n"),
				},
			},
		},
		{
			title: "rule broken",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: rule1
    expr: sum(up)
  - record: rule2
    expr: sum(up)
  - record: rule3
    expr: sum(up)
  - record: rule4
    expr: sum(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: rule1
    expr: sum(up)
  - record: rule2
    expr: sum(up)
  - record: rule3
    expr: sum(up)
    +
    sum(up)
  - record: rule4
    expr: sum(up)
  - record: rule5
    expr: sum(up)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 0, After: 11, Modified: true},
							{Before: 0, After: 12, Modified: true},
							{Before: 11, After: 13},
							{Before: 12, After: 14},
							{Before: 0, After: 15, Modified: true},
							{Before: 0, After: 16, Modified: true},
						},
					},
					PathError: parser.ParseError{
						Line: 11,
						Err:  errors.New("could not find expected ':'"),
					},
				},
			},
		},
		{
			title: "file/disable comment removed",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
# pint file/disable promql/series

- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `


- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 2, After: 2, Modified: true},
						},
					},
					Rule: mustParse(3, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 2, After: 2, Modified: true},
						},
					},
					Rule: mustParse(5, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 2, After: 2, Modified: true},
						},
					},
					Rule: mustParse(7, "- alert: rule3\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "add two dups",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule2
  expr: sum(foo) by(job)
- alert: rule3
  expr: sum(foo) by(job)
- alert: rule1
  expr: sum(foo) by(job)
- alert: rule1
  expr: sum(foo) by(job)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
						},
					},
					Rule: mustParse(3, "- alert: rule2\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
						},
					},
					Rule: mustParse(5, "- alert: rule3\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
						},
					},
					Rule: mustParse(7, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 8, Modified: true},
							{Before: 0, After: 9, Modified: true},
							{Before: 0, After: 10, Modified: true},
							{Before: 0, After: 11, Modified: true},
						},
					},
					Rule: mustParse(9, "- alert: rule1\n  expr: sum(foo) by(job)\n"),
				},
			},
		},
		{
			title: "rule comment modified",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    pint disable promql/series(up)
    expr: sum(up)
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v2
  rules:
  - record: up:count
    # pint disable promql/series(up)
    expr: sum(up)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:count\n    # pint disable promql/series(up)\n    expr: sum(up)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 6, After: 6, Modified: true},
						},
					},
					PathError: parser.ParseError{
						Err:  errors.New("xxx"),
						Line: 6,
					},
				},
			},
		},
		{
			title: "rule partially replaced",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:sum
    expr: |
      sum(
        up
      )
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: up:count
    expr: |
      count(
        up
      )
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 5, After: 5, Modified: true},
							{Before: 7, After: 7, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:count\n    expr: |\n      count(\n        up\n      )\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 5, After: 5, Modified: true},
							{Before: 7, After: 7, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: up:sum\n    expr: |\n      sum(\n        up\n      )\n"),
				},
			},
		},
		{
			title: "broken symlink does not prevent rule discovery",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: foo
    expr: bar
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
groups:
- name: v1
  rules:
  - record: foo
    expr: sum(bar)
`, "v2")

				require.NoError(t, os.Symlink("/nonexistent/path", "broken.yml"))
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, nil), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 6, After: 6, Modified: true},
						},
					},
					Rule: mustParse(4, "  - record: foo\n    expr: sum(bar)\n"),
				},
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "broken.yml",
						SymlinkTarget: "broken.yml",
					},
					PathError: fmt.Errorf(
						"this is a symlink but target file cannot be evaluated: %w",
						&fs.PathError{
							Op:   "lstat",
							Path: "/nonexistent",
							Err:  syscall.ENOENT,
						},
					),
				},
			},
		},
		{
			title: "multiple rules with same name in before",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- alert: rule1
  expr: up == 0
- alert: rule1
  expr: up == 1
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- alert: rule1
  expr: up != 0
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 4, After: 0},
							{Before: 5, After: 0},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: up != 0\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 4, After: 0},
							{Before: 5, After: 0},
						},
					},
					Rule: mustParse(1, "- alert: rule1\n  expr: up == 0\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
							{Before: 4, After: 0},
							{Before: 5, After: 0},
						},
					},
					Rule: mustParse(3, "- alert: rule1\n  expr: up == 1\n"),
				},
			},
		},
		{
			title: "allEntries matching updates existing entry",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: foo
  expr: bar
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- record: foo
  expr: sum(bar)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			allEntries: []*discovery.Entry{
				{
					State: discovery.Noop,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Rule: mustParse(1, "- record: foo\n  expr: sum(bar)\n"),
				},
			},
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: foo\n  expr: sum(bar)\n"),
				},
			},
		},
		{
			title: "symlink passes path filter",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: foo
  expr: bar
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- record: foo
  expr: sum(bar)
`, "v2")

				require.NoError(t, os.Symlink("rules.yml", "link.yml"))
			},
			finder: discovery.NewGitBranchFinder(git.RunGit, git.NewPathFilter(includeAll, nil, includeAll), "main", 4, parser.DefaultOptions, nil),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: foo\n  expr: sum(bar)\n"),
				},
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "link.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: foo\n  expr: sum(bar)\n"),
				},
			},
		},
		{
			title: "symlink filtered by path filter",
			setup: func(t *testing.T) {
				commitFile(t, "rules.yml", `
- record: foo
  expr: bar
`, "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				commitFile(t, "rules.yml", `
- record: foo
  expr: sum(bar)
`, "v2")

				require.NoError(t, os.Symlink("rules.yml", "link.yml"))
			},
			finder: discovery.NewGitBranchFinder(
				git.RunGit,
				git.NewPathFilter(
					[]*regexp.Regexp{regexp.MustCompile(`^rules\.yml$`)},
					nil,
					includeAll,
				),
				"main", 4, parser.DefaultOptions, nil,
			),
			entries: []*discovery.Entry{
				{
					State: discovery.Modified,
					Path: discovery.Path{
						Name:          "rules.yml",
						SymlinkTarget: "rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 3, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: foo\n  expr: sum(bar)\n"),
				},
			},
		},
		{
			title: "rule added - relaxed path",
			setup: func(t *testing.T) {
				commitFile(t, "base.yml", "groups:\n- name: base\n  rules: []\n", "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				require.NoError(t, os.MkdirAll("relaxed", 0o755))
				commitFile(t, "relaxed/rules.yml", `
- record: up:count
  expr: count(up == 1)
`, "v2")
			},
			finder: discovery.NewGitBranchFinder(
				git.RunGit,
				git.NewPathFilter(
					includeAll,
					nil,
					[]*regexp.Regexp{
						regexp.MustCompile("^relaxed/.*"),
					},
				),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "relaxed/rules.yml",
						SymlinkTarget: "relaxed/rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 1, Modified: true},
							{Before: 0, After: 2, Modified: true},
							{Before: 0, After: 3, Modified: true},
						},
					},
					Rule: mustParse(1, "- record: up:count\n  expr: count(up == 1)\n"),
				},
			},
		},
		{
			title: "rule file moved from strict to relaxed path",
			setup: func(t *testing.T) {
				commitFile(t, "strict.yml", "groups:\n- name: base\n  rules:\n  - record: up:count\n    expr: count(up)\n", "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				require.NoError(t, os.MkdirAll("relaxed", 0o755))
				_, err = git.RunGit(t.Context(), "mv", "strict.yml", "relaxed/rules.yml")
				require.NoError(t, err, "git mv")

				commitFile(t, "relaxed/rules.yml", "- record: up:count\n  expr: count(up == 1)\n", "v2")
			},
			finder: discovery.NewGitBranchFinder(
				git.RunGit,
				git.NewPathFilter(
					includeAll,
					nil,
					[]*regexp.Regexp{
						regexp.MustCompile("^relaxed/.*"),
					},
				),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: []*discovery.Entry{
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "relaxed/rules.yml",
						SymlinkTarget: "relaxed/rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 1, Modified: true},
							{Before: 0, After: 2, Modified: true},
						},
					},
					Rule: mustParse(0, "- record: up:count\n  expr: count(up == 1)\n"),
				},
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "strict.yml",
						SymlinkTarget: "strict.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 1, After: 0, Modified: false},
							{Before: 2, After: 0, Modified: false},
							{Before: 3, After: 0, Modified: false},
							{Before: 4, After: 0, Modified: false},
							{Before: 5, After: 0, Modified: false},
						},
					},
					Rule: mustParse(3, "  - record: up:count\n    expr: count(up)\n"),
				},
			},
		},
		{
			title: "rule file moved from relaxed to strict path",
			setup: func(t *testing.T) {
				require.NoError(t, os.MkdirAll("relaxed", 0o755))
				commitFile(t, "relaxed/rules.yml", "- record: up:count\n  expr: count(up)\n", "v1")

				_, err := git.RunGit(t.Context(), "checkout", "-b", "v2")
				require.NoError(t, err, "git checkout v2")

				_, err = git.RunGit(t.Context(), "mv", "relaxed/rules.yml", "strict.yml")
				require.NoError(t, err, "git mv")

				commitFile(t, "strict.yml", "groups:\n- name: base\n  rules:\n  - record: up:count\n    expr: count(up == 1)\n", "v2")
			},
			finder: discovery.NewGitBranchFinder(
				git.RunGit,
				git.NewPathFilter(
					includeAll,
					nil,
					[]*regexp.Regexp{
						regexp.MustCompile("^relaxed/.*"),
					},
				),
				"main",
				4,
				parser.DefaultOptions,
				nil,
			),
			entries: []*discovery.Entry{
				{
					State: discovery.Removed,
					Path: discovery.Path{
						Name:          "relaxed/rules.yml",
						SymlinkTarget: "relaxed/rules.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 1, After: 0, Modified: false},
							{Before: 2, After: 0, Modified: false},
						},
					},
					Rule: mustParse(0, "- record: up:count\n  expr: count(up)\n"),
				},
				{
					State: discovery.Added,
					Path: discovery.Path{
						Name:          "strict.yml",
						SymlinkTarget: "strict.yml",
					},
					Changes: &discovery.Changes{
						Lines: []git.LineNumber{
							{Before: 0, After: 1, Modified: true},
							{Before: 0, After: 2, Modified: true},
							{Before: 0, After: 3, Modified: true},
							{Before: 0, After: 4, Modified: true},
							{Before: 0, After: 5, Modified: true},
						},
					},
					Rule: mustParse(3, "  - record: up:count\n    expr: count(up == 1)\n"),
				},
			},
		},
	}

	for _, tc := range testCases {
		t.Run(tc.title, func(t *testing.T) {
			dir := t.TempDir()
			t.Chdir(dir)

			_, err := git.RunGit(t.Context(), "init", "--initial-branch=main", ".")
			require.NoError(t, err, "git init")

			tc.setup(t)
			entries, err := tc.finder.Find(t.Context(), tc.allEntries)
			if tc.err != "" {
				require.EqualError(t, err, tc.err)
			} else {
				require.NoError(t, err, "tc.finder.Find()")

				expected, err := json.MarshalIndent(tc.entries, "", "  ")
				require.NoError(t, err, "json(expected)")
				got, err := json.MarshalIndent(entries, "", "  ")
				require.NoError(t, err, "json(got)")
				if diff := cmp.Diff(string(expected), string(got)); diff != "" {
					t.Errorf("tc.finder.Find() returned wrong output (-want +got):\n%s", diff)
					return
				}
			}
		})
	}
}
