diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-29 10:17:46 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-29 10:17:46 +0300 |
| commit | 2b60fb406a178edb1822fb62b28b4e07e77dc0ed (patch) | |
| tree | b677f69bf7ede39643b82bab28de4c439b97422f /internal | |
| parent | 2d62ca0240f985703f98c430ff5f16214c41f708 (diff) | |
refactor(showcase): decompose formatGemtext and guard zero totals (oq)
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/showcase/showcase.go | 292 | ||||
| -rw-r--r-- | internal/showcase/showcase_test.go | 40 |
2 files changed, 197 insertions, 135 deletions
diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 7b80de6..90a4dc7 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -860,10 +860,29 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool return projectSummary, nil } -// formatGemtext formats the summaries as Gemini Gemtext +type showcaseOverviewStats struct { + totalProjects int + totalCommits int + totalLOC int + totalDocs int + releasedCount int + languageStats []LanguageStats + docStats []LanguageStats +} + +// formatGemtext formats the summaries as Gemini Gemtext. func (g *Generator) formatGemtext(summaries []ProjectSummary) string { var builder strings.Builder + writeGemtextHeader(&builder) + stats := collectShowcaseOverviewStats(summaries) + writeShowcaseOverviewStats(&builder, stats) + g.writeProjectsGemtext(&builder, summaries) + + return builder.String() +} + +func writeGemtextHeader(builder *strings.Builder) { // Header builder.WriteString("# Project Showcase\n\n") @@ -879,192 +898,195 @@ func (g *Generator) formatGemtext(summaries []ProjectSummary) string { // Template inline TOC builder.WriteString("<< template::inline::toc\n\n") +} - // Calculate total stats - totalProjects := len(summaries) - totalCommits := 0 - totalLOC := 0 - totalDocs := 0 - releasedCount := 0 +func collectShowcaseOverviewStats(summaries []ProjectSummary) showcaseOverviewStats { + stats := showcaseOverviewStats{ + totalProjects: len(summaries), + } languageTotals := make(map[string]int) docTotals := make(map[string]int) for _, summary := range summaries { - if summary.Metadata != nil { - totalCommits += summary.Metadata.CommitCount - totalLOC += summary.Metadata.LinesOfCode - totalDocs += summary.Metadata.LinesOfDocs + if summary.Metadata == nil { + continue + } - // Count projects with releases - if summary.Metadata.HasReleases { - releasedCount++ - } + stats.totalCommits += summary.Metadata.CommitCount + stats.totalLOC += summary.Metadata.LinesOfCode + stats.totalDocs += summary.Metadata.LinesOfDocs - // Aggregate language statistics - for _, lang := range summary.Metadata.Languages { - languageTotals[lang.Name] += lang.Lines - } + if summary.Metadata.HasReleases { + stats.releasedCount++ + } - // Aggregate documentation statistics - for _, doc := range summary.Metadata.Documentation { - docTotals[doc.Name] += doc.Lines - } + for _, lang := range summary.Metadata.Languages { + languageTotals[lang.Name] += lang.Lines } - } - // Calculate language percentages - var languageStats []LanguageStats - for name, lines := range languageTotals { - percentage := 0.0 - if totalLOC > 0 { - percentage = float64(lines) * 100.0 / float64(totalLOC) + for _, doc := range summary.Metadata.Documentation { + docTotals[doc.Name] += doc.Lines } - languageStats = append(languageStats, LanguageStats{ - Name: name, - Lines: lines, - Percentage: percentage, - }) } - // Sort languages by percentage - sort.Slice(languageStats, func(i, j int) bool { - return languageStats[i].Percentage > languageStats[j].Percentage - }) + stats.languageStats = buildSortedPercentageStats(languageTotals, stats.totalLOC) + stats.docStats = buildSortedPercentageStats(docTotals, stats.totalDocs) + return stats +} - // Calculate documentation percentages - var docStats []LanguageStats - for name, lines := range docTotals { +func buildSortedPercentageStats(lineTotalsByName map[string]int, totalLines int) []LanguageStats { + stats := make([]LanguageStats, 0, len(lineTotalsByName)) + for name, lines := range lineTotalsByName { percentage := 0.0 - if totalDocs > 0 { - percentage = float64(lines) * 100.0 / float64(totalDocs) + if totalLines > 0 { + percentage = float64(lines) * 100.0 / float64(totalLines) } - docStats = append(docStats, LanguageStats{ + stats = append(stats, LanguageStats{ Name: name, Lines: lines, Percentage: percentage, }) } - // Sort documentation by percentage - sort.Slice(docStats, func(i, j int) bool { - return docStats[i].Percentage > docStats[j].Percentage + sort.Slice(stats, func(i, j int) bool { + return stats[i].Percentage > stats[j].Percentage }) + return stats +} - // Write total stats section +func writeShowcaseOverviewStats(builder *strings.Builder, stats showcaseOverviewStats) { builder.WriteString("## Overall Statistics\n\n") - builder.WriteString(fmt.Sprintf("* ๐ฆ Total Projects: %d\n", totalProjects)) - builder.WriteString(fmt.Sprintf("* ๐ Total Commits: %s\n", formatNumber(totalCommits))) - builder.WriteString(fmt.Sprintf("* ๐ Total Lines of Code: %s\n", formatNumber(totalLOC))) - if totalDocs > 0 { - builder.WriteString(fmt.Sprintf("* ๐ Total Lines of Documentation: %s\n", formatNumber(totalDocs))) + builder.WriteString(fmt.Sprintf("* ๐ฆ Total Projects: %d\n", stats.totalProjects)) + builder.WriteString(fmt.Sprintf("* ๐ Total Commits: %s\n", formatNumber(stats.totalCommits))) + builder.WriteString(fmt.Sprintf("* ๐ Total Lines of Code: %s\n", formatNumber(stats.totalLOC))) + if stats.totalDocs > 0 { + builder.WriteString(fmt.Sprintf("* ๐ Total Lines of Documentation: %s\n", formatNumber(stats.totalDocs))) } - if len(languageStats) > 0 { - builder.WriteString(fmt.Sprintf("* ๐ป Languages: %s\n", FormatLanguagesWithPercentages(languageStats))) + if len(stats.languageStats) > 0 { + builder.WriteString(fmt.Sprintf("* ๐ป Languages: %s\n", FormatLanguagesWithPercentages(stats.languageStats))) } - if len(docStats) > 0 { - builder.WriteString(fmt.Sprintf("* ๐ Documentation: %s\n", FormatLanguagesWithPercentages(docStats))) + if len(stats.docStats) > 0 { + builder.WriteString(fmt.Sprintf("* ๐ Documentation: %s\n", FormatLanguagesWithPercentages(stats.docStats))) } - experimentalCount := totalProjects - releasedCount + + experimentalCount, releasedPercentage, experimentalPercentage := releaseStatusBreakdown(stats.totalProjects, stats.releasedCount) builder.WriteString(fmt.Sprintf("* ๐ Release Status: %d released, %d experimental (%.1f%% with releases, %.1f%% experimental)\n", - releasedCount, experimentalCount, - float64(releasedCount)*100/float64(totalProjects), - float64(experimentalCount)*100/float64(totalProjects))) + stats.releasedCount, experimentalCount, releasedPercentage, experimentalPercentage)) builder.WriteString("\n") +} + +func releaseStatusBreakdown(totalProjects, releasedCount int) (experimentalCount int, releasedPercentage, experimentalPercentage float64) { + experimentalCount = totalProjects - releasedCount + if totalProjects == 0 { + return experimentalCount, 0, 0 + } + releasedPercentage = float64(releasedCount) * 100 / float64(totalProjects) + experimentalPercentage = float64(experimentalCount) * 100 / float64(totalProjects) + return experimentalCount, releasedPercentage, experimentalPercentage +} - // Add Projects section +func (g *Generator) writeProjectsGemtext(builder *strings.Builder, summaries []ProjectSummary) { builder.WriteString("## Projects\n\n") - // Add each project for i, summary := range summaries { if i > 0 { builder.WriteString("\n---\n\n") } - builder.WriteString(fmt.Sprintf("### %d. %s%s\n\n", i+1, summary.Name, formatRankHistoryForHeader(summary.RankHistory))) + g.writeProjectGemtext(builder, i, summary) + } +} - // Add metadata if available - if summary.Metadata != nil { - if len(summary.Metadata.Languages) > 0 { - builder.WriteString(fmt.Sprintf("* ๐ป Languages: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Languages))) - } - if len(summary.Metadata.Documentation) > 0 { - builder.WriteString(fmt.Sprintf("* ๐ Documentation: %s\n", FormatLanguagesWithPercentages(summary.Metadata.Documentation))) - } - builder.WriteString(fmt.Sprintf("* ๐ Commits: %d\n", summary.Metadata.CommitCount)) - builder.WriteString(fmt.Sprintf("* ๐ Lines of Code: %d\n", summary.Metadata.LinesOfCode)) - if summary.Metadata.LinesOfDocs > 0 { - builder.WriteString(fmt.Sprintf("* ๐ Lines of Documentation: %d\n", summary.Metadata.LinesOfDocs)) - } - builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Tags: %d\n", summary.Metadata.TagCount)) - builder.WriteString(fmt.Sprintf("* ๐
Development Period: %s to %s\n", summary.Metadata.FirstCommitDate, summary.Metadata.LastCommitDate)) - builder.WriteString(fmt.Sprintf("* ๐ Score: %.1f (combines recent activity, code size, tags, and release status)\n", summary.Metadata.Score)) - builder.WriteString(fmt.Sprintf("* โ๏ธ License: %s\n", summary.Metadata.License)) - - // Add release information or experimental status - if summary.Metadata.HasReleases && summary.Metadata.LatestTag != "" { - if summary.Metadata.LatestTagDate != "" { - builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Latest Release: %s (%s)\n", summary.Metadata.LatestTag, summary.Metadata.LatestTagDate)) - } else { - builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Latest Release: %s\n", summary.Metadata.LatestTag)) - } - } else { - builder.WriteString("* ๐งช Status: Experimental (no releases yet)\n") - } +func (g *Generator) writeProjectGemtext(builder *strings.Builder, index int, summary ProjectSummary) { + builder.WriteString(fmt.Sprintf("### %d. %s%s\n\n", index+1, summary.Name, formatRankHistoryForHeader(summary.RankHistory))) + writeProjectMetadata(builder, summary.Metadata) + writeProjectSummaryContent(builder, summary) + writeProjectLinks(builder, summary) +} - // Mark as inactive when the average age of the last 42 commits exceeds - // 730 days (~2 years). A single recent commit (e.g. a deprecation - // notice) does not rescue a dormant project โ ~42 recent commits are - // required to move the average below the threshold. This matches the - // grey-line rule used in the interactive rank-history SVG. - if summary.Metadata.AvgCommitAge > 730 { - builder.WriteString("\nโ ๏ธ **Notice**: This project appears to be inactive or no longer maintained. The average age of its last 42 commits exceeds 2 years. Use at your own risk.") - } - builder.WriteString("\n\n") - } +func writeProjectMetadata(builder *strings.Builder, metadata *RepoMetadata) { + if metadata == nil { + return + } - // Handle images and paragraphs - paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary)) + if len(metadata.Languages) > 0 { + builder.WriteString(fmt.Sprintf("* ๐ป Languages: %s\n", FormatLanguagesWithPercentages(metadata.Languages))) + } + if len(metadata.Documentation) > 0 { + builder.WriteString(fmt.Sprintf("* ๐ Documentation: %s\n", FormatLanguagesWithPercentages(metadata.Documentation))) + } + builder.WriteString(fmt.Sprintf("* ๐ Commits: %d\n", metadata.CommitCount)) + builder.WriteString(fmt.Sprintf("* ๐ Lines of Code: %d\n", metadata.LinesOfCode)) + if metadata.LinesOfDocs > 0 { + builder.WriteString(fmt.Sprintf("* ๐ Lines of Documentation: %d\n", metadata.LinesOfDocs)) + } + builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Tags: %d\n", metadata.TagCount)) + builder.WriteString(fmt.Sprintf("* ๐
Development Period: %s to %s\n", metadata.FirstCommitDate, metadata.LastCommitDate)) + builder.WriteString(fmt.Sprintf("* ๐ Score: %.1f (combines recent activity, code size, tags, and release status)\n", metadata.Score)) + builder.WriteString(fmt.Sprintf("* โ๏ธ License: %s\n", metadata.License)) - // If we have images, distribute them nicely - if len(summary.Images) > 0 { - // First image after metadata, before text - builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[0], summary.Name)) + // Add release information or experimental status. + if metadata.HasReleases && metadata.LatestTag != "" { + if metadata.LatestTagDate != "" { + builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Latest Release: %s (%s)\n", metadata.LatestTag, metadata.LatestTagDate)) + } else { + builder.WriteString(fmt.Sprintf("* ๐ท๏ธ Latest Release: %s\n", metadata.LatestTag)) + } + } else { + builder.WriteString("* ๐งช Status: Experimental (no releases yet)\n") + } - // First paragraph - if len(paragraphs) > 0 { - builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0]))) - } + // Mark as inactive when the average age of the last 42 commits exceeds + // 730 days (~2 years). A single recent commit (e.g. a deprecation + // notice) does not rescue a dormant project โ ~42 recent commits are + // required to move the average below the threshold. This matches the + // grey-line rule used in the interactive rank-history SVG. + if metadata.AvgCommitAge > 730 { + builder.WriteString("\nโ ๏ธ **Notice**: This project appears to be inactive or no longer maintained. The average age of its last 42 commits exceeds 2 years. Use at your own risk.") + } + builder.WriteString("\n\n") +} - // Second image after first paragraph (if we have 2 images and multiple paragraphs) - if len(summary.Images) > 1 && len(paragraphs) > 1 { - builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[1], summary.Name)) - } +func writeProjectSummaryContent(builder *strings.Builder, summary ProjectSummary) { + paragraphs := splitSummaryParagraphs(sanitizeSummaryForGemtext(summary.Summary)) - // Remaining paragraphs - for i := 1; i < len(paragraphs); i++ { - builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[i]))) - } - } else { - // No images - just add all paragraphs - for _, para := range paragraphs { - builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(para))) - } - } + if len(summary.Images) > 0 { + // First image after metadata, before text. + builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[0], summary.Name)) - // Add links - if summary.CodebergURL != "" { - builder.WriteString(fmt.Sprintf("=> %s View on Codeberg\n", summary.CodebergURL)) + // First paragraph. + if len(paragraphs) > 0 { + builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[0]))) } - if summary.GitHubURL != "" { - builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL)) + + // Second image after first paragraph (if we have 2 images and multiple paragraphs). + if len(summary.Images) > 1 && len(paragraphs) > 1 { + builder.WriteString(fmt.Sprintf("=> %s %s screenshot\n\n", summary.Images[1], summary.Name)) } - if summary.CgitURL != "" { - builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL)) + + // Remaining paragraphs. + for i := 1; i < len(paragraphs); i++ { + builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(paragraphs[i]))) } + return + } + // No images - just add all paragraphs. + for _, para := range paragraphs { + builder.WriteString(fmt.Sprintf("%s\n\n", strings.TrimSpace(para))) } +} - return builder.String() +func writeProjectLinks(builder *strings.Builder, summary ProjectSummary) { + if summary.CodebergURL != "" { + builder.WriteString(fmt.Sprintf("=> %s View on Codeberg\n", summary.CodebergURL)) + } + if summary.GitHubURL != "" { + builder.WriteString(fmt.Sprintf("=> %s View on GitHub\n", summary.GitHubURL)) + } + if summary.CgitURL != "" { + builder.WriteString(fmt.Sprintf("=> %s View in cgit\n", summary.CgitURL)) + } } // showcaseOutputDir returns the canonical directory where showcase output files diff --git a/internal/showcase/showcase_test.go b/internal/showcase/showcase_test.go index ca40293..6341e41 100644 --- a/internal/showcase/showcase_test.go +++ b/internal/showcase/showcase_test.go @@ -167,6 +167,46 @@ func TestFormatGemtext_IncludesCgitLink(t *testing.T) { } } +func TestFormatGemtext_ZeroProjectsReleasePercentagesAreZero(t *testing.T) { + t.Parallel() + + g := &Generator{config: &config.Config{}} + content := g.formatGemtext(nil) + + if !strings.Contains(content, "* ๐ Release Status: 0 released, 0 experimental (0.0% with releases, 0.0% experimental)\n") { + t.Fatalf("unexpected release status for zero projects: %s", content) + } + if strings.Contains(content, "NaN") || strings.Contains(content, "Inf") { + t.Fatalf("release status should not include NaN/Inf: %s", content) + } +} + +func TestFormatGemtext_ReleaseStatusPercentagesForNonEmptySummaries(t *testing.T) { + t.Parallel() + + g := &Generator{config: &config.Config{}} + content := g.formatGemtext([]ProjectSummary{ + { + Name: "released", + Summary: "released summary", + Metadata: &RepoMetadata{ + HasReleases: true, + }, + }, + { + Name: "experimental", + Summary: "experimental summary", + Metadata: &RepoMetadata{ + HasReleases: false, + }, + }, + }) + + if !strings.Contains(content, "* ๐ Release Status: 1 released, 1 experimental (50.0% with releases, 50.0% experimental)\n") { + t.Fatalf("unexpected release status for non-empty summaries: %s", content) + } +} + func TestFindReadmeContent_UsesRepoPathWithoutChangingCWD(t *testing.T) { t.Parallel() |
