Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 27 additions & 3 deletions cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
67 changes: 63 additions & 4 deletions cmd/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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-- {
Expand Down
16 changes: 16 additions & 0 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion internal/generator/tsv.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package generator
import (
"fmt"
"io"
"strings"

"github.com/sidisinsane/hashfm-agent/internal/model"
)
Expand All @@ -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
}