diff --git a/README.md b/README.md index 377a76e..3219ee4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ curl -o- https://raw.githubusercontent.com/sidisinsane/hashfm-agent/main/install irm https://raw.githubusercontent.com/sidisinsane/hashfm-agent/main/install.ps1 | iex ``` +--- + **Generate an index:** ```bash @@ -71,8 +73,20 @@ hashfm-agent: format: tsv recursive: true output: index.tsv + exclude: + - _*.sh + include: + - scripts/*.sh ``` +| Field | Type | Default | Description | +|---|---|---|---| +| `format` | string | `tsv` | Output format: `tsv`, `jsonl`, or `yaml`. | +| `output` | string | — | File path to write the index to. If empty, writes to stdout. | +| `recursive` | boolean | `false` | Whether to scan subdirectories. | +| `exclude` | list | — | Blacklist of glob patterns. Matching files are skipped. | +| `include` | list | — | Whitelist of glob patterns. Only matching files are processed. If empty, all `.sh` files are included. | + See [`hashfm/CONFIG.md`](https://github.com/sidisinsane/hashfm/blob/main/CONFIG.md) for supported filenames, schema, and validation rules. diff --git a/cmd/root.go b/cmd/root.go index e863b86..73893af 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -111,7 +111,7 @@ func runGenerate(args []string) { os.Exit(1) } - entries, warnings, err := ScanDir(dir, recursive) + entries, warnings, err := ScanDir(dir, recursive, cfg.Agent.Generate.Include, cfg.Agent.Generate.Exclude) if err != nil { fmt.Fprintln(os.Stderr, "generate:", err) os.Exit(1) diff --git a/cmd/scan.go b/cmd/scan.go index dd9218b..f3e421e 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -27,9 +27,9 @@ func NewGenerator(format string) (generator.Generator, error) { } } -// ScanDir recursively or shallowly walks a directory to find and process all .sh files. +// ScanDir recursively or shallowly walks a directory to find and process matching .sh files. // It returns a flattened list of all index entries found across all processed files. -func ScanDir(dir string, recursive bool) (entries []model.IndexEntry, warnings []string, err error) { +func ScanDir(dir string, recursive bool, include, exclude []string) (entries []model.IndexEntry, warnings []string, err error) { walkFn := func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr @@ -44,6 +44,20 @@ func ScanDir(dir string, recursive bool) (entries []model.IndexEntry, warnings [ return nil } + // Compute rel for filtering — relative to dir, using forward slashes. + filterRel, err := filepath.Rel(dir, path) + if err != nil { + filterRel = filepath.Base(path) + } + filterRel = filepath.ToSlash(filterRel) + + if len(include) > 0 && !matchesAny(filterRel, include) { + return nil + } + if len(exclude) > 0 && matchesAny(filterRel, exclude) { + return nil + } + src, err := os.ReadFile(path) if err != nil { return err @@ -52,7 +66,7 @@ func ScanDir(dir string, recursive bool) (entries []model.IndexEntry, warnings [ block, err := pipeline.Process(string(src)) if err != nil { if _, ok := err.(pipeline.ErrNoBlock); ok { - return nil // silently skip + return nil } warnings = append(warnings, fmt.Sprintf("%s: malformed hashfm block", path)) return nil @@ -70,4 +84,14 @@ func ScanDir(dir string, recursive bool) (entries []model.IndexEntry, warnings [ err = filepath.WalkDir(dir, walkFn) return +} + +func matchesAny(relPath string, patterns []string) bool { + for _, pattern := range patterns { + matched, err := filepath.Match(pattern, relPath) + if err == nil && matched { + return true + } + } + return false } \ No newline at end of file diff --git a/cmd/scan_test.go b/cmd/scan_test.go index e690cbc..d6b1c02 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -62,7 +62,7 @@ func TestNewGenerator_UnknownFormat(t *testing.T) { func TestScanDir_ReturnsEntriesForValidScripts(t *testing.T) { // Scans the whole testdir — invalid-*.sh fixtures will produce warnings, // which is expected. We only assert on the entry count here. - entries, _, err := cmd.ScanDir(testdataDir, false) + entries, _, err := cmd.ScanDir(testdataDir, false, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -73,7 +73,7 @@ func TestScanDir_ReturnsEntriesForValidScripts(t *testing.T) { } func TestScanDir_SkipsNoBlockFiles(t *testing.T) { - entries, warnings, err := cmd.ScanDir(testdataDir, false) + entries, warnings, err := cmd.ScanDir(testdataDir, false, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -91,7 +91,7 @@ func TestScanDir_SkipsNoBlockFiles(t *testing.T) { } func TestScanDir_WarnsOnInvalidScripts(t *testing.T) { - entries, warnings, err := cmd.ScanDir(testdataDir, false) + entries, warnings, err := cmd.ScanDir(testdataDir, false, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -109,12 +109,71 @@ func TestScanDir_WarnsOnInvalidScripts(t *testing.T) { } func TestScanDir_NonExistentDir(t *testing.T) { - _, _, err := cmd.ScanDir("/nonexistent/path", false) + _, _, err := cmd.ScanDir("/nonexistent/path", false, nil, nil) if err == nil { t.Error("expected error for non-existent directory, got nil") } } +func TestScanDir_ExcludePattern(t *testing.T) { + entries, _, err := cmd.ScanDir(testdataDir, false, nil, []string{"invalid-*.sh"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, e := range entries { + base := pathBase(e.Path) + if len(base) > 8 && base[:8] == "invalid-" { + t.Errorf("invalid script %q should be excluded", base) + } + } + // valid scripts still included + if len(entries) != 4 { + t.Errorf("expected 4 entries, got %d", len(entries)) + } +} + +func TestScanDir_IncludePattern(t *testing.T) { + entries, _, err := cmd.ScanDir(testdataDir, false, []string{"valid-*.sh"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // only valid-single.sh and valid-multi.sh match + if len(entries) != 4 { + t.Errorf("expected 4 entries, got %d", len(entries)) + } + for _, e := range entries { + base := pathBase(e.Path) + if base[:6] != "valid-" { + t.Errorf("expected only valid-*.sh, got %q", base) + } + } +} + +func TestScanDir_ExcludeTakesPrecedence(t *testing.T) { + entries, _, err := cmd.ScanDir(testdataDir, false, []string{"valid-*.sh"}, []string{"valid-multi.sh"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if pathBase(entries[0].Path) != "valid-single.sh" { + t.Errorf("expected valid-single.sh, got %q", pathBase(entries[0].Path)) + } +} + +func TestScanDir_InvalidPattern(t *testing.T) { + // Invalid exclude pattern is silently ignored; files are processed normally. + _, warnings, err := cmd.ScanDir(testdataDir, false, nil, []string{"["}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // invalid-*.sh still produce warnings despite the invalid exclude pattern + if len(warnings) == 0 { + t.Error("expected warnings for invalid scripts") + } +} + // pathBase returns the base name of a path. func pathBase(path string) string { for i := len(path) - 1; i >= 0; i-- { diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 167ff7a..78caf1c 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -72,6 +72,22 @@ func TestTSV_Empty(t *testing.T) { } } +func TestTSV_NewlineInDescription(t *testing.T) { + entries := []model.IndexEntry{ + {Name: "test", Path: "./test.sh", Description: "Line one\nLine two"}, + } + var buf bytes.Buffer + generator.TSV{}.Generate(&buf, entries) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines (header + 1 entry), got %d", len(lines)) + } + // description should have spaces instead of newlines + if !strings.Contains(lines[1], "Line one Line two") { + t.Errorf("description should have spaces instead of newlines, got %q", lines[1]) + } +} + func TestJSONL(t *testing.T) { entries := loadEntries(t) var buf bytes.Buffer diff --git a/internal/generator/tsv.go b/internal/generator/tsv.go index acb4cdb..0b6e502 100644 --- a/internal/generator/tsv.go +++ b/internal/generator/tsv.go @@ -3,6 +3,7 @@ package generator import ( "fmt" "io" + "strings" "github.com/sidisinsane/hashfm-agent/internal/model" ) @@ -15,7 +16,8 @@ type TSV struct{} func (TSV) Generate(w io.Writer, entries []model.IndexEntry) error { fmt.Fprintln(w, "name\tpath\tdescription") for _, e := range entries { - fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, e.Path, e.Description) + desc := strings.ReplaceAll(strings.TrimSpace(e.Description), "\n", " ") + fmt.Fprintf(w, "%s\t%s\t%s\n", e.Name, e.Path, desc) } return nil }