Chore: unix tools tests

This commit is contained in:
Grail Finder
2026-04-12 10:39:03 +03:00
parent 39e099cbe9
commit 78918b2949
8 changed files with 697 additions and 86 deletions

17
bot.go
View File

@@ -6,7 +6,6 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"gf-lt/config" "gf-lt/config"
"gf-lt/models" "gf-lt/models"
@@ -1771,20 +1770,4 @@ func init() {
// atomic default values // atomic default values
cachedModelColor.Store("orange") cachedModelColor.Store("orange")
go chatWatcher(ctx) go chatWatcher(ctx)
// parse flags
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools")
flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use")
flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file")
flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)")
flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
flag.Parse()
if !cfg.CLIMode {
initTUI()
}
chatBody.Model = cfg.CurrentModel
go updateModelLists()
tools.InitTools(cfg, logger, store)
// tooler = tools.InitTools(cfg, logger, store)
// tooler.RegisterWindowTools(modelHasVision)
} }

View File

@@ -30,6 +30,7 @@ type Config struct {
DBPATH string `toml:"DBPATH"` DBPATH string `toml:"DBPATH"`
FilePickerDir string `toml:"FilePickerDir"` FilePickerDir string `toml:"FilePickerDir"`
FilePickerExts string `toml:"FilePickerExts"` FilePickerExts string `toml:"FilePickerExts"`
FSAllowOutOfRoot bool `toml:"FSAllowOutOfRoot"`
ImagePreview bool `toml:"ImagePreview"` ImagePreview bool `toml:"ImagePreview"`
EnableMouse bool `toml:"EnableMouse"` EnableMouse bool `toml:"EnableMouse"`
// embeddings // embeddings

18
main.go
View File

@@ -2,9 +2,11 @@ package main
import ( import (
"bufio" "bufio"
"flag"
"fmt" "fmt"
"gf-lt/models" "gf-lt/models"
"gf-lt/pngmeta" "gf-lt/pngmeta"
"gf-lt/tools"
"os" "os"
"slices" "slices"
"strconv" "strconv"
@@ -36,6 +38,22 @@ var (
) )
func main() { func main() {
// parse flags
flag.BoolVar(&cfg.CLIMode, "cli", false, "Run in CLI mode without TUI")
flag.BoolVar(&cfg.ToolUse, "tools", true, "run with tools")
flag.StringVar(&cfg.CurrentModel, "model", "modelname", "name of the model to use")
flag.StringVar(&cliCardPath, "card", "", "Path to syscard JSON file")
flag.BoolVar(&cliContinue, "continue", false, "Continue from last chat (by agent or card)")
flag.StringVar(&cliMsg, "msg", "", "Send message and exit (one-shot mode)")
flag.Parse()
if !cfg.CLIMode {
initTUI()
}
chatBody.Model = cfg.CurrentModel
go updateModelLists()
tools.InitTools(cfg, logger, store)
// tooler = tools.InitTools(cfg, logger, store)
// tooler.RegisterWindowTools(modelHasVision)
if cfg.CLIMode { if cfg.CLIMode {
runCLIMode() runCLIMode()
return return

View File

@@ -126,6 +126,9 @@ func makePropsTable(props map[string]float32) *tview.Table {
addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) { addCheckboxRow("Image Preview (file picker)", cfg.ImagePreview, func(checked bool) {
cfg.ImagePreview = checked cfg.ImagePreview = checked
}) })
addCheckboxRow("Allow FS out of root", cfg.FSAllowOutOfRoot, func(checked bool) {
cfg.FSAllowOutOfRoot = checked
})
addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) { addCheckboxRow("Auto turn (for cards with many chars)", cfg.AutoTurn, func(checked bool) {
cfg.AutoTurn = checked cfg.AutoTurn = checked
}) })

View File

@@ -54,6 +54,12 @@ func (d dummyStore) Recall(agent, topic string) (string, error) { return
func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil } func (d dummyStore) RecallTopics(agent string) ([]string, error) { return nil, nil }
func (d dummyStore) Forget(agent, topic string) error { return nil } func (d dummyStore) Forget(agent, topic string) error { return nil }
// TableLister method
func (d dummyStore) ListTables() ([]string, error) { return nil, nil }
func (d dummyStore) GetTableColumns(table string) ([]storage.TableColumn, error) {
return nil, nil
}
// VectorRepo methods (not used but required by interface) // VectorRepo methods (not used but required by interface)
func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil } func (d dummyStore) WriteVector(row *models.VectorRow) error { return nil }
func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) { func (d dummyStore) SearchClosest(q []float32, limit int) ([]models.VectorRow, error) {

View File

@@ -135,8 +135,7 @@ func ExecChain(command string) string {
return "[error] empty command" return "[error] empty command"
} }
// Handle redirects: find the segment with OpRedirect or OpAppend // Check if we have a redirect
// The NEXT segment (if any) is the target file
var redirectTo string var redirectTo string
var isAppend bool var isAppend bool
redirectIdx := -1 redirectIdx := -1
@@ -149,25 +148,30 @@ func ExecChain(command string) string {
} }
if redirectIdx >= 0 && redirectIdx+1 < len(segments) { if redirectIdx >= 0 && redirectIdx+1 < len(segments) {
// The segment after redirect is the target path
targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw) targetPath, err := resolveRedirectPath(segments[redirectIdx+1].Raw)
if err != nil { if err != nil {
return fmt.Sprintf("[error] redirect: %v", err) return fmt.Sprintf("[error] redirect: %v", err)
} }
redirectTo = targetPath redirectTo = targetPath
// Get the redirect command BEFORE removing segments
redirectCmd := segments[redirectIdx].Raw redirectCmd := segments[redirectIdx].Raw
// Check if there's chaining after redirect by looking at the operator of the segment AFTER the target
// The segment at redirectIdx+1 is the target file, and its Op tells us what operator follows
hasChainingAfterRedirect := false
if redirectIdx+1 < len(segments) && segments[redirectIdx+1].Op != OpNone && segments[redirectIdx+1].Op != OpPipe {
hasChainingAfterRedirect = true
}
// Remove both the redirect segment and its target // Remove both the redirect segment and its target
segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...) segments = append(segments[:redirectIdx], segments[redirectIdx+2:]...)
// Execute the redirect command explicitly // Execute the redirect command
var lastOutput string var lastOutput string
var lastErr error var lastErr error
lastOutput, lastErr = execSingle(redirectCmd, "") lastOutput, lastErr = execSingle(redirectCmd, "")
if lastErr != nil { if lastErr != nil {
return fmt.Sprintf("[error] redirect: %v", lastErr) return fmt.Sprintf("[error] redirect: %v", lastErr)
} }
// Write output to file
if err := writeFile(redirectTo, lastOutput, isAppend); err != nil { if err := writeFile(redirectTo, lastOutput, isAppend); err != nil {
return fmt.Sprintf("[error] redirect: %v", err) return fmt.Sprintf("[error] redirect: %v", err)
} }
@@ -176,9 +180,25 @@ func ExecChain(command string) string {
mode = "Appended" mode = "Appended"
} }
size := humanSizeChain(int64(len(lastOutput))) size := humanSizeChain(int64(len(lastOutput)))
return fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo)) redirectResult := fmt.Sprintf("%s %s → %s", mode, size, filepath.Base(redirectTo))
// If no remaining segments or no chaining, just return the write confirmation
if len(segments) == 0 || !hasChainingAfterRedirect {
return redirectResult
}
// There are remaining commands after the redirect
collected := []string{redirectResult}
// Execute remaining commands
for _, seg := range segments {
lastOutput, lastErr = execSingle(seg.Raw, "")
if lastOutput != "" {
collected = append(collected, lastOutput)
}
}
return strings.Join(collected, "\n")
} else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) { } else if redirectIdx >= 0 && redirectIdx+1 >= len(segments) {
// Redirect but no target file
return "[error] redirect: target file required" return "[error] redirect: target file required"
} }

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -61,16 +62,17 @@ func resolvePath(rel string) (string, error) {
if cfg.FilePickerDir == "" { if cfg.FilePickerDir == "" {
return "", errors.New("fs root not set") return "", errors.New("fs root not set")
} }
if filepath.IsAbs(rel) { isAbs := filepath.IsAbs(rel)
if isAbs {
abs := filepath.Clean(rel) abs := filepath.Clean(rel)
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
return "", fmt.Errorf("path escapes fs root: %s", rel) return "", fmt.Errorf("path escapes fs root: %s", rel)
} }
return abs, nil return abs, nil
} }
abs := filepath.Join(cfg.FilePickerDir, rel) abs := filepath.Join(cfg.FilePickerDir, rel)
abs = filepath.Clean(abs) abs = filepath.Clean(abs)
if !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir { if !cfg.FSAllowOutOfRoot && !strings.HasPrefix(abs, cfg.FilePickerDir+string(os.PathSeparator)) && abs != cfg.FilePickerDir {
return "", fmt.Errorf("path escapes fs root: %s", rel) return "", fmt.Errorf("path escapes fs root: %s", rel)
} }
return abs, nil return abs, nil
@@ -111,6 +113,59 @@ func FsLs(args []string, stdin string) string {
dir = a dir = a
} }
} }
hasGlob := strings.ContainsAny(dir, "*?[")
if hasGlob {
absDir := cfg.FilePickerDir
if filepath.IsAbs(dir) {
absDir = filepath.Dir(dir)
} else if strings.Contains(dir, "/") {
absDir = filepath.Join(cfg.FilePickerDir, filepath.Dir(dir))
}
globPattern := filepath.Base(dir)
fullPattern := filepath.Join(absDir, globPattern)
matches, err := filepath.Glob(fullPattern)
if err != nil {
return fmt.Sprintf("[error] ls: %v", err)
}
if len(matches) == 0 {
return "[error] ls: no such file or directory"
}
var out strings.Builder
filter := func(name string) bool {
return showAll || !strings.HasPrefix(name, ".")
}
for _, match := range matches {
info, err := os.Stat(match)
if err != nil {
continue
}
name := filepath.Base(match)
if !filter(name) {
continue
}
if longFormat {
if info.IsDir() {
fmt.Fprintf(&out, "d %-8s %s/\n", "-", name)
} else {
fmt.Fprintf(&out, "f %-8s %s\n", humanSize(info.Size()), name)
}
} else {
if info.IsDir() {
fmt.Fprintf(&out, "%s/\n", name)
} else {
fmt.Fprintf(&out, "%s\n", name)
}
}
}
if out.Len() == 0 {
return "(empty directory)"
}
return strings.TrimRight(out.String(), "\n")
}
abs, err := resolvePath(dir) abs, err := resolvePath(dir)
if err != nil { if err != nil {
return fmt.Sprintf("[error] %v", err) return fmt.Sprintf("[error] %v", err)
@@ -153,36 +208,59 @@ func FsLs(args []string, stdin string) string {
func FsCat(args []string, stdin string) string { func FsCat(args []string, stdin string) string {
b64 := false b64 := false
var path string var paths []string
for _, a := range args { for _, a := range args {
if a == "-b" || a == "--base64" { if a == "-b" || a == "--base64" {
b64 = true b64 = true
} else if path == "" { } else if a != "" {
path = a paths = append(paths, a)
} }
} }
if path == "" { if len(paths) == 0 {
if stdin != "" { if stdin != "" {
return stdin return stdin
} }
return "[error] usage: cat <path> or cat (with stdin)" return "[error] usage: cat <path> or cat (with stdin)"
} }
abs, err := resolvePath(path)
if err != nil { var allFiles []string
return fmt.Sprintf("[error] %v", err) for _, path := range paths {
} if strings.ContainsAny(path, "*?[") {
data, err := os.ReadFile(abs) matches, err := filepath.Glob(path)
if err != nil { if err != nil {
return fmt.Sprintf("[error] cat: %v", err) return fmt.Sprintf("[error] cat: %v", err)
} }
if b64 { allFiles = append(allFiles, matches...)
result := base64.StdEncoding.EncodeToString(data) } else {
if IsImageFile(path) { allFiles = append(allFiles, path)
result += fmt.Sprintf("\n![image](file://%s)", abs)
} }
return result
} }
return string(data)
if len(allFiles) == 0 {
return "[error] cat: no files found"
}
var results []string
for _, path := range allFiles {
abs, err := resolvePath(path)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(abs)
if err != nil {
return fmt.Sprintf("[error] cat: %v", err)
}
if b64 {
result := base64.StdEncoding.EncodeToString(data)
if IsImageFile(path) {
result += fmt.Sprintf("\n![image](file://%s)", abs)
}
results = append(results, result)
} else {
results = append(results, string(data))
}
}
return strings.Join(results, "")
} }
func FsViewImg(args []string, stdin string) string { func FsViewImg(args []string, stdin string) string {
@@ -323,60 +401,211 @@ func FsRm(args []string, stdin string) string {
if len(args) == 0 { if len(args) == 0 {
return "[error] usage: rm <path>" return "[error] usage: rm <path>"
} }
abs, err := resolvePath(args[0]) force := false
if err != nil { var paths []string
return fmt.Sprintf("[error] %v", err) for _, a := range args {
if a == "-f" || a == "--force" {
force = true
} else if !strings.HasPrefix(a, "-") {
paths = append(paths, a)
}
} }
if err := os.RemoveAll(abs); err != nil { if len(paths) == 0 {
return fmt.Sprintf("[error] rm: %v", err) return "[error] usage: rm <path>"
} }
return "Removed " + args[0]
var removed []string
var errs []string
for _, path := range paths {
if strings.ContainsAny(path, "*?[") {
matches, err := filepath.Glob(path)
if err != nil {
if !force {
return fmt.Sprintf("[error] rm: %v", err)
}
continue
}
for _, m := range matches {
if err := os.RemoveAll(m); err != nil {
if !force {
errs = append(errs, fmt.Sprintf("%v", err))
}
continue
}
removed = append(removed, m)
}
} else {
abs, err := resolvePath(path)
if err != nil {
if !force {
return fmt.Sprintf("[error] %v", err)
}
continue
}
if err := os.RemoveAll(abs); err != nil {
if !force {
return fmt.Sprintf("[error] rm: %v", err)
}
continue
}
removed = append(removed, path)
}
}
if len(removed) == 0 && len(errs) > 0 {
return "[error] rm: " + strings.Join(errs, "; ")
}
return "Removed " + strings.Join(removed, ", ")
} }
func FsCp(args []string, stdin string) string { func FsCp(args []string, stdin string) string {
if len(args) < 2 { if len(args) < 2 {
return "[error] usage: cp <src> <dst>" return "[error] usage: cp <src> <dst>"
} }
srcAbs, err := resolvePath(args[0]) srcPattern := args[0]
if err != nil { dstPath := args[1]
return fmt.Sprintf("[error] %v", err)
// Check if dst is an existing directory (ends with / or is a directory)
dstIsDir := strings.HasSuffix(dstPath, "/")
if !dstIsDir {
if info, err := os.Stat(dstPath); err == nil && info.IsDir() {
dstIsDir = true
}
} }
dstAbs, err := resolvePath(args[1])
if err != nil { // Check for single file copy (no glob and dst doesn't end with / and is not an existing dir)
return fmt.Sprintf("[error] %v", err) hasGlob := strings.ContainsAny(srcPattern, "*?[")
// Single source file to a specific file path (not a glob, not a directory)
if !hasGlob && !dstIsDir {
// Check if destination is an existing file - if not, treat as single file copy
if info, err := os.Stat(dstPath); err != nil || !info.IsDir() {
srcAbs, err := resolvePath(srcPattern)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Sprintf("[error] cp read: %v", err)
}
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
return fmt.Sprintf("[error] cp write: %v", err)
}
return fmt.Sprintf("Copied %s → %s (%s)", srcPattern, dstPath, humanSize(int64(len(data))))
}
} }
data, err := os.ReadFile(srcAbs)
if err != nil { // Copy to directory (either glob, or explicit directory)
return fmt.Sprintf("[error] cp read: %v", err) var srcFiles []string
if hasGlob {
matches, err := filepath.Glob(srcPattern)
if err != nil {
return fmt.Sprintf("[error] cp: %v", err)
}
if len(matches) == 0 {
return "[error] cp: no files match pattern"
}
srcFiles = matches
} else {
srcFiles = []string{srcPattern}
} }
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] cp mkdir: %v", err) var results []string
for _, srcPath := range srcFiles {
srcAbs, err := resolvePath(srcPath)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
data, err := os.ReadFile(srcAbs)
if err != nil {
return fmt.Sprintf("[error] cp read: %v", err)
}
dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath)))
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] cp mkdir: %v", err)
}
if err := os.WriteFile(dstAbs, data, 0o644); err != nil {
return fmt.Sprintf("[error] cp write: %v", err)
}
results = append(results, fmt.Sprintf("%s → %s (%s)", srcPath, filepath.Join(dstPath, filepath.Base(srcPath)), humanSize(int64(len(data)))))
} }
if err := os.WriteFile(dstAbs, data, 0o644); err != nil { return strings.Join(results, ", ")
return fmt.Sprintf("[error] cp write: %v", err)
}
return fmt.Sprintf("Copied %s → %s (%s)", args[0], args[1], humanSize(int64(len(data))))
} }
func FsMv(args []string, stdin string) string { func FsMv(args []string, stdin string) string {
if len(args) < 2 { if len(args) < 2 {
return "[error] usage: mv <src> <dst>" return "[error] usage: mv <src> <dst>"
} }
srcAbs, err := resolvePath(args[0]) srcPattern := args[0]
if err != nil { dstPath := args[1]
return fmt.Sprintf("[error] %v", err)
// Check if dst is an existing directory (ends with / or is a directory)
dstIsDir := strings.HasSuffix(dstPath, "/")
if !dstIsDir {
if info, err := os.Stat(dstPath); err == nil && info.IsDir() {
dstIsDir = true
}
} }
dstAbs, err := resolvePath(args[1])
if err != nil { // Check for single file move (no glob and dst doesn't end with / and is not an existing dir)
return fmt.Sprintf("[error] %v", err) hasGlob := strings.ContainsAny(srcPattern, "*?[")
// Single source file to a specific file path (not a glob, not a directory)
if !hasGlob && !dstIsDir {
// Check if destination is an existing file - if not, treat as single file move
if info, err := os.Stat(dstPath); err != nil || !info.IsDir() {
srcAbs, err := resolvePath(srcPattern)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err)
}
if err := os.Rename(srcAbs, dstPath); err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
return fmt.Sprintf("Moved %s → %s", srcPattern, dstPath)
}
} }
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err) // Move to directory (either glob, or explicit directory)
var srcFiles []string
if hasGlob {
matches, err := filepath.Glob(srcPattern)
if err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
if len(matches) == 0 {
return "[error] mv: no files match pattern"
}
srcFiles = matches
} else {
srcFiles = []string{srcPattern}
} }
if err := os.Rename(srcAbs, dstAbs); err != nil {
return fmt.Sprintf("[error] mv: %v", err) var results []string
for _, srcPath := range srcFiles {
srcAbs, err := resolvePath(srcPath)
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
dstAbs, err := resolvePath(filepath.Join(dstPath, filepath.Base(srcPath)))
if err != nil {
return fmt.Sprintf("[error] %v", err)
}
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
return fmt.Sprintf("[error] mv mkdir: %v", err)
}
if err := os.Rename(srcAbs, dstAbs); err != nil {
return fmt.Sprintf("[error] mv: %v", err)
}
results = append(results, fmt.Sprintf("%s → %s", srcPath, filepath.Join(dstPath, filepath.Base(srcPath))))
} }
return fmt.Sprintf("Moved %s → %s", args[0], args[1]) return strings.Join(results, ", ")
} }
func FsMkdir(args []string, stdin string) string { func FsMkdir(args []string, stdin string) string {
@@ -433,14 +662,31 @@ func FsTime(args []string, stdin string) string {
func FsGrep(args []string, stdin string) string { func FsGrep(args []string, stdin string) string {
if len(args) == 0 { if len(args) == 0 {
return "[error] usage: grep [-i] [-v] [-c] <pattern> [file]" return "[error] usage: grep [-i] [-v] [-c] [-E] <pattern> [file]"
} }
ignoreCase := false ignoreCase := false
invert := false invert := false
countOnly := false countOnly := false
useRegex := false
var pattern string var pattern string
var filePath string var filePath string
for _, a := range args { for _, a := range args {
if strings.HasPrefix(a, "-") && !strings.HasPrefix(a, "--") && len(a) > 1 {
flags := strings.TrimLeft(a, "-")
for _, c := range flags {
switch c {
case 'i':
ignoreCase = true
case 'v':
invert = true
case 'c':
countOnly = true
case 'E':
useRegex = true
}
}
continue
}
switch a { switch a {
case "-i": case "-i":
ignoreCase = true ignoreCase = true
@@ -448,6 +694,8 @@ func FsGrep(args []string, stdin string) string {
invert = true invert = true
case "-c": case "-c":
countOnly = true countOnly = true
case "-E":
useRegex = true
default: default:
if pattern == "" { if pattern == "" {
pattern = a pattern = a
@@ -475,16 +723,32 @@ func FsGrep(args []string, stdin string) string {
} else { } else {
return "[error] grep: no input (use file path or pipe from stdin)" return "[error] grep: no input (use file path or pipe from stdin)"
} }
if ignoreCase {
pattern = strings.ToLower(pattern)
}
var matched []string var matched []string
for _, line := range lines { for _, line := range lines {
haystack := line var match bool
if ignoreCase { if useRegex {
haystack = strings.ToLower(line) re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Sprintf("[error] grep: invalid regex: %v", err)
}
match = re.MatchString(line)
if ignoreCase && !match {
reIC, err := regexp.Compile("(?i)" + pattern)
if err == nil {
match = reIC.MatchString(line)
}
}
} else {
haystack := line
if ignoreCase {
haystack = strings.ToLower(line)
patternLower := strings.ToLower(pattern)
match = strings.Contains(haystack, patternLower)
} else {
match = strings.Contains(haystack, pattern)
}
} }
match := strings.Contains(haystack, pattern)
if invert { if invert {
match = !match match = !match
} }

316
tools/unix_test.go Normal file
View File

@@ -0,0 +1,316 @@
package tools
import (
"os"
"path/filepath"
"strings"
"testing"
"gf-lt/config"
)
func init() {
cfg = &config.Config{}
cwd, _ := os.Getwd()
if strings.HasSuffix(cwd, "/tools") || strings.HasSuffix(cwd, "\\tools") {
cwd = filepath.Dir(cwd)
}
cfg.FilePickerDir = cwd
}
func TestUnixGlobExpansion(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_glob_tmp")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content1"), 0644)
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content2"), 0644)
os.WriteFile(filepath.Join(tmpDir, "file3.log"), []byte("content3"), 0644)
tests := []struct {
name string
cmd string
wantErr bool
check func(string) bool
}{
{
name: "ls glob txt files",
cmd: "ls " + tmpDir + "/*.txt",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "file1.txt") && strings.Contains(r, "file2.txt") },
},
{
name: "cat glob txt files",
cmd: "cat " + tmpDir + "/*.txt",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "content1") && strings.Contains(r, "content2") },
},
{
name: "ls glob no matches",
cmd: "ls " + tmpDir + "/*.nonexistent",
wantErr: false,
check: func(r string) bool { return strings.Contains(r, "no such file") || strings.Contains(r, "(empty") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if tt.wantErr && result == "" {
t.Errorf("expected error for %q, got empty", tt.cmd)
}
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixCatMultipleFiles(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_cat_multi")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("file a content\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("file b content\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "c.txt"), []byte("file c content\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "cat multiple files with paths",
cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content")
},
},
{
name: "cat three files",
cmd: "cat " + tmpDir + "/a.txt " + tmpDir + "/b.txt " + tmpDir + "/c.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content") && strings.Contains(r, "file c content")
},
},
{
name: "cat via shell with glob",
cmd: "cat " + tmpDir + "/*.txt",
check: func(r string) bool {
return strings.Contains(r, "file a content") && strings.Contains(r, "file b content")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixGrepPatternQuoting(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_grep_quote")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "animals.txt"), []byte("dog\ncat\nbird\nfish\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "colors.txt"), []byte("red\nblue\ngreen\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "grep with double quotes OR pattern",
cmd: "grep -E \"dog|cat\" " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep with single quotes OR pattern",
cmd: "grep -E 'dog|cat' " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep case insensitive with quotes",
cmd: "grep -iE \"DOG|CAT\" " + tmpDir + "/animals.txt",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep piped from cat",
cmd: "cat " + tmpDir + "/animals.txt | grep -E \"dog|cat\"",
check: func(r string) bool { return strings.Contains(r, "dog") && strings.Contains(r, "cat") },
},
{
name: "grep with complex pattern",
cmd: "grep -E \"red|blue|green\" " + tmpDir + "/colors.txt",
check: func(r string) bool {
return strings.Contains(r, "red") && strings.Contains(r, "blue") && strings.Contains(r, "green")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}
func TestUnixForLoop(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_forloop")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "dog.txt"), []byte("I have a dog\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "cat.txt"), []byte("I have a cat\n"), 0644)
os.WriteFile(filepath.Join(tmpDir, "red.txt"), []byte("red color\n"), 0644)
result := ExecChain("cd " + tmpDir + " && for f in *.txt; do echo \"file: $f\"; done")
if result == "" {
t.Error("empty result from for loop execution")
}
if strings.Contains(result, "file:") {
t.Logf("for loop is supported: %s", result)
} else {
t.Logf("for loops not supported (expected): %s", result)
}
}
func TestUnixGlobWithFileOps(t *testing.T) {
tests := []struct {
name string
cmd string
setup func() string
check func(string) bool
}{
{
name: "rm glob txt files",
cmd: "rm {dir}/*.txt",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_rm_glob")
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "cp glob to dest",
cmd: "cp {dir}/*.txt {dir}/dest/",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_cp_glob")
os.MkdirAll(tmpDir, 0755)
os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content a"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content b"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "mv glob to dest",
cmd: "mv {dir}/*.log {dir}/dest/",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_mv_glob")
os.MkdirAll(tmpDir, 0755)
os.MkdirAll(filepath.Join(tmpDir, "dest"), 0755)
os.WriteFile(filepath.Join(tmpDir, "c.log"), []byte("content c"), 0644)
return tmpDir
},
check: func(r string) bool { return !strings.Contains(r, "[error]") },
},
{
name: "ls with flags and glob",
cmd: "ls -la {dir}/*.txt",
setup: func() string {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_ls_glob")
os.MkdirAll(tmpDir, 0755)
os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("content"), 0644)
os.WriteFile(filepath.Join(tmpDir, "b.txt"), []byte("content"), 0644)
return tmpDir
},
check: func(r string) bool { return strings.Contains(r, "a.txt") || strings.Contains(r, "b.txt") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := tt.setup()
defer os.RemoveAll(tmpDir)
cmd := strings.ReplaceAll(tt.cmd, "{dir}", tmpDir)
result := ExecChain(cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", cmd, result)
}
})
}
}
func TestUnixComplexPiping(t *testing.T) {
tmpDir := filepath.Join(cfg.FilePickerDir, "test_pipe_complex")
os.MkdirAll(tmpDir, 0755)
defer os.RemoveAll(tmpDir)
os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte("apple\nbanana\nAPPLE\ncherry\nbanana\n"), 0644)
tests := []struct {
name string
cmd string
check func(string) bool
}{
{
name: "cat | grep -i | sort",
cmd: "cat " + tmpDir + "/data.txt | grep -i apple | sort",
check: func(r string) bool { return strings.Contains(r, "apple") && !strings.Contains(r, "banana") },
},
{
name: "ls | wc -l",
cmd: "ls " + tmpDir + " | wc -l",
check: func(r string) bool { return strings.TrimSpace(r) == "1" },
},
{
name: "echo > file && cat file",
cmd: "echo 'hello world' > " + tmpDir + "/out.txt && cat " + tmpDir + "/out.txt",
check: func(r string) bool { return strings.Contains(r, "hello world") },
},
{
name: "grep file | head -2",
cmd: "grep a " + tmpDir + "/data.txt | head -2",
check: func(r string) bool { return strings.Contains(r, "apple") || strings.Contains(r, "banana") },
},
{
name: "cat | grep | wc -l",
cmd: "cat " + tmpDir + "/data.txt | grep -i apple | wc -l",
check: func(r string) bool { return strings.TrimSpace(r) == "2" },
},
{
name: "ls | grep txt | head -1",
cmd: "ls " + tmpDir + " | grep txt | head -1",
check: func(r string) bool { return strings.Contains(r, "data.txt") },
},
{
name: "echo | sed replacement",
cmd: "echo 'hello world' | sed 's/world/universe/'",
check: func(r string) bool { return strings.Contains(r, "hello universe") },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExecChain(tt.cmd)
if !tt.check(result) {
t.Errorf("check failed for %q, got %q", tt.cmd, result)
}
})
}
}