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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ profiles:
| `Shift+letter` | Sort by column |
| `c` | Cancel query (in query detail) |
| `t` | Terminate backend (in query/transaction detail) |
| `n` | New rule (in rules view) |
| `e` | Edit selected rule (in rules view) |
| `d` | Delete selected rule (in rules view) |
| `Space` | Toggle rule enabled/disabled (in rules view) |
| `q` | Quit |

## Development
Expand Down
2 changes: 1 addition & 1 deletion cmd/tusk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func main() {
engine = rules.NewEngine(compiled, database, 5*time.Minute, 1000)
}

app := tui.NewApp(database, profileName, profileColor, connUser, readonly, engine)
app := tui.NewApp(database, cfg, profileName, profileColor, connUser, readonly, engine)
if err := app.Run(); err != nil {
return fmt.Errorf("running tusk: %w", err)
}
Expand Down
126 changes: 126 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"bytes"
"fmt"
"net/url"
"os"
Expand Down Expand Up @@ -183,3 +184,128 @@ func envOrDefault(key, fallback string) string {
}
return fallback
}

// SaveProfileRules writes ruleConfigs back to the given profile in
// ~/.config/tusk/config.yaml, replacing only that profile's `rules:` sequence.
//
// It edits the file's YAML node tree in place rather than re-marshaling the
// whole Config, so comments, key ordering, and every other field (other
// profiles, connection URLs, env-derived values) are preserved untouched.
// Because only the rules subtree is rewritten, defaults injected by
// applyProfileDefaults (port, refresh_interval) and env-derived passwords are
// never persisted. The write is atomic (temp file + rename).
func SaveProfileRules(profileName string, ruleConfigs []rules.RuleConfig) error {
path, err := configPath()
if err != nil {
return err
}

// Load the existing document (preserving comments/formatting), or start a
// fresh one if the file does not exist yet.
root := &yaml.Node{Kind: yaml.MappingNode}
if data, readErr := os.ReadFile(path); readErr == nil { //nolint:gosec // path from configPath(), not user input
var doc yaml.Node
if parseErr := yaml.Unmarshal(data, &doc); parseErr != nil {
return fmt.Errorf("parsing config %s: %w", path, parseErr)
}
if len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode {
root = doc.Content[0]
}
} else if !os.IsNotExist(readErr) {
return fmt.Errorf("reading config %s: %w", path, readErr)
}

// profiles:
profiles := mapValue(root, "profiles")
if profiles == nil || profiles.Kind != yaml.MappingNode {
profiles = &yaml.Node{Kind: yaml.MappingNode}
setMapValue(root, "profiles", profiles)
}

// profiles.<name>:
profile := mapValue(profiles, profileName)
if profile == nil || profile.Kind != yaml.MappingNode {
profile = &yaml.Node{Kind: yaml.MappingNode}
setMapValue(profiles, profileName, profile)
}

// profiles.<name>.rules: (marshal the configs to a fresh sequence node)
rulesNode, err := toNode(ruleConfigs)
if err != nil {
return fmt.Errorf("encoding rules: %w", err)
}
setMapValue(profile, "rules", rulesNode)

// default_profile: fill in for a freshly created file so it round-trips.
if mapValue(root, "default_profile") == nil {
setMapValue(root, "default_profile", &yaml.Node{
Kind: yaml.ScalarNode, Tag: "!!str", Value: profileName,
})
}

// Encode with 2-space indent to match the documented config style and keep
// diffs minimal (yaml.Marshal defaults to 4).
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
if err := enc.Encode(root); err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
_ = enc.Close()
out := buf.Bytes()

dir := filepath.Dir(path)
if mkErr := os.MkdirAll(dir, 0o750); mkErr != nil {
return fmt.Errorf("creating config dir: %w", mkErr)
}
tmp := path + ".tmp"
if writeErr := os.WriteFile(tmp, out, 0o600); writeErr != nil {
return fmt.Errorf("writing config: %w", writeErr)
}
return os.Rename(tmp, path)
}

// mapValue returns the value node for key in a mapping node, or nil if absent.
// Mapping content alternates key, value, key, value, ...
func mapValue(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}

// setMapValue replaces the value for key in a mapping node, or appends the
// key/value pair if the key is absent.
func setMapValue(m *yaml.Node, key string, val *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content[i+1] = val
return
}
}
m.Content = append(m.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key},
val,
)
}

// toNode marshals v and returns its top-level node (unwrapping the document).
func toNode(v any) (*yaml.Node, error) {
data, err := yaml.Marshal(v)
if err != nil {
return nil, err
}
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return &yaml.Node{Kind: yaml.SequenceNode}, nil
}
return doc.Content[0], nil
}
132 changes: 132 additions & 0 deletions internal/config/config_save_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package config_test

import (
"os"
"path/filepath"
"strings"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/fraser-isbester/tusk/internal/config"
"github.com/fraser-isbester/tusk/internal/rules"
)

