├── .gitignore ├── README.md ├── api ├── error.go └── list.go ├── client ├── api.go ├── api_test.go └── server_control.go ├── common ├── blop.go ├── list.go ├── task.go ├── task_test.go ├── testutil.go ├── tree.go └── tree_test.go ├── go.mod ├── go.sum ├── main.go ├── server ├── api.go ├── datastore.go ├── datastore_test.go ├── rpc_get_task.go ├── rpc_get_task_test.go ├── taskstore.go ├── taskstore_test.go └── testutil.go └── testdata ├── make_pasta ├── malformed ├── excess_delta_indent └── zero_length └── multiple_nested /.gitignore: -------------------------------------------------------------------------------- 1 | impulse 2 | .*.sw? 3 | .sw? 4 | *~ 5 | debug.test 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # impulse 2 | 3 | **Caveat: this README is aspirational. The code doesn't do anything useful yet.** 4 | 5 | Impulse is a workflow, described 6 | [here](https://blog.danslimmon.com/2021/03/15/managing-attention-with-stacks/), for managing your 7 | intentions as a stack. Impulse can be used for lots of purposes: 8 | 9 | * As a replacement for traditional to-do lists 10 | * As a lightweight project planning tool 11 | * To work effectively in the face of ADHD and anxiety. 12 | 13 | `impulse` is a CLI tool that implements the Impulse workflow. 14 | 15 | ## The Impulse workflow 16 | 17 | Suppose you set out to cook some pasta. You write `cook pasta` on a sheet of paper. You place that 18 | sheet of paper on a desk. Cooking pasta is a multi-step process, and the first step is to put water 19 | in a pot. So you take another sheet of paper and write on it, `put water in pot`. You place this 20 | sheet on top of the `cook pasta` sheet. You now have a stack of papers that looks like this: 21 | 22 | put water in pot 23 | cook pasta 24 | 25 | There is a `cook pasta` task on the stack. Above the `cook pasta` task is a `put water in pot` 26 | task. So, working from the top of the stack down: you want to put water in the pot, and then return 27 | to the cook pasta frame. 28 | 29 | So, once you've put water in the pot, you remove that sheet and return to the next task down, `cook 30 | pasta`. You're left with this: 31 | 32 | cook pasta 33 | 34 | With Impulse, we always work from the top of the stack. Therefore, what you want to do next is 35 | continue to `cook pasta`. 36 | 37 | Are you done cooking pasta? Well no. So you need to put some more sheets onto the stack: 38 | 39 | place pot on burner 40 | turn on burner 41 | wait for water to boil 42 | cook pasta 43 | 44 | There are now 4 sheets of paper on the stack. Again: you always work from the top of the stack. So 45 | the next thing you have to do is `place pot on burner`. After doing this and removing the `place pot 46 | on burner` sheet from the stack, you are now on to `turn on burner`. So you turn on the burner, 47 | removing _that_ frame from the stack. Now your task is `wait for water to boil`. 48 | 49 | wait for water to boil 50 | cook pasta 51 | 52 | There are now 2 sheets of paper on the stack. But water takes a while to boil. Maybe while you're 53 | waiting for the water to boil, you decide to check Twitter. So you write `check Twitter` on a new 54 | sheet of paper, and place it on top of the stack: 55 | 56 | 57 | check Twitter 58 | wait for water to boil 59 | cook pasta 60 | 61 | There are 3 tasks on the stack. The top sheet, `check Twitter`, has 62 | interrupted the `cook pasta … wait for water to boil` tasks. It is now the thing you're doing. If 63 | you want to be really precise, you can add more sheets for the Twitter task: 64 | 65 | check Twitter notifications 66 | check Twitter timeline 67 | check Twitter 68 | wait for water to boil 69 | cook pasta 70 | 71 | So now the thing you're doing is `check Twitter notifications`. When you finish that, you'll take it 72 | off the stack and proceed to `check Twitter timeline`. And finally you'll remove _that_, and get 73 | back to `check Twitter`. There are no more tasks involved in `check[ing] Twitter`, so nothing more 74 | needs to be added. Instead, `check Twitter` itself is now removed, returning us to `wait for water 75 | to boil`. If the water hasn't boiled yet, we may add another interrupt, such as `text Mom`. If the 76 | water is boiling, then we remove the `wait for water to boil` task and return to `cook pasta`: 77 | 78 | cook pasta 79 | 80 | which now necessitates adding new frames onto the stack (namely, `open pasta box` and `pour pasta in 81 | pot`.) 82 | 83 | open pasta box 84 | pour pasta in pot 85 | cook pasta 86 | 87 | You continue like this until you're done, at which point you move on to whatever task is beneath 88 | `cook pasta` (for example, `eat dinner`). 89 | 90 | ## The `impulse` tool 91 | 92 | **Reminder: this README is aspirational. Don't expect the code to do what I'm describing here yet.** 93 | 94 | This repo is home to a CLI tool called `impulse` that implements the Impulse workflow described 95 | above. You use it like so: 96 | 97 | --- Moving the Cursor 98 | 99 | j ↓ move cursor down 100 | k ↑ move cursor up 101 | h ← move cursor to parent 102 | l → move cursor to child 103 | t move cursor to top 104 | 105 | --- Moving tasks 106 | 107 | J ⇧↓ move task down (among its siblings) 108 | K ⇧↑ move task up (among its siblings) 109 | H ⇧← move task left (make it a child of the task that's currently its grandparent) 110 | L ⇧→ move task right (make it a child of the sibling directly above it) 111 | 112 | --- Changing tasks 113 | 114 | c add child task(s) 115 | s add sibling task(s) 116 | d delete task 117 | Enter edit task name 118 | 119 | --- Etc. 120 | 121 | ? help (this message) 122 | -------------------------------------------------------------------------------- /api/error.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type ErrorResponse struct { 4 | Error error `json:"error"` 5 | } 6 | -------------------------------------------------------------------------------- /api/list.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/danslimmon/impulse/common" 5 | ) 6 | 7 | type ListResponse struct { 8 | List *common.BlopList `json:"data"` 9 | } 10 | -------------------------------------------------------------------------------- /client/api.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/danslimmon/impulse/common" 12 | "github.com/danslimmon/impulse/server" 13 | ) 14 | 15 | // Client provides methods for using the Impulse API. 16 | type Client struct { 17 | addr string 18 | } 19 | 20 | // url returns the full URL to the Impulse API endpoint with the given path. 21 | func (apiClient *Client) url(path string) string { 22 | u := url.URL{ 23 | Scheme: "http", 24 | Host: apiClient.addr, 25 | Path: path, 26 | } 27 | return u.String() 28 | } 29 | 30 | func (apiClient *Client) GetTaskList(listName string) (*server.GetTaskListResponse, error) { 31 | path := fmt.Sprintf("/tasklist/%s", listName) 32 | resp, err := http.Get(apiClient.url(path)) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | b, err := ioutil.ReadAll(resp.Body) 38 | resp.Body.Close() 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | respObj := new(server.GetTaskListResponse) 44 | err = json.Unmarshal(b, respObj) 45 | if err != nil { 46 | return nil, err 47 | } 48 | if respObj.Error != "" { 49 | return nil, fmt.Errorf("Error response from server: %s", respObj.Error) 50 | } 51 | 52 | if resp.StatusCode == 200 { 53 | return respObj, nil 54 | } else { 55 | fmt.Printf("DEBUG: server response: '%s'", string(b)) 56 | return respObj, fmt.Errorf("error: response code; body in logs") 57 | } 58 | } 59 | 60 | func (apiClient *Client) ArchiveLine(lineId common.LineID) (*server.ArchiveLineResponse, error) { 61 | path := fmt.Sprintf("/archive_line/%s", url.PathEscape(string(lineId))) 62 | resp, err := http.Get(apiClient.url(path)) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | b, err := ioutil.ReadAll(resp.Body) 68 | resp.Body.Close() 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | respObj := new(server.ArchiveLineResponse) 74 | err = json.Unmarshal(b, respObj) 75 | if err != nil { 76 | return nil, err 77 | } 78 | if respObj.Error != "" { 79 | return nil, fmt.Errorf("Error response from server: %s", respObj.Error) 80 | } 81 | 82 | if resp.StatusCode == 200 { 83 | return respObj, nil 84 | } else { 85 | fmt.Printf("DEBUG: server response: '%s'", string(b)) 86 | return respObj, fmt.Errorf("error: response code; body in logs") 87 | } 88 | } 89 | 90 | func (apiClient *Client) InsertTask(lineId common.LineID, task *common.Task) (*server.InsertTaskResponse, error) { 91 | reqObj := &server.InsertTaskRequest{ 92 | LineID: lineId, 93 | Task: task, 94 | } 95 | reqB, err := json.Marshal(reqObj) 96 | if err != nil { 97 | return nil, fmt.Errorf("Failed to marshal request object: %s", err.Error()) 98 | } 99 | 100 | path := "/insert_task/" 101 | resp, err := http.Post( 102 | apiClient.url(path), 103 | "application/json", 104 | bytes.NewReader(reqB), 105 | ) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | respB, err := ioutil.ReadAll(resp.Body) 111 | resp.Body.Close() 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | respObj := new(server.InsertTaskResponse) 117 | err = json.Unmarshal(respB, respObj) 118 | if err != nil { 119 | return nil, err 120 | } 121 | if respObj.Error != "" { 122 | return nil, fmt.Errorf("Error response from server: %s", respObj.Error) 123 | } 124 | 125 | if resp.StatusCode == 200 { 126 | return respObj, nil 127 | } else { 128 | fmt.Printf("DEBUG: server response: '%s'", string(respB)) 129 | return respObj, fmt.Errorf("error: response code; body in logs") 130 | } 131 | } 132 | 133 | // NewClient returns a fresh Client. 134 | // 135 | // addr is the host:port pair on which the server is listening. 136 | func NewClient(addr string) *Client { 137 | return &Client{addr: addr} 138 | } 139 | -------------------------------------------------------------------------------- /client/api_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "os/exec" 7 | "path" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/danslimmon/impulse/common" 13 | "github.com/danslimmon/impulse/server" 14 | ) 15 | 16 | // Returns the path to a directory containing the contents of server/testdata/make_pasta as _. 17 | // 18 | // Also returns a cleanup function that should be called when the caller is done with the temporary 19 | // directory. 20 | func copyMakePastaDir() (string, func()) { 21 | d, err := ioutil.TempDir("", "impulse-test-*") 22 | if err != nil { 23 | panic(err.Error()) 24 | } 25 | 26 | c := exec.Command("cp", "../server/testdata/make_pasta", path.Join(d, "make_pasta")) 27 | if err := c.Run(); err != nil { 28 | panic(err.Error()) 29 | } 30 | 31 | cleanup := func() { 32 | os.RemoveAll(d) 33 | } 34 | 35 | return d, cleanup 36 | } 37 | 38 | // testServerAndClient returns a server/client pair with the server/testdata data. 39 | // 40 | // The returned server is already started. 41 | // 42 | // Also returns a function that should be called when the test is done with the returned server and 43 | // client. 44 | func testServerAndClient() (*server.Server, *Client, func()) { 45 | addr := "127.0.0.1:30272" 46 | ts, tsCleanup := server.NewBasicTaskstoreWithTestdata() 47 | 48 | apiServer := server.NewServer(ts) 49 | if err := apiServer.Start(addr); err != nil { 50 | panic("failed to start test server on " + addr + ": " + err.Error()) 51 | } 52 | 53 | apiClient := NewClient(addr) 54 | 55 | cleanup := func() { 56 | apiServer.Stop() 57 | tsCleanup() 58 | } 59 | return apiServer, apiClient, cleanup 60 | } 61 | 62 | func Test_Client_GetTaskList(t *testing.T) { 63 | // no t.Parallel() so we don't have to worry about giving out unique server ports 64 | assert := assert.New(t) 65 | 66 | _, client, cleanup := testServerAndClient() 67 | defer cleanup() 68 | resp, err := client.GetTaskList("make_pasta") 69 | assert.Nil(err) 70 | assert.Equal(common.MakePasta(), resp.Result) 71 | } 72 | 73 | func Test_Client_GetTaskList_Nonexistent(t *testing.T) { 74 | // no t.Parallel() so we don't have to worry about giving out unique server ports 75 | assert := assert.New(t) 76 | 77 | _, client, cleanup := testServerAndClient() 78 | defer cleanup() 79 | _, err := client.GetTaskList("nonexistent_task_list") 80 | assert.NotNil(err) 81 | } 82 | 83 | func Test_Client_ArchiveLine(t *testing.T) { 84 | // no t.Parallel() so we don't have to worry about giving out unique server ports 85 | assert := assert.New(t) 86 | 87 | _, client, cleanup := testServerAndClient() 88 | defer cleanup() 89 | _, err := client.ArchiveLine(common.GetLineID("make_pasta", "\t\tput water in pot")) 90 | assert.Nil(err) 91 | } 92 | 93 | func Test_Client_ArchiveLine_Error(t *testing.T) { 94 | // no t.Parallel() so we don't have to worry about giving out unique server ports 95 | assert := assert.New(t) 96 | 97 | _, client, cleanup := testServerAndClient() 98 | defer cleanup() 99 | _, err := client.ArchiveLine("malformed_task_id") 100 | assert.NotNil(err) 101 | } 102 | 103 | func Test_Client_InsertTask(t *testing.T) { 104 | // no t.Parallel() so we don't have to worry about giving out unique server ports 105 | assert := assert.New(t) 106 | 107 | _, client, cleanup := testServerAndClient() 108 | defer cleanup() 109 | _, err := client.InsertTask( 110 | common.LineID("make_pasta:0"), 111 | common.NewTask(common.NewTreeNode("alpha")), 112 | ) 113 | assert.Nil(err) 114 | } 115 | -------------------------------------------------------------------------------- /client/server_control.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/danslimmon/impulse/server" 7 | ) 8 | 9 | var serverHandle *server.Server 10 | 11 | // StartServer starts an impulse server listening on the given IP:port pair. 12 | // 13 | // dataDir is the path to where the server's data lives 14 | func StartServer(addr, dataDir string) error { 15 | if serverHandle != nil { 16 | return fmt.Errorf("server already started") 17 | } 18 | 19 | // got a plan to actually split apart the server and client… until then, this nonsense 20 | ds := server.NewFilesystemDatastore("_") 21 | ts := server.NewBasicTaskstore(ds) 22 | serverHandle = server.NewServer(ts) 23 | return serverHandle.Start(addr) 24 | } 25 | 26 | // StopServer stops the impulse server previously started with StartServer. 27 | func StopServer() error { 28 | if serverHandle == nil { 29 | return fmt.Errorf("server already stopped") 30 | } 31 | 32 | return serverHandle.Stop() 33 | } 34 | -------------------------------------------------------------------------------- /common/blop.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type Blip struct { 4 | Title string 5 | } 6 | 7 | func (blip *Blip) GetTitle() string { 8 | return blip.Title 9 | } 10 | 11 | func (blip *Blip) SetTitle(s string) { 12 | blip.Title = s 13 | } 14 | 15 | type Blop struct { 16 | Title string 17 | } 18 | 19 | func (blop *Blop) GetTitle() string { 20 | return blop.Title 21 | } 22 | 23 | func (blop *Blop) SetTitle(s string) { 24 | blop.Title = s 25 | } 26 | -------------------------------------------------------------------------------- /common/list.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type BlopList struct { 4 | Name string `json:"name"` 5 | } 6 | -------------------------------------------------------------------------------- /common/task.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | // LineID represents a line in the data file. 11 | // 12 | // This is an abstraction leak. API clients shouldn't have to know about "lines", since those are an 13 | // implementation detail of the storage backend. But, it gets us off the ground for now. 14 | // 15 | // A Line ID is composed of two parts, separated by a colon. The first part is the name of a task 16 | // list, e.g. `make_pasta`. The second part is either 0 (which indicates the top line of the file), 17 | // or a SHA256 sum of the line's content, indentation included and trailing whitespace excluded (see 18 | // GetLineID). 19 | type LineID string 20 | 21 | // GetLineID returns the line ID for the line in the list identified by listName, with content s. 22 | // 23 | // s will be stripped of trailing newlines before the ID is calculated. 24 | func GetLineID(listName, s string) LineID { 25 | s = strings.TrimRight(s, "\n") 26 | shasumArray := sha256.Sum256([]byte(s)) 27 | shasum := shasumArray[:] 28 | digest := hex.EncodeToString(shasum) 29 | return LineID(fmt.Sprintf("%s:%s", listName, digest)) 30 | } 31 | 32 | type Task struct { 33 | RootNode *TreeNode `json:"tree"` 34 | } 35 | 36 | // Equal determines whether the tasks a and b are equal. 37 | func (a *Task) Equal(b *Task) bool { 38 | return a.RootNode.Equal(b.RootNode) 39 | } 40 | 41 | func NewTask(n *TreeNode) *Task { 42 | return &Task{RootNode: n} 43 | } 44 | -------------------------------------------------------------------------------- /common/task_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestGetLineID(t *testing.T) { 10 | t.Parallel() 11 | assert := assert.New(t) 12 | 13 | // trailing newlines ignored 14 | assert.Equal( 15 | GetLineID("foo", "\t\tbar"), 16 | GetLineID("foo", "\t\tbar\n"), 17 | ) 18 | assert.Equal( 19 | GetLineID("foo", "\t\tbar"), 20 | GetLineID("foo", "\t\tbar\n\n\n"), 21 | ) 22 | 23 | // no collisions 24 | assert.NotEqual( 25 | GetLineID("foo", "bar"), 26 | GetLineID("not_foo", "bar"), 27 | ) 28 | assert.NotEqual( 29 | GetLineID("foo", "bar"), 30 | GetLineID("foo", "not_bar"), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /common/testutil.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // Returns a "make pasta" tasklist. 4 | // 5 | // Should be identical to the contents of server/testdata/make_pasta 6 | // 7 | // put water in pot 8 | // put pot on burner 9 | // turn burner on 10 | // boil water 11 | // put pasta in water 12 | // [b cooked] 13 | // drain pasta 14 | // make pasta 15 | func MakePasta() []*Task { 16 | makePasta := NewTreeNode("make pasta") 17 | 18 | boilWater := NewTreeNode("boil water") 19 | boilWater.AddChild(NewTreeNode("put water in pot")) 20 | boilWater.AddChild(NewTreeNode("put pot on burner")) 21 | boilWater.AddChild(NewTreeNode("turn burner on")) 22 | makePasta.AddChild(boilWater) 23 | 24 | makePasta.AddChild(NewTreeNode("put pasta in water")) 25 | makePasta.AddChild(NewTreeNode("[b cooked]")) 26 | makePasta.AddChild(NewTreeNode("drain pasta")) 27 | 28 | return []*Task{NewTask(makePasta)} 29 | } 30 | 31 | // Returns a "multiple nested" tasklist. 32 | // 33 | // Should be identical to the contents of server/testdata/multiple_nested 34 | // 35 | // subsubtask 0 36 | // subtask 0 37 | // task 0 38 | // subtask 1 39 | // task 1 40 | func MultipleNested() []*Task { 41 | task0 := NewTreeNode("task 0") 42 | subtask0 := NewTreeNode("subtask 0") 43 | subsubtask0 := NewTreeNode("subsubtask 0") 44 | subtask0.AddChild(subsubtask0) 45 | task0.AddChild(subtask0) 46 | 47 | task1 := NewTreeNode("task 1") 48 | subtask1 := NewTreeNode("subtask 1") 49 | task1.AddChild(subtask1) 50 | 51 | return []*Task{ 52 | NewTask(task0), 53 | NewTask(task1), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /common/tree.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | ) 10 | 11 | // SkipSubtree is returned by a TreeWalkFunc in order to skip descending into a given subtree 12 | var SkipSubtree = errors.New("skip this subtree") 13 | 14 | // TreeWalkFunc is a function called on each node in a tree by TreeNode.Walk. 15 | // 16 | // If TreeWalkFunc returns an error, and that error is not SkipSubtree, the rest of the walk is 17 | // aborted. If SkipSubtree is returned, Walk will not descend into node's children, but will proceed 18 | // with the rest of the walk as normal. 19 | type TreeWalkFunc func(*TreeNode) error 20 | 21 | type TreeNode struct { 22 | // Parent field is excluded from JSON to avoid a cycle 23 | Parent *TreeNode `json:"-"` 24 | Children []*TreeNode `json:"children,omitempty"` 25 | Referent string `json:"referent"` 26 | 27 | mu sync.Mutex `json:"-"` 28 | } 29 | 30 | // TreeNode needs its own UnmarshalJSON so that .Children gets initialized. 31 | // 32 | // Initializing .Parent is done further up the call stack. 33 | func (n *TreeNode) UnmarshalJSON(b []byte) error { 34 | type tmpTreeNode *TreeNode 35 | tmp := tmpTreeNode(n) 36 | err := json.Unmarshal(b, tmp) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | n.Referent = tmp.Referent 42 | if n.Children == nil { 43 | n.Children = make([]*TreeNode, 0) 44 | } else { 45 | copy(n.Children, tmp.Children) 46 | } 47 | return nil 48 | } 49 | 50 | // Depth returns the number of ancestors of n. 51 | func (n *TreeNode) Depth() int { 52 | i := 0 53 | for n.Parent != nil { 54 | i++ 55 | n = n.Parent 56 | } 57 | return i 58 | } 59 | 60 | // AddChild makes childNode a child of n. 61 | // 62 | // childNode is added at the end of n.Children. 63 | func (n *TreeNode) AddChild(childNode *TreeNode) { 64 | n.mu.Lock() 65 | defer n.mu.Unlock() 66 | childNode.Parent = n 67 | n.Children = append(n.Children, childNode) 68 | } 69 | 70 | // InsertChild makes childNode a child of n, placing it at the given position among n.Children. 71 | // 72 | // ind is the index in n.Children at which childNode will be inserted. 73 | func (n *TreeNode) InsertChild(ind int, childNode *TreeNode) { 74 | n.mu.Lock() 75 | defer n.mu.Unlock() 76 | 77 | childNode.Parent = n 78 | n.Children = append(n.Children, nil) 79 | copy(n.Children[ind+1:], n.Children[ind:]) 80 | n.Children[ind] = childNode 81 | } 82 | 83 | // Walk walks the tree rooted at n, calling fn for each TreeNode, including n. 84 | // 85 | // All errors that arise are filtered by fn: see the TreeWalkFunc documentation for details. 86 | // 87 | // After a node is visited, each of its children is walked, in the order in which they would be 88 | // executed. For example, for the "make pasta" task (server/testdata/make_pasta), fn would be called 89 | // on the nodes in this order: 90 | // 91 | // - make pasta 92 | // - boil water 93 | // - put water in pot 94 | // - put pot on burner 95 | // - turn burner on 96 | // - put pasta in water 97 | // - [b cooked] 98 | // - drain pasta 99 | func (n *TreeNode) Walk(fn TreeWalkFunc) error { 100 | err := fn(n) 101 | if err == SkipSubtree { 102 | return nil 103 | } 104 | if err != nil { 105 | return err 106 | } 107 | 108 | for _, cn := range n.Children { 109 | err = cn.Walk(fn) 110 | if err != nil { 111 | return err 112 | } 113 | } 114 | 115 | return nil 116 | } 117 | 118 | // WalkFromTop walks the tree rooted at n, calling fn for each TreeNode, including n. 119 | // 120 | // All errors that arise are filtered by fn: see the TreeWalkFunc documentation for details. 121 | // 122 | // WalkFromTop visits nodes from the top to the bottom of the task. For example, for the "make 123 | // pasta" task (server/testdata/make_pasta), fn would be called on the nodes in this order: 124 | // 125 | // - put water in pot 126 | // - put pot on burner 127 | // - turn burner on 128 | // - boil water 129 | // - put pasta in water 130 | // - [b cooked] 131 | // - drain pasta 132 | // - make pasta 133 | // 134 | // Since this is a depth-first walk, SkipSubtree is not treated specially: if fn returns 135 | // SkipSubtree, the walk exits with that error. 136 | func (n *TreeNode) WalkFromTop(fn TreeWalkFunc) error { 137 | var err error 138 | for _, cn := range n.Children { 139 | err = cn.WalkFromTop(fn) 140 | if err != nil { 141 | return err 142 | } 143 | } 144 | 145 | err = fn(n) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // String returns a string representation of n, for use in logging and debugging. 154 | // 155 | // One should not use the return value of String() to compare TreeNodes. Instead, one should use 156 | // Equal() and/or write a custom TreeWalkFunc. String() is only for convenience in development. 157 | func (n *TreeNode) String() string { 158 | var b strings.Builder 159 | n.Walk(func(m *TreeNode) error { 160 | b.WriteString(fmt.Sprintf("%s%s\n", strings.Repeat("\t", m.Depth()), m.Referent)) 161 | return nil 162 | }) 163 | return b.String() 164 | } 165 | 166 | // Equal determines whether a is identical to b. 167 | // 168 | // Equal walks both trees and compares the corresponding nodes in a and b. If the two nodes' depth 169 | // and referent are equal, then the nodes are equal. 170 | func (a *TreeNode) Equal(b *TreeNode) bool { 171 | if a == nil || b == nil { 172 | // technically nil should equal nil, but… i don't want a situation where anybody's ever 173 | // passing nil to this function 174 | panic("called *TreeNode.Equal on nil TreeNode") 175 | } 176 | 177 | toChan := func(rootNode *TreeNode, ch chan *TreeNode) { 178 | rootNode.Walk(func(n *TreeNode) error { 179 | ch <- n 180 | return nil 181 | }) 182 | close(ch) 183 | } 184 | 185 | aCh := make(chan *TreeNode) 186 | bCh := make(chan *TreeNode) 187 | go toChan(a, aCh) 188 | go toChan(b, bCh) 189 | 190 | for aNode := range aCh { 191 | bNode := <-bCh 192 | if len(aNode.Children) != len(bNode.Children) { 193 | return false 194 | } 195 | if aNode.Referent != bNode.Referent { 196 | return false 197 | } 198 | } 199 | 200 | // Make sure b is finished 201 | _, ok := <-bCh 202 | if ok { 203 | return false 204 | } 205 | 206 | return true 207 | } 208 | 209 | // NewTreeNode returns a TreeNode with the given Referent. 210 | func NewTreeNode(referent string) *TreeNode { 211 | return &TreeNode{ 212 | Children: []*TreeNode{}, 213 | Referent: referent, 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /common/tree_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestTreeNode_AddChild(t *testing.T) { 11 | t.Parallel() 12 | assert := assert.New(t) 13 | 14 | n := NewTreeNode("") 15 | n.AddChild(NewTreeNode("a")) 16 | n.AddChild(NewTreeNode("b")) 17 | n.AddChild(NewTreeNode("c")) 18 | 19 | rslt := make([]string, 0) 20 | for _, ch := range n.Children { 21 | rslt = append(rslt, ch.Referent) 22 | } 23 | assert.Equal([]string{"a", "b", "c"}, rslt) 24 | } 25 | 26 | func TestTreeNode_InsertChild(t *testing.T) { 27 | t.Parallel() 28 | assert := assert.New(t) 29 | 30 | n := NewTreeNode("") 31 | n.AddChild(NewTreeNode("b")) 32 | n.AddChild(NewTreeNode("d")) 33 | n.InsertChild(0, NewTreeNode("a")) 34 | n.InsertChild(2, NewTreeNode("c")) 35 | n.InsertChild(4, NewTreeNode("e")) 36 | 37 | rslt := make([]string, 0) 38 | for _, ch := range n.Children { 39 | rslt = append(rslt, ch.Referent) 40 | } 41 | assert.Equal([]string{"a", "b", "c", "d", "e"}, rslt) 42 | } 43 | 44 | func TestTreeNode_InsertChild_Empty(t *testing.T) { 45 | t.Parallel() 46 | assert := assert.New(t) 47 | 48 | n := NewTreeNode("") 49 | n.InsertChild(0, NewTreeNode("a")) 50 | 51 | rslt := make([]string, 0) 52 | for _, ch := range n.Children { 53 | rslt = append(rslt, ch.Referent) 54 | } 55 | assert.Equal([]string{"a"}, rslt) 56 | } 57 | 58 | func TestTreeNode_Walk(t *testing.T) { 59 | t.Parallel() 60 | assert := assert.New(t) 61 | 62 | mp := MakePasta()[0] 63 | referents := make([]string, 0) 64 | err := mp.RootNode.Walk(func(vn *TreeNode) error { 65 | referents = append(referents, vn.Referent) 66 | return nil 67 | }) 68 | 69 | assert.Nil(err) 70 | assert.Equal( 71 | []string{ 72 | "make pasta", 73 | "boil water", 74 | "put water in pot", 75 | "put pot on burner", 76 | "turn burner on", 77 | "put pasta in water", 78 | "[b cooked]", 79 | "drain pasta", 80 | }, 81 | referents, 82 | ) 83 | } 84 | 85 | func TestTreeNode_Walk_SkipSubtree(t *testing.T) { 86 | t.Parallel() 87 | assert := assert.New(t) 88 | 89 | mp := MakePasta()[0] 90 | referents := make([]string, 0) 91 | err := mp.RootNode.Walk(func(vn *TreeNode) error { 92 | referents = append(referents, vn.Referent) 93 | if vn.Referent == "boil water" { 94 | return SkipSubtree 95 | } 96 | return nil 97 | }) 98 | 99 | assert.Nil(err) 100 | assert.Equal( 101 | []string{ 102 | "make pasta", 103 | "boil water", 104 | "put pasta in water", 105 | "[b cooked]", 106 | "drain pasta", 107 | }, 108 | referents, 109 | ) 110 | } 111 | 112 | func TestTreeNode_Walk_Error(t *testing.T) { 113 | t.Parallel() 114 | assert := assert.New(t) 115 | 116 | mp := MakePasta()[0] 117 | referents := make([]string, 0) 118 | expErr := errors.New("bleuhgarrg") 119 | err := mp.RootNode.Walk(func(vn *TreeNode) error { 120 | referents = append(referents, vn.Referent) 121 | if vn.Referent == "boil water" { 122 | return expErr 123 | } 124 | return nil 125 | }) 126 | 127 | assert.Equal(expErr, err) 128 | assert.Equal( 129 | []string{ 130 | "make pasta", 131 | "boil water", 132 | }, 133 | referents, 134 | ) 135 | } 136 | 137 | func TestTreeNode_Walk_ZeroChildren(t *testing.T) { 138 | t.Parallel() 139 | assert := assert.New(t) 140 | 141 | n := NewTreeNode("childless node") 142 | referents := make([]string, 0) 143 | err := n.Walk(func(vn *TreeNode) error { 144 | referents = append(referents, vn.Referent) 145 | return nil 146 | }) 147 | 148 | assert.Equal(nil, err) 149 | assert.Equal( 150 | []string{ 151 | "childless node", 152 | }, 153 | referents, 154 | ) 155 | } 156 | 157 | func TestTreeNode_WalkFromTop(t *testing.T) { 158 | t.Parallel() 159 | assert := assert.New(t) 160 | 161 | mp := MakePasta()[0] 162 | referents := make([]string, 0) 163 | err := mp.RootNode.WalkFromTop(func(vn *TreeNode) error { 164 | referents = append(referents, vn.Referent) 165 | return nil 166 | }) 167 | 168 | assert.Nil(err) 169 | assert.Equal( 170 | []string{ 171 | "put water in pot", 172 | "put pot on burner", 173 | "turn burner on", 174 | "boil water", 175 | "put pasta in water", 176 | "[b cooked]", 177 | "drain pasta", 178 | "make pasta", 179 | }, 180 | referents, 181 | ) 182 | } 183 | 184 | func TestTreeNode_WalkFromTop_Error(t *testing.T) { 185 | t.Parallel() 186 | assert := assert.New(t) 187 | 188 | mp := MakePasta()[0] 189 | referents := make([]string, 0) 190 | expErr := errors.New("bleuhgarrg") 191 | err := mp.RootNode.WalkFromTop(func(vn *TreeNode) error { 192 | referents = append(referents, vn.Referent) 193 | if vn.Referent == "turn burner on" { 194 | return expErr 195 | } 196 | return nil 197 | }) 198 | 199 | assert.Equal(expErr, err) 200 | assert.Equal( 201 | []string{ 202 | "put water in pot", 203 | "put pot on burner", 204 | "turn burner on", 205 | }, 206 | referents, 207 | ) 208 | } 209 | 210 | func TestTreeNode_WalkFromTop_ZeroChildren(t *testing.T) { 211 | t.Parallel() 212 | assert := assert.New(t) 213 | 214 | n := NewTreeNode("childless node") 215 | referents := make([]string, 0) 216 | err := n.WalkFromTop(func(vn *TreeNode) error { 217 | referents = append(referents, vn.Referent) 218 | return nil 219 | }) 220 | 221 | assert.Equal(nil, err) 222 | assert.Equal( 223 | []string{ 224 | "childless node", 225 | }, 226 | referents, 227 | ) 228 | } 229 | 230 | func TestTreeNode_Equal(t *testing.T) { 231 | t.Parallel() 232 | assert := assert.New(t) 233 | 234 | a := MakePasta()[0] 235 | b := MakePasta()[0] 236 | assert.True(a.Equal(b)) 237 | assert.True(b.Equal(a)) 238 | } 239 | 240 | // Two trees should not be evaluated as Equal if one of the corresponding node pairs differs in 241 | // referent 242 | func TestTreeNode_Equal_Not_Referent(t *testing.T) { 243 | t.Parallel() 244 | assert := assert.New(t) 245 | 246 | a := MakePasta()[0] 247 | b := MakePasta()[0] 248 | b.RootNode.Walk(func(n *TreeNode) error { 249 | if n.Referent == "put pot on burner" { 250 | n.Referent = "put pot in bong" 251 | } 252 | return nil 253 | }) 254 | assert.False(a.Equal(b)) 255 | assert.False(b.Equal(a)) 256 | } 257 | 258 | // Two trees should not be evaluated as Equal if one of the corresponding node pairs differs in 259 | // the number of children they have 260 | func TestTreeNode_Equal_Not_ChildCount(t *testing.T) { 261 | t.Parallel() 262 | assert := assert.New(t) 263 | 264 | a := MakePasta()[0] 265 | b := MakePasta()[0] 266 | b.RootNode.Walk(func(n *TreeNode) error { 267 | if n.Referent == "put pot on burner" { 268 | n.AddChild(NewTreeNode("fhgwhgads")) 269 | } 270 | return nil 271 | }) 272 | assert.False(a.Equal(b)) 273 | assert.False(b.Equal(a)) 274 | } 275 | 276 | func TestTreeNode_Equal_ZeroChildren(t *testing.T) { 277 | t.Parallel() 278 | assert := assert.New(t) 279 | 280 | a := NewTreeNode("childless node") 281 | b := NewTreeNode("childless node") 282 | assert.True(a.Equal(b)) 283 | assert.True(b.Equal(a)) 284 | } 285 | 286 | func TestTreeNode_Depth(t *testing.T) { 287 | t.Parallel() 288 | assert := assert.New(t) 289 | 290 | a := NewTreeNode("grandparent") 291 | b := NewTreeNode("parent") 292 | c := NewTreeNode("child") 293 | b.AddChild(c) 294 | a.AddChild(b) 295 | 296 | assert.Equal(0, a.Depth()) 297 | assert.Equal(1, b.Depth()) 298 | assert.Equal(2, c.Depth()) 299 | } 300 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/danslimmon/impulse 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/gin-gonic/gin v1.7.4 // indirect 7 | github.com/sirupsen/logrus v1.8.1 8 | github.com/stretchr/testify v1.7.0 // indirect 9 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 5 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 6 | github.com/gin-gonic/gin v1.7.4 h1:QmUZXrvJ9qZ3GfWvQ+2wnW/1ePrTEJqPKMYEU3lD/DM= 7 | github.com/gin-gonic/gin v1.7.4/go.mod h1:jD2toBW3GZUr5UMcdrwQA10I7RuaFOl/SGeDjXkfUtY= 8 | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 9 | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= 10 | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= 11 | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= 12 | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= 13 | github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= 14 | github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= 15 | github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= 16 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 17 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 18 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= 19 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 20 | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= 21 | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= 22 | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= 23 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 24 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= 25 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 26 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= 27 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= 31 | github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 32 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 33 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 34 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 35 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 36 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 37 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= 39 | github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= 40 | github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= 41 | github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= 42 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 43 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= 44 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 45 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 46 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 48 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2 h1:46ULzRKLh1CwgRq2dC5SlBzEqqNCi8rreOZnNrbqcIY= 51 | golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 54 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 55 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 58 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 59 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/danslimmon/impulse/client" 9 | "github.com/danslimmon/impulse/common" 10 | "github.com/danslimmon/impulse/server" 11 | ) 12 | 13 | func main() { 14 | dataDir := os.Getenv("IMPULSE_DATADIR") 15 | if dataDir == "" { 16 | panic("error: IMPULSE_DATADIR environment variable required") 17 | } 18 | 19 | addr := "127.0.0.1:30271" 20 | ds := server.NewFilesystemDatastore(dataDir) 21 | ts := server.NewBasicTaskstore(ds) 22 | apiServer := server.NewServer(ts) 23 | if err := apiServer.Start(addr); err != nil { 24 | panic("failed to start test server on " + addr + ": " + err.Error()) 25 | } 26 | 27 | apiClient := client.NewClient(addr) 28 | switch os.Args[1] { 29 | case "show": 30 | resp, err := apiClient.GetTaskList(os.Args[2]) 31 | if err != nil { 32 | panic(fmt.Sprintf("failed to get task list `%s`: %s", os.Args[2], err.Error())) 33 | } 34 | 35 | for _, t := range resp.Result { 36 | t.RootNode.WalkFromTop(func(n *common.TreeNode) error { 37 | fmt.Printf( 38 | "%s%v\n", 39 | strings.Repeat(" ", n.Depth()), 40 | n.Referent, 41 | ) 42 | return nil 43 | }) 44 | } 45 | case "archive": 46 | lineID := common.GetLineID(os.Args[2], os.Args[3]) 47 | _, err := apiClient.ArchiveLine(lineID) 48 | if err != nil { 49 | panic(fmt.Sprintf("failed to archive line with ID `%s`: %s", os.Args[2], err.Error())) 50 | } 51 | case "insert": 52 | lineID := common.LineID(fmt.Sprintf("%s:%s", os.Args[2], os.Args[3])) 53 | text := os.Args[4] 54 | _, err := apiClient.InsertTask(lineID, common.NewTask(common.NewTreeNode(text))) 55 | if err != nil { 56 | panic(fmt.Sprintf("failed to insert task: %s", err.Error())) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/api.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net" 5 | "net/rpc" 6 | "net/rpc/jsonrpc" 7 | ) 8 | 9 | type Server struct { 10 | taskstore Taskstore 11 | } 12 | 13 | // assignTaskstore obtains a default Taskstore implementation if one is not already injected. 14 | func (api *Server) assignTaskstore() { 15 | if api.taskstore != nil { 16 | // We already have a Taskstore implementation dependency-injected. 17 | return 18 | } 19 | 20 | ds := NewFilesystemDatastore(DataDir) 21 | ts := NewBasicTaskstore(ds) 22 | api.taskstore = ts 23 | } 24 | 25 | // Start starts the Impulse API server, which will listen for requests until Stop is called. 26 | func (api *Server) Start(addr string) error { 27 | api.assignTaskstore() 28 | rpc.Register(api) 29 | 30 | tcpAddr, err := net.ResolveTCPAddr("tcp", addr) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | listener, err := net.ListenTCP("tcp", tcpAddr) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | go func() { 41 | for { 42 | conn, err := listener.Accept() 43 | if err != nil { 44 | continue 45 | } 46 | jsonrpc.ServeConn(conn) 47 | } 48 | }() 49 | 50 | return nil 51 | } 52 | 53 | func NewServer(ts Taskstore) *Server { 54 | return &Server{ 55 | taskstore: ts, 56 | } 57 | } 58 | 59 | type Response struct { 60 | StateID string 61 | } 62 | -------------------------------------------------------------------------------- /server/datastore.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | const DataDir = "/Users/danslimmon/j_workspace/impulse" 10 | 11 | // Datastore is an interface to raw marshaled Impulse data. 12 | // 13 | // A Datastore implementation is responsible for: 14 | // 15 | // - Mapping task list names to the corresponding objects in the database (e.g. files on disk) 16 | // - Reading and writing the data in those objects 17 | type Datastore interface { 18 | // Get returns the contents of the file corresponding to the given task list 19 | Get(string) ([]byte, error) 20 | // Put writes the given data to the file with the given name. 21 | Put(string, []byte) error 22 | // Append appends to the file identified by the given name, the given bytes. 23 | // 24 | // If no file with the given name exists, Append creates the file and sets its contents to the 25 | // provided []byte. 26 | // 27 | // The caller is responsible for including a \n. 28 | Append(string, []byte) error 29 | } 30 | 31 | // FilesystemDatastore is a Datastore implementation in which trees are marshaled into files in a 32 | // straightforward directory tree according to their names. 33 | // 34 | // The root of the filesystem in which data is stored is RootDir. 35 | // 36 | // For example, if RootDir is "/path", the contents of the tree with name "pers" can be found in the 37 | // file "/path/pers". If a given tree name contains slashes, they are treated as path separators by 38 | // FilesystemDatastore. 39 | type FilesystemDatastore struct { 40 | rootDir string 41 | } 42 | 43 | // absPath returns the full path to the file that should contain the given task list's marshaled data. 44 | func (ds *FilesystemDatastore) absPath(name string) string { 45 | return filepath.Join(ds.rootDir, name) 46 | } 47 | 48 | // See Datastore interface 49 | func (ds *FilesystemDatastore) Get(name string) ([]byte, error) { 50 | return ioutil.ReadFile(ds.absPath(name)) 51 | } 52 | 53 | // See Datastore interface 54 | func (ds *FilesystemDatastore) Put(name string, b []byte) error { 55 | return ioutil.WriteFile(ds.absPath(name), b, 0644) 56 | } 57 | 58 | // See Datastore interface 59 | func (ds *FilesystemDatastore) Append(name string, b []byte) error { 60 | // If the file doesn't exist, create it, or append to the file 61 | f, err := os.OpenFile(ds.absPath(name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 62 | if err != nil { 63 | return err 64 | } 65 | if _, err := f.Write(b); err != nil { 66 | f.Close() 67 | return err 68 | } 69 | if err := f.Close(); err != nil { 70 | return err 71 | } 72 | return nil 73 | } 74 | 75 | func NewFilesystemDatastore(rootDir string) *FilesystemDatastore { 76 | return &FilesystemDatastore{rootDir: rootDir} 77 | } 78 | -------------------------------------------------------------------------------- /server/datastore_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestFilesystemDatastore_Append(t *testing.T) { 11 | t.Parallel() 12 | assert := assert.New(t) 13 | 14 | ds, cleanup := newFSDatastoreWithTestdata() 15 | defer cleanup() 16 | 17 | // Append to a file that doesn't exist yet 18 | err := ds.Append("foo", []byte("first line\n")) 19 | assert.Nil(err) 20 | rslt, err := ds.Get("foo") 21 | assert.Nil(err) 22 | assert.Equal([]byte("first line\n"), rslt, fmt.Sprintf("unexpected file contents: '%s'", string(rslt))) 23 | 24 | // Append to a file that already exists 25 | err = ds.Append("foo", []byte("second line\n")) 26 | assert.Nil(err) 27 | rslt, err = ds.Get("foo") 28 | assert.Nil(err) 29 | assert.Equal([]byte("first line\nsecond line\n"), rslt, fmt.Sprintf("unexpected file contents: '%s'", string(rslt))) 30 | } 31 | -------------------------------------------------------------------------------- /server/rpc_get_task.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/danslimmon/impulse/common" 5 | ) 6 | 7 | type GetTaskRequest struct { 8 | TaskIDs []string 9 | } 10 | 11 | type GetTaskResponse struct { 12 | Response 13 | Tasks []*common.Task 14 | } 15 | 16 | // GetTask retrieves the tasks whose task IDs are those specified by req.TaskIDs. The response's 17 | // Tasks attribute contains the tasks returned, in the same order as they were listed in TaskIDs. 18 | // 19 | // Should any of the IDs in TaskIDs not match a task known by the server, it's an error. Duplicate 20 | // TaskIDs values are okay – resp.Tasks will just contain the same task however many times its ID is 21 | // repeated in the input. 22 | func (s *Server) GetTask(req *GetTaskRequest, resp *GetTaskResponse) error { 23 | resp.Tasks = make([]*common.Task, len(req.TaskIDs)) 24 | for i := range req.TaskIDs { 25 | task, err := s.taskstore.GetTask(common.LineID(req.TaskIDs[i])) 26 | if err != nil { 27 | return err 28 | } 29 | resp.Tasks[i] = task 30 | } 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /server/rpc_get_task_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/danslimmon/impulse/common" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGetTask(t *testing.T) { 11 | t.Parallel() 12 | assert := assert.New(t) 13 | 14 | s, cleanup := NewServerWithTestdata() 15 | defer cleanup() 16 | 17 | // One task exactly 18 | apiReq := new(GetTaskRequest) 19 | apiReq.TaskIDs = []string{ 20 | string(common.GetLineID("make_pasta", "make pasta")), 21 | } 22 | apiResp := new(GetTaskResponse) 23 | err := s.GetTask(apiReq, apiResp) 24 | assert.Nil(err) 25 | 26 | makePasta := common.MakePasta()[0] 27 | assert.Equal(1, len(apiResp.Tasks)) 28 | assert.True(apiResp.Tasks[0].RootNode.Equal(makePasta.RootNode)) 29 | 30 | // Zero task IDs supplied 31 | apiReq = new(GetTaskRequest) 32 | apiReq.TaskIDs = []string{} 33 | apiResp = new(GetTaskResponse) 34 | err = s.GetTask(apiReq, apiResp) 35 | assert.Nil(err) 36 | assert.Equal(0, len(apiResp.Tasks)) 37 | 38 | // Multiple task IDs supplied 39 | apiReq = new(GetTaskRequest) 40 | apiReq.TaskIDs = []string{ 41 | string(common.GetLineID("make_pasta", "make pasta")), 42 | string(common.GetLineID("multiple_nested", "task 0")), 43 | string(common.GetLineID("multiple_nested", "task 1")), 44 | } 45 | apiResp = new(GetTaskResponse) 46 | err = s.GetTask(apiReq, apiResp) 47 | assert.Nil(err) 48 | 49 | multipleNested := common.MultipleNested() 50 | assert.Equal(3, len(apiResp.Tasks)) 51 | assert.True(apiResp.Tasks[0].RootNode.Equal(makePasta.RootNode)) 52 | assert.True(apiResp.Tasks[1].RootNode.Equal(multipleNested[0].RootNode)) 53 | assert.True(apiResp.Tasks[2].RootNode.Equal(multipleNested[1].RootNode)) 54 | 55 | // Same task ID supplied twice 56 | apiReq = new(GetTaskRequest) 57 | apiReq.TaskIDs = []string{ 58 | string(common.GetLineID("multiple_nested", "task 1")), 59 | string(common.GetLineID("multiple_nested", "task 1")), 60 | } 61 | apiResp = new(GetTaskResponse) 62 | err = s.GetTask(apiReq, apiResp) 63 | assert.Nil(err) 64 | 65 | assert.Equal(2, len(apiResp.Tasks)) 66 | assert.True(apiResp.Tasks[0].RootNode.Equal(multipleNested[1].RootNode)) 67 | assert.True(apiResp.Tasks[1].RootNode.Equal(multipleNested[1].RootNode)) 68 | } 69 | 70 | // Tests GetTask's behavior when a nonexistent task ID is given 71 | func TestGetTask_Nonexistent(t *testing.T) { 72 | t.Parallel() 73 | assert := assert.New(t) 74 | 75 | s, cleanup := NewServerWithTestdata() 76 | defer cleanup() 77 | 78 | // Malformatted line ID 79 | apiReq := new(GetTaskRequest) 80 | apiReq.TaskIDs = []string{"shmooooooooooooo"} 81 | apiResp := new(GetTaskResponse) 82 | err := s.GetTask(apiReq, apiResp) 83 | assert.NotNil(err) 84 | 85 | // Nonexistent line 86 | apiReq.TaskIDs = []string{string(common.GetLineID("make_pasta", "fhghwhgads"))} 87 | err = s.GetTask(apiReq, apiResp) 88 | assert.NotNil(err) 89 | 90 | // Line that exists but doesn't correspond to a task 91 | apiReq.TaskIDs = []string{string(common.GetLineID("multiple_nested", "\tsubtask 0"))} 92 | err = s.GetTask(apiReq, apiResp) 93 | assert.NotNil(err) 94 | 95 | // One task that exists, and one that doesn't 96 | apiReq.TaskIDs = []string{ 97 | string(common.GetLineID("make_pasta", "make pasta")), 98 | string(common.GetLineID("make_pasta", "burn self on pot")), 99 | } 100 | err = s.GetTask(apiReq, apiResp) 101 | assert.NotNil(err) 102 | } 103 | -------------------------------------------------------------------------------- /server/taskstore.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/danslimmon/impulse/common" 10 | ) 11 | 12 | // Taskstore provides read and write access to Tree structs persisted to the Datastore. 13 | type Taskstore interface { 14 | GetTask(common.LineID) (*common.Task, error) 15 | 16 | GetList(string) ([]*common.Task, error) 17 | PutList(string, []*common.Task) error 18 | InsertTask(common.LineID, *common.Task) error 19 | ArchiveLine(common.LineID) error 20 | } 21 | 22 | // BasicTaskstore is a Taskstore implementation in which trees are stored in a basic, 23 | // text-editor-centric serialization format. 24 | // 25 | // The basic format consists of a sequence of lines. A line that is not indented is a direct child 26 | // of the root node. A line that is indented (by any number of consecutive tab characters at the 27 | // beginning of the line) represents a direct child of the next line down with an indentation level 28 | // one less. The bottom line of a tree representation must not be indented. 29 | // 30 | // For examples, see treestore_test.go. 31 | type BasicTaskstore struct { 32 | datastore Datastore 33 | } 34 | 35 | // parseLine parses a line of basic-format tree data. 36 | // 37 | // It returns the integer number of tabs that occur at the beginning of the line (its indent level) 38 | // and the remaining text of the line as a string. 39 | func (ts *BasicTaskstore) parseLine(line []byte) (int, string) { 40 | textBytes := bytes.TrimLeft(line, "\t") 41 | indent := len(line) - len(textBytes) 42 | return indent, string(textBytes) 43 | } 44 | 45 | // derefLineId takes a line ID and determines the corresponding list name and line number. 46 | // 47 | // The line number returned is zero-indexed (the first line of the file is line 0). 48 | func (ts *BasicTaskstore) derefLineId(lineId common.LineID) (string, int, error) { 49 | parts := strings.SplitN(string(lineId), ":", 2) 50 | if len(parts) != 2 { 51 | return "", 0, fmt.Errorf("malformatted line ID `%s`", string(lineId)) 52 | } 53 | 54 | listName := parts[0] 55 | 56 | if parts[1] == "0" { 57 | return listName, 0, nil 58 | } 59 | 60 | b, err := ts.datastore.Get(listName) 61 | if err != nil { 62 | return "", 0, err 63 | } 64 | 65 | lines := bytes.Split(b, []byte("\n")) 66 | lineNo := -1 67 | for n, line := range lines { 68 | if common.GetLineID(listName, string(line)) == lineId { 69 | lineNo = n 70 | } 71 | } 72 | if lineNo == -1 { 73 | return "", 0, fmt.Errorf("no line with ID `%s`", string(lineId)) 74 | } 75 | 76 | return listName, lineNo, nil 77 | } 78 | 79 | // GetTask returns the task at the given line ID. 80 | // 81 | // If no such task exists, GetTask returns an error. 82 | func (ts *BasicTaskstore) GetTask(lineId common.LineID) (*common.Task, error) { 83 | taskListId, _, err := ts.derefLineId(lineId) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | taskList, err := ts.GetList(taskListId) 89 | for _, task := range taskList { 90 | if lineId == common.GetLineID(taskListId, task.RootNode.Referent) { 91 | return task, nil 92 | } 93 | } 94 | return nil, fmt.Errorf("Task '%s' not found", lineId) 95 | } 96 | 97 | // Get retrieves the task list with the given name from the persistent Datastore. 98 | func (ts *BasicTaskstore) GetList(name string) ([]*common.Task, error) { 99 | b, err := ts.datastore.Get(name) 100 | if err != nil { 101 | return nil, err 102 | } 103 | if len(b) == 0 { 104 | return nil, fmt.Errorf("data for tree '%s' is zero-length", name) 105 | } 106 | 107 | // lines ends up just being splut in reverse. so lines is all the lines in the file, from the 108 | // bottom to the top of the file. that's how we want it for constructing the tree further down. 109 | splut := bytes.Split(b, []byte("\n")) 110 | if len(splut) < 2 { 111 | return []*common.Task{}, nil 112 | } 113 | 114 | nLines := len(splut) 115 | lines := make([][]byte, nLines) 116 | for i := range splut { 117 | lines[nLines-1-i] = splut[i] 118 | } 119 | 120 | if len(lines[0]) == 0 { 121 | lines = lines[1:] 122 | nLines = nLines - 1 123 | } else { 124 | return nil, fmt.Errorf("data for tree '%s' does not end in newline", name) 125 | } 126 | 127 | rootNode := common.NewTreeNode("") 128 | // prevIndent is the indent of the previous line. remember: lines contains all the file's lines 129 | // _from the bottom to the top!_ 130 | // 131 | // the indent of the "root node", we can say, is -1. such that the bottommost line, which should 132 | // start with 0 indent, is a child of that root node. 133 | prevIndent := -1 134 | // prevNode points to the line parsed in the previous iteration of the loop. 135 | prevNode := rootNode 136 | for i, line := range lines { 137 | indent, text := ts.parseLine(line) 138 | deltaIndent := indent - prevIndent 139 | newNode := common.NewTreeNode(text) 140 | 141 | if deltaIndent == 1 { 142 | // this is a child of the previous node 143 | prevNode.AddChild(newNode) 144 | } else if deltaIndent <= 0 { 145 | // we've gone back up to an ancestor node. figure out which one and add the child there 146 | // (again, before the previous node parsed) 147 | // 148 | // ascend the tree by however much it takes to get back to the ancestor of node that 149 | // newNode is a child of 150 | ancestorNode := prevNode.Parent 151 | for ancestorNode.Depth() != prevNode.Depth()+deltaIndent-1 { 152 | ancestorNode = ancestorNode.Parent 153 | } 154 | ancestorNode.InsertChild(0, newNode) 155 | } else { 156 | return nil, fmt.Errorf( 157 | "error parsing line %d of tree '%s': unexpected deltaIndent = %d", 158 | i, 159 | name, 160 | deltaIndent, 161 | ) 162 | } 163 | prevNode = newNode 164 | prevIndent = indent 165 | } 166 | 167 | // Now we take all the children of rootNode and load them into the list to be returned. 168 | rslt := make([]*common.Task, 0) 169 | for _, n := range rootNode.Children { 170 | n.Parent = nil 171 | rslt = append(rslt, common.NewTask(n)) 172 | } 173 | return rslt, nil 174 | } 175 | 176 | // Put writes taskList to the Datastore as name. 177 | func (ts *BasicTaskstore) PutList(name string, taskList []*common.Task) error { 178 | b := []byte{} 179 | for _, t := range taskList { 180 | t.RootNode.WalkFromTop(func(n *common.TreeNode) error { 181 | b = append(b, bytes.Repeat([]byte("\t"), n.Depth())...) 182 | b = append(b, []byte(n.Referent)...) 183 | b = append(b, []byte("\n")...) 184 | return nil 185 | }) 186 | } 187 | return ts.datastore.Put(name, b) 188 | } 189 | 190 | // InsertTask inserts the given task after the given position. 191 | // 192 | // See common.LineID docs for information about how position is interpreted. 193 | func (ts *BasicTaskstore) InsertTask(lineId common.LineID, task *common.Task) error { 194 | listName, lineNo, err := ts.derefLineId(lineId) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | taskList, err := ts.GetList(listName) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | if lineNo == 0 { 205 | taskList = append([]*common.Task{task}, taskList...) 206 | return ts.PutList(listName, taskList) 207 | } 208 | 209 | for i := range taskList { 210 | if common.GetLineID(listName, taskList[i].RootNode.Referent) == lineId { 211 | if i+1 == len(taskList) { 212 | return ts.PutList(listName, append(taskList, task)) 213 | } 214 | taskList = append(taskList[:i+1], taskList[i:]...) 215 | taskList[i] = task 216 | return ts.PutList(listName, taskList) 217 | } 218 | } 219 | 220 | return fmt.Errorf("no line exists with ID '%s'", lineId) 221 | } 222 | 223 | // historyLine returns a line for the history file based on the given line from an impulse file. 224 | // 225 | // History lines are of the form: 226 | // 227 | // 2021-12-30T19:24:48 [full contents of b, including any leading whitespace] 228 | func (ts *BasicTaskstore) historyLine(b []byte) []byte { 229 | now := time.Now() 230 | // make sure this is UTC before using it ^ 231 | timestamp := now.Format("2006-01-02T15:04:05") 232 | return []byte(fmt.Sprintf("%s %s", timestamp, b)) 233 | } 234 | 235 | // ArchiveLine archives the line identified by lineId. 236 | // 237 | // lineId may refer either to a subtask or a task proper. 238 | func (ts *BasicTaskstore) ArchiveLine(lineId common.LineID) error { 239 | listName, lineNo, err := ts.derefLineId(lineId) 240 | if err != nil { 241 | return err 242 | } 243 | 244 | b, err := ts.datastore.Get(listName) 245 | if err != nil { 246 | return err 247 | } 248 | 249 | lines := bytes.Split(b, []byte("\n")) 250 | // Will panic on index-out-of-range, but it should. That means there's a bug in derefLineId. 251 | removedLine := make([]byte, len(lines[lineNo])) 252 | copy(removedLine, lines[lineNo]) 253 | 254 | lines = append(lines[0:lineNo], lines[lineNo+1:]...) 255 | // Add empty line so that file will end with a newline 256 | lines = append(lines, []byte{}) 257 | b = bytes.Join(lines, []byte("\n")) 258 | 259 | ts.datastore.Append("history", ts.historyLine(removedLine)) 260 | 261 | return ts.datastore.Put(listName, b) 262 | } 263 | 264 | // NewBasicTaskstore returns a BasicTaskstore with the given underlying datastore. 265 | func NewBasicTaskstore(datastore Datastore) *BasicTaskstore { 266 | return &BasicTaskstore{datastore: datastore} 267 | } 268 | -------------------------------------------------------------------------------- /server/taskstore_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/danslimmon/impulse/common" 10 | ) 11 | 12 | func TestBasicTaskstore_derefLineId(t *testing.T) { 13 | t.Parallel() 14 | assert := assert.New(t) 15 | 16 | type testCase struct { 17 | LineId common.LineID 18 | ExpListName string 19 | ExpLineNo int 20 | ExpErr bool 21 | } 22 | 23 | testCases := []testCase{ 24 | // happy path 25 | testCase{ 26 | LineId: common.GetLineID("make_pasta", "\tboil water"), 27 | ExpListName: "make_pasta", 28 | ExpLineNo: 3, 29 | ExpErr: false, 30 | }, 31 | testCase{ 32 | LineId: common.GetLineID("multiple_nested", "\t\tsubsubtask 0"), 33 | ExpListName: "multiple_nested", 34 | ExpLineNo: 0, 35 | ExpErr: false, 36 | }, 37 | testCase{ 38 | LineId: common.GetLineID("multiple_nested", "task 1"), 39 | ExpListName: "multiple_nested", 40 | ExpLineNo: 4, 41 | ExpErr: false, 42 | }, 43 | testCase{ 44 | LineId: common.LineID("multiple_nested:0"), 45 | ExpListName: "multiple_nested", 46 | ExpLineNo: 0, 47 | ExpErr: false, 48 | }, 49 | // sad path 50 | testCase{ 51 | LineId: common.LineID("malformed line ID (no slash)"), 52 | ExpErr: true, 53 | }, 54 | testCase{ 55 | LineId: common.GetLineID("no_such_file", "blah blah"), 56 | ExpErr: true, 57 | }, 58 | testCase{ 59 | LineId: common.GetLineID("make_pasta", "line that doesn't exist"), 60 | ExpErr: true, 61 | }, 62 | } 63 | 64 | for _, tc := range testCases { 65 | ts, cleanup := NewBasicTaskstoreWithTestdata() 66 | defer cleanup() 67 | 68 | rsltListName, rsltLineNo, rsltErr := ts.derefLineId(tc.LineId) 69 | if !tc.ExpErr { 70 | assert.Nil(rsltErr) 71 | assert.Equal(tc.ExpListName, rsltListName) 72 | assert.Equal(tc.ExpLineNo, rsltLineNo) 73 | } else { 74 | assert.NotNil(rsltErr) 75 | } 76 | } 77 | } 78 | 79 | func TestBasicTaskstore_GetTask(t *testing.T) { 80 | t.Parallel() 81 | assert := assert.New(t) 82 | 83 | ts, cleanup := NewBasicTaskstoreWithTestdata() 84 | defer cleanup() 85 | 86 | task, err := ts.GetTask(common.GetLineID("make_pasta", "make pasta")) 87 | assert.Nil(err) 88 | makePasta := common.MakePasta()[0] 89 | assert.True(makePasta.RootNode.Equal(task.RootNode)) 90 | 91 | task, err = ts.GetTask(common.GetLineID("multiple_nested", "task 1")) 92 | assert.Nil(err) 93 | multipleNestedTask1 := common.MultipleNested()[1] 94 | assert.True(multipleNestedTask1.RootNode.Equal(task.RootNode)) 95 | } 96 | 97 | func TestBasicTaskstore_GetList(t *testing.T) { 98 | t.Parallel() 99 | assert := assert.New(t) 100 | 101 | type testCase struct { 102 | ListName string 103 | Exp []*common.Task 104 | } 105 | 106 | testCases := []testCase{ 107 | testCase{ 108 | ListName: "make_pasta", 109 | Exp: common.MakePasta(), 110 | }, 111 | testCase{ 112 | ListName: "multiple_nested", 113 | Exp: common.MultipleNested(), 114 | }, 115 | } 116 | 117 | for _, tc := range testCases { 118 | ts, cleanup := NewBasicTaskstoreWithTestdata() 119 | defer cleanup() 120 | 121 | rslt, err := ts.GetList(tc.ListName) 122 | assert.Nil(err) 123 | assert.Equal(len(tc.Exp), len(rslt)) 124 | for i := range tc.Exp { 125 | if i > len(rslt)-1 { 126 | break 127 | } 128 | assert.Equal(tc.Exp[i], rslt[i]) 129 | } 130 | } 131 | } 132 | 133 | func TestBasicTaskstore_GetList_Malformed(t *testing.T) { 134 | t.Parallel() 135 | assert := assert.New(t) 136 | 137 | ds := NewFilesystemDatastore("./testdata") 138 | ts := NewBasicTaskstore(ds) 139 | 140 | paths := []string{ 141 | "malformed/zero_length", 142 | "malformed/excess_delta_indent", 143 | "malformed/missing", 144 | } 145 | for _, p := range paths { 146 | t.Log(p) 147 | taskList, err := ts.GetList(p) 148 | t.Log(err) 149 | assert.Empty(taskList) 150 | assert.NotNil(err) 151 | } 152 | } 153 | 154 | func TestBasicTaskstore_PutList(t *testing.T) { 155 | t.Parallel() 156 | assert := assert.New(t) 157 | 158 | a := common.NewTreeNode("alpha") 159 | a.AddChild(common.NewTreeNode("zulu")) 160 | a.AddChild(common.NewTreeNode("yankee")) 161 | b := common.NewTreeNode("bravo") 162 | 163 | ts, cleanup := NewBasicTaskstoreWithTestdata() 164 | defer cleanup() 165 | 166 | err := ts.PutList("foo", []*common.Task{ 167 | common.NewTask(a), 168 | common.NewTask(b), 169 | }) 170 | assert.Nil(err) 171 | 172 | taskList, err := ts.GetList("foo") 173 | assert.Nil(err) 174 | assert.True(taskList[0].RootNode.Equal(a)) 175 | assert.True(taskList[1].RootNode.Equal(b)) 176 | } 177 | 178 | func TestBasicTaskstore_InsertTask(t *testing.T) { 179 | t.Parallel() 180 | assert := assert.New(t) 181 | 182 | a := common.NewTreeNode("alpha") 183 | a.AddChild(common.NewTreeNode("zulu")) 184 | a.AddChild(common.NewTreeNode("yankee")) 185 | b := common.NewTreeNode("bravo") 186 | 187 | ts, cleanup := NewBasicTaskstoreWithTestdata() 188 | defer cleanup() 189 | 190 | // Insert alpha at the top of make_pasta 191 | err := ts.InsertTask(common.LineID("make_pasta:0"), common.NewTask(a)) 192 | assert.Nil(err) 193 | 194 | taskList, err := ts.GetList("make_pasta") 195 | assert.Nil(err) 196 | assert.True(a.Equal(taskList[0].RootNode)) 197 | 198 | // Insert bravo at the bottom of multiple_nested 199 | err = ts.InsertTask(common.GetLineID("multiple_nested", "task 1"), common.NewTask(b)) 200 | assert.Nil(err) 201 | 202 | taskList, err = ts.GetList("multiple_nested") 203 | assert.Nil(err) 204 | assert.True(b.Equal(taskList[2].RootNode)) 205 | } 206 | 207 | // Tests that ArchiveLine works when given an ID that corresponds to a subtask. 208 | func TestBasicTaskstore_ArchiveLine_Subtask(t *testing.T) { 209 | t.Parallel() 210 | assert := assert.New(t) 211 | 212 | ds, cleanup := newFSDatastoreWithTestdata() 213 | ts := NewBasicTaskstore(ds) 214 | defer cleanup() 215 | 216 | err := ts.ArchiveLine(common.GetLineID("make_pasta", "\t\tput water in pot")) 217 | assert.Nil(err) 218 | 219 | // make sure that the archive operation didn't cause malformation of the list file 220 | _, err = ts.GetList("make_pasta") 221 | assert.Nil(err) 222 | 223 | // make sure that the line actually got removed from the list file 224 | b, err := ds.Get("make_pasta") 225 | assert.Nil(err) 226 | assert.False(regexp.MustCompile("put water in pot").Match(b)) 227 | 228 | // make sure that the history file now contains the line we archived 229 | b, err = ds.Get("history") 230 | assert.Nil(err) 231 | assert.True(regexp.MustCompile("^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9] \t\tput water in pot$").Match(b)) 232 | } 233 | 234 | // Tests that ArchiveLine works when given an ID that corresponds to a task. 235 | func TestBasicTaskstore_ArchiveLine_Task(t *testing.T) { 236 | t.Parallel() 237 | assert := assert.New(t) 238 | 239 | ds, cleanup := newFSDatastoreWithTestdata() 240 | ts := NewBasicTaskstore(ds) 241 | defer cleanup() 242 | 243 | err := ts.ArchiveLine(common.GetLineID("make_pasta", "make pasta")) 244 | assert.Nil(err) 245 | 246 | // make sure that the archive operation didn't cause malformation of the list file 247 | _, err = ts.GetList("make_pasta") 248 | assert.Nil(err) 249 | 250 | // make sure that the line actually got removed from the list file 251 | b, err := ds.Get("make_pasta") 252 | assert.Nil(err) 253 | assert.False(regexp.MustCompile("make pasta").Match(b)) 254 | 255 | // make sure that the history file now contains the line we archived 256 | b, err = ds.Get("history") 257 | assert.Nil(err) 258 | assert.True(regexp.MustCompile("^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9] make pasta$").Match(b)) 259 | } 260 | -------------------------------------------------------------------------------- /server/testutil.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "sync" 10 | ) 11 | 12 | // cloneTestData copies server/testdata to a new tempdir and returns that tempdir's path. 13 | // 14 | // cloneTestData also returns a function to call when the test is over. Calling this function will 15 | // remove the temporary directory. 16 | func cloneTestData() (string, func()) { 17 | tempDir, err := ioutil.TempDir("", "impulse_*") 18 | if err != nil { 19 | panic("unable to create tempdir for testdata clone: " + err.Error()) 20 | } 21 | 22 | cwd, err := os.Getwd() 23 | if err != nil { 24 | panic("unable to get current working directory: " + err.Error()) 25 | } 26 | 27 | var srcDir string 28 | if path.Base(cwd) == "impulse" { 29 | srcDir = "testdata" 30 | } else { 31 | srcDir = "../testdata" 32 | } 33 | 34 | if err := exec.Command("cp", "-rp", srcDir+"/", tempDir+"/").Run(); err != nil { 35 | panic("unable to clone testdata to tempdir: " + err.Error()) 36 | } 37 | return tempDir, func() { 38 | os.RemoveAll(tempDir) 39 | } 40 | } 41 | 42 | // newFSDatastoreWithTestdata returns a FilesystemDatastore whose filesystem has been cloned from 43 | // server/testdata. 44 | // 45 | // newFSDatastoreWithTestdata also returns a function to call when the test is over. Calling this 46 | // function will remove the temporary directory that is the FilesystemDatastore's rootDir. 47 | func newFSDatastoreWithTestdata() (*FilesystemDatastore, func()) { 48 | tempDir, cleanup := cloneTestData() 49 | return NewFilesystemDatastore(tempDir), cleanup 50 | } 51 | 52 | // NewBasicTaskstoreWithTestdata returns a BasicTaskstore based on a clone of the server/testdata 53 | // directory in a tempdir. 54 | // 55 | // NewBasicTaskstoreWithTestdata also returns a function to call when the test is over. Calling this 56 | // function will remove the temporary directory. 57 | func NewBasicTaskstoreWithTestdata() (*BasicTaskstore, func()) { 58 | ds, cleanup := newFSDatastoreWithTestdata() 59 | return NewBasicTaskstore(ds), cleanup 60 | } 61 | 62 | // ap is a singleton addrPool that we use to provision addrs for tests to listen on. 63 | var ap *addrPool 64 | 65 | // addrPool provisions unique addrs for tests to listen on. 66 | // 67 | // An addr is a string of the form ":". We will provision up to 10000 unique addrs. If 68 | // more than 10000 are requested, addrPool.Get panics. 69 | type addrPool struct { 70 | port int 71 | mu sync.Mutex 72 | } 73 | 74 | func (a *addrPool) Get() string { 75 | a.mu.Lock() 76 | defer a.mu.Unlock() 77 | if a.port == 0 { 78 | a.port = 30000 79 | } else if a.port >= 39999 { 80 | panic("more than 10000 addrs requested from addrPool") 81 | } else { 82 | a.port = a.port + 1 83 | } 84 | return fmt.Sprintf("127.0.0.1:%d", a.port) 85 | } 86 | 87 | // listenAddr returns a loopback address for a test instance of the API to listen on. 88 | // 89 | // The address is guaranteed not to be in use by any other test in the suite. 90 | func listenAddr() string { 91 | if ap == nil { 92 | ap = new(addrPool) 93 | } 94 | return ap.Get() 95 | } 96 | 97 | // NewServerWithTestdata returns a Server based on a clone of the server/testdata directory in a 98 | // teempdir. 99 | // 100 | // NewServerWithTestdata also returns a function to call when the test is over. Calling this 101 | // function will remove the temporary directory. 102 | func NewServerWithTestdata() (*Server, func()) { 103 | ts, cleanup := NewBasicTaskstoreWithTestdata() 104 | s := &Server{ 105 | taskstore: ts, 106 | } 107 | addr := listenAddr() 108 | err := s.Start(addr) 109 | if err != nil { 110 | panic(err.Error()) 111 | } 112 | 113 | return s, cleanup 114 | } 115 | -------------------------------------------------------------------------------- /testdata/make_pasta: -------------------------------------------------------------------------------- 1 | put water in pot 2 | put pot on burner 3 | turn burner on 4 | boil water 5 | put pasta in water 6 | [b cooked] 7 | drain pasta 8 | make pasta 9 | -------------------------------------------------------------------------------- /testdata/malformed/excess_delta_indent: -------------------------------------------------------------------------------- 1 | this one is double-indented oh no! 2 | this one is okay 3 | mmhmm 4 | -------------------------------------------------------------------------------- /testdata/malformed/zero_length: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danslimmon/impulse/3fd179894b04b18581747f245b1a6e3b4df4250f/testdata/malformed/zero_length -------------------------------------------------------------------------------- /testdata/multiple_nested: -------------------------------------------------------------------------------- 1 | subsubtask 0 2 | subtask 0 3 | task 0 4 | subtask 1 5 | task 1 6 | --------------------------------------------------------------------------------