package render import ( "go/build" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" "github.com/KofTwentyTwo/license-tool/internal/filetype" managedheader "github.com/KofTwentyTwo/license-tool/internal/header" "github.com/KofTwentyTwo/license-tool/internal/model" "github.com/KofTwentyTwo/license-tool/internal/spdx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // writeFile writes content to path for tests, returning any error so callers can // assert on it with require.NoError. func writeFile(path, content string) error { return os.WriteFile(path, []byte(content), 0o644) } // mustLicense fetches a curated license from the frozen spdx package, failing the // test if it is missing (which would mean the vendored snapshot regressed). func mustLicense(t *testing.T, id string) model.License { t.Helper() lic, ok := spdx.Lookup(id) require.Truef(t, ok, "spdx.Lookup(%q)", id) return lic } // ftFor pulls a real FileType from the frozen filetype table. func ftFor(t *testing.T, sample string) model.FileType { t.Helper() ft, ok := filetype.Lookup(sample) require.Truef(t, ok, "filetype.Lookup(%q)", sample) return ft } func TestREUSETags(t *testing.T) { mit := mustLicense(t, "MIT") cases := []struct { name string holder string year string want string }{ { name: "holder and year", holder: "Kingsrook, LLC", year: "2021-2026", want: "SPDX-FileCopyrightText: 2021-2026 Kingsrook, LLC\nSPDX-License-Identifier: MIT", }, { name: "year only", holder: "", year: "2026", want: "SPDX-FileCopyrightText: 2026\nSPDX-License-Identifier: MIT", }, { name: "holder only", holder: "Acme", year: "", want: "SPDX-FileCopyrightText: Acme\nSPDX-License-Identifier: MIT", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { assert.Equal(t, c.want, REUSETags(mit, c.holder, c.year)) }) } } func TestHeaderStylesPlaintextContent(t *testing.T) { agpl := mustLicense(t, "AGPL-3.0-or-later") mit := mustLicense(t, "MIT") goFT := ftFor(t, "x.go") cases := []struct { name string in HeaderInput mustContain []string mustNotHave []string }{ { name: "reuse only emits tags and sentinel, no notice", in: HeaderInput{ License: agpl, Holder: "Kingsrook, LLC", Year: "2026", Style: model.StyleReuse, FileType: goFT, }, mustContain: []string{ managedheader.Sentinel, "SPDX-FileCopyrightText: 2026 Kingsrook, LLC", "SPDX-License-Identifier: AGPL-3.0-or-later", }, mustNotHave: []string{"This program is free software"}, }, { name: "notice only emits the notice block, not the REUSE id tag", in: HeaderInput{ License: agpl, Holder: "Kingsrook, LLC", Year: "2026", Style: model.StyleNotice, FileType: goFT, }, mustContain: []string{ managedheader.Sentinel, "Copyright (c) 2026 Kingsrook, LLC", "This program is free software", "GNU Affero General Public License", }, mustNotHave: []string{"SPDX-License-Identifier:"}, }, { name: "reuse+notice emits both", in: HeaderInput{ License: agpl, Holder: "Kingsrook, LLC", Year: "2021-2026", Style: model.StyleReusePlusNotice, FileType: goFT, }, mustContain: []string{ managedheader.Sentinel, "SPDX-FileCopyrightText: 2021-2026 Kingsrook, LLC", "SPDX-License-Identifier: AGPL-3.0-or-later", "This program is free software", }, }, { name: "notice style on a license without a standard header omits notice gracefully", in: HeaderInput{ License: mit, Holder: "Acme", Year: "2026", Style: model.StyleReusePlusNotice, FileType: goFT, }, mustContain: []string{ "SPDX-License-Identifier: MIT", }, // MIT defines no StandardHeader, so no notice body appears. mustNotHave: []string{"This program is free software", "Permission is hereby granted"}, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := Header(c.in) require.NoError(t, err) for _, s := range c.mustContain { assert.Containsf(t, got, s, "expected header to contain %q", s) } for _, s := range c.mustNotHave { assert.NotContainsf(t, got, s, "expected header NOT to contain %q", s) } }) } } func TestHeaderCommentWrapping(t *testing.T) { mit := mustLicense(t, "MIT") cases := []struct { name string sampleFile string blockOpen string blockClose string linePrefix string isBlock bool }{ {name: "Go block", sampleFile: "x.go", blockOpen: "/*", blockClose: "*/", isBlock: true}, {name: "Java block", sampleFile: "X.java", blockOpen: "/*", blockClose: "*/", isBlock: true}, {name: "XML block", sampleFile: "pom.xml", blockOpen: "", isBlock: true}, {name: "Shell line", sampleFile: "x.sh", linePrefix: "# ", isBlock: false}, {name: "Python line", sampleFile: "x.py", linePrefix: "# ", isBlock: false}, {name: "Rust line", sampleFile: "x.rs", linePrefix: "// ", isBlock: false}, {name: "SQL line", sampleFile: "x.sql", linePrefix: "-- ", isBlock: false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { ft := ftFor(t, c.sampleFile) got, err := Header(HeaderInput{ License: mit, Holder: "Acme", Year: "2026", Style: model.StyleReuse, FileType: ft, }) require.NoError(t, err) // Always LF, always a trailing blank-line separator. assert.True(t, strings.HasSuffix(got, "\n\n"), "header must end with a blank-line separator") assert.NotContains(t, got, "\r", "Header output must be LF-only") lines := strings.Split(strings.TrimRight(got, "\n"), "\n") if c.isBlock { assert.Equal(t, c.blockOpen, lines[0], "first line is the block opener") assert.Equal(t, c.blockClose, lines[len(lines)-1], "last line is the block closer") } else { for _, l := range lines { assert.Truef(t, strings.HasPrefix(l, strings.TrimRight(c.linePrefix, " ")), "every line carries the line prefix; got %q", l) } } }) } } func TestHeaderRejectsSkipType(t *testing.T) { mit := mustLicense(t, "MIT") jsonFT := ftFor(t, "x.json") require.True(t, jsonFT.Skip, "JSON must be a skip type in the frozen table") _, err := Header(HeaderInput{ License: mit, Holder: "Acme", Year: "2026", Style: model.StyleReuse, FileType: jsonFT, }) assert.Error(t, err, "rendering a header for an uncommentable type must error") } func TestLicenseFileAndEntry(t *testing.T) { for _, id := range []string{"MIT", "Apache-2.0", "AGPL-3.0-or-later"} { t.Run(id, func(t *testing.T) { lic := mustLicense(t, id) body, err := LicenseFile(lic) require.NoError(t, err) assert.NotEmpty(t, body) assert.True(t, strings.HasSuffix(body, "\n"), "LICENSE body ends with a newline") assert.False(t, strings.HasSuffix(body, "\n\n"), "exactly one trailing newline") assert.Contains(t, body, strings.SplitN(lic.Text, "\n", 2)[0], "verbatim text preserved") entry, err := LicensesEntry(lic) require.NoError(t, err) assert.Equal(t, body, entry, "LICENSES/.txt is byte-identical to LICENSE") }) } } func TestLicenseFileEmptyText(t *testing.T) { _, err := LicenseFile(model.License{SPDXID: "FAKE"}) assert.Error(t, err, "a license with no vendored text cannot render a LICENSE file") } func TestYearResolver(t *testing.T) { const now = 2026 cases := []struct { name string spec model.YearSpec want string }{ {"current", model.YearSpec{Kind: model.YearCurrent}, "2026"}, {"explicit", model.YearSpec{Kind: model.YearExplicit, Start: 2019}, "2019"}, {"range", model.YearSpec{Kind: model.YearRange, Start: 2019, End: 2026}, "2019-2026"}, {"range collapses equal bounds", model.YearSpec{Kind: model.YearRange, Start: 2026, End: 2026}, "2026"}, {"range collapses inverted bounds", model.YearSpec{Kind: model.YearRange, Start: 2026, End: 2019}, "2026"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, err := NewYearResolver(c.spec).Resolve("", now) require.NoError(t, err) assert.Equal(t, c.want, got) }) } } // --- Mutation: insertion, preserve-first ordering, line endings, BOM --- // makeHeaderLF renders a small known header for mutation tests so assertions can // match exact substrings without depending on full license text. func makeHeaderLF(t *testing.T, sampleFile string) (model.FileType, string) { t.Helper() mit := mustLicense(t, "MIT") ft := ftFor(t, sampleFile) h, err := Header(HeaderInput{ License: mit, Holder: "Acme", Year: "2026", Style: model.StyleReuse, FileType: ft, }) require.NoError(t, err) return ft, h } func TestSpliceInsertGoBeforePackage(t *testing.T) { ft, header := makeHeaderLF(t, "x.go") content := []byte("package main\n\nfunc main() {}\n") out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) // Header must come before the package declaration, which must survive intact. idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT") idxPkg := strings.Index(got, "package main") require.NotEqual(t, -1, idxHeader) require.NotEqual(t, -1, idxPkg) assert.Less(t, idxHeader, idxPkg, "header precedes the package declaration") assert.True(t, strings.HasSuffix(got, "func main() {}\n"), "trailing content preserved exactly") } func TestSpliceInsertAfterShebang(t *testing.T) { ft, header := makeHeaderLF(t, "x.sh") content := []byte("#!/usr/bin/env bash\nset -e\necho hi\n") out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) assert.True(t, strings.HasPrefix(got, "#!/usr/bin/env bash\n"), "shebang must remain the first line") idxShebang := strings.Index(got, "#!/usr/bin/env bash") idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT") assert.Less(t, idxShebang, idxHeader, "header is placed after the shebang") } func TestSpliceInsertAfterXMLDecl(t *testing.T) { ft, header := makeHeaderLF(t, "pom.xml") content := []byte("\n\n") out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) assert.True(t, strings.HasPrefix(got, "\n"), "xml declaration must remain first") idxDecl := strings.Index(got, "\n\n") out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) assert.True(t, strings.HasPrefix(got, "\n"), "doctype must remain the first document content") idxDoctype := strings.Index(strings.ToLower(got), " no tags, no notice. _, err := Header(HeaderInput{ License: mit, Holder: "Acme", Year: "2026", Style: model.StyleNotice, FileType: goFT, }) assert.Error(t, err, "notice-only style on a no-notice license has no body and must error") } func TestCopyrightLineOmittedWhenNoYearOrHolder(t *testing.T) { // AGPL notice with neither holder nor year must not emit a dangling "Copyright" // attribution line. agpl := mustLicense(t, "AGPL-3.0-or-later") goFT := ftFor(t, "x.go") got, err := Header(HeaderInput{ License: agpl, Holder: "", Year: "", Style: model.StyleNotice, FileType: goFT, }) require.NoError(t, err) assert.NotContains(t, got, "Copyright (c)", "no attribution line when holder and year are empty") assert.Contains(t, got, "This program is free software", "notice body still emitted") } func TestInsertAfterShebangNoTrailingNewline(t *testing.T) { // A shebang that is the entire file (no trailing newline) exercises both the // advanceLineIf no-newline branch and the prefix-EOL guard in insertAt. ft, header := makeHeaderLF(t, "x.sh") content := []byte("#!/usr/bin/env bash") // no newline at all out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) assert.True(t, strings.HasPrefix(got, "#!/usr/bin/env bash\n"), "a newline is inserted after a newline-less shebang before the header") idxShebang := strings.Index(got, "#!/usr/bin/env bash") idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT") assert.Less(t, idxShebang, idxHeader) } func TestInsertWhenShebangAbsent(t *testing.T) { // A shell file whose first line is NOT a shebang exercises advanceLineIf's // no-match branch: the boundary stays at 0 and the header leads the file. ft, header := makeHeaderLF(t, "x.sh") content := []byte("set -e\necho hi\n") out, action := Splice(content, ft, header, model.DetectedHeader{Present: false}) got := string(out) assert.Equal(t, "insert", action) idxHeader := strings.Index(got, "SPDX-License-Identifier: MIT") idxBody := strings.Index(got, "set -e") assert.Less(t, idxHeader, idxBody, "header leads when there is no shebang to preserve") } func TestYearResolverGit(t *testing.T) { // gitutil.FirstCommitYear shells out to git; build a tiny real repo so the git // year strategy is exercised end to end. dir := t.TempDir() run := func(args ...string) { cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(cmd.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e", ) out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git %v: %s", args, out) } run("init") require.NoError(t, writeFile(filepath.Join(dir, "f.txt"), "x")) run("add", ".") // Pin the commit date so the first-commit year is deterministic. commitDate := "2019-01-01T00:00:00 +0000" cmd := exec.Command("git", "commit", "-m", "init", "--date", commitDate) cmd.Dir = dir cmd.Env = append(cmd.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e", "GIT_COMMITTER_DATE="+commitDate, ) out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git commit: %s", out) now := time.Now().Year() got, err := NewYearResolver(model.YearSpec{Kind: model.YearGit}).Resolve(dir, now) require.NoError(t, err) want := formatRangeForTest(2019, now) assert.Equal(t, want, got, "git year is first-commit-year..now") } // formatRangeForTest mirrors the production range formatting for the assertion // above without reaching into unexported state in a brittle way. func formatRangeForTest(start, end int) string { if end <= start { return strconv.Itoa(start) } return strconv.Itoa(start) + "-" + strconv.Itoa(end) } func TestYearResolverGitErrorOutsideRepo(t *testing.T) { // A non-repo path must surface an error rather than a guessed year. _, err := NewYearResolver(model.YearSpec{Kind: model.YearGit}).Resolve(t.TempDir(), 2026) assert.Error(t, err, "git year on a non-repo must error, never guess") } func TestYearResolverUnknownKind(t *testing.T) { // An out-of-range YearKind is a programmer error; it must error, not panic. _, err := NewYearResolver(model.YearSpec{Kind: model.YearKind(99)}).Resolve("", 2026) assert.Error(t, err) } // --- White-box helper coverage: blank-line line-wrapping, hasRule miss, EOF guard --- // TestWrapCommentLineStyleBlankLines drives wrapComment directly so the // blank-body-line branch of the line-comment path is exercised deterministically: // a blank input line must become the line prefix trimmed of trailing whitespace // (no trailing spaces emitted), while non-blank lines keep the full prefix. func TestWrapCommentLineStyleBlankLines(t *testing.T) { cases := []struct { name string body string cs model.CommentStyle want string }{ { name: "hash prefix blank line trims trailing space", body: "first\n\nsecond", cs: model.CommentStyle{Block: false, LinePrefix: "# "}, // The middle blank line becomes a bare "#" with no trailing space. want: "# first\n#\n# second", }, { name: "slash prefix blank line trims trailing space", body: "a\n\nb", cs: model.CommentStyle{Block: false, LinePrefix: "// "}, want: "// a\n//\n// b", }, { name: "leading and trailing blank lines", body: "\nmid\n", cs: model.CommentStyle{Block: false, LinePrefix: "-- "}, want: "--\n-- mid\n--", }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := wrapComment(c.body, c.cs) assert.Equal(t, c.want, got) // A blank body line must never carry trailing whitespace. for _, l := range strings.Split(got, "\n") { assert.Equal(t, strings.TrimRight(l, " \t"), l, "no line may carry trailing whitespace, got %q", l) } }) } } // TestWrapCommentBlankLineThroughHeader confirms the same blank-line behavior end // to end: a line-commented file rendered with StyleReusePlusNotice on a license // that defines a standard header produces a blank separator line between the REUSE // tags and the notice, which must wrap to a bare (whitespace-free) prefix. func TestWrapCommentBlankLineThroughHeader(t *testing.T) { agpl := mustLicense(t, "AGPL-3.0-or-later") shFT := ftFor(t, "x.sh") require.False(t, shFT.CommentStyle.Block, "shell must be a line-comment type") got, err := Header(HeaderInput{ License: agpl, Holder: "Acme", Year: "2026", Style: model.StyleReusePlusNotice, FileType: shFT, }) require.NoError(t, err) prefix := strings.TrimRight(shFT.CommentStyle.LinePrefix, " \t") // The blank separator between tags and notice must appear as a bare prefix line. assert.Contains(t, got, "\n"+prefix+"\n", "blank separator wraps to a bare prefix line") for _, l := range strings.Split(strings.TrimRight(got, "\n"), "\n") { assert.Equal(t, strings.TrimRight(l, " \t"), l, "no wrapped line may carry trailing whitespace, got %q", l) } } func TestSpliceNeverAltersBytesOutsideHeaderRegion(t *testing.T) { // Safety invariant: for an insert, the entire original content must appear // verbatim and contiguously somewhere in the output (only the header is added). ft, header := makeHeaderLF(t, "x.go") content := []byte("package main\n\n// pre-existing comment kept\nvar X = 1\n") out, _ := Splice(content, ft, header, model.DetectedHeader{Present: false}) // Everything from the package decl onward must be byte-identical and contiguous. pkgIdx := strings.Index(string(out), "package main") require.NotEqual(t, -1, pkgIdx) assert.Equal(t, string(content), string(out[pkgIdx:]), "all original bytes from the insertion point survive unchanged") } func goFileMatches(t *testing.T, content []byte, goos string) bool { t.Helper() dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "buildtag.go"), content, 0o644)) ctxt := build.Default ctxt.GOOS = goos ctxt.GOARCH = "amd64" matches, err := ctxt.MatchFile(dir, "buildtag.go") require.NoError(t, err) return matches }