var _ = Describe("SaveProfileRules", func() {
var (
home string
cfgPath string
)

boolPtr := func(b bool) *bool { return &b }

BeforeEach(func() {
home = GinkgoT().TempDir()
GinkgoT().Setenv("HOME", home)
cfgPath = filepath.Join(home, ".config", "tusk", "config.yaml")
})

readConfig := func() string {
data, err := os.ReadFile(cfgPath) //nolint:gosec // test-controlled path under a temp HOME
Expect(err).NotTo(HaveOccurred())
return string(data)
}

It("creates the config file when it does not exist", func() {
err := config.SaveProfileRules("dev", []rules.RuleConfig{
{Name: "r1", Resource: "query", When: "true", Action: "log"},
})
Expect(err).NotTo(HaveOccurred())

out := readConfig()
Expect(out).To(ContainSubstring("default_profile: dev"))
Expect(out).To(ContainSubstring("name: r1"))

// Round-trips back through Load.
cfg, err := config.Load()
Expect(err).NotTo(HaveOccurred())
p, err := cfg.ResolveProfile("dev")
Expect(err).NotTo(HaveOccurred())
Expect(p.Rules).To(HaveLen(1))
Expect(p.Rules[0].Name).To(Equal("r1"))
})

It("preserves comments and unrelated keys in an existing file", func() {
Expect(os.MkdirAll(filepath.Dir(cfgPath), 0o750)).To(Succeed())
original := `# my tusk config
default_profile: prod

profiles:
prod:
# production database
url: "postgres://monitor@db.example.com:5432/prod?sslmode=require"
readonly: true
rules:
- name: old-rule
resource: query
when: "true"
action: log
`
Expect(os.WriteFile(cfgPath, []byte(original), 0o600)).To(Succeed())

err := config.SaveProfileRules("prod", []rules.RuleConfig{
{Name: "new-rule", Resource: "transaction", When: "xact_duration > duration('5m')", Action: "terminate", Cooldown: "5m", DryRun: true},
})
Expect(err).NotTo(HaveOccurred())

out := readConfig()
Expect(out).To(ContainSubstring("# my tusk config"))
Expect(out).To(ContainSubstring("# production database"))
Expect(out).To(ContainSubstring(`url: "postgres://monitor@db.example.com:5432/prod?sslmode=require"`))
Expect(out).To(ContainSubstring("readonly: true"))
Expect(out).To(ContainSubstring("name: new-rule"))
Expect(out).NotTo(ContainSubstring("old-rule")) // rules replaced, not appended
})

It("omits injected defaults and never writes an env-derived password", func() {
// A password present in the environment must not leak into the file,
// and applyProfileDefaults values (port, refresh_interval) must not be
// baked in — SaveProfileRules only touches the rules subtree.
GinkgoT().Setenv("PGPASSWORD", "supersecret")

Expect(os.MkdirAll(filepath.Dir(cfgPath), 0o750)).To(Succeed())
original := "default_profile: dev\nprofiles:\n dev:\n host: localhost\n"
Expect(os.WriteFile(cfgPath, []byte(original), 0o600)).To(Succeed())

err := config.SaveProfileRules("dev", []rules.RuleConfig{
{Name: "r1", Resource: "query", When: "true", Action: "log", Enabled: boolPtr(true)},
})
Expect(err).NotTo(HaveOccurred())

out := readConfig()
Expect(out).NotTo(ContainSubstring("supersecret"))
Expect(out).NotTo(ContainSubstring("password"))
Expect(out).NotTo(ContainSubstring("port:"))
Expect(out).NotTo(ContainSubstring("refresh_interval"))
// enabled:true was explicitly set, so it is written; false/empty fields are omitted.
Expect(out).To(ContainSubstring("enabled: true"))
Expect(out).NotTo(ContainSubstring("dry_run"))
Expect(out).NotTo(ContainSubstring("cooldown"))
})

It("adds, edits, and removes rules across saves", func() {
Expect(config.SaveProfileRules("dev", []rules.RuleConfig{
{Name: "a", Resource: "query", When: "true", Action: "log"},
{Name: "b", Resource: "query", When: "false", Action: "log"},
})).To(Succeed())

// Replace with a single edited rule.
Expect(config.SaveProfileRules("dev", []rules.RuleConfig{
{Name: "a", Resource: "query", When: "duration > duration('1s')", Action: "cancel"},
})).To(Succeed())

cfg, err := config.Load()
Expect(err).NotTo(HaveOccurred())
p, err := cfg.ResolveProfile("dev")
Expect(err).NotTo(HaveOccurred())
Expect(p.Rules).To(HaveLen(1))
Expect(p.Rules[0].Name).To(Equal("a"))
Expect(p.Rules[0].Action).To(Equal("cancel"))
Expect(strings.Contains(p.Rules[0].When, "duration")).To(BeTrue())
})
})
11 changes: 7 additions & 4 deletions internal/rules/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ import (
"github.com/google/cel-go/cel"
)

// RuleConfig is the YAML representation of a rule.
// RuleConfig is the YAML representation of a rule. Optional fields use
// omitempty so rules written back to disk (by the TUI editor) stay clean:
// a nil Enabled, false DryRun, or empty Cooldown are omitted rather than
// serialized as null/false/"".
type RuleConfig struct {
Name string `yaml:"name"`
Enabled *bool `yaml:"enabled"`
DryRun bool `yaml:"dry_run"`
Enabled *bool `yaml:"enabled,omitempty"`
DryRun bool `yaml:"dry_run,omitempty"`
Resource string `yaml:"resource"`
When string `yaml:"when"`
Action string `yaml:"action"`
Cooldown string `yaml:"cooldown"`
Cooldown string `yaml:"cooldown,omitempty"`
}

// BuildRules compiles rule configs into executable rules.
Expand Down
Loading