diff --git a/cmd/cmd.go b/cmd/cmd.go index 4b9bd7c..f96d94e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -3,7 +3,11 @@ package cmd import ( "fmt" "io" + "log" "os" + "path/filepath" + "slices" + "strings" "github.com/chaoss/disclosure/detection" "github.com/chaoss/disclosure/detection/coauthor" @@ -14,6 +18,7 @@ import ( "github.com/chaoss/disclosure/output" "github.com/chaoss/disclosure/scan" "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" ) var Version = "dev" @@ -51,6 +56,7 @@ func Run(args []string, stdout, stderr io.Writer) int { rootCmd.AddCommand(scanCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(textCommand(stdout, stderr, &exitCode)) rootCmd.AddCommand(versionCommand(stdout, &exitCode)) + rootCmd.AddCommand(generateDocs(&exitCode)) rootCmd.SetArgs(args) if err := rootCmd.Execute(); err != nil { @@ -231,3 +237,76 @@ func filterReport(report scan.Report, minConf detection.Confidence) scan.Report return filtered } + +func generateDocs(exitCode *int) *cobra.Command { + var outputDir string + var formatFlag string + + defaultOutputDir := filepath.FromSlash("./docs/cli") + supportedFormats := []string{"markdown", "manpages", "rest"} + exampleCustomDir := filepath.FromSlash("./documentation") + cmd := &cobra.Command{ + Use: "docs", + Short: fmt.Sprintf("Build docs in %s formats", strings.Join(supportedFormats, ", ")), + Example: fmt.Sprintf(` # simply build markdown docs at default output dir (%s) + ai-detection-action docs + + # build rest docs at default output dir + ai-detection-action docs --format rest + + # build manpages docs at a specific 'documentation' dir + ai-detection-action docs --format manpages --out %s`, defaultOutputDir, exampleCustomDir), + Args: cobra.MaximumNArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + prepareError := func(err error) error { + log.Println(err) + *exitCode = ExitError + return err + } + + var docDir string + var err error + + root := cmd.Root() + // as per cobra docs: disable autogen tag for + // stable, reproducible files (no timestamp footer) + root.DisableAutoGenTag = true + + // create required dir for docs inside output dir + if slices.Contains(supportedFormats, formatFlag) { + docDir = filepath.Clean(filepath.Join(outputDir, formatFlag)) + err = os.MkdirAll(docDir, 0o755) + } else { + err = fmt.Errorf("unknown format: %s\n", formatFlag) + } + if err != nil { + return prepareError(err) + } + + // gen docs as per specified flag + switch formatFlag { + case "markdown": + log.Println("Building docs in Markdown format.") + err = doc.GenMarkdownTree(root, docDir) + case "manpages": + log.Println("Building docs in Manpages format.") + hdr := &doc.GenManHeader{Title: strings.ToUpper(root.Name()), Section: "1"} + err = doc.GenManTree(root, hdr, docDir) + case "rest": + log.Println("Building docs in ReST (reStructuredText) format.") + err = doc.GenReSTTree(root, docDir) + } + + if err != nil { + return prepareError(err) + } + log.Printf("Docs built successfully at %s\n", docDir) + return nil + }, + } + + cmd.Flags().StringVar(&outputDir, "out", defaultOutputDir, "output directory") + cmd.Flags().StringVar(&formatFlag, "format", "markdown", strings.Join(supportedFormats, "|")) + + return cmd +} diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 694563f..1233c15 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -227,3 +227,117 @@ func TestFilterReport(t *testing.T) { t.Errorf("ai_commits = %d, want 1", filtered.Summary.AICommits) } } + +func TestRunDocsMarkdownDefault(t *testing.T) { + // Clean up default directory paths after the test finishes + defer func() { + _ = os.RemoveAll("./docs") + }() + + var stdout, stderr bytes.Buffer + code := Run([]string{"docs"}, &stdout, &stderr) + + if code != ExitNoAI { + t.Errorf("exit code = %d, want %d (stderr: %s)", code, ExitNoAI, stderr.String()) + } + + defaultDir := filepath.FromSlash("./docs/cli/markdown") + if _, err := os.Stat(defaultDir); os.IsNotExist(err) { + t.Fatalf("expected markdown output directory to exist: %s", defaultDir) + } + + files, err := os.ReadDir(defaultDir) + if err != nil || len(files) == 0 { + t.Error("expected documentation files inside the markdown directory") + } +} + +func TestRunDocsFormats(t *testing.T) { + tests := []struct { + format string + expectFile string + }{ + {format: "markdown", expectFile: ".md"}, + {format: "manpages", expectFile: "1"}, + {format: "rest", expectFile: ".rst"}, + } + + for _, tt := range tests { + t.Run(tt.format, func(t *testing.T) { + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + code := Run([]string{"docs", "--format=" + tt.format, "--out=" + tmpDir}, &stdout, &stderr) + + if code != ExitNoAI { + t.Errorf("exit code = %d, want %d (stderr: %s)", code, ExitNoAI, stderr.String()) + } + + docDir := filepath.Join(tmpDir, tt.format) + if _, err := os.Stat(docDir); os.IsNotExist(err) { + t.Fatalf("expected format directory to exist: %s", docDir) + } + + files, err := os.ReadDir(docDir) + if err != nil || len(files) == 0 { + t.Fatalf("no files generated for format: %s", tt.format) + } + + foundMatch := false + for _, f := range files { + if strings.Contains(strings.ToLower(f.Name()), tt.expectFile) { + foundMatch = true + break + } + fileInfo, err := f.Info() + if err != nil { + t.Fatalf("error getting info for file: %s", f.Name()) + } + if fileInfo.Size() == 0 { + t.Errorf("expected size to be non-zero for file: %s", f.Name()) + } + } + if !foundMatch { + t.Errorf("could not find expected documentation artifact matching '%s' in output", tt.expectFile) + } + }) + } +} + +func TestRunDocsInvalidFormat(t *testing.T) { + tmpDir := t.TempDir() + + var stdout, stderr bytes.Buffer + code := Run([]string{"docs", "--format=html", "--out=" + tmpDir}, &stdout, &stderr) + + if code != ExitError { + t.Errorf("exit code = %d, want %d", code, ExitError) + } + if !strings.Contains(stderr.String(), "unknown format: html") { + t.Errorf("expected unknown format error message, got: %s", stderr.String()) + } +} + +func TestRunDocsInvalidArgument(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Run([]string{"docs", "unexpected-argument"}, &stdout, &stderr) + + if code != ExitError { + t.Errorf("exit code = %d, want %d", code, ExitError) + } +} + +func TestRunDocsWriteError(t *testing.T) { + tmpDir := t.TempDir() + blockedPath := filepath.Join(tmpDir, "blocked_file") + if err := os.WriteFile(blockedPath, []byte("this is a test file"), 0644); err != nil { + t.Fatalf("setup failed: %v", err) + } + + var stdout, stderr bytes.Buffer + code := Run([]string{"docs", "--out=" + blockedPath}, &stdout, &stderr) + + if code != ExitError { + t.Errorf("exit code = %d, want %d", code, ExitError) + } +} diff --git a/go.mod b/go.mod index fa75ae6..64f3e66 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -21,10 +22,12 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index 1e4e0cf..6b323de 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= @@ -58,6 +59,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= @@ -75,6 +77,7 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=