diff --git a/cmd/brief/html.go b/cmd/brief/html.go new file mode 100644 index 0000000..9118836 --- /dev/null +++ b/cmd/brief/html.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/git-pkgs/brief" + "github.com/git-pkgs/brief/detect" + "github.com/git-pkgs/brief/kb" + "github.com/git-pkgs/brief/remote" + "github.com/git-pkgs/brief/report" +) + +func cmdHTML(args []string) { + fs := flag.NewFlagSet("brief html", flag.ExitOnError) + out := fs.String("o", "", "Output file (default stdout)") + keep := fs.Bool("keep", false, "Keep downloaded remote source") + depth := fs.Int("depth", -1, "Git clone depth (0 = full clone, default shallow)") + dir := fs.String("dir", "", "Directory to clone remote source into") + scanDepth := fs.Int("scan-depth", 0, "Max directory depth for language detection (0 = unlimited)") + skip := fs.String("skip", "", "Additional directories to skip, comma-separated") + tracked := fs.Bool("tracked", false, "Only consider files tracked by git") + _ = fs.Parse(args) + + path := "." + if fs.NArg() > 0 { + path = fs.Arg(0) + } + + src, err := remote.Resolve(context.Background(), path, remote.Options{ + Keep: *keep, + Depth: *depth, + Dir: *dir, + }) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + code := runHTML(src.Dir, *out, *scanDepth, *skip, *tracked) + src.Cleanup() + os.Exit(code) +} + +func runHTML(dir, out string, scanDepth int, skip string, tracked bool) int { + knowledgeBase, err := kb.Load(brief.KnowledgeFS) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error loading knowledge base: %v\n", err) + return 1 + } + + engine := detect.New(knowledgeBase, dir) + engine.ScanDepth = scanDepth + engine.TrackedOnly = tracked + if skip != "" { + engine.SkipDirs = strings.Split(skip, ",") + } + r, err := engine.Run() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %v\n", err) + return 1 + } + + var w io.Writer = os.Stdout + if out != "" { + f, ferr := os.Create(out) //nolint:gosec + if ferr != nil { + _, _ = fmt.Fprintf(os.Stderr, "error creating %s: %v\n", out, ferr) + return 1 + } + defer func() { _ = f.Close() }() + w = f + } + + if err := report.HTML(w, r); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error rendering HTML: %v\n", err) + return 1 + } + if out != "" { + _, _ = fmt.Fprintf(os.Stderr, "wrote %s\n", out) + } + return 0 +} diff --git a/cmd/brief/main.go b/cmd/brief/main.go index 7efe2c4..db933bb 100644 --- a/cmd/brief/main.go +++ b/cmd/brief/main.go @@ -54,6 +54,9 @@ func main() { case "outline": cmdOutline(os.Args[2:]) return + case "html": + cmdHTML(os.Args[2:]) + return } } diff --git a/go.mod b/go.mod index fc5adb1..d883ae8 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/git-pkgs/purl v0.1.12 github.com/git-pkgs/registries v0.6.1 github.com/git-pkgs/spdx v0.1.4 + github.com/stretchr/testify v1.11.1 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -199,7 +200,6 @@ require ( github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect github.com/stretchr/objx v0.5.2 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.4 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect diff --git a/report/html.go b/report/html.go new file mode 100644 index 0000000..3ae04d8 --- /dev/null +++ b/report/html.go @@ -0,0 +1,705 @@ +package report + +import ( + "fmt" + "hash/fnv" + "html/template" + "io" + "path/filepath" + "sort" + "strings" + + "github.com/git-pkgs/brief" +) + +// HTML writes the report as a self-contained HTML page. +func HTML(w io.Writer, r *brief.Report) error { + return htmlTmpl.Execute(w, newHTMLData(r)) +} + +type htmlData struct { + R *brief.Report + Title string + RepoURL string + Langs []langSlice + ToolGroups []toolGroup + Direct []brief.DepInfo + Transitive []brief.DepInfo + DepSummary string + Resources []resourceLink + Facts []fact +} + +type langSlice struct { + Name string + Lines int + Percent float64 + Color string +} + +type toolGroup struct { + Key string + Label string + Icon string + Tools []brief.Detection +} + +type resourceLink struct { + Label string + Path string + Note string + Icon string +} + +type fact struct { + Label string + Value string +} + +func newHTMLData(r *brief.Report) *htmlData { + d := &htmlData{R: r, Title: projectTitle(r), RepoURL: repoWebURL(r)} + d.Langs = languageSlices(r.Lines) + d.ToolGroups = orderedToolGroups(r) + d.Direct, d.Transitive = splitDeps(r.Dependencies) + d.DepSummary = depSummary(r.Dependencies) + d.Resources = resourceLinks(r.Resources) + d.Facts = facts(r) + return d +} + +func projectTitle(r *brief.Report) string { + if r.Git != nil { + if u := r.Git.Remotes["origin"]; u != "" { + s := strings.TrimSuffix(u, ".git") + s = strings.TrimSuffix(s, "/") + if i := strings.LastIndexAny(s, "/:"); i >= 0 && i < len(s)-1 { + return s[i+1:] + } + } + } + if r.Path != "" && r.Path != "." { + return filepath.Base(r.Path) + } + return "project" +} + +func repoWebURL(r *brief.Report) string { + if r.Git == nil { + return "" + } + u := r.Git.Remotes["origin"] + if u == "" { + return "" + } + u = strings.TrimSuffix(u, ".git") + if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") { + return u + } + if rest, ok := strings.CutPrefix(u, "git@"); ok { + return "https://" + strings.Replace(rest, ":", "/", 1) + } + return "" +} + +//nolint:mnd // 100 is percent, 360 is hue degrees +func languageSlices(lc *brief.LineCount) []langSlice { + if lc == nil || len(lc.ByLanguage) == 0 { + return nil + } + skip := map[string]bool{"License": true, "Plain Text": true, "gitignore": true} + type kv struct { + k string + v int + } + var pairs []kv + total := 0 + for k, v := range lc.ByLanguage { + if skip[k] || v == 0 { + continue + } + pairs = append(pairs, kv{k, v}) + total += v + } + if total == 0 { + return nil + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v }) + + const maxSlices = 8 + var out []langSlice + other := 0 + for i, p := range pairs { + if i >= maxSlices { + other += p.v + continue + } + out = append(out, langSlice{ + Name: p.k, + Lines: p.v, + Percent: float64(p.v) / float64(total) * 100, + Color: hashColor(p.k), + }) + } + if other > 0 { + out = append(out, langSlice{ + Name: "Other", + Lines: other, + Percent: float64(other) / float64(total) * 100, + Color: "#6e7681", + }) + } + return out +} + +func hashColor(s string) string { + const hueRange = 360 + h := fnv.New32a() + _, _ = h.Write([]byte(s)) + return fmt.Sprintf("hsl(%d 65%% 55%%)", h.Sum32()%hueRange) +} + +//nolint:goconst // category keys repeat CategoryOrder/CategoryLabels by design +var categoryIcons = map[string]string{ + "language": "code", + "package_manager": "package", + "test": "flask-conical", + "lint": "search-check", + "format": "align-left", + "typecheck": "check-check", + "docs": "book-open", + "build": "hammer", + "library": "library", + "codegen": "wand", + "database": "database", + "security": "shield", + "ci": "rocket", + "container": "container", + "infrastructure": "server", + "monorepo": "layers", + "environment": "leaf", + "i18n": "languages", + "release": "tag", + "coverage": "gauge", + "dependency_bot": "bot", +} + +func categoryIcon(cat string) string { + if i, ok := categoryIcons[cat]; ok { + return i + } + return "circle-dot" +} + +const languageLineThreshold = 10.0 + +func majorLanguages(langs []brief.Detection, lc *brief.LineCount) []brief.Detection { + if lc == nil || len(lc.ByLanguage) == 0 { + return langs + } + total := 0 + for _, n := range lc.ByLanguage { + total += n + } + if total == 0 { + return langs + } + var out []brief.Detection + for _, l := range langs { + n, ok := lc.ByLanguage[l.Name] + if !ok { + out = append(out, l) + continue + } + if float64(n)/float64(total)*100 >= languageLineThreshold { + out = append(out, l) + } + } + if len(out) == 0 { + return langs + } + return out +} + +func orderedToolGroups(r *brief.Report) []toolGroup { + var groups []toolGroup + add := func(key, label string, dets []brief.Detection) { + if len(dets) == 0 { + return + } + groups = append(groups, toolGroup{Key: key, Label: label, Icon: categoryIcon(key), Tools: dets}) + } + add("language", "Language", majorLanguages(r.Languages, r.Lines)) + add("package_manager", "Package Manager", r.PackageManagers) + seen := map[string]bool{} + for _, cat := range CategoryOrder { + if dets := r.Tools[cat]; len(dets) > 0 { + label := CategoryLabels[cat] + if label == "" { + label = cat + } + add(cat, label, dets) + seen[cat] = true + } + } + extras := make([]string, 0) + for cat := range r.Tools { + if !seen[cat] { + extras = append(extras, cat) + } + } + sort.Strings(extras) + for _, cat := range extras { + add(cat, cat, r.Tools[cat]) + } + return groups +} + +func splitDeps(deps []brief.DepInfo) (direct, transitive []brief.DepInfo) { + for _, d := range deps { + if d.Direct { + direct = append(direct, d) + } else { + transitive = append(transitive, d) + } + } + return direct, transitive +} + +func resourceLinks(res *brief.ResourceInfo) []resourceLink { + if res == nil { + return nil + } + var out []resourceLink + add := func(label, path, note, icon string) { + if path != "" { + out = append(out, resourceLink{Label: label, Path: path, Note: note, Icon: icon}) + } + } + add("Readme", res.Readme, "", "book-open") + add("Changelog", res.Changelog, "", "history") + add("Roadmap", res.Roadmap, "", "map") + add("License", res.License, res.LicenseType, "scale") + for _, k := range sortedKeys(res.Community) { + add("Community", res.Community[k], k, "users") + } + for _, k := range sortedKeys(res.Security) { + add("Security", res.Security[k], k, "shield") + } + for _, k := range sortedKeys(res.Legal) { + add("Legal", res.Legal[k], k, "gavel") + } + for _, k := range sortedKeys(res.Metadata) { + add("Metadata", res.Metadata[k], k, "file-text") + } + for _, k := range sortedKeys(res.Agents) { + add("Agents", res.Agents[k], k, "bot") + } + return out +} + +func facts(r *brief.Report) []fact { + var out []fact + if r.Resources != nil && r.Resources.LicenseType != "" { + out = append(out, fact{"License", r.Resources.LicenseType}) + } + if r.Style != nil && r.Style.Indentation != "" { + out = append(out, fact{"Indent", r.Style.Indentation}) + } + if r.Style != nil && r.Style.LineEnding != "" { + out = append(out, fact{"Line ending", r.Style.LineEnding}) + } + if r.Layout != nil && len(r.Layout.SourceDirs) > 0 { + out = append(out, fact{"Source", joinDirs(r.Layout.SourceDirs)}) + } + if r.Layout != nil && len(r.Layout.TestDirs) > 0 { + out = append(out, fact{"Tests", joinDirs(r.Layout.TestDirs)}) + } + if r.Platforms != nil && len(r.Platforms.CIMatrixOS) > 0 { + out = append(out, fact{"CI OS", strings.Join(r.Platforms.CIMatrixOS, ", ")}) + } + if r.Git != nil && r.Git.DefaultBranch != "" { + out = append(out, fact{"Default branch", r.Git.DefaultBranch}) + } + return out +} + +//nolint:mnd // small formatting helpers +var htmlFuncs = template.FuncMap{ + "comma": func(n int) string { + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + return s + } + var b strings.Builder + pre := len(s) % 3 + if pre > 0 { + b.WriteString(s[:pre]) + if len(s) > pre { + b.WriteByte(',') + } + } + for i := pre; i < len(s); i += 3 { + if i > pre { + b.WriteByte(',') + } + b.WriteString(s[i : i+3]) + } + return b.String() + }, + "pct": func(f float64) string { + if f > 0 && f < 0.1 { + return "<0.1" + } + return fmt.Sprintf("%.1f", f) + }, + "short": func(s string) string { + const n = 12 + if len(s) > n { + return s[:n] + } + return s + }, + "safeCSS": func(s string) template.CSS { return template.CSS(s) }, + "dict": func(kv ...any) map[string]any { + m := make(map[string]any, len(kv)/2) + for i := 0; i+1 < len(kv); i += 2 { + if k, ok := kv[i].(string); ok { + m[k] = kv[i+1] + } + } + return m + }, +} + +var htmlTmpl = template.Must(template.New("report").Funcs(htmlFuncs).Parse(htmlPage)) + +const htmlPage = ` + +
+ + +{{.R.Path}}
+{{.Run}}
+
+ | {{.Name}} | +{{.Run}} | +{{.Source}} | +
| {{.Name}}{{if and .Scope (ne .Scope "runtime")}}{{.Scope}}{{end}} | +{{short .Version}} | +