diff --git a/README.md b/README.md index 9741258..352e4d8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # go-readme `go-readme` is a README automation CLI for Go modules. It parses project metadata -from `go.mod` and git, renders a template, and idempotently updates `README.md` -between managed markers so custom content is preserved. +from `go.mod`, git, Go source, and common repo files, renders a template, and +idempotently updates `README.md` between managed markers so custom content is +preserved. If you are new to the project, start with `go-readme generate --dry-run` to see what will be written before changing any files. @@ -40,6 +41,10 @@ Run from inside a Go module to generate or update `README.md`: go-readme generate ``` +In interactive mode, `go-readme` now asks for a short project description, key +features, a usage example, configuration notes, and contributing notes when +those values were not already supplied by flags. + Preview without writing: ```sh @@ -66,10 +71,14 @@ go-readme generate --dir ./path/to/module |------|---------|-------------| | `--dir` | `.` | Target project directory | | `--description`, `-d` | empty | Project description | +| `--features` | empty | Comma-separated key project features | +| `--usage-example` | empty | Usage command or code snippet | +| `--configuration` | empty | Configuration notes | +| `--contributing-notes` | empty | Contributor guidance | | `--template`, `-t` | `go_default.md` | Embedded template name | | `--dry-run` | `false` | Print output without writing README | | `--force` | `false` | Overwrite entire README (skip marker replacement) | -| `--non-interactive` | `false` | Disable interactive prompt | +| `--non-interactive` | `false` | Disable the interactive questionnaire | ### Doctor Flags @@ -79,10 +88,18 @@ go-readme generate --dir ./path/to/module ## What gets generated -- **Title / metadata** — module name, install command, go version -- **Repository** — git remote URL when configured -- **Description** — from flag or interactive prompt -- **License** — detected from license file name +- **Overview** — project name plus description from a flag, prompt, or package + doc comment fallback +- **Features** — optional bullet list from prompt or `--features` +- **Installation / usage** — install command plus a smarter default usage + example that can be overridden +- **Configuration / development** — optional config notes plus standard Go + build and test commands +- **Requirements / dependencies** — Go version and direct dependencies from + `go.mod` +- **Repository / contributing / security** — git remote plus links to + `CONTRIBUTING.md` and `SECURITY.md` when present +- **License** — detected from the license file name and linked in the output ## Managed markers (beginner-friendly) diff --git a/cmd/generate.go b/cmd/generate.go index af2fa3d..7ab7f19 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -14,6 +14,10 @@ import ( var ( generateDir string generateDescription string + generateFeatures []string + generateUsageExample string + generateConfiguration string + generateContributing string generateTemplate string generateDryRun bool generateForce bool @@ -32,6 +36,10 @@ idempotently updates) README.md.`, func init() { generateCmd.Flags().StringVar(&generateDir, "dir", ".", "target project directory") generateCmd.Flags().StringVarP(&generateDescription, "description", "d", "", "project description") + generateCmd.Flags().StringSliceVar(&generateFeatures, "features", nil, "comma-separated key project features") + generateCmd.Flags().StringVar(&generateUsageExample, "usage-example", "", "usage example command or snippet") + generateCmd.Flags().StringVar(&generateConfiguration, "configuration", "", "configuration notes") + generateCmd.Flags().StringVar(&generateContributing, "contributing-notes", "", "contributing guidance") generateCmd.Flags().StringVarP(&generateTemplate, "template", "t", "go_default.md", "template file name") generateCmd.Flags().BoolVar(&generateDryRun, "dry-run", false, "print the README without writing to disk") generateCmd.Flags().BoolVar(&generateForce, "force", false, "overwrite the entire README (ignore markers)") @@ -39,17 +47,27 @@ func init() { } func runGenerate(cmd *cobra.Command, _ []string) error { - description := generateDescription - if description == "" && !generateNonInteractive { - description = promptDescription(cmd) + answers := generatePromptAnswers{ + Description: strings.TrimSpace(generateDescription), + Features: append([]string(nil), generateFeatures...), + UsageExample: strings.TrimSpace(generateUsageExample), + Configuration: strings.TrimSpace(generateConfiguration), + Contributing: strings.TrimSpace(generateContributing), + } + if !generateNonInteractive { + answers = promptGenerateMetadata(cmd, answers) } opts := app.GenerateOptions{ - Dir: generateDir, - Description: description, - Template: generateTemplate, - DryRun: generateDryRun, - Force: generateForce, + Dir: generateDir, + Description: answers.Description, + Features: answers.Features, + UsageExample: answers.UsageExample, + Configuration: answers.Configuration, + Contributing: answers.Contributing, + Template: generateTemplate, + DryRun: generateDryRun, + Force: generateForce, } result, err := app.Generate(opts) @@ -70,16 +88,71 @@ func runGenerate(cmd *cobra.Command, _ []string) error { return nil } -// promptDescription asks the user for a short project description on stdin. -// Returns an empty string if stdin is not a terminal or the user skips. -func promptDescription(cmd *cobra.Command) string { - if fi, err := os.Stdin.Stat(); err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { - return "" +type generatePromptAnswers struct { + Description string + Features []string + UsageExample string + Configuration string + Contributing string +} + +// promptGenerateMetadata asks the user for optional README metadata on stdin. +// Returns the existing answers unchanged if stdin is not a terminal. +func promptGenerateMetadata(cmd *cobra.Command, answers generatePromptAnswers) generatePromptAnswers { + if !stdinIsInteractive() { + return answers } - fmt.Fprint(cmd.OutOrStdout(), "Project description (leave blank to skip): ") + scanner := bufio.NewScanner(os.Stdin) + if answers.Description == "" { + answers.Description = promptLine(cmd, scanner, "Project description (leave blank to auto-detect/skip): ") + } + if len(answers.Features) == 0 { + answers.Features = parsePromptList(promptLine(cmd, scanner, "Key features (comma-separated, leave blank to skip): ")) + } + if answers.UsageExample == "" { + answers.UsageExample = promptLine(cmd, scanner, "Usage example or command (leave blank for default): ") + } + if answers.Configuration == "" { + answers.Configuration = promptLine(cmd, scanner, "Configuration notes (leave blank to skip): ") + } + if answers.Contributing == "" { + answers.Contributing = promptLine(cmd, scanner, "Contributing notes (leave blank to skip): ") + } + return answers +} + +func stdinIsInteractive() bool { + fi, err := os.Stdin.Stat() + return err == nil && (fi.Mode()&os.ModeCharDevice) != 0 +} + +func promptLine(cmd *cobra.Command, scanner *bufio.Scanner, prompt string) string { + fmt.Fprint(cmd.OutOrStdout(), prompt) if scanner.Scan() { return strings.TrimSpace(scanner.Text()) } return "" } + +func parsePromptList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '\n' + }) + items := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item == "" { + continue + } + items = append(items, item) + } + if len(items) == 0 { + return nil + } + return items +} diff --git a/cmd/generate_test.go b/cmd/generate_test.go index 94411b2..243eb47 100644 --- a/cmd/generate_test.go +++ b/cmd/generate_test.go @@ -85,7 +85,7 @@ func TestRunGenerate_UsesConfiguredDir(t *testing.T) { } } -func TestPromptDescription_NonTTYReturnsEmpty(t *testing.T) { +func TestPromptGenerateMetadata_NonTTYReturnsEmpty(t *testing.T) { originalStdin := os.Stdin r, w, err := os.Pipe() if err != nil { @@ -99,15 +99,28 @@ func TestPromptDescription_NonTTYReturnsEmpty(t *testing.T) { }) c := &cobraCommandStub{} - got := promptDescription(c.command()) - if got != "" { - t.Fatalf("promptDescription = %q, want empty string for non-tty stdin", got) + got := promptGenerateMetadata(c.command(), generatePromptAnswers{}) + if got.Description != "" || len(got.Features) != 0 || got.UsageExample != "" || got.Configuration != "" || got.Contributing != "" { + t.Fatalf("promptGenerateMetadata = %#v, want zero values for non-tty stdin", got) } if c.output() != "" { t.Fatalf("expected no prompt output for non-tty stdin, got: %q", c.output()) } } +func TestParsePromptList(t *testing.T) { + got := parsePromptList("fast, simple,\nportable") + want := []string{"fast", "simple", "portable"} + if len(got) != len(want) { + t.Fatalf("parsePromptList length = %d, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parsePromptList[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + type cobraCommandStub struct { out bytes.Buffer } @@ -146,6 +159,10 @@ func withWorkingDir(t *testing.T, dir string) { func resetGenerateFlags(t *testing.T) { t.Helper() generateDescription = "" + generateFeatures = nil + generateUsageExample = "" + generateConfiguration = "" + generateContributing = "" generateDir = "." generateTemplate = "go_default.md" generateDryRun = false @@ -153,6 +170,10 @@ func resetGenerateFlags(t *testing.T) { generateNonInteractive = false t.Cleanup(func() { generateDescription = "" + generateFeatures = nil + generateUsageExample = "" + generateConfiguration = "" + generateContributing = "" generateDir = "." generateTemplate = "go_default.md" generateDryRun = false diff --git a/internal/app/generate.go b/internal/app/generate.go index bca21ff..dc70682 100644 --- a/internal/app/generate.go +++ b/internal/app/generate.go @@ -3,10 +3,12 @@ package app import ( "fmt" + "go/doc" "os" "path/filepath" "strings" + "github.com/sermachage/go-readme/internal/analyzer" "github.com/sermachage/go-readme/internal/detectors" "github.com/sermachage/go-readme/internal/domain" "github.com/sermachage/go-readme/internal/markers" @@ -30,6 +32,11 @@ type GitReader interface { ParseGit(dir string) *parser.GitInfo } +// SourceAnalyzer extracts lightweight documentation metadata from Go source. +type SourceAnalyzer interface { + Analyze(dir string) (*analyzer.Package, error) +} + // ProjectRenderer renders a README template from project metadata. type ProjectRenderer interface { Render(templateName string, project domain.Project) (string, error) @@ -46,6 +53,7 @@ type GenerateService struct { Detector ProjectDetector GoMod GoModReader Git GitReader + Source SourceAnalyzer Renderer ProjectRenderer Store ReadmeStore } @@ -56,6 +64,14 @@ type GenerateOptions struct { Dir string // Description is an optional description to embed in the README. Description string + // Features is an optional list of key project features. + Features []string + // UsageExample is an optional usage snippet or command. + UsageExample string + // Configuration is optional configuration guidance. + Configuration string + // Contributing is optional contributor guidance. + Contributing string // Template is the template file name (without path). Defaults to "go_default.md". Template string // DryRun prints the output without writing to disk. @@ -83,6 +99,12 @@ func (r defaultGitReader) ParseGit(dir string) *parser.GitInfo { return parser.ParseGit(dir) } +type defaultSourceAnalyzer struct{} + +func (a defaultSourceAnalyzer) Analyze(dir string) (*analyzer.Package, error) { + return analyzer.Analyze(dir) +} + type defaultReadmeStore struct{} func (s defaultReadmeStore) ReadExisting(dir string) (string, error) { @@ -99,6 +121,7 @@ func NewGenerateService() *GenerateService { Detector: &detectors.GoDetector{}, GoMod: defaultGoModReader{}, Git: defaultGitReader{}, + Source: defaultSourceAnalyzer{}, Renderer: tmpl.NewRenderer(), Store: defaultReadmeStore{}, } @@ -131,14 +154,34 @@ func (s *GenerateService) Generate(opts GenerateOptions) (*GenerateResult, error } git := s.Git.ParseGit(opts.Dir) + var pkg *analyzer.Package + if s.Source != nil { + analyzed, err := s.Source.Analyze(opts.Dir) + if err == nil { + pkg = analyzed + } + } + + features := cleanList(opts.Features) + dependencies, additionalDependencies := summarizeDependencies(gomod.Dependencies, 8) + usageExample, usageLanguage := resolveUsageExample(opts.UsageExample, gomod.ModulePath, pkg) project := domain.Project{ - Name: moduleName(gomod.ModulePath), - ModulePath: gomod.ModulePath, - GoVersion: gomod.GoVersion, - RepoURL: git.RemoteURL, - Description: opts.Description, - License: detectLicense(opts.Dir), + Name: moduleName(gomod.ModulePath), + ModulePath: gomod.ModulePath, + GoVersion: gomod.GoVersion, + RepoURL: git.RemoteURL, + Description: resolveDescription(opts.Description, pkg), + Features: features, + UsageExample: usageExample, + UsageLanguage: usageLanguage, + Configuration: strings.TrimSpace(opts.Configuration), + Dependencies: dependencies, + AdditionalDependencies: additionalDependencies, + Contributing: strings.TrimSpace(opts.Contributing), + ContributingGuide: detectContributingGuide(opts.Dir), + SecurityPolicy: detectSecurityPolicy(opts.Dir), + License: detectLicense(opts.Dir), } rendered, err := s.Renderer.Render(opts.Template, project) @@ -182,9 +225,97 @@ func moduleName(modulePath string) string { return parts[len(parts)-1] } +func resolveDescription(description string, pkg *analyzer.Package) string { + if trimmed := strings.TrimSpace(description); trimmed != "" { + return trimmed + } + if pkg == nil { + return "" + } + return strings.TrimSpace(doc.Synopsis(pkg.Doc)) +} + +func resolveUsageExample(usageExample, modulePath string, pkg *analyzer.Package) (string, string) { + if trimmed := strings.TrimSpace(usageExample); trimmed != "" { + return trimmed, inferCodeFenceLanguage(trimmed) + } + return defaultUsageExample(modulePath, pkg) +} + +func defaultUsageExample(modulePath string, pkg *analyzer.Package) (string, string) { + if pkg != nil && pkg.Name == "main" { + return fmt.Sprintf("%s --help", moduleName(modulePath)), "sh" + } + return fmt.Sprintf("import %q", modulePath), "go" +} + +func inferCodeFenceLanguage(example string) string { + trimmed := strings.TrimSpace(example) + switch { + case trimmed == "": + return "text" + case strings.Contains(trimmed, "package ") || strings.Contains(trimmed, "import ") || strings.Contains(trimmed, "func "): + return "go" + case strings.HasPrefix(trimmed, "$ "): + return "sh" + case strings.HasPrefix(trimmed, "go ") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "make "): + return "sh" + default: + return "text" + } +} + +func cleanList(values []string) []string { + if len(values) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(values)) + cleaned := make([]string, 0, len(values)) + for _, value := range values { + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == '\n' + }) + for _, part := range parts { + item := strings.TrimSpace(part) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + cleaned = append(cleaned, item) + } + } + if len(cleaned) == 0 { + return nil + } + return cleaned +} + +func summarizeDependencies(deps []string, limit int) ([]string, int) { + cleaned := cleanList(deps) + if len(cleaned) <= limit { + return cleaned, 0 + } + return cleaned[:limit], len(cleaned) - limit +} + // detectLicense looks for a LICENSE file and returns its name, or "". func detectLicense(dir string) string { - candidates := []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE"} + return detectDocFile(dir, []string{"LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE"}) +} + +func detectContributingGuide(dir string) string { + return detectDocFile(dir, []string{"CONTRIBUTING.md", "CONTRIBUTING.txt", "CONTRIBUTING"}) +} + +func detectSecurityPolicy(dir string) string { + return detectDocFile(dir, []string{"SECURITY.md", "SECURITY.txt", "SECURITY"}) +} + +func detectDocFile(dir string, candidates []string) string { for _, name := range candidates { path := filepath.Join(dir, name) if _, err := os.Stat(path); err == nil { diff --git a/internal/app/generate_test.go b/internal/app/generate_test.go index c60428e..190f1a8 100644 --- a/internal/app/generate_test.go +++ b/internal/app/generate_test.go @@ -132,6 +132,85 @@ func TestGenerate_DetectsLicenseCandidates(t *testing.T) { } } +func TestGenerate_RendersRicherMetadataSections(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", strings.Join([]string{ + "module github.com/example/richreadme", + "", + "go 1.24", + "", + "require (", + "\tgithub.com/charmbracelet/bubbletea v1.3.4", + "\tgithub.com/spf13/cobra v1.9.1", + ")", + }, "\n")) + writeTestFile(t, dir, "main.go", strings.Join([]string{ + "// Package main provides a friendly README generator.", + "package main", + "", + "func main() {}", + }, "\n")) + writeTestFile(t, dir, "CONTRIBUTING.md", "Open a pull request.") + writeTestFile(t, dir, "SECURITY.md", "Email security@example.com.") + writeTestFile(t, dir, "LICENSE", "MIT") + + res, err := Generate(GenerateOptions{ + Dir: dir, + Features: []string{"interactive prompts", "template-driven output"}, + Configuration: "Set `README_TEMPLATE` if you want a custom template path.", + Contributing: "Please open an issue before larger changes.", + DryRun: true, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + checks := []string{ + "Package main provides a friendly README generator.", + "## Features", + "interactive prompts", + "template-driven output", + "```sh\nrichreadme --help", + "## Configuration", + "README_TEMPLATE", + "## Development", + "go test ./...", + "## Dependencies", + "github.com/charmbracelet/bubbletea", + "github.com/spf13/cobra", + "## Contributing", + "Please open an issue before larger changes.", + "[CONTRIBUTING.md](CONTRIBUTING.md)", + "## Security", + "[SECURITY.md](SECURITY.md)", + "## License", + "[LICENSE](LICENSE)", + } + for _, want := range checks { + if !strings.Contains(res.Content, want) { + t.Fatalf("generated README missing %q\nfull output:\n%s", want, res.Content) + } + } +} + +func TestGenerate_UsesCustomUsageExample(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "go.mod", "module github.com/example/library\n\ngo 1.24\n") + + res, err := Generate(GenerateOptions{ + Dir: dir, + UsageExample: "go test ./...", + DryRun: true, + }) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + if !strings.Contains(res.Content, "```sh\ngo test ./...\n```") { + t.Fatalf("expected shell fenced custom usage example, got:\n%s", res.Content) + } +} + func TestGenerateService_UsesInjectedDependencies(t *testing.T) { store := &fakeStore{} s := &GenerateService{ diff --git a/internal/domain/project.go b/internal/domain/project.go index d8ac671..f2e9c11 100644 --- a/internal/domain/project.go +++ b/internal/domain/project.go @@ -3,10 +3,19 @@ package domain // Project holds all metadata extracted from a Go project. type Project struct { - Name string - ModulePath string - GoVersion string - RepoURL string - Description string - License string + Name string + ModulePath string + GoVersion string + RepoURL string + Description string + Features []string + UsageExample string + UsageLanguage string + Configuration string + Dependencies []string + AdditionalDependencies int + Contributing string + ContributingGuide string + SecurityPolicy string + License string } diff --git a/internal/template/renderer_test.go b/internal/template/renderer_test.go index aa86862..e1896cb 100644 --- a/internal/template/renderer_test.go +++ b/internal/template/renderer_test.go @@ -11,12 +11,20 @@ import ( func TestRender_GoDefault(t *testing.T) { renderer := tmpl.NewRenderer() project := domain.Project{ - Name: "myproject", - ModulePath: "github.com/example/myproject", - GoVersion: "1.21", - RepoURL: "https://github.com/example/myproject", - Description: "A great project", - License: "MIT", + Name: "myproject", + ModulePath: "github.com/example/myproject", + GoVersion: "1.21", + RepoURL: "https://github.com/example/myproject", + Description: "A great project", + Features: []string{"fast", "friendly"}, + UsageExample: "import \"github.com/example/myproject\"", + UsageLanguage: "go", + Configuration: "Set MYPROJECT_ENV before running the CLI.", + Dependencies: []string{"github.com/spf13/cobra"}, + ContributingGuide: "CONTRIBUTING.md", + SecurityPolicy: "SECURITY.md", + License: "LICENSE", + AdditionalDependencies: 1, } got, err := renderer.Render("go_default.md", project) @@ -27,10 +35,18 @@ func TestRender_GoDefault(t *testing.T) { checks := []string{ "# myproject", "A great project", + "## Features", + "friendly", + "## Configuration", + "MYPROJECT_ENV", + "## Development", "github.com/example/myproject", "Go 1.21", + "github.com/spf13/cobra", "https://github.com/example/myproject", - "MIT", + "CONTRIBUTING.md", + "SECURITY.md", + "LICENSE", } for _, want := range checks { if !strings.Contains(got, want) { diff --git a/internal/template/templates/go_default.md b/internal/template/templates/go_default.md index c39d65a..3aafd8b 100644 --- a/internal/template/templates/go_default.md +++ b/internal/template/templates/go_default.md @@ -1,7 +1,15 @@ # {{ .Name }} - +{{ if .Description }} {{ .Description }} +{{ end }} +{{ if .Features }} +## Features + +{{- range .Features }} +- {{ . }} +{{- end }} +{{ end }} ## Installation ```bash @@ -10,22 +18,61 @@ go install {{ .ModulePath }}@latest ## Usage -```go -import "{{ .ModulePath }}" +```{{ .UsageLanguage }} +{{ .UsageExample }} ``` +{{ if .Configuration }} +## Configuration +{{ .Configuration }} +{{ end }} +## Development + +```bash +go test ./... +go build ./... +``` +{{ if .GoVersion }} ## Requirements -* Go {{ .GoVersion }} -{{- if .RepoURL }} +- Go {{ .GoVersion }} +{{ end }} +{{ if .Dependencies }} +## Dependencies +{{- range .Dependencies }} +- `{{ . }}` +{{- end }} +{{ if gt .AdditionalDependencies 0 }} +- ...and {{ .AdditionalDependencies }} more direct dependencies in `go.mod` +{{ end }} + +{{ end }} +{{ if .RepoURL }} ## Repository [{{ .RepoURL }}]({{ .RepoURL }}) -{{- end }} -{{- if .License }} +{{ end }} +{{ if or .Contributing .ContributingGuide }} +## Contributing +{{ if .Contributing }} +{{ .Contributing }} +{{ if .ContributingGuide }} + +See [{{ .ContributingGuide }}]({{ .ContributingGuide }}) for the full contribution guide. +{{ end }} +{{ else }} +Contributions are welcome. See [{{ .ContributingGuide }}]({{ .ContributingGuide }}) for guidelines. +{{ end }} +{{ end }} +{{ if .SecurityPolicy }} +## Security + +Review [{{ .SecurityPolicy }}]({{ .SecurityPolicy }}) before reporting vulnerabilities. +{{ end }} +{{ if .License }} ## License -{{ .License }} -{{- end }} +See [{{ .License }}]({{ .License }}). +{{ end }}