diff --git a/cmd/complete.go b/cmd/complete.go index 464438a..b4ad467 100644 --- a/cmd/complete.go +++ b/cmd/complete.go @@ -36,8 +36,9 @@ Examples: # Preview changes without modifying the file down-force complete evidence.yaml --dry-run `, - Args: cobra.ExactArgs(1), - Run: runComplete, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeEvidenceYAMLArg, + Run: runComplete, } func init() { diff --git a/cmd/completion_helpers.go b/cmd/completion_helpers.go new file mode 100644 index 0000000..1d19a55 --- /dev/null +++ b/cmd/completion_helpers.go @@ -0,0 +1,227 @@ +/* +Copyright © 2025 canaria-computer +*/ +package cmd + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/canaria-computer/down-force/internal/config" + "github.com/spf13/cobra" +) + +// RegisterAllCompletions registers all flag completion functions for the CLI. +// Call this after all commands are initialized. +func RegisterAllCompletions() { + // email --report: complete with report-* directories + _ = emailCmd.RegisterFlagCompletionFunc("report", completeReportDirectories) + + // devices --name: complete with device names + _ = devicesCmd.RegisterFlagCompletionFunc("name", completeDeviceNames) + + // lite --config: complete with yaml files + _ = liteCmd.RegisterFlagCompletionFunc("config", completeYAMLFiles) + + // root --config-file: complete with yaml files + _ = rootCmd.RegisterFlagCompletionFunc("config-file", completeConfigFiles) +} + +// completeReportDirectories provides completion for report directories +func completeReportDirectories(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cwd, err := os.Getwd() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + entries, err := os.ReadDir(cwd) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") { + name := entry.Name() + if strings.HasPrefix(name, toComplete) { + completions = append(completions, name) + } + } + } + + // Sort by name (newest first by timestamp convention) + sort.Sort(sort.Reverse(sort.StringSlice(completions))) + + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// completeDeviceNames provides completion for device names +func completeDeviceNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + allDevices := config.GetAllPresetDevices() + + var completions []string + toCompleteLower := strings.ToLower(toComplete) + + for name, device := range allDevices { + nameLower := strings.ToLower(name) + titleLower := strings.ToLower(device.Title) + + if strings.HasPrefix(nameLower, toCompleteLower) || + strings.HasPrefix(titleLower, toCompleteLower) || + strings.Contains(nameLower, toCompleteLower) || + strings.Contains(titleLower, toCompleteLower) { + // Use device title as completion value + completions = append(completions, device.Title) + } + } + + sort.Strings(completions) + + // Remove duplicates + completions = uniqueStrings(completions) + + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// completeYAMLFiles provides completion for YAML config files +func completeYAMLFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + cwd, err := os.Getwd() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + + // Match yaml/yml files + patterns := []string{"*.yaml", "*.yml"} + for _, pattern := range patterns { + matches, err := filepath.Glob(filepath.Join(cwd, pattern)) + if err != nil { + continue + } + for _, match := range matches { + base := filepath.Base(match) + if strings.HasPrefix(base, toComplete) || toComplete == "" { + completions = append(completions, base) + } + } + } + + // Also check subdirectories for evidence.yaml + entries, _ := os.ReadDir(cwd) + for _, entry := range entries { + if entry.IsDir() { + subPath := filepath.Join(cwd, entry.Name(), "evidence.yaml") + if _, err := os.Stat(subPath); err == nil { + relPath := filepath.Join(entry.Name(), "evidence.yaml") + if strings.HasPrefix(relPath, toComplete) || toComplete == "" { + completions = append(completions, relPath) + } + } + } + } + + sort.Strings(completions) + + return completions, cobra.ShellCompDirectiveDefault +} + +// completeConfigFiles provides completion for application config files +func completeConfigFiles(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var completions []string + + // Add default config location + homeDir, err := os.UserHomeDir() + if err == nil { + defaultPath := filepath.Join(homeDir, ".config", "down-force", "config.yaml") + if _, err := os.Stat(defaultPath); err == nil { + completions = append(completions, defaultPath) + } + } + + // Also complete local yaml files + localCompletions, _ := completeYAMLFiles(cmd, args, toComplete) + completions = append(completions, localCompletions...) + + return completions, cobra.ShellCompDirectiveDefault +} + +// uniqueStrings removes duplicate strings from a sorted slice +func uniqueStrings(s []string) []string { + if len(s) == 0 { + return s + } + + result := make([]string, 0, len(s)) + prev := "" + for _, str := range s { + if str != prev { + result = append(result, str) + prev = str + } + } + return result +} + +// SMTPSecureValues returns valid values for SMTP secure mode +var SMTPSecureValues = []string{"ssl", "tls", "starttls", "none"} + +// SMTPAuthTypes returns valid values for SMTP authentication type +var SMTPAuthTypes = []string{"AUTO", "PLAIN", "LOGIN", "CRAM-MD5"} + +// IMAPSecureValues returns valid values for IMAP secure mode +var IMAPSecureValues = []string{"ssl", "tls", "starttls", "none"} + +// completeEvidenceYAMLArg provides completion for evidence.yaml positional argument +func completeEvidenceYAMLArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + // Only complete first argument + return nil, cobra.ShellCompDirectiveNoFileComp + } + return completeYAMLFiles(cmd, args, toComplete) +} + +// completeEMLFileArg provides completion for .eml file positional argument +func completeEMLFileArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + cwd, err := os.Getwd() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + + // Match .eml files in current directory + matches, err := filepath.Glob(filepath.Join(cwd, "*.eml")) + if err == nil { + for _, match := range matches { + base := filepath.Base(match) + if strings.HasPrefix(base, toComplete) || toComplete == "" { + completions = append(completions, base) + } + } + } + + // Also check in report-* directories + entries, _ := os.ReadDir(cwd) + for _, entry := range entries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") { + emlMatches, _ := filepath.Glob(filepath.Join(cwd, entry.Name(), "*.eml")) + for _, match := range emlMatches { + relPath, _ := filepath.Rel(cwd, match) + if strings.HasPrefix(relPath, toComplete) || toComplete == "" { + completions = append(completions, relPath) + } + } + } + } + + sort.Strings(completions) + + return completions, cobra.ShellCompDirectiveDefault +} diff --git a/cmd/email.go b/cmd/email.go index 15218ee..58028b2 100644 --- a/cmd/email.go +++ b/cmd/email.go @@ -265,8 +265,9 @@ Examples: Configuration: SMTP server settings must be configured before sending. Use 'down-force config email' for SMTP setup.`, - Args: cobra.ExactArgs(1), - Run: runEmailResend, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeEMLFileArg, + Run: runEmailResend, } func init() { @@ -364,7 +365,7 @@ func runEmailDraft(cmd *cobra.Command, args []string) { if emailDraftExport { log.Info("Exporting draft as .eml file...") log.Infof("HTML Sanitization: %v", !effectiveAllowHTML) - + if err := saveEmlFile(msg, reportDir); err != nil { log.Errorf("Failed to save .eml file: %v", err) } else { @@ -641,7 +642,6 @@ func randomInt(min, max int) int { return min + rand.Intn(max-min) } - func runEmailSend(cmd *cobra.Command, args []string) { cfg := appconfig.GetConfig() @@ -786,13 +786,10 @@ func getReportDirectory(reportDir string) (string, error) { } var reportDirs []string for _, entry := range entries { - if entry.IsDir() && strings.HasPrefix(entry.Name(), "report-") { - hasFile, err := hasAnyFile(entry.Name()) - if err != nil { - log.Error("Failed to check if directory is empty: %v", err) - return "", err - } - if hasFile { + if entry.IsDir() { + // Check if the directory contains Report.md (evidence report directory) + reportFile := filepath.Join(entry.Name(), "Report.md") + if _, err := os.Stat(reportFile); err == nil { reportDirs = append(reportDirs, entry.Name()) } } diff --git a/cmd/lite.go b/cmd/lite.go index 3332df2..475dbb9 100644 --- a/cmd/lite.go +++ b/cmd/lite.go @@ -165,23 +165,25 @@ func performPreflightChecks(targetURL string, userAgents []config.UserAgentConfi } // generateReportID creates a unique report ID from config or UNIX timestamp. -// Returns Report ID without "report-" prefix. +// If configReportID is empty, returns "report-{timestamp}" format. +// If configReportID is provided, returns it as-is. func generateReportID(configReportID string) string { if configReportID != "" { - // Remove "report-" prefix if present (for compatibility) - return strings.TrimPrefix(configReportID, "report-") + // Use the specified report_id as-is + return configReportID } - // Generate Base58-encoded UNIX timestamp + // Generate report-{timestamp} format for default case ts := time.Now().Unix() tsBytes := make([]byte, 8) binary.BigEndian.PutUint64(tsBytes, uint64(ts)) - return base58Encode(tsBytes) + return fmt.Sprintf("report-%s", base58Encode(tsBytes)) } -// getReportDir creates directory name from Report ID +// getReportDir returns the directory name directly +// (reportID is already in the correct format) func getReportDir(reportID string) string { - return fmt.Sprintf("report-%s", reportID) + return reportID } // base58Encode encodes bytes to Base58 string diff --git a/cmd/root.go b/cmd/root.go index 5f82ce6..cf83214 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -43,6 +43,9 @@ var rootCmd = &cobra.Command{ } func Execute() { + // Register all flag completion functions + RegisterAllCompletions() + err := rootCmd.Execute() if err != nil { os.Exit(1) diff --git a/cmd/validate.go b/cmd/validate.go index bbc8ac8..51c4b9e 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -38,8 +38,9 @@ Examples: # Lenient validation (schema only) down-force validate evidence.yaml --lax `, - Args: cobra.ExactArgs(1), - Run: runValidate, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeEvidenceYAMLArg, + Run: runValidate, } func init() { diff --git a/internal/utils/email_address.go b/internal/utils/email_address.go index 3b5c937..9ea3402 100644 --- a/internal/utils/email_address.go +++ b/internal/utils/email_address.go @@ -16,20 +16,22 @@ type PlaceholderContext struct { } // NewPlaceholderContextFromReportDir creates a PlaceholderContext from a report directory name -// Expected format: report-{REPORT_ID} (e.g., "report-MjAyNjAxMDgtMTc0NTQ0") +// The directory name is used as the report ID. +// If it starts with "report-", the prefix is removed for the report ID. func NewPlaceholderContextFromReportDir(reportDir string) (*PlaceholderContext, error) { basename := filepath.Base(reportDir) - // Directory name must start with "report-" - if !strings.HasPrefix(basename, "report-") { - return nil, fmt.Errorf("invalid report directory format (expected 'report-*'): %s", basename) + if basename == "" { + return nil, fmt.Errorf("report directory name is empty") } - // Extract Report ID by removing "report-" prefix - reportID := strings.TrimPrefix(basename, "report-") - - if reportID == "" { - return nil, fmt.Errorf("report ID is empty in directory: %s", basename) + // Extract Report ID: remove "report-" prefix if present + reportID := basename + if strings.HasPrefix(basename, "report-") { + reportID = strings.TrimPrefix(basename, "report-") + if reportID == "" { + return nil, fmt.Errorf("report ID is empty in directory: %s", basename) + } } return &PlaceholderContext{ diff --git a/internal/utils/email_address_test.go b/internal/utils/email_address_test.go index bb4c7fb..9f2bd27 100644 --- a/internal/utils/email_address_test.go +++ b/internal/utils/email_address_test.go @@ -27,25 +27,27 @@ func TestNewPlaceholderContextFromReportDir(t *testing.T) { wantErr: false, }, { - name: "valid custom report ID", + name: "valid custom report ID with prefix", reportDir: "report-custom-id-123", wantReportID: "custom-id-123", wantErr: false, }, { - name: "invalid format - no report- prefix", - reportDir: "MjAyNjAxMDgtMTc0NTQ0", - wantErr: true, + name: "custom report ID without prefix", + reportDir: "custom-id-456", + wantReportID: "custom-id-456", + wantErr: false, }, { - name: "invalid format - empty report ID", + name: "invalid format - empty report ID with prefix only", reportDir: "report-", wantErr: true, }, { - name: "invalid format - not a report directory", - reportDir: "some-other-directory", - wantErr: true, + name: "directory without prefix is valid", + reportDir: "some-other-directory", + wantReportID: "some-other-directory", + wantErr: false, }, }