diff options
Diffstat (limited to 'gui/internal/model/rules.go')
| -rw-r--r-- | gui/internal/model/rules.go | 261 |
1 files changed, 261 insertions, 0 deletions
diff --git a/gui/internal/model/rules.go b/gui/internal/model/rules.go new file mode 100644 index 0000000..1b2eec8 --- /dev/null +++ b/gui/internal/model/rules.go @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package model + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "strings" + + "krino/internal/cond" + "krino/internal/config" + "krino/internal/engine" + "krino/internal/xdg" +) + +// ErrChangedOnDisk is Save's refusal when the file was edited elsewhere +// since the editor read it: saving would throw that edit away, so the +// caller is asked what to do (GUI design §5.3). +var ErrChangedOnDisk = errors.New("the file changed on disk since it was opened") + +// Rules is one directory's own configuration file, open for editing. Only +// dirs/NAME.conf is edited; krino.conf is not, in version 1 (GUI design §5). +type Rules struct { + Name string // the directory + File string // its file, absolute + Text string // what the editor holds, saved or not + Diags []*config.Diag + + e *engine.Engine + saved string // the text as last read from or written to disk + stamp [32]byte // what was on disk then, to catch another editor +} + +// RuleFiles is every included directory that has a file of its own, in the +// order krino.conf includes them. +func RuleFiles(e *engine.Engine) []string { + var out []string + for _, d := range e.Dirs { + if _, err := os.Stat(config.DirFile(e.MainFile, d.Name)); err == nil { + out = append(out, d.Name) + } + } + return out +} + +// OpenRules reads the file of the included directory called name. +func OpenRules(e *engine.Engine, name string) (*Rules, error) { + found := false + for _, d := range e.Dirs { + if d.Name == name { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("model: %s is not an included directory", name) + } + file := config.DirFile(e.MainFile, name) + text, err := os.ReadFile(file) + if err != nil { + return nil, err + } + r := &Rules{Name: name, File: file, Text: string(text), e: e, + saved: string(text), stamp: sha256.Sum256(text)} + return r, nil +} + +// SetText replaces what the editor holds. Nothing is written until Save. +func (r *Rules) SetText(s string) { r.Text = s } + +// Modified reports whether the text differs from what is on disk. +func (r *Rules) Modified() bool { return r.Text != r.saved } + +// Revert throws the unsaved text away. +func (r *Rules) Revert() { r.Text = r.saved } + +// Reload re-reads the file, dropping unsaved text - what to do when +// something else has edited it. +func (r *Rules) Reload() error { + text, err := os.ReadFile(r.File) + if err != nil { + return err + } + r.Text, r.saved, r.stamp = string(text), string(text), sha256.Sum256(text) + return nil +} + +// Check loads the whole configuration with this file's unsaved text in +// place of what is on disk, and reports every problem it finds - in this +// file or in another, since one file's text can break another's include. +// Nothing is written. +func (r *Rules) Check() []*config.Diag { + _, diags := r.load() + r.Diags = diags + return diags +} + +// ErrorsHere is the subset of the last Check's diagnostics that belong to +// this file, for marking lines in the editor. +func (r *Rules) ErrorsHere() []*config.Diag { + var out []*config.Diag + for _, d := range r.Diags { + if d.File == r.File { + out = append(out, d) + } + } + return out +} + +// load builds an engine from the unsaved text. +func (r *Rules) load() (*engine.Engine, []*config.Diag) { + over := map[string][]byte{r.File: []byte(r.Text)} + e, diags := engine.LoadWith(r.e.MainFile, over) + if e != nil { + e.CacheDir = r.e.CacheDir + } + return e, diags +} + +// Explain answers what the unsaved rules would do to one file - "Test on +// file" - as text. It can take seconds: content is extracted, so callers +// run it off the main loop. Nothing is written, and the file is not +// touched (GUI design §5.3). +func (r *Rules) Explain(ctx context.Context, path string) (string, error) { + e, diags := r.load() + if len(diags) > 0 { + return "", fmt.Errorf("%s", diags[0]) + } + x, err := e.ExplainWithChain(ctx, xdg.Expand(path)) + if err != nil { + return "", err + } + return explainText(x), nil +} + +// Save writes the text, keeping what was there as NAME.conf.bak. It +// refuses a file that will not load - a window must not leave krino unable +// to run - and refuses to overwrite an edit made elsewhere since the file +// was opened (GUI design §5.3). +func (r *Rules) Save() error { + if diags := r.Check(); len(diags) > 0 { + return fmt.Errorf("%s", diags[0]) + } + on, err := os.ReadFile(r.File) + if err != nil { + return err + } + if sha256.Sum256(on) != r.stamp { + return ErrChangedOnDisk + } + // The backup is the text being replaced, under the file's own + // permissions: a rules file can hold real tax numbers, and a backup + // readable by everyone would leak them. + mode := os.FileMode(0o600) + if fi, err := os.Stat(r.File); err == nil { + mode = fi.Mode().Perm() + } + if err := os.WriteFile(r.File+".bak", on, mode); err != nil { + return err + } + text := []byte(r.Text) + if err := config.Replace(r.File, text); err != nil { + return err + } + r.saved, r.stamp = r.Text, sha256.Sum256(text) + return nil +} + +// SaveOverwriting saves over an edit made elsewhere - the Overwrite the +// caller offers after ErrChangedOnDisk. The other edit is not lost: it +// becomes NAME.conf.bak, as any replaced text does. +func (r *Rules) SaveOverwriting() error { + on, err := os.ReadFile(r.File) + if err != nil { + return err + } + r.stamp = sha256.Sum256(on) + return r.Save() +} + +// explainText renders an explanation for the Test on file pane: every +// exclude and rule with each test's answer, the captures a matching rule +// took, and the chain the file alone would get. It is the GUI's own +// rendering - krino explain(1) prints the same traces but no captures or +// chain. +func explainText(x *engine.Explanation) string { + var b strings.Builder + fmt.Fprintf(&b, "%s (directory %s)\n", xdg.Abbrev(x.File.Path), x.Dir.Name) + if x.Skip != "" { + fmt.Fprintf(&b, "krino would not look at this file: %s\n", x.Skip) + } + if x.Excluded != "" { + fmt.Fprintf(&b, "krino would set this file aside: %s\n", x.Excluded) + } + b.WriteString("\n") + for _, xt := range x.Excludes { + status := "no" + if xt.Match { + status = "MATCH" + } + fmt.Fprintf(&b, "%s: %s\n", xt.Text, status) + writeTrace(&b, xt.Trace) + } + for _, rt := range x.Rules { + if rt.Stopped != "" { + fmt.Fprintf(&b, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped) + continue + } + status := "no" + switch { + case rt.Match: + status = "MATCH" + case rt.Trace != nil && rt.Trace.Unknown: + status = "undecided" + } + fmt.Fprintf(&b, "rule %s: %s\n", rt.Rule.Name, status) + writeTrace(&b, rt.Trace) + writeCaptures(&b, rt.Captures) + } + if x.NoDelete != "" { + fmt.Fprintf(&b, "\nkrino would skip deleting this file: %s\n", x.NoDelete) + } + if len(x.Chain) > 0 { + b.WriteString("\nthis file alone would get:\n") + for _, s := range x.Chain { + switch { + case s.Skip != "": + fmt.Fprintf(&b, " %s skipped: %s\n", s.Kind, s.Skip) + case s.Dst == "": + fmt.Fprintf(&b, " %s\n", s.Kind) + default: + fmt.Fprintf(&b, " %s -> %s\n", s.Kind, xdg.Abbrev(s.Dst)) + } + } + } + return b.String() +} + +// writeTrace writes a condition's tree, indented two spaces. +func writeTrace(b *strings.Builder, t *cond.Trace) { + if t == nil { + return + } + var buf bytes.Buffer + t.Format(&buf) + for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { + b.WriteString(" " + line + "\n") + } +} + +// writeCaptures lists what a matching rule's patterns captured, numbered as +// its actions' {1}, {2} ... see them. +func writeCaptures(b *strings.Builder, captures []string) { + for i, c := range captures { + fmt.Fprintf(b, " {%d} = %s\n", i+1, c) + } +} |
