package cli import ( "flag" "fmt" "os" "path/filepath" "regexp" "strconv" "strings" "time" "git.vakhrushev.me/av/convy/internal/doc" "git.vakhrushev.me/av/convy/internal/lang" "git.vakhrushev.me/av/convy/internal/suite" ) // Retirement is the one operation in the model whose mistakes cannot be undone. // A text rewritten badly gets rewritten again; a prefix handed out twice is a // reference from a foreign repository that now points at a different statement, // and that is discovered by its content rather than by any check. // // So the command does the bookkeeping the discipline otherwise leans on // attention for: it writes the date, it words the stub out of the suite's own // vocabulary, it moves the name into the retired half instead of deleting it, // and it refuses whatever would leave a reference pointing at nothing. var ruleIDRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)$`) func runSuiteRetire(env Env, args []string) ExitCode { fs := flag.NewFlagSet("convy suite retire", flag.ContinueOnError) fs.SetOutput(env.Err) root := fs.String("root", "", "root of the suite; by default it is looked up upwards") rule := fs.String("rule", "", "identifier of a rule to retire, PREFIX-N") prefix := fs.String("prefix", "", "prefix of a convention to retire together with its file") topic := fs.String("topic", "", "name of a topic to retire") reason := fs.String("reason", "", "why it is being retired") date := fs.String("date", "", "date of retirement; today by default") if err := fs.Parse(args); err != nil { return Usage } dir, code := suiteRoot(env, *root) if code != OK { return code } s, err := suite.Load(dir) if err != nil { fmt.Fprintln(env.Err, err) return Usage } when := *date if when == "" { when = time.Now().Format("2006-01-02") } given := map[string]string{"rule": *rule, "prefix": *prefix, "topic": *topic, "reason": *reason} talk := newDialogue(env) if len(args) == 0 { if !env.Interactive { fmt.Fprintln(env.Err, "convy suite retire without arguments asks questions, and there is no terminal to ask on; pass one of --rule, --prefix, --topic together with --reason") return Usage } if err := askRetire(env, talk, s, given); err != nil { return Usage } } else if err := resolve(retireFields(s), given); err != nil { fmt.Fprintln(env.Err, err) return Usage } targets := 0 for _, key := range []string{"rule", "prefix", "topic"} { if given[key] != "" { targets++ } } if targets != 1 { fmt.Fprintln(env.Err, "name exactly one of --rule, --prefix, --topic: the three retire different things and undo differently") return Usage } // The dialogue is built once and carried through: a second one over the // same input would find it drained, because the first reads ahead. var d *dialogue if env.Interactive { d = talk } switch { case given["rule"] != "": return retireRule(env, d, s, given, when) case given["prefix"] != "": return retirePrefix(env, d, s, given, when) default: return retireTopic(env, d, s, given, when) } } func retireFields(s *suite.Suite) []Field { return []Field{{ Flag: "rule", Ask: "Rule", Optional: true, Check: func(v string) error { if !ruleIDRe.MatchString(v) { return fmt.Errorf("a rule is named by its identifier, PREFIX-N") } return nil }, }, { Flag: "prefix", Ask: "Prefix", Optional: true, Check: func(v string) error { if _, ok := s.Manifest.PathOf(v); !ok { return fmt.Errorf("the suite declares no live prefix %s", v) } return nil }, }, { Flag: "topic", Ask: "Topic", Optional: true, Check: func(v string) error { if !s.Manifest.TopicLive(v) { return fmt.Errorf("the suite declares no live topic %q", v) } return nil }, }, { Flag: "reason", Ask: "Reason", Hint: "Why it is going. The reason outlives the thing itself — it is what a reader finds in place of what they were looking for.", }} } // askRetire walks the dialogue: what kind of thing, which one, and why. func askRetire(env Env, d *dialogue, s *suite.Suite, given map[string]string) error { fields := retireFields(s) kind, err := d.ask(Field{ Ask: "What is being retired", Hint: "A rule keeps its number and turns into a stub; a convention takes its file with it; a topic goes once no layer of it is left.", Options: []string{"rule", "convention", "topic"}, Default: "rule", }) if err != nil { fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") return err } flagOf := map[string]string{"rule": "rule", "convention": "prefix", "topic": "topic"}[kind] for _, f := range fields { if f.Flag != flagOf && f.Flag != "reason" { continue } f.Optional = false answer, err := d.ask(f) if err != nil { fmt.Fprintln(env.Err, "\ninterrupted, nothing was written") return err } given[f.Flag] = answer } return nil } // retireRule replaces the norm and the rationale with a stub, keeping the // heading and the number. The number stays taken forever, so a reference from a // foreign repository lands on the explanation instead of on nothing. func retireRule(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode { m := ruleIDRe.FindStringSubmatch(given["rule"]) num, _ := strconv.Atoi(m[2]) target, ok := s.ByPrefix[m[1]] if !ok { fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", m[1]) return Usage } var found *doc.Rule for i := range target.Rules { if target.Rules[i].Num == num { found = &target.Rules[i] } } if found == nil { fmt.Fprintf(env.Err, "%s holds no rule numbered %d\n", target.Path, num) return Usage } if _, already := found.Block(lang.Retired); already { fmt.Fprintf(env.Err, "%s is retired already\n", found.ID()) return Usage } if d != nil { ok, err := d.confirm(fmt.Sprintf( "%s in %s loses its norm and its rationale, keeping the number. Go on?", found.ID(), target.Path)) if err != nil || !ok { fmt.Fprintln(env.Out, "nothing was written") return Usage } } name := filepath.Join(s.Root, filepath.FromSlash(target.Path)) body, err := os.ReadFile(name) if err != nil { fmt.Fprintln(env.Err, err) return Failed } lines := strings.Split(string(body), "\n") stub := fmt.Sprintf("**%s %s.** %s", s.Vocab.MarkWord(lang.Retired), when, given["reason"]) replacement := append([]string{""}, wrap(stub, 78)...) replacement = append(replacement, "") out := make([]string, 0, len(lines)) out = append(out, lines[:found.Line]...) out = append(out, replacement...) if found.End < len(lines) { out = append(out, lines[found.End:]...) } if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil { fmt.Fprintln(env.Err, err) return Failed } fmt.Fprintf(env.Out, "\n%s is retired in %s\n", found.ID(), target.Path) fmt.Fprintf(env.Out, "the number stays taken, and a reference to it now lands on the reason\n") return OK } // retirePrefix retires a convention together with its file. The rules of that // file cease to exist, so the command refuses while anything still points at // them: a stub can stand in for a rule, but nothing stands in for a file. func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode { prefix := given["prefix"] target, ok := s.ByPrefix[prefix] if !ok { fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", prefix) return Usage } if pointing := referencesTo(s, prefix, target); len(pointing) > 0 { fmt.Fprintf(env.Err, "%s is still pointed at, so retiring it would leave references resolving to nothing:\n", prefix) for _, where := range pointing { fmt.Fprintf(env.Err, " %s\n", where) } fmt.Fprintln(env.Err, "move or retire those rules first") return Usage } if d != nil { ok, err := d.confirm(fmt.Sprintf( "%s goes, and %s is deleted. The prefix is never reissued. Go on?", target.Path, target.Path)) if err != nil || !ok { fmt.Fprintln(env.Out, "nothing was written") return Usage } } note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"]) s.Manifest.Prefixes.Retire(prefix, note) if err := s.Manifest.Save(); err != nil { fmt.Fprintln(env.Err, err) return Failed } if err := os.Remove(filepath.Join(s.Root, filepath.FromSlash(target.Path))); err != nil { fmt.Fprintf(env.Err, "the manifest was updated, but the file was not removed: %s\n", err) return Failed } fmt.Fprintf(env.Out, "\n%s is retired and %s is gone\n", prefix, target.Path) if left := s.Layers(target.Front.Topic); len(left) == 1 { fmt.Fprintf(env.Out, "the topic %q has no layer left; retire the topic too if it is done\n", target.Front.Topic) } return OK } // retireTopic sends a topic name to the retired half. A topic lives as long as // one layer of it does, so the command refuses while any layer is left. func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode { topic := given["topic"] if layers := s.Layers(topic); len(layers) > 0 { fmt.Fprintf(env.Err, "the topic %q still holds layers, and a topic lives as long as one of them does:\n", topic) for _, d := range layers { fmt.Fprintf(env.Err, " %s\n", d.Path) } fmt.Fprintln(env.Err, "retire those conventions first") return Usage } if d != nil { ok, err := d.confirm(fmt.Sprintf( "the name %q goes to the retired half and is never handed to another topic. Go on?", topic)) if err != nil || !ok { fmt.Fprintln(env.Out, "nothing was written") return Usage } } s.Manifest.Topics.Retire(topic, when+": "+given["reason"]) if err := s.Manifest.Save(); err != nil { fmt.Fprintln(env.Err, err) return Failed } fmt.Fprintf(env.Out, "\nthe topic %q is retired\n", topic) return OK } // referencesTo finds where the rules of a prefix are pointed at from outside // the file that owns them. func referencesTo(s *suite.Suite, prefix string, own *doc.Document) []string { var out []string for _, d := range s.Docs { if d == own { continue } for _, ref := range refsOf(d, prefix) { out = append(out, fmt.Sprintf("%s:%d %s", d.Path, ref.line, ref.text)) } } return out } type refAt struct { line int text string } var anyRefRe = regexp.MustCompile(`\b[A-Z]{4}-\d+(?:\.\d+)?`) func refsOf(d *doc.Document, prefix string) []refAt { var out []refAt for n := d.Body; n <= d.Len(); n++ { if d.Fenced(n) { continue } for _, text := range anyRefRe.FindAllString(doc.StripInline(d.Line(n)), -1) { if strings.HasPrefix(text, prefix+"-") { out = append(out, refAt{line: n, text: text}) } } } return out } // wrap breaks a paragraph at a width, the way the text around it is written by // hand. A stub that runs off in one long line reads as machine-made and gets // reflowed by the next person to touch the file. func wrap(text string, width int) []string { words := strings.Fields(text) if len(words) == 0 { return []string{""} } lines := []string{words[0]} for _, w := range words[1:] { last := len(lines) - 1 if len([]rune(lines[last]))+1+len([]rune(w)) <= width { lines[last] += " " + w continue } lines = append(lines, w) } return lines }