diff options
58 files changed, 2663 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea
\ No newline at end of file diff --git a/.pik/.alias b/.pik/.alias new file mode 100644 index 0000000..d1f63a3 --- /dev/null +++ b/.pik/.alias @@ -0,0 +1,2 @@ +pik +godo diff --git a/.pik/.icon b/.pik/.icon new file mode 100644 index 0000000..13efc71 --- /dev/null +++ b/.pik/.icon @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/.pik/build.sh b/.pik/build.sh new file mode 100644 index 0000000..3b12060 --- /dev/null +++ b/.pik/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# build uwu +echo "$@" +CGO_ENABLED=0 go build -v "$@" . +echo "Congratulations!" diff --git a/.pik/script.sh b/.pik/script.sh new file mode 100644 index 0000000..c59344e --- /dev/null +++ b/.pik/script.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env fish +# example script +echo "Your input is : $@"
\ No newline at end of file diff --git a/.pik/subdir/loc.here.sh b/.pik/subdir/loc.here.sh new file mode 100644 index 0000000..379cc75 --- /dev/null +++ b/.pik/subdir/loc.here.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "$PWD"
\ No newline at end of file diff --git a/.pik/subdir/nested/subtest.sh b/.pik/subdir/nested/subtest.sh new file mode 100644 index 0000000..a18a426 --- /dev/null +++ b/.pik/subdir/nested/subtest.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "Deepest test" diff --git a/.pik/subdir/subtrigger.pre.sh b/.pik/subdir/subtrigger.pre.sh new file mode 100644 index 0000000..2638038 --- /dev/null +++ b/.pik/subdir/subtrigger.pre.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "Subtrigger" diff --git a/.pik/thing.py b/.pik/thing.py new file mode 100644 index 0000000..f3833d2 --- /dev/null +++ b/.pik/thing.py @@ -0,0 +1 @@ +print("From python!")
\ No newline at end of file diff --git a/.pik/trigger.pre.sh b/.pik/trigger.pre.sh new file mode 100644 index 0000000..39d4cc8 --- /dev/null +++ b/.pik/trigger.pre.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +echo "Yep! I exist!"
\ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f3b945 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# uwu +> targets should be files, after all + +## current features + +* run targets by their approximate name: `uwu build` will trigger `.uwu/build.sh`, or `.uwu/build.py`, or `make build` depending on what's possible. +* specify source names to search for the target in that source explicitly: `uwu myproject build` can run `../../myproject/.uwu/build.sh` +* unless overridden, targets will run in the directory where the `.uwu` folder resides. +* use `--all` flag to start out-of-tree targets without having to navigate to the directory. +* `--here` to run the target in the current working directory instead of the source directory. +* target tags in filenames which trigger flag behaviours +* aliases to sources through the `.alias` file +* tui for viewing and running targets + +## planned features + +As this program has already gone through a number of iterations and forms, this (hopefully permanent) version will need +time to catch up with all the features it used to have. This list is not exhaustive, but it is ordered by importance I +attach to these features. + +* y/n confirmation with yes as default +* runtime flags: + * `--there` to override a targets `here` flag. + * `--yes` to automatically confirm y/n prompts +* support for .env files + * the .env files will be reindexed for every script, meaning a `.pre` trigger can prepare the `.env` file for the + real targets. +* python runner and indexer + * runner with support for uv and venvs + * indexer with support for `pyproject.toml` script targets +* runner for executable files + * this will also enable arbitrary shells like node by way of the shebang +* indexers for other target types such as `make`, `just`, and `npm` +* expand tui: + * adding descriptions to targets based on the first comment in a target + * support for categories and ordering of targets through the `.order` file + * search + * more hotkeys (filter jumping, toggle all, etc.) +* git pre-commit and pre-push triggers +* linking sources together by `.include` and `.wants` files diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..b57d8b8 --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,113 @@ +package cache + +import ( + "bufio" + "errors" + "io" + "io/fs" + "os" + "path" + "pik/model" + "pik/paths" + "strings" +) + +type Cache struct { + Entries []Entry +} + +func (c Cache) Merge(other Cache) Cache { + mp := make(map[string]string) + for _, e := range append(c.Entries, other.Entries...) { + mp[e.Path] = e.Label + } + result := Cache{} + for p, l := range mp { + result.Entries = append(result.Entries, Entry{Label: l, Path: p}) + } + return result +} + +type Entry struct { + Path string + Label string +} + +var Path = path.Join(paths.Cache, "contexts") + +var UnexpectedEntryError = errors.New("unexpected cache entry") + +func Load() (Cache, error) { + fd, err := os.Open(Path) + if errors.Is(err, os.ErrNotExist) { + return Cache{}, nil + } else if err != nil { + return Cache{}, err + } + defer fd.Close() + return FromReader(fd) +} + +func FromReader(r io.Reader) (Cache, error) { + c := Cache{} + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || line[0] == '#' || line[0:2] == "//" { + continue + } + + entry := &Entry{} + parts := strings.SplitN(line, "#", 2) + switch len(parts) { + case 2: + entry.Label = strings.TrimSpace(parts[1]) + fallthrough + case 1: + entry.Path = strings.TrimSpace(parts[0]) + default: + return c, UnexpectedEntryError + } + c.Entries = append(c.Entries, *entry) + } + return c, nil +} + +func (c Cache) String() string { + b := strings.Builder{} + for _, e := range c.Entries { + b.WriteString(e.Path) + b.WriteString(" # ") + b.WriteString(e.Label) + b.WriteString("\n") + } + return b.String() +} + +func New(st *model.State) Cache { + c := &Cache{} + for _, s := range st.Sources { + c.Entries = append(c.Entries, Entry{ + Path: s.Path, + Label: s.Label(), + }) + } + return *c +} + +func Save(s *model.State) error { + ld, err := Load() + if err != nil { + return err + } + c := New(s).Merge(ld) + return os.WriteFile(Path, []byte(c.String()), os.ModePerm) +} + +func LoadState(f fs.FS, cache Cache, indexers []model.Indexer, runners []model.Runner) (*model.State, error) { + var locs []string + for _, e := range cache.Entries { + locs = append(locs, e.Path) + } + return model.NewState(f, locs, indexers, runners) +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..fa81974 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,79 @@ +package cache + +import ( + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestFromReader_Blank(t *testing.T) { + input := ` +` + sr := strings.NewReader(input) + c, err := FromReader(sr) + assert.Nil(t, err) + assert.Len(t, c.Entries, 0) +} + +func TestFromReader_OneEntry(t *testing.T) { + input := `/abc/def # deffers` + sr := strings.NewReader(input) + c, err := FromReader(sr) + assert.Nil(t, err) + assert.Len(t, c.Entries, 1) + assert.Equal(t, c.Entries[0], Entry{ + Path: "/abc/def", + Label: "deffers", + }) +} + +func TestFromReader_ManyEntries(t *testing.T) { + input := `/abc/def # deffers +/123/aa # i love aa +/path/src # da source +` + sr := strings.NewReader(input) + c, err := FromReader(sr) + assert.Nil(t, err) + assert.Len(t, c.Entries, 3) + assert.Equal(t, c.Entries[0], Entry{ + Path: "/abc/def", + Label: "deffers", + }) + assert.Equal(t, c.Entries[1], Entry{ + Path: "/123/aa", + Label: "i love aa", + }) + assert.Equal(t, c.Entries[2], Entry{ + Path: "/path/src", + Label: "da source", + }) +} + +func TestFromReader_Comments(t *testing.T) { + input := ` +// comment +/abc/def # deffers +# comment +/123/aa # i love aa +// # comment +/path/src # da source +# // comment +` + sr := strings.NewReader(input) + c, err := FromReader(sr) + assert.Nil(t, err) + assert.Len(t, c.Entries, 3) + assert.Equal(t, c.Entries[0], Entry{ + Path: "/abc/def", + Label: "deffers", + }) + assert.Equal(t, c.Entries[1], Entry{ + Path: "/123/aa", + Label: "i love aa", + }) + assert.Equal(t, c.Entries[2], Entry{ + Path: "/path/src", + Label: "da source", + }) +} diff --git a/crawl/crawl.go b/crawl/crawl.go new file mode 100644 index 0000000..dac6b19 --- /dev/null +++ b/crawl/crawl.go @@ -0,0 +1,54 @@ +package crawl + +import ( + "path" + "path/filepath" + "slices" + "strings" +) + +func Evaluated(loc string) (string, error) { + return filepath.EvalSymlinks(loc) +} + +func RichLocations(origin string) []string { + locs := Locations(origin) + + eval, err := Evaluated(origin) + if err == nil && eval != origin { + i := 0 + evaledLocations := Locations(eval) + var result []string + for i < len(locs) && i < len(evaledLocations) { + result = append(result, evaledLocations[i], locs[i]) + } + result = slices.Compact(result) + return result + } + return locs +} + +func Locations(origin string) []string { + origin = path.Clean(origin) + var locs = []string{ + origin, + } + for { + previous := locs[len(locs)-1] + parent := ParentDir(previous) + if previous == parent { + break + } + locs = append(locs, parent) + } + return locs +} + +func ParentDir(origin string) string { + trimmedOrigin := strings.TrimSuffix(origin, "/") + dir, _ := path.Split(trimmedOrigin) + if dir == "" { + return origin + } + return dir +} diff --git a/crawl/crawl_test.go b/crawl/crawl_test.go new file mode 100644 index 0000000..88b70f1 --- /dev/null +++ b/crawl/crawl_test.go @@ -0,0 +1,58 @@ +package crawl + +import ( + "github.com/stretchr/testify/assert" + "io/fs" + "testing" +) + +func TestParentDir(t *testing.T) { + input := "/var/lib" + parent := ParentDir(input) + assert.Equal(t, parent, "/var/") +} + +func TestParentDir_TrailingSlash(t *testing.T) { + input := "/var/lib/" + parent := ParentDir(input) + assert.Equal(t, parent, "/var/") +} + +func TestParentDir_ToRoot(t *testing.T) { + input := "/var/" + parent := ParentDir(input) + assert.Equal(t, parent, "/") +} +func TestParentDir_ToRoot_NoTrailingSlash(t *testing.T) { + input := "/var" + parent := ParentDir(input) + assert.Equal(t, parent, "/") +} + +func TestParentDir_WithoutParent(t *testing.T) { + input := "/" + parent := ParentDir(input) + assert.Equal(t, parent, "/") +} + +func TestLocations(t *testing.T) { + input := "/var/lib/uwu/asdf" + locs := Locations(input) + assert.Equal(t, locs, []string{"/var/lib/uwu/asdf", "/var/lib/uwu/", "/var/lib/", "/var/", "/"}) +} + +func TestLocations_WithDotPath(t *testing.T) { + input := "/root/./second/asdf/../third" + locs := Locations(input) + assert.Equal(t, locs, []string{"/root/second/third", "/root/second/", "/root/", "/"}) +} + +func TestLocations_HighestFirst(t *testing.T) { + input := "/one/two/three" + locs := Locations(input) + assert.Equal(t, locs[0], "/one/two/three") +} + +func Test(t *testing.T) { + assert.True(t, fs.ValidPath("asdf/hjkl")) +} diff --git a/flags/flags.go b/flags/flags.go new file mode 100644 index 0000000..2ce8045 --- /dev/null +++ b/flags/flags.go @@ -0,0 +1,12 @@ +package flags + +import "github.com/spf13/pflag" + +var ( + Here = pflag.BoolP("here", "h", false, "run target in current directory instead of source location") + At = pflag.StringP("at", "@", "", "override run location") + Single = pflag.BoolP("singgle", "s", false, "do not run any triggers") + All = pflag.BoolP("all", "a", false, "get sources from cache instead of crawling") + Dry = pflag.BoolP("dry", "d", false, "print cmdlines instead of running them") + Root = pflag.BoolP("root", "r", false, "run targets (including triggers) with sudo") +) @@ -0,0 +1,32 @@ +module pik + +go 1.26.1 + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/identity/identity.go b/identity/identity.go new file mode 100644 index 0000000..ee4dfea --- /dev/null +++ b/identity/identity.go @@ -0,0 +1,30 @@ +package identity + +import "strings" + +type Identity struct { + Full string + Reduced string +} + +func (i Identity) Is(input string) bool { + return Reduce(input) == i.Reduced +} + +func New(input string) Identity { + return Identity{ + Full: input, + Reduced: Reduce(input), + } + +} + +func Reduce(input string) string { + reduced := input + if !strings.HasPrefix(reduced, ".") { + reduced = strings.Split(reduced, ".")[0] + } + reduced = strings.ToLower(reduced) + return reduced + +} diff --git a/indexers/pikdex/hydrate.go b/indexers/pikdex/hydrate.go new file mode 100644 index 0000000..078f3a6 --- /dev/null +++ b/indexers/pikdex/hydrate.go @@ -0,0 +1,18 @@ +package pikdex + +import ( + "pik/model" +) + +func (u *uwudex) Hydrate(src *model.Source, result *model.HydratedSource) error { + mod := u.mods[src.Path] + if mod.Path != "" { + if mod.Aliases != nil { + result.Aliases = append(result.Aliases, mod.Aliases...) + } + if mod.Icon != "" { + result.Icon = mod.Icon + } + } + return nil +} diff --git a/indexers/pikdex/index.go b/indexers/pikdex/index.go new file mode 100644 index 0000000..e365d6f --- /dev/null +++ b/indexers/pikdex/index.go @@ -0,0 +1,154 @@ +package pikdex + +import ( + "errors" + "io/fs" + "os" + "path" + "path/filepath" + "pik/model" + "pik/spool" + "slices" + "strings" +) + +var Roots = []string{ + + // current name + ".pik", + "_pik", + + // program names from a previous life + ".godo", + "_godo", + ".pik", + "_uwu", + + //utility + ".bin", + "_bin", + "tasks", + ".tasks", + "_tasks", +} + +var SkippedFolders = []string{ + ".git", + ".config", + ".idea", +} + +func init() { + // add own executable name to uwudexable dirs + self, err := os.Executable() + if strings.HasSuffix(self, ".test") { + return + } + if err != nil { + _, _ = spool.Warn("%v\n", err) + return + } + self = strings.TrimSuffix(self, ".exe") + Roots = append(Roots, "."+self, "_"+self) +} + +var Indexer = &uwudex{mods: make(map[string]*SourceData)} + +type uwudex struct { + mods map[string]*SourceData +} + +type SourceData struct { + Aliases []string + Icon string + Path string +} + +func (u *uwudex) Index(absPath string, f fs.FS, runners []model.Runner) ([]model.Target, error) { + wants, root, err := u.WantsWalk(f) + if !wants { + return nil, err + } + var targets []model.Target + mod := u.mods[absPath] + if mod == nil { + u.mods[absPath] = &SourceData{ + Path: absPath, + } + mod = u.mods[absPath] + } + err = fs.WalkDir(f, root, func(p string, d fs.DirEntry, err error) error { + + if !d.IsDir() { + for trigger, applier := range MetaFiles { + + // during the crawl, we might find meta files + expectedLocation := filepath.Join(absPath, root, trigger) + actualLocation := filepath.Join(absPath, p) + if expectedLocation != actualLocation { + continue + } + + content, err := os.ReadFile(expectedLocation) + if err != nil { + spool.Warn("%v\n", err) + continue + } + applier(mod, string(content)) + + } + } + + if d.IsDir() { + _, dirName := path.Split(p) + if slices.Contains(SkippedFolders, dirName) { + return fs.SkipDir + } + } + + for _, r := range runners { + wants, err := r.Wants(f, p, d) + if err != nil { + // TODO: Gracefully keep crawling? + return err + } + if wants { + t, err := r.CreateTarget(f, absPath, p, d) + if err != nil { + // TODO: Gracefully keep crawling? + return err + } + targets = append(targets, t) + } + if err != nil { + // TODO: Gracefully keep crawling? + return err + } + } + return nil + }) + u.mods[absPath] = mod + + return targets, err +} + +func (u *uwudex) WantsWalk(f fs.FS) (bool, string, error) { + entries, err := fs.ReadDir(f, ".") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, "", nil + } else { + return false, "", err + } + } + + for _, e := range entries { + for _, r := range Roots { + if e.Name() == r && e.IsDir() { + return true, r, nil + } + } + } + + return false, "", nil +} diff --git a/indexers/pikdex/index_test.go b/indexers/pikdex/index_test.go new file mode 100644 index 0000000..885b29f --- /dev/null +++ b/indexers/pikdex/index_test.go @@ -0,0 +1,54 @@ +package pikdex + +import ( + "github.com/stretchr/testify/assert" + "io/fs" + "testing" + "testing/fstest" +) + +func TestUwudex_WantsWalk_AnyRoot(t *testing.T) { + for _, r := range Roots { + data := fstest.MapFS{ + r: &fstest.MapFile{ + Data: nil, + Mode: fs.ModeDir, + }, + } + u := &uwudex{} + result, root, err := u.WantsWalk(data) + assert.Equal(t, root, r) + assert.NoError(t, err) + assert.True(t, result) + } +} + +func TestUwudex_WantsWalk_TwoRoots(t *testing.T) { + data := fstest.MapFS{ + Roots[0]: &fstest.MapFile{ + Data: nil, + Mode: fs.ModeDir, + }, + Roots[1]: &fstest.MapFile{ + Data: nil, + Mode: fs.ModeDir, + }, + } + u := &uwudex{} + result, r, err := u.WantsWalk(data) + // no guarantee we pick any one lol + assert.Contains(t, Roots, r) + assert.NoError(t, err) + assert.True(t, result) +} + +func TestUwudex_WantsWalk_NoRoots(t *testing.T) { + data := fstest.MapFS{ + "asdf.txt": &fstest.MapFile{}, + } + u := &uwudex{} + result, r, err := u.WantsWalk(data) + assert.Equal(t, "", r) + assert.NoError(t, err) + assert.False(t, result) +} diff --git a/indexers/pikdex/meta.go b/indexers/pikdex/meta.go new file mode 100644 index 0000000..af1af93 --- /dev/null +++ b/indexers/pikdex/meta.go @@ -0,0 +1,27 @@ +package pikdex + +import ( + "strings" +) + +type MetaSetter func(s *SourceData, content string) + +var MetaFiles = map[string]MetaSetter{ + ".alias": func(s *SourceData, content string) { + split := strings.Split(content, "\n") + s.Aliases = make([]string, 0, len(split)) + for _, line := range split { + stripped := strip(line) + if stripped != "" { + s.Aliases = append(s.Aliases, stripped) + } + } + }, + ".icon": func(s *SourceData, content string) { + s.Icon = string([]rune(strip(content))[0:2]) + }, +} + +func strip(input string) string { + return strings.TrimSpace(input) +} @@ -0,0 +1,122 @@ +package main + +import ( + "github.com/spf13/pflag" + "os" + "pik/cache" + "pik/crawl" + "pik/flags" + "pik/indexers/pikdex" + "pik/menu" + "pik/model" + "pik/run" + "pik/runner/python" + "pik/runner/shell" + "pik/search" + "pik/spool" +) + +var initializers = []model.HasInit{ + python.Python, +} + +var indexers = []model.Indexer{ + pikdex.Indexer, +} + +var runners = []model.Runner{ + shell.Runner, + python.Python, +} + +var hydrators = []model.Hydrator{ + pikdex.Indexer, +} + +var ForceConfirm = false + +func main() { + pflag.Parse() + for _, i := range initializers { + err := i.Init() + if err != nil { + _, _ = spool.Warn("%v\n", err) + } + } + here, err := os.Getwd() + if err != nil { + panic(err) + } + locs := crawl.RichLocations(here) + last := locs[len(locs)-1] + root, err := os.OpenRoot(last) + if root == nil { + panic(err) + } + fs := root.FS() + if err != nil { + panic(err) + } + var st *model.State + var stateError error + if !*flags.All { + st, stateError = model.NewState(fs, locs, indexers, runners) + } else { + c, err := cache.Load() + if err != nil { + panic(err) + } + st, stateError = cache.LoadState(fs, c, indexers, runners) + } + if stateError != nil { + panic(stateError) + } + + err = cache.Save(st) + if err != nil { + _, _ = spool.Warn("%v", err) + } + + args := pflag.Args() + + if len(args) == 0 { + source, target, err := menu.Show(st, hydrators) + if err != nil { + panic(err) + } + err = run.Run(source.Source, target, args...) + if err != nil { + panic(err) + } + + return + } + + target, src, confirm, _, args := search.Search(st, args...) + if !*flags.All && target == nil && len(args) > 0 { + err := pflag.Set("all", "true") + ForceConfirm = true + if err != nil { + panic(err) + } + main() + return + } + + if target == nil { + _, _ = spool.Print("no target found.") + os.Exit(1) + return + } + + if confirm || ForceConfirm { + if !menu.Confirm(os.Stdin, src, target, args...) { + os.Exit(0) + } + } + + err = run.Run(src, target, args...) + if err != nil { + panic(err) + } +} diff --git a/menu/banner.go b/menu/banner.go new file mode 100644 index 0000000..c9d8e56 --- /dev/null +++ b/menu/banner.go @@ -0,0 +1,92 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "os/exec" + "pik/menu/style" + "pik/model" + "pik/paths" + "strings" +) + +var ( + BannerStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + BannerSourceLabelStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Faint(true).MarginRight(1) + }) + BannerSubItemStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Faint(true).MarginRight(1) + }) + BannerSubStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + BannerSelfStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().MarginRight(1) + }) + BannerPromptStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + BannerArgsStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().MarginLeft(1) + }) + BannerArgStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + BannerTerminatorColor = lipgloss.Color("1") + BannerTerminatorStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Faint(true).Foreground(BannerTerminatorColor) + }) +) + +func Banner(source *model.Source, target model.Target, args ...string) string { + var parts, argParts []string + parts = append(parts, BannerPromptStyle.Render("> ")) + parts = append(parts, BannerSelfStyle.Render("pik")) + parts = append(parts, BannerSourceLabelStyle.Render(source.Label())) + if sub := target.Sub(); sub != nil { + for i, s := range sub { + sub[i] = BannerSubItemStyle.Render(s) + } + parts = append(parts, BannerSubStyle.Render(sub...)) + } + parts = append(parts, target.ShortestId()) + if args != nil { + needsTerminator := false + for _, a := range args { + if strings.HasPrefix(a, "-") { + needsTerminator = true + } + argParts = append(argParts, BannerArgStyle.Render(a)) + } + + if needsTerminator { + argParts = append([]string{BannerTerminatorStyle.Render("--")}, argParts...) + } + + parts = append(parts, BannerArgsStyle.Render(argParts...)) + } + result := BannerStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...)) + return result +} + +var ( + CmdStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Faint(true) + }) + CmdDirStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) + CmdArgStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) +) + +func InlineCmd(cmd *exec.Cmd) string { + var args []string + for _, a := range cmd.Args { + args = append(args, paths.ReplaceHome(a)) + } + return CmdStyle.Render(" # "+CmdDirStyle.Render(paths.ReplaceHome(cmd.Dir)+":"), CmdArgStyle.Render(args...)) +} diff --git a/menu/confirm.go b/menu/confirm.go new file mode 100644 index 0000000..b9063c9 --- /dev/null +++ b/menu/confirm.go @@ -0,0 +1,43 @@ +package menu + +import ( + "bufio" + "fmt" + "github.com/charmbracelet/lipgloss" + "io" + "os" + "pik/menu/style" + "pik/model" + "slices" +) + +var confirmations = []rune{ + 'y', + 'Y', + ' ', + '\n', +} + +var ( + PromptColor = lipgloss.Color("1") + PromptStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(PromptColor) + }) + ConfirmStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) +) + +func Confirm(r io.Reader, source *model.Source, target model.Target, args ...string) bool { + banner := ConfirmStyle.Render(PromptStyle.Render("[Y/n]"), Banner(source, target, args...), "? ") + fmt.Print(banner) + scanner := bufio.NewScanner(r) + scanner.Split(bufio.ScanRunes) + scanner.Scan() + if slices.Contains(confirmations, []rune(scanner.Text())[0]) { + return true + } else { + _, _ = fmt.Fprint(os.Stderr, "confirmation was not given.") + } + return false +} diff --git a/menu/icon.go b/menu/icon.go new file mode 100644 index 0000000..ec2135f --- /dev/null +++ b/menu/icon.go @@ -0,0 +1,28 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" + "strings" +) + +var ( + IconStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().Width(2).Height(1) + return st + }) +) + +func Icon(input string) string { + if strings.TrimSpace(input) == "" { + return "" + } + return IconStyle.Render(input) +} + +func PaddedIcon(input string) string { + if strings.TrimSpace(input) == "" { + return Icon(" ") + } + return Icon(input) +} diff --git a/menu/menu.go b/menu/menu.go new file mode 100644 index 0000000..232a837 --- /dev/null +++ b/menu/menu.go @@ -0,0 +1,47 @@ +package menu + +import ( + "errors" + tea "github.com/charmbracelet/bubbletea" + "pik/model" + "pik/spool" +) + +var WrongModelTypeError = errors.New("wrong model type") + +func Show(st *model.State, hydrators []model.Hydrator) (*model.HydratedSource, model.HydratedTarget, error) { + md := NewModel(st, hydrators) + program := tea.NewProgram(md) + resultModel, err := program.Run() + if err != nil { + return nil, nil, err + } + result, ok := resultModel.(*Model) + if !ok { + return nil, nil, WrongModelTypeError + } + + src, t := result.Result() + return src, t, nil +} + +func Hydrate(st *model.State, hydrators []model.Hydrator) *model.HydratedState { + hyd := &model.HydratedState{ + State: st, + HydratedSources: make([]*model.HydratedSource, len(st.Sources)), + } + for i, s := range st.Sources { + hydSrc := s.Hydrate(hydrators) + + for _, h := range hydrators { + err := h.Hydrate(s, hydSrc) + if err != nil { + spool.Warn("%v\n", err) + continue + } + } + + hyd.HydratedSources[i] = hydSrc + } + return hyd +} diff --git a/menu/model.go b/menu/model.go new file mode 100644 index 0000000..ceeb861 --- /dev/null +++ b/menu/model.go @@ -0,0 +1,101 @@ +package menu + +import ( + tea "github.com/charmbracelet/bubbletea" + "pik/model" + "pik/spool" +) + +type Model struct { + *model.HydratedState + Index int + Indices map[int]model.HydratedTarget + SourceIndices map[int]*model.HydratedSource +} + +func (m *Model) Init() tea.Cmd { + return nil +} + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var err error + var result tea.Cmd + switch mt := msg.(type) { + case tea.KeyMsg: + result, err = m.HandleInput(mt) + case tea.Cmd: + result, err = m.HandleSignal(mt) + } + if err != nil { + spool.Warn("%v\n", err) + } + return m, result +} + +func (m *Model) HandleInput(msg tea.KeyMsg) (tea.Cmd, error) { + var cmd tea.Cmd + switch msg.String() { + case "up", "k": + m.Index-- + case "down", "j": + m.Index++ + case "q", "esc": + cmd = tea.Quit + case "space", " ", "enter": + cmd = tea.Quit + } + + m.Validate() + + return cmd, nil +} + +func (m *Model) HandleSignal(cmd tea.Cmd) (tea.Cmd, error) { + return nil, nil +} + +func (m *Model) View() string { + return m.State(m.HydratedState) +} + +func (m *Model) Result() (*model.HydratedSource, model.HydratedTarget) { + return m.SourceIndices[m.Index], m.Indices[m.Index] +} + +func (m *Model) Validate() { + if m.Index < 0 { + m.Index = 0 + } + if m.Index > len(m.Indices)-1 { + m.Index = len(m.Indices) - 1 + } +} + +func NewModel(st *model.State, hydrators []model.Hydrator) *Model { + m := &Model{ + HydratedState: Hydrate(st, hydrators), + Index: 0, + Indices: make(map[int]model.HydratedTarget), + SourceIndices: make(map[int]*model.HydratedSource), + } + idx := 0 + for _, src := range st.Sources { + hydSrc := src.Hydrate(hydrators) + for _, target := range src.Targets { + + if !target.Visible() { + continue + } + + hydTarget, err := target.Hydrate(src) + m.Indices[idx] = hydTarget + if err != nil { + spool.Warn("%v\n", err) + } + m.SourceIndices[idx] = hydSrc + + idx++ + } + } + return m +} diff --git a/menu/source.go b/menu/source.go new file mode 100644 index 0000000..39b3280 --- /dev/null +++ b/menu/source.go @@ -0,0 +1,70 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" + "pik/model" +) + +var ( + SourceStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle() + return st + }) + SourceHeaderBackground = lipgloss.Color("5") + SourceHeaderStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle() + return st + + }) + SourceLabelStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).Background(SourceHeaderBackground).BorderBackground(SourceHeaderBackground).PaddingRight(1).PaddingLeft(1).MarginRight(1) + return st + + }) + SourceIconStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().PaddingRight(1) + return st + + }) + SourceTargetsStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle() + return st + }) + SourcePathStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().Faint(true) + return st + }) +) + +func (m *Model) Source(src *model.HydratedSource) string { + targets := make([]string, 0, len(src.Targets)) + for _, t := range src.HydratedTargets { + targets = append(targets, m.Target(t)) + } + + targetContent := lipgloss.JoinVertical(lipgloss.Top, targets...) + + icon := SourceIconStyle.Render(Icon(src.Icon)) + + return SourceStyle.Render(lipgloss.JoinVertical(lipgloss.Top, + SourceHeaderStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, SourceLabelStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, icon, src.Label()), SourcePathStyle.Render(src.ShortPath())))), + SourceTargetsStyle.Render(targetContent), + )) + +} + +var ( + StateStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle().MarginBottom(1) + }) +) + +func (m *Model) State(st *model.HydratedState) string { + sources := make([]string, 0, len(st.Sources)) + for _, hs := range st.HydratedSources { + sources = append(sources, m.Source(hs)) + } + + return StateStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sources...)) +} diff --git a/menu/style/style.go b/menu/style/style.go new file mode 100644 index 0000000..e7164e1 --- /dev/null +++ b/menu/style/style.go @@ -0,0 +1,30 @@ +package style + +import "github.com/charmbracelet/lipgloss" + +type StyleBuilder func() lipgloss.Style + +type Style struct { + style *lipgloss.Style + builder StyleBuilder +} + +func New(builder StyleBuilder) Style { + return Style{ + builder: builder, + } +} + +func (s *Style) Get() lipgloss.Style { + + if s.style == nil { + st := s.builder() + s.style = &st + } + + return *s.style +} + +func (s *Style) Render(input ...string) string { + return s.Get().Render(input...) +} diff --git a/menu/target.go b/menu/target.go new file mode 100644 index 0000000..b17ed68 --- /dev/null +++ b/menu/target.go @@ -0,0 +1,56 @@ +package menu + +import ( + "github.com/charmbracelet/lipgloss" + "pik/menu/style" + "pik/model" +) + +var ( + TargetBackgroundColor = lipgloss.Color("8") + SelectedTargetBackgroundColor = lipgloss.Color("2") + TargetStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).BorderBackground(TargetBackgroundColor).Background(TargetBackgroundColor) + return st + }) + SelectedTargetStyle = style.New(func() lipgloss.Style { + return TargetStyle.Get().BorderBackground(SelectedTargetBackgroundColor).Background(SelectedTargetBackgroundColor) + }) + TargetLabelStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().MarginRight(1) + return st + }) + TargetDescriptionStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().Faint(true).MarginLeft(1) + return st + }) + SelectedTargetDescriptionStyle = style.New(func() lipgloss.Style { + st := TargetDescriptionStyle.Get().Faint(false) + return st + }) + TargetIconStyle = style.New(func() lipgloss.Style { + st := lipgloss.NewStyle().PaddingLeft(1) + return st + }) + TargetSubStyle = style.New(func() lipgloss.Style { + return lipgloss.NewStyle() + }) +) + +func (m *Model) Target(t model.HydratedTarget) string { + icon := TargetIconStyle.Render(PaddedIcon(t.Icon())) + selectionStyle := TargetStyle + selectionDescriptionStyle := TargetDescriptionStyle + _, sel := m.Result() + if sel.Target() == t.Target() { + selectionStyle = SelectedTargetStyle + selectionDescriptionStyle = SelectedTargetDescriptionStyle + } + var labelParts []string + labelParts = append(labelParts, icon) + if t.Sub() != nil { + labelParts = append(labelParts, TargetSubStyle.Render(t.Sub()...)) + } + labelParts = append(labelParts, TargetLabelStyle.Render(t.Label())) + return lipgloss.JoinHorizontal(lipgloss.Left, selectionStyle.Render(labelParts...), selectionDescriptionStyle.Render(t.Description())) +} diff --git a/model/indexer.go b/model/indexer.go new file mode 100644 index 0000000..e82e3af --- /dev/null +++ b/model/indexer.go @@ -0,0 +1,9 @@ +package model + +import ( + "io/fs" +) + +type Indexer interface { + Index(path string, fs fs.FS, runners []Runner) ([]Target, error) +} diff --git a/model/init.go b/model/init.go new file mode 100644 index 0000000..503d2b8 --- /dev/null +++ b/model/init.go @@ -0,0 +1,5 @@ +package model + +type HasInit interface { + Init() error +} diff --git a/model/matches.go b/model/matches.go new file mode 100644 index 0000000..da16c4f --- /dev/null +++ b/model/matches.go @@ -0,0 +1,5 @@ +package model + +type Matches interface { + Matches(input string) bool +} diff --git a/model/mod.go b/model/mod.go new file mode 100644 index 0000000..f6a7a94 --- /dev/null +++ b/model/mod.go @@ -0,0 +1,5 @@ +package model + +type Hydrator interface { + Hydrate(source *Source, result *HydratedSource) error +} diff --git a/model/new.go b/model/new.go new file mode 100644 index 0000000..f26bc8f --- /dev/null +++ b/model/new.go @@ -0,0 +1,45 @@ +package model + +import ( + "io/fs" + "path/filepath" + "pik/identity" + "strings" +) + +func NewState(f fs.FS, locations []string, indexers []Indexer, runners []Runner) (*State, error) { + st := &State{} + for _, loc := range locations { + _, dirName := filepath.Split(loc) + src := &Source{ + Path: loc, + Identity: identity.New(dirName), + } + loc = strings.TrimSuffix(loc, "/") + loc = strings.TrimPrefix(loc, "/") + + if loc == "" { + continue + } + + for _, indexer := range indexers { + + s, err := fs.Sub(f, loc) + if err != nil { + return nil, err + } + targets, err := indexer.Index("/"+loc, s, runners) + if err != nil { + return nil, err + } + src.Targets = append(src.Targets, targets...) + } + + if src.Targets != nil { + st.Sources = append(st.Sources, src) + } + + } + + return st, nil +} diff --git a/model/runner.go b/model/runner.go new file mode 100644 index 0000000..b413d51 --- /dev/null +++ b/model/runner.go @@ -0,0 +1,11 @@ +package model + +import ( + "io/fs" +) + +type Runner interface { + Hydrate(target Target) (HydratedTarget, error) + Wants(fs fs.FS, file string, entry fs.DirEntry) (bool, error) + CreateTarget(fs fs.FS, source string, file string, entry fs.DirEntry) (Target, error) +} diff --git a/model/source.go b/model/source.go new file mode 100644 index 0000000..3a5fe10 --- /dev/null +++ b/model/source.go @@ -0,0 +1,61 @@ +package model + +import ( + "pik/identity" + "pik/paths" + "pik/spool" +) + +type Source struct { + identity.Identity + Tags + Path string + Targets []Target +} + +type HydratedSource struct { + *Source + HydratedTargets []HydratedTarget + Aliases []string + Icon string +} + +func (s *Source) Label() string { + return s.Identity.Full +} + +func (s *HydratedSource) Label() string { + if len(s.Aliases) > 0 { + return s.Aliases[0] + } + return s.Identity.Full +} + +func (s *Source) Hydrate(hydrators []Hydrator) *HydratedSource { + hs := &HydratedSource{ + Source: s, + HydratedTargets: make([]HydratedTarget, 0, len(s.Targets)), + } + for _, h := range hydrators { + err := h.Hydrate(s, hs) + if err != nil { + spool.Warn("%v", err) + } + } + for _, t := range s.Targets { + if !t.Visible() { + continue + } + ht, err := t.Hydrate(s) + if err != nil { + spool.Warn("%v", err) + continue + } + hs.HydratedTargets = append(hs.HydratedTargets, ht) + } + return hs +} + +func (s *Source) ShortPath() string { + return paths.ReplaceHome(s.Path) +} diff --git a/model/state.go b/model/state.go new file mode 100644 index 0000000..96da3eb --- /dev/null +++ b/model/state.go @@ -0,0 +1,10 @@ +package model + +type State struct { + Sources []*Source +} + +type HydratedState struct { + *State + HydratedSources []*HydratedSource +} diff --git a/model/tags.go b/model/tags.go new file mode 100644 index 0000000..e3f9f2e --- /dev/null +++ b/model/tags.go @@ -0,0 +1,77 @@ +package model + +import ( + "slices" + "strings" +) + +type Tag *string +type TagAction func(src *Source) + +func New(input string) Tag { + result := &input + TagMap[input] = result + TagList = append(TagList, result) + return result +} + +var ( + Here = New("here") + Pre = New("pre") + Post = New("post") + Final = New("final") + Hidden = New("hidden") + Single = New("single") +) + +var TagList []Tag + +var TagMap = map[string]Tag{} + +type Tags []Tag + +func (t Tags) AnyOf(expected ...Tag) bool { + if len(expected) > 1 && len(t) == 0 { + return false + } + if len(expected) == 0 { + return true + } + for _, e := range expected { + if slices.Contains(t, e) { + return true + } + } + return false +} + +func (t Tags) Has(expected Tag) bool { + return slices.Contains(t, expected) +} + +func TagsFromFilename(filename string) Tags { + var tags Tags + // if hidden + if strings.HasPrefix(filename, ".") { + filename = strings.TrimPrefix(filename, ".") + tags = append(tags, Hidden) + } + + parts := strings.Split(filename, ".") + if len(parts) == 1 { + return nil + } + + for _, p := range parts { + p = strings.ToLower(p) + if TagMap[p] != nil { + tags = append(tags, TagMap[p]) + } + } + + return tags +} + +func (t Tags) Visible() bool { + return !t.AnyOf(Hidden, Pre, Post, Final) +} diff --git a/model/tags_test.go b/model/tags_test.go new file mode 100644 index 0000000..0ca159b --- /dev/null +++ b/model/tags_test.go @@ -0,0 +1,128 @@ +//go:build test + +package model + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "os/exec" + "testing" +) + +type taggedTarget struct { + MyTags Tags +} + +func (t taggedTarget) Matches(input string) bool { + //TODO implement me + panic("implement me") +} + +func (t taggedTarget) Create(s *Source) *exec.Cmd { + //TODO implement me + panic("implement me") +} + +func (t taggedTarget) Sub() []string { + //TODO implement me + panic("implement me") +} + +func (t taggedTarget) Label() string { + //TODO implement me + panic("implement me") +} + +func (t taggedTarget) Hydrate(src *Source) (HydratedTarget, error) { + //TODO implement me + panic("implement me") +} + +func (t taggedTarget) Tags() Tags { + return t.MyTags +} + +func (t taggedTarget) ShortestId() string { + //TODO implement me + panic("implement me") +} + +func tagged(in ...Tag) taggedTarget { + return taggedTarget{MyTags: tags(in...)} +} + +func tags(in ...Tag) Tags { + return Tags(in) +} + +func TestTags_Count(t *testing.T) { + input := tags(Here, Hidden) + assert.Len(t, input, 2) +} + +func TestTags_Is(t *testing.T) { + input := tags(Final, Post) + assert.True(t, input.Has(Final)) + assert.True(t, input.Has(Post)) + assert.False(t, input.Has(Pre)) +} + +func TestTags_AnyOf(t *testing.T) { + input := tags(Final, Post) + assert.True(t, input.AnyOf(Final)) + assert.False(t, input.AnyOf(Pre, Hidden)) +} + +func TestTags_AnyOf_Mix(t *testing.T) { + input := tags(Final, Post) + assert.True(t, input.AnyOf(Post, Hidden)) +} + +func TestTags_AnyOf_EmptyInput(t *testing.T) { + input := tags() + assert.False(t, input.AnyOf(Final)) + assert.False(t, input.AnyOf(Pre, Hidden)) +} + +func TestTags_AnyOf_EmptySearch(t *testing.T) { + input := tags(Final, Post) + assert.True(t, input.AnyOf()) +} + +func TestTagsFromFilename(t *testing.T) { + inputs := []any{ + *Pre, + *Here, + *Final, + } + input := fmt.Sprintf("script.%v.%v.%v.ext", inputs...) + output := TagsFromFilename(input) + assert.Len(t, output, len(inputs)) + assert.Contains(t, output, Pre) + assert.Contains(t, output, Here) + assert.Contains(t, output, Final) +} + +func TestTagsHidden_VisibleByDefault(t *testing.T) { + input := tags(Here) + target := tagged(input...) + assert.True(t, target.Tags().Visible()) +} + +func TestTagsHidden_FromHidden(t *testing.T) { + input := tags(Hidden) + target := tagged(input...) + assert.False(t, target.Tags().Visible()) +} + +func TestTagsHidden_FromFilename(t *testing.T) { + input := TagsFromFilename(".asdf.sh") + target := tagged(input...) + assert.False(t, target.Tags().Visible()) +} + +func TestTagsHidden_FromTrigger(t *testing.T) { + input := tags(Pre, Here) + target := tagged(input...) + assert.False(t, target.Tags().Visible()) +} diff --git a/model/target.go b/model/target.go new file mode 100644 index 0000000..5be97ed --- /dev/null +++ b/model/target.go @@ -0,0 +1,23 @@ +package model + +import ( + "os/exec" +) + +type Target interface { + Matches + Create(s *Source) *exec.Cmd + Sub() []string + Label() string + Hydrate(src *Source) (HydratedTarget, error) + Tags() Tags + ShortestId() string + Visible() bool +} + +type HydratedTarget interface { + Target + Icon() string + Description() string + Target() Target +} diff --git a/paths/paths.go b/paths/paths.go new file mode 100644 index 0000000..0445658 --- /dev/null +++ b/paths/paths.go @@ -0,0 +1,31 @@ +package paths + +import ( + "github.com/adrg/xdg" + "os" + "path/filepath" + "pik/spool" + "strings" +) + +var ( + Home = xdg.Home + This = "pik" + Cache = filepath.Join(xdg.CacheHome, This) + Config = filepath.Join(xdg.ConfigHome, This) +) + +func init() { + err := os.MkdirAll(Cache, 0700) + if err != nil { + spool.Warn("%v\n", err) + } + err = os.MkdirAll(Config, 0700) + if err != nil { + spool.Warn("%v\n", err) + } +} + +func ReplaceHome(input string) string { + return strings.Replace(input, Home, "~", 1) +} diff --git a/run/run.go b/run/run.go new file mode 100644 index 0000000..578e552 --- /dev/null +++ b/run/run.go @@ -0,0 +1,104 @@ +package run + +import ( + "fmt" + "os" + "pik/flags" + "pik/menu" + "pik/model" + "slices" +) + +func Run(source *model.Source, target model.Target, args ...string) error { + tags := target.Tags() + skipTriggers := tags.Has(model.Single) || *flags.Single + + if !skipTriggers { + err := Pre(source, target) + if err != nil { + return err + } + } + err := Exec(source, target, args...) + fmt.Println() + if err != nil { + return err + } + if !skipTriggers { + err := Post(source, target) + if err != nil { + return err + } + err = Final(source, target) + if err != nil { + return err + } + } + return nil +} + +func Pre(source *model.Source, target model.Target) error { + return ExecWithTrigger(source, target, model.Pre) +} + +func Post(source *model.Source, target model.Target) error { + return ExecWithTrigger(source, target, model.Post) +} + +func Final(source *model.Source, target model.Target) error { + return ExecWithTrigger(source, target, model.Final) +} + +func ExecWithTrigger(source *model.Source, target model.Target, tag model.Tag) error { + for _, t := range source.Targets { + if t.Tags().Has(tag) { + triggerSub := t.Sub() + targetSub := target.Sub() + + for _, targetSubPart := range triggerSub { + if !slices.Contains(targetSub, targetSubPart) { + continue + } + } + + err := Exec(source, t) + fmt.Println() + if err != nil { + return err + } + } + } + return nil +} + +func Exec(source *model.Source, target model.Target, args ...string) error { + _, _ = fmt.Fprint(os.Stderr, menu.Banner(source, target, args...)) + loc := source.Path + tags := target.Tags() + if *flags.At != "" { + loc = *flags.At + } else if tags.Has(model.Here) || *flags.Here { + wd, err := os.Getwd() + if err != nil { + return err + } + loc = wd + } + cmd := target.Create(source) + cmd.Dir = loc + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + cmd.Args = append(cmd.Args, args...) + + _, _ = fmt.Fprintln(os.Stderr, menu.InlineCmd(cmd)) + + if *flags.Dry { + return nil + } + + if *flags.Root { + cmd.Args = append([]string{"sudo"}, cmd.Args...) + } + return cmd.Run() +} diff --git a/runner/base.go b/runner/base.go new file mode 100644 index 0000000..7a6ce81 --- /dev/null +++ b/runner/base.go @@ -0,0 +1,82 @@ +package runner + +import ( + "os/exec" + "pik/identity" + "pik/model" +) + +type BaseTarget struct { + identity.Identity + MyTags model.Tags +} + +func (t *BaseTarget) Tags() model.Tags { + return t.MyTags +} + +func (t *BaseTarget) Matches(input string) bool { + return t.Identity.Is(input) +} + +func (t *BaseTarget) ShortestId() string { + return t.Reduced +} + +func Hydrated[T model.Target](in T) BaseHydration[T] { + return BaseHydration[T]{ + BaseTarget: in, + } +} + +type BaseHydration[T model.Target] struct { + BaseTarget T +} + +func (b BaseHydration[T]) Matches(input string) bool { + return b.BaseTarget.Matches(input) +} + +func (b BaseHydration[T]) Create(s *model.Source) *exec.Cmd { + return b.BaseTarget.Create(s) +} + +func (b BaseHydration[T]) Sub() []string { + return b.BaseTarget.Sub() +} + +func (b BaseHydration[T]) Label() string { + return b.BaseTarget.Label() +} + +func (b BaseHydration[T]) Hydrate(src *model.Source) (model.HydratedTarget, error) { + return b, nil +} + +func (b BaseHydration[T]) Visible() bool { + return b.BaseTarget.Visible() +} + +func (b BaseHydration[T]) Tags() model.Tags { + return b.BaseTarget.Tags() +} + +func (b BaseHydration[T]) ShortestId() string { + return b.BaseTarget.ShortestId() +} + +func (b BaseHydration[T]) Icon() string { + return " " +} + +func (b BaseHydration[T]) Description() string { + return "//TODO" +} + +func (b BaseHydration[T]) Target() model.Target { + return b.BaseTarget +} + +func (b BaseTarget) Visible() bool { + return b.Tags().Visible() +} diff --git a/runner/python/filetarget.go b/runner/python/filetarget.go new file mode 100644 index 0000000..6793f4b --- /dev/null +++ b/runner/python/filetarget.go @@ -0,0 +1,51 @@ +package python + +import ( + "os/exec" + "path/filepath" + "pik/model" + "pik/runner" +) + +type FileTarget struct { + runner.BaseTarget + File string +} + +type HydratedFileTarget struct { + runner.BaseHydration[*FileTarget] +} + +func (h *HydratedFileTarget) Icon() string { + return "\uE606" +} + +func (p *FileTarget) Create(s *model.Source) *exec.Cmd { + var cmd []string + if Python.Uv != "" { + cmd = []string{Python.Uv, "run", "--", p.File} + } else if venv := Python.VenvFor(s); venv != "" { + cmd = []string{filepath.Join(s.Path, venv, "bin", "python3"), p.File} + } else { + sysPath, err := exec.LookPath("python3") + if err != nil { + return nil + } + cmd = []string{sysPath, p.File} + } + return exec.Command(cmd[0], cmd[1:]...) +} + +func (p *FileTarget) Sub() []string { + return nil +} + +func (p *FileTarget) Label() string { + return p.Full +} + +func (p *FileTarget) Hydrate(src *model.Source) (model.HydratedTarget, error) { + return &HydratedFileTarget{ + BaseHydration: runner.Hydrated(p), + }, nil +} diff --git a/runner/python/indexer.go b/runner/python/indexer.go new file mode 100644 index 0000000..d0dd4e3 --- /dev/null +++ b/runner/python/indexer.go @@ -0,0 +1,44 @@ +package python + +import ( + "github.com/pelletier/go-toml/v2" + "io/fs" + "os" + "path/filepath" + "pik/model" +) + +type Pyproj struct { + Project struct { + Scripts map[string]string + } +} + +func (p python) Index(path string, f fs.FS, runners []model.Runner) ([]model.Target, error) { + for _, pt := range VenvPaths { + if stat, err := fs.Stat(f, filepath.Join(pt)); err == nil { + if stat.IsDir() { + p.Venvs[path] = filepath.Join(path, pt) + } + } + } + content, err := fs.ReadFile(f, "pyproject.toml") + if os.IsNotExist(err) { + return nil, nil + } else if err != nil { + return nil, err + } + + pp := &Pyproj{} + + err = toml.Unmarshal(content, pp) + if err != nil { + return nil, err + } + + var targets = make([]model.Target, 0, len(pp.Project.Scripts)) + for n, s := range pp.Project.Scripts { + targets = append(targets, Python.CreateProjTarget(n, s)) + } + return targets, nil +} diff --git a/runner/python/projtarget.go b/runner/python/projtarget.go new file mode 100644 index 0000000..1604948 --- /dev/null +++ b/runner/python/projtarget.go @@ -0,0 +1,50 @@ +package python + +import ( + "os/exec" + "path/filepath" + "pik/model" + "pik/runner" +) + +type ProjTarget struct { + runner.BaseTarget + Cmd string +} + +type HydratedProjTarget struct { + runner.BaseHydration[*ProjTarget] +} + +func (h *HydratedProjTarget) Icon() string { + return "\uE606" +} + +func (h *HydratedProjTarget) Description() string { + //TODO implement me + return "//TODO" +} + +func (p *ProjTarget) Create(s *model.Source) *exec.Cmd { + var cmd []string + if Python.Uv != "" { + cmd = []string{Python.Uv, "run", "--", p.Cmd} + } else if venv := Python.VenvFor(s); venv != "" { + cmd = []string{filepath.Join(s.Path, venv, "bin", "python"), p.Cmd} + } + return exec.Command(cmd[0], cmd[1:]...) +} + +func (p *ProjTarget) Sub() []string { + return nil +} + +func (p *ProjTarget) Label() string { + return p.Cmd +} + +func (p *ProjTarget) Hydrate(src *model.Source) (model.HydratedTarget, error) { + return &HydratedProjTarget{ + BaseHydration: runner.Hydrated(p), + }, nil +} diff --git a/runner/python/runner.go b/runner/python/runner.go new file mode 100644 index 0000000..b06d8ab --- /dev/null +++ b/runner/python/runner.go @@ -0,0 +1,86 @@ +package python + +import ( + "errors" + "io/fs" + "os/exec" + "path/filepath" + "pik/identity" + "pik/model" + "pik/runner" +) + +type python struct { + Venvs map[string]string + Uv string + System string +} + +func (p python) Init() error { + uv, err := exec.LookPath("uv") + if err != nil && !errors.Is(err, exec.ErrNotFound) { + return err + } + p.Uv = uv + sys, err := exec.LookPath("python3") + if err == nil { + p.System = sys + } + return err +} + +func (p python) Hydrate(target model.Target) (model.HydratedTarget, error) { + //TODO implement me + panic("implement me") +} + +func (p python) Wants(fs fs.FS, file string, entry fs.DirEntry) (bool, error) { + return !entry.IsDir() && filepath.Ext(entry.Name()) == ".py", nil +} + +func (p python) VenvFor(src *model.Source) string { + venvPath := p.Venvs[src.Path] + if venvPath != "" { + return venvPath + } + return "" +} + +func (p python) PyFor(src *model.Source) []string { + if p.Uv != "" { + return []string{p.Uv, "run", "--"} + } + if venv := p.VenvFor(src); venv != "" { + return []string{filepath.Join(src.Path, venv, "bin", "python")} + } + return nil +} + +func (p python) CreateProjTarget(name string, cmd string) model.Target { + return &ProjTarget{ + BaseTarget: runner.BaseTarget{ + Identity: identity.New(name), + }, + Cmd: cmd, + } +} + +func (p python) CreateTarget(fs fs.FS, source string, file string, entry fs.DirEntry) (model.Target, error) { + _, filename := filepath.Split(file) + return &FileTarget{ + BaseTarget: runner.BaseTarget{ + Identity: identity.New(filename), + MyTags: model.TagsFromFilename(filename), + }, + File: file, + }, nil +} + +var VenvPaths = []string{ + ".venv", + "venv", +} + +var Python = &python{ + Venvs: map[string]string{}, +} diff --git a/runner/shell/hydrated.go b/runner/shell/hydrated.go new file mode 100644 index 0000000..5a86d5a --- /dev/null +++ b/runner/shell/hydrated.go @@ -0,0 +1,17 @@ +package shell + +import ( + "pik/runner" +) + +type HydratedShellTarget struct { + runner.BaseHydration[*ShellTarget] +} + +func (h *HydratedShellTarget) Icon() string { + return "\uF489" +} + +func (h *HydratedShellTarget) Description() string { + return "//TODO" +} diff --git a/runner/shell/shell.go b/runner/shell/shell.go new file mode 100644 index 0000000..5333279 --- /dev/null +++ b/runner/shell/shell.go @@ -0,0 +1,176 @@ +package shell + +import ( + "bufio" + "errors" + "io/fs" + "os/exec" + "path/filepath" + "pik/identity" + "pik/indexers/pikdex" + "pik/model" + "pik/runner" + "pik/spool" + "slices" + "strings" +) + +//TODO: Clean up shell selection? Maybe default to bash? + +var NoContentError = errors.New("not enough content in target") +var NoShellError = errors.New("could not find any shell interpreters") + +var ExtShellMap = map[string]string{ + ".sh": "bash", + ".ps1": "powershell", +} + +var Shells = []string{"bash", "bash.exe", "zsh", "fish", "powershell", "powershell.exe", "cmd.exe"} + +var Runner = &shell{ + Locations: map[string]string{}, +} + +type shell struct { + Locations map[string]string +} + +var WrongTargetError = errors.New("wrong target type") + +func (s *shell) Hydrate(target model.Target) (model.HydratedTarget, error) { + cast, ok := target.(*ShellTarget) + if !ok { + return nil, WrongTargetError + } + hyd := &HydratedShellTarget{BaseHydration: runner.Hydrated(cast)} + return hyd, nil +} + +func (s *shell) Wants(f fs.FS, file string, entry fs.DirEntry) (bool, error) { + if entry != nil && entry.IsDir() { + return false, nil + } + + fd, err := f.Open(file) + if err != nil { + return false, err + } + scanner := bufio.NewScanner(fd) + scanner.Split(bufio.ScanRunes) + if !scanner.Scan() { + return false, nil + } + txt := scanner.Text() + if txt == "#" { // + return true, nil + } + for k, _ := range ExtShellMap { + if strings.HasSuffix(file, k) { + return true, nil + } + } + return false, nil +} + +func (s *shell) Find(shell string) (string, error) { + if s.Locations[shell] != "" { + return s.Locations[shell], nil + } + + if p, err := exec.LookPath(shell); err == nil { + s.Locations[shell] = p + return shell, nil + } else { + return "", err + } +} + +func (s *shell) CreateTarget(fs fs.FS, src string, file string, _ fs.DirEntry) (model.Target, error) { + shell, err := s.ShellFor(fs, file) + if err != nil { + return nil, err + } + _, filename := filepath.Split(file) + var sub []string + split := strings.Split(file, "/") + for _, p := range split { + if slices.Contains(pikdex.Roots, p) { + continue + } + if filename == p { + continue + } + sub = append(sub, p) + } + return &ShellTarget{ + BaseTarget: runner.BaseTarget{ + Identity: identity.New(filename), + MyTags: model.TagsFromFilename(filename), + }, + Shell: shell, + Script: file, + SubValue: sub, + }, nil +} + +func (s *shell) ShellFor(fs fs.FS, file string) (string, error) { + + var shell, shebang string + + // low-hanging fruit - indicative filename + if byFile := s.ShellByFilename(file); byFile != "" { + return byFile, nil + } + + fd, err := fs.Open(file) + if err != nil { + return "", err + } + scanner := bufio.NewScanner(fd) + scanner.Split(bufio.ScanLines) + if !scanner.Scan() { + return "", NoContentError + } + txt := scanner.Text() + if strings.HasPrefix(txt, "#!") { + // shebang found + for _, potentialShell := range Shells { + if strings.Contains(txt, potentialShell) { + shebang = shell + if loc, err := s.Find(potentialShell); err == nil { + shell = loc + } else { + _, _ = spool.Warn("script has %s but could not find %s (%s)\n", shebang, potentialShell) + } + } + } + } + + if shebang == "" { + // if no shebang, just send the first one we find + for _, s := range Shells { + if p, err := exec.LookPath(s); err != nil { + shell = p + } + } + } + + if shell == "" { + return "", NoShellError + } + + return shell, nil + +} + +func (s *shell) ShellByFilename(file string) string { + ext := filepath.Ext(file) + if ExtShellMap[ext] != "" { + sh, err := s.Find(ExtShellMap[ext]) + if err == nil { + return sh + } + } + + return "" +} diff --git a/runner/shell/shell_test.go b/runner/shell/shell_test.go new file mode 100644 index 0000000..b28bd6c --- /dev/null +++ b/runner/shell/shell_test.go @@ -0,0 +1,22 @@ +package shell + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestShell_ShellByFilename(t *testing.T) { + s := &shell{ + Locations: map[string]string{"powershell": "/pws", "bash": "/bash"}, + } + inputs := map[string]string{ + "script.ps1": "/pws", + "script.sh": "/bash", + "asdf": "", + } + + for k, v := range inputs { + sh := s.ShellByFilename(k) + assert.Equal(t, sh, v) + } +} diff --git a/runner/shell/target.go b/runner/shell/target.go new file mode 100644 index 0000000..e823d41 --- /dev/null +++ b/runner/shell/target.go @@ -0,0 +1,35 @@ +package shell + +import ( + "os/exec" + "path/filepath" + "pik/model" + "pik/runner" +) + +type ShellTarget struct { + runner.BaseTarget + Shell string + Script string + SubValue []string +} + +func (s *ShellTarget) String() string { + return s.Label() +} + +func (s *ShellTarget) Hydrate(_ *model.Source) (model.HydratedTarget, error) { + return Runner.Hydrate(s) +} + +func (s *ShellTarget) Sub() []string { + return s.SubValue +} + +func (s *ShellTarget) Label() string { + return s.Identity.Full +} + +func (s *ShellTarget) Create(src *model.Source) *exec.Cmd { + return exec.Command(s.Shell, filepath.Join(src.Path, s.Script)) +} diff --git a/search/search.go b/search/search.go new file mode 100644 index 0000000..5b227b2 --- /dev/null +++ b/search/search.go @@ -0,0 +1,86 @@ +package search + +import ( + "pik/model" + "slices" +) + +func Search(s *model.State, args ...string) (model.Target, *model.Source, bool, []string, []string) { + var target model.Target + var suspect model.Target + var suspectSource *model.Source + var targetSource *model.Source + var forward []string + var subdir []string + confirm := false + +args_loop: + for _, a := range args { + for _, src := range s.Sources { + + if targetSource == nil { + if src.Is(a) { + targetSource = src + for _, t := range targetSource.Targets { + if t.Matches(a) { + target = t + continue args_loop + } + } + continue args_loop + } + } + + if target == nil && targetSource == nil { + for _, t := range src.Targets { + if t.Matches(a) { + target = t + targetSource = src + continue args_loop + } + } + } else if target == nil { // && targetSource == nil (but it is always true) + for _, t := range targetSource.Targets { + if t.Matches(a) { + target = t + continue args_loop + } + } + // if we find the right target + for _, t := range src.Targets { + if t.Matches(a) { + confirm = true + suspect = t + suspectSource = src + continue args_loop + } + } + } + + } + + if target == nil { + subdir = append(subdir, a) + continue args_loop + } else if targetSource != nil { + forward = append(forward, a) + continue args_loop + } + } + + if suspect != nil && target == nil { + target = suspect + targetSource = suspectSource + confirm = true + } + + if target != nil && target.Sub() != nil && subdir != nil && !slices.Equal(target.Sub(), subdir) { + confirm = true + } + + if target == nil { + forward = args + } + + return target, targetSource, confirm, subdir, forward +} diff --git a/search/search_test.go b/search/search_test.go new file mode 100644 index 0000000..59edef1 --- /dev/null +++ b/search/search_test.go @@ -0,0 +1,111 @@ +package search + +import ( + "github.com/stretchr/testify/assert" + "pik/testx" + "testing" +) + +func TestSearch_TargetOnly(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "def")) + target, source, _, _, _ := Search(st, "def") + testx.AssertSourceIs(t, "src", source) + testx.AssertTargetIs(t, "def", target) +} + +func TestSearch_TargetAndSource(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "def")) + target, source, _, _, _ := Search(st, "src", "def") + testx.AssertSourceIs(t, "src", source) + testx.AssertTargetIs(t, "def", target) +} + +func TestSearch_SourceDefaultTarget(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "src")) + target, src, _, _, _ := Search(st, "src") + testx.AssertSourceIs(t, "src", src) + assert.NotNil(t, target) +} + +func TestSearch_SubdirWrong(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "src")) + st.Sources[0].Targets = append(st.Sources[0].Targets, testx.TestTarget{ + Identifier: "script", + SubValue: []string{"subdir"}, + }) + target, src, confirm, sd, _ := Search(st, "wrong", "script") + testx.AssertSourceIs(t, "src", src) + testx.AssertTargetIs(t, "script", target) + assert.Equal(t, sd, []string{"wrong"}) + assert.NotNil(t, target) + assert.True(t, confirm) +} + +func TestSearch_SubdirMissing(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "src")) + st.Sources[0].Targets = append(st.Sources[0].Targets, testx.TestTarget{ + Identifier: "script", + SubValue: []string{"subdir"}, + }) + target, src, confirm, sd, _ := Search(st, "script") + testx.AssertSourceIs(t, "src", src) + testx.AssertTargetIs(t, "script", target) + assert.Nil(t, sd) + assert.NotNil(t, target) + assert.False(t, confirm) +} + +func TestSearch_Args(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "def")) + target, source, _, _, args := Search(st, "def", "a1", "a2") + testx.AssertSourceIs(t, "src", source) + testx.AssertTargetIs(t, "def", target) + assert.Equal(t, []string{"a1", "a2"}, args) +} + +func TestSearch_Args_SubdirMissing(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "src")) + st.Sources[0].Targets = append(st.Sources[0].Targets, testx.TestTarget{ + Identifier: "script", + SubValue: []string{"subdir"}, + }) + target, src, _, _, args := Search(st, "script", "a1", "a2") + testx.AssertSourceIs(t, "src", src) + testx.AssertTargetIs(t, "script", target) + assert.Equal(t, []string{"a1", "a2"}, args) +} + +func TestSearch_Args_SubdirPresent(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "src")) + st.Sources[0].Targets = append(st.Sources[0].Targets, testx.TestTarget{ + Identifier: "script", + SubValue: []string{"subdir"}, + }) + target, src, _, _, args := Search(st, "subdir", "script", "a1", "a2") + testx.AssertSourceIs(t, "src", src) + testx.AssertTargetIs(t, "script", target) + assert.Equal(t, []string{"a1", "a2"}, args) +} + +func TestSearch_SecondarySource(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "def"), testx.TSource("aaa", "hjkl")) + target, source, _, _, _ := Search(st, "aaa", "hjkl") + testx.AssertSourceIs(t, "aaa", source) + testx.AssertTargetIs(t, "hjkl", target) +} + +func TestSearch_SecondarySource_DuplicateTargetName(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc", "def"), testx.TSource("aaa", "abc")) + target, source, confirm, _, _ := Search(st, "aaa", "def") + testx.AssertSourceIs(t, "src", source) + testx.AssertTargetIs(t, "def", target) + assert.True(t, confirm) +} + +func TestSearch_SourceTargetMixup(t *testing.T) { + st := testx.TState(testx.TSource("src", "abc"), testx.TSource("aaa", "ccc")) + target, source, confirm, _, _ := Search(st, "src", "ccc") + testx.AssertSourceIs(t, "aaa", source) + testx.AssertTargetIs(t, "ccc", target) + assert.True(t, confirm) +} diff --git a/spool/spool.go b/spool/spool.go new file mode 100644 index 0000000..d582cfb --- /dev/null +++ b/spool/spool.go @@ -0,0 +1,6 @@ +package spool + +import "fmt" + +var Print = fmt.Printf +var Warn = fmt.Printf diff --git a/testx/create.go b/testx/create.go new file mode 100644 index 0000000..7153e54 --- /dev/null +++ b/testx/create.go @@ -0,0 +1,75 @@ +//go:build test + +package testx + +import ( + "github.com/stretchr/testify/assert" + "os/exec" + "pik/identity" + "pik/model" + "testing" +) + +func TTarget(name string) model.Target { + return TestTarget{Identifier: name} +} + +func TSource(name string, targets ...string) *model.Source { + src := &model.Source{ + Identity: identity.New(name), + } + for _, t := range targets { + src.Targets = append(src.Targets, TTarget(t)) + } + return src +} + +func TState(sources ...*model.Source) *model.State { + return &model.State{ + Sources: sources, + } +} + +type TestTarget struct { + Identifier string + SubValue []string + Tags model.Tags +} + +func (t TestTarget) Visible() bool { + return true +} + +func (t TestTarget) Hydrate(src *model.Source) (model.HydratedTarget, error) { + //TODO implement me + panic("implement me") +} + +func (t TestTarget) Sub() []string { + return t.SubValue +} + +func (t TestTarget) Label() string { + return t.Identifier +} + +func (t TestTarget) Matches(input string) bool { + return input == t.Identifier +} + +func (t TestTarget) Create(s *model.Source) *exec.Cmd { + panic("whadafak") +} + +func AssertTargetIs(t *testing.T, input string, target model.Target) { + assert.Equal(t, input, target.Label()) +} +func AssertTargetIsNot(t *testing.T, input string, target model.Target) { + assert.NotEqual(t, input, target.Label()) +} +func AssertSourceIs(t *testing.T, input string, src *model.Source) { + assert.Equal(t, input, src.Identity.Reduced) +} +func AssertSourceIsNot(t *testing.T, input string, src *model.Source) { + assert.NotEqual(t, input, src.Identity.Reduced) +} diff --git a/testx/create_test.go b/testx/create_test.go new file mode 100644 index 0000000..c9f2925 --- /dev/null +++ b/testx/create_test.go @@ -0,0 +1,27 @@ +//go:build test + +package testx + +import ( + "testing" +) + +func TestAssertSourceIs_Correct(t *testing.T) { + src := TSource("abc", "def") + AssertSourceIs(t, "abc", src) +} + +func TestAssertSourceIs_Wrong(t *testing.T) { + src := TSource("abc", "def") + AssertSourceIsNot(t, ";lkjh", src) +} + +func TestAssertTargetIs_Correct(t *testing.T) { + ta := TTarget("aaaa") + AssertTargetIs(t, "aaaa", ta) +} + +func TestAssertTargetIs_Wrong(t *testing.T) { + ta := TTarget("aaaa") + AssertTargetIsNot(t, "bbbbbb", ta) +} |
