Exit and entry points

The result channel should be closed by either the cancellation of the context, or by the search being over. Since a channel cannot be closed twice, we can use sync.Once to avoid closing the channel for the second time. To keep track of the goroutines that are running, we can use sync.Waitgroup:

ch, wg, once := make(chan Result), sync.WaitGroup{}, sync.Once{}
go func() {
wg.Wait()
fmt.Println("* Search done *")
once.Do(func() {
close(ch)
})
}()
go func() {
<-ctx.Done()
fmt.Println("* Context done *")
once.Do(func() {
close(ch)
})
}()

We could launch a goroutine for each file so that we can define a private function that we can use as an entry point and then use it recursively for subdirectories:

func fileSearch(ctx context.Context, ch chan<- Result, wg *sync.WaitGroup, file, term string, o *Options)

The main exported function will start by adding a value to the wait group. It will then launch the private function, starting it as an asynchronous process:

wg.Add(1)
go fileSearch(ctx, ch, &wg, root, term, o)

The last thing each fileSearch should do is call WaitGroup.Done to mark the end of the current file.