-
Notifications
You must be signed in to change notification settings - Fork 687
fix: replace manifest.txt help mechanism with embedded user docs directory #6919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -55,4 +55,4 @@ scripts/Brewfile.lock.json | |
| test/fixtures/**/go.sum | ||
| .cursor | ||
| .windsurf | ||
| .claude | ||
| .claude | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,3 +12,4 @@ _cache | |
| bin | ||
| internal/embedded/_data | ||
| /.bin/ | ||
| internal/helpdocs/cli-commands | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Adding this file to the project will prevent IDE errors between builds | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| package helpdocs | ||
|
|
||
| import ( | ||
| "io/fs" | ||
| "regexp" | ||
| "strings" | ||
| ) | ||
|
|
||
| var nonDocChars = regexp.MustCompile(`[^a-zA-Z0-9-]`) | ||
|
|
||
| // CommandHelp indexes embedded or test-supplied CLI command help markdown files. | ||
| type CommandHelp struct { | ||
| files map[string]struct{} | ||
| } | ||
|
|
||
| // NewCommandHelp builds a lookup by walking root on fsys for *.md files. | ||
| func NewCommandHelp(fsys fs.FS, root string) (*CommandHelp, error) { | ||
| docFiles, err := docFilesFromEmbed(fsys, root) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &CommandHelp{files: docFiles}, nil | ||
| } | ||
|
|
||
| // HasUserDoc reports whether legacy user-doc help should be shown for command segments. | ||
| // Empty segments -> true (top-level README via legacy help). | ||
| // Non-empty segments -> true only if a matching .md exists (README excluded). | ||
| func (h *CommandHelp) HasUserDoc(segments []string) bool { | ||
| return hasUserDoc(segments, h.files) | ||
| } | ||
|
|
||
| func docFilesFromEmbed(fsys fs.FS, root string) (map[string]struct{}, error) { | ||
| files := make(map[string]struct{}) | ||
| err := fs.WalkDir(fsys, root, func(_ string, d fs.DirEntry, err error) error { | ||
| if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { | ||
| return err | ||
| } | ||
| files[d.Name()] = struct{}{} | ||
| return nil | ||
| }) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return files, nil | ||
| } | ||
|
|
||
| // helpFileName mirrors src/cli/commands/help/index.ts findHelpFile() join + replace. | ||
| func helpFileName(segments []string) string { | ||
| joined := strings.Join(segments, "-") | ||
| cleaned := nonDocChars.ReplaceAllString(joined, "") | ||
| return cleaned + ".md" | ||
| } | ||
|
|
||
| func hasUserDoc(segments []string, files map[string]struct{}) bool { | ||
| if len(segments) == 0 { | ||
| return true | ||
| } | ||
| if len(files) == 0 { | ||
| // Missing or empty embed at build time: prefer legacy help lookup. | ||
| return true | ||
| } | ||
| for len(segments) > 0 { | ||
| if _, ok := files[helpFileName(segments)]; ok { | ||
| return true | ||
| } | ||
| segments = segments[:len(segments)-1] | ||
| } | ||
| return false | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package helpdocs | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func Test_helpFileName(t *testing.T) { | ||
| assert.Equal(t, "container-test.md", helpFileName([]string{"container", "test"})) | ||
| assert.Equal(t, "iac-describe.md", helpFileName([]string{"iac", "describe"})) | ||
| assert.Equal(t, "secrets-test.md", helpFileName([]string{"secrets", "test"})) | ||
| } | ||
|
|
||
| func Test_HasUserDoc(t *testing.T) { | ||
| help := CommandHelpForTest(t) | ||
|
|
||
| tests := map[string]struct { | ||
| segments []string | ||
| want bool | ||
| }{ | ||
| "empty uses readme path": {segments: []string{}, want: true}, | ||
| "test command": {segments: []string{"test"}, want: true}, | ||
| "container test subcommand": {segments: []string{"container", "test"}, want: true}, | ||
| "iac describe subcommand": {segments: []string{"iac", "describe"}, want: true}, | ||
| "unknown command": {segments: []string{"rainmaker"}, want: false}, | ||
| "undocumented secrets test": {segments: []string{"secrets", "test"}, want: false}, | ||
| "redteam setup walks back to parent": {segments: []string{"redteam", "setup"}, want: true}, | ||
| "undocumented agent-scan": {segments: []string{"agent-scan"}, want: false}, | ||
| } | ||
|
|
||
| for name, tc := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| assert.Equal(t, tc.want, help.HasUserDoc(tc.segments)) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func Test_NewCommandHelpFromFS(t *testing.T) { | ||
| expected := FixtureCommandHelp() | ||
|
|
||
| help, err := NewCommandHelp(fixtureCommandHelpFS(), ".") | ||
| require.NoError(t, err) | ||
|
|
||
| for _, segments := range [][]string{ | ||
| {"test"}, | ||
| {"container", "test"}, | ||
| {"rainmaker"}, | ||
| } { | ||
| assert.Equal(t, expected.HasUserDoc(segments), help.HasUserDoc(segments), segments) | ||
| } | ||
| } |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: For readability it might be better to integrate these small files (embed.go, and export_test.go) into other relevant files; perhaps command_help.go, and fixture.go, respectively. Other than this, LGTM :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ack :-) about embed.go: the Go convention is that
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in snyk you can find similar examples https://github.com/snyk/policy-engine/blob/main/rego/embed.go
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. about export_test.go: export_help.go is a kind of standard for test only package helpers. Example: os/export_test.go, fmt/export_test.go, etc.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TIL, thanks for the explanation! I noticed CommandHelpFromRepo is already used directly in a couple of test files (helprouting_test.go:233 and externally at main_test.go:770), while CommandHelpForTest is only used internally at command_help_test.go:17. Do we need the extra re-export via CommandHelpForTest? |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package helpdocs | ||
|
|
||
| import "embed" | ||
|
|
||
| const cliCommandsDir = "cli-commands" | ||
|
|
||
| //go:embed cli-commands | ||
| var cliCommands embed.FS | ||
|
|
||
| var defaultCommandHelp *CommandHelp | ||
|
|
||
| // DefaultCommandHelp returns the compile-time embedded CLI command help lookup. | ||
| func DefaultCommandHelp() *CommandHelp { | ||
| if defaultCommandHelp == nil { | ||
| var err error | ||
| if defaultCommandHelp, err = NewCommandHelp(cliCommands, cliCommandsDir); err != nil { | ||
| panic("helpdocs: index cli-commands: " + err.Error()) | ||
| } | ||
| } | ||
| return defaultCommandHelp | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| package helpdocs | ||
|
|
||
| import "testing" | ||
|
|
||
| func CommandHelpForTest(t *testing.T) *CommandHelp { | ||
| t.Helper() | ||
| return FixtureCommandHelp() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package helpdocs | ||
|
|
||
| import "testing/fstest" | ||
|
|
||
| var fixtureCommandHelpFiles = map[string]struct{}{ | ||
| "test.md": {}, | ||
| "container.md": {}, | ||
| "container-test.md": {}, | ||
| "iac.md": {}, | ||
| "iac-describe.md": {}, | ||
| "redteam.md": {}, | ||
| } | ||
|
|
||
| func fixtureCommandHelpFS() fstest.MapFS { | ||
| fsMap := fstest.MapFS{ | ||
| "do-not-delete": {Data: []byte("placeholder")}, | ||
| "nested/ignored.md": {Data: []byte("# nested")}, | ||
| } | ||
| for name := range fixtureCommandHelpFiles { | ||
| fsMap[name] = &fstest.MapFile{Data: []byte("# doc")} | ||
| } | ||
| return fsMap | ||
| } | ||
|
|
||
| // FixtureCommandHelp returns a minimal CommandHelp built from fstest.MapFS for unit tests. | ||
| func FixtureCommandHelp() *CommandHelp { | ||
| help, err := NewCommandHelp(fixtureCommandHelpFS(), ".") | ||
| if err != nil { | ||
| panic("helpdocs: fixture command help: " + err.Error()) | ||
| } | ||
| return help | ||
| } |
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: does this directory need to go into gitignore
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mmmmh good one, adding - but you get the reasoning behind this file, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we perhaps ignore the directory contents entirely in case an interrupted
make build/testleaves a dirty tree such that they cannot be committed by accident?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is what I meant, gitignore the whole directory and just have this one file.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok! for sure