返回 DeepSeek-Reasonix
quickadd.go
根目录 / internal / memory / quickadd.go
1 package memory
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8
9 "reasonix/internal/fileutil"
10 fileencoding "reasonix/internal/fileutil/encoding"
11 )
12
13 // quickAddHeading marks the section quick-added notes accumulate under, so
14 // repeated "#" additions group together instead of scattering through a
15 // hand-written file.
16 const quickAddHeading = "## Notes"
17
18 // AppendDoc appends a one-line note as a bullet under a "## Notes" section in
19 // the doc-memory file at path, creating the file (and section) when absent. The
20 // note is normalised to a single line so it can't corrupt the section. This is
21 // the write side of the "#" quick-add: a plain file edit the user can later
22 // reorganise by hand.
23 func AppendDoc(path, note string) error {
24 note = oneLine(note)
25 if note == "" {
26 return nil
27 }
28 if err := ensureDestinationNotSymlink(path); err != nil {
29 return err
30 }
31 if dir := filepath.Dir(path); dir != "" {
32 if err := os.MkdirAll(dir, 0o755); err != nil {
33 return err
34 }
35 }
36 if err := ensureDestinationNotSymlink(path); err != nil {
37 return err
38 }
39
40 existing, _ := fileencoding.ReadFileUTF8(path) // missing → new file
41 body := string(existing)
42 bullet := "- " + note
43
44 var out string
45 switch {
46 case strings.TrimSpace(body) == "":
47 out = "# Project memory\n\n" + quickAddHeading + "\n\n" + bullet + "\n"
48 case strings.Contains(body, quickAddHeading):
49 // Insert the bullet at the end of the existing Notes section (before the
50 // next heading, or at EOF), keeping additions chronological.
51 out = insertUnderHeading(body, quickAddHeading, bullet)
52 default:
53 out = strings.TrimRight(body, "\n") + "\n\n" + quickAddHeading + "\n\n" + bullet + "\n"
54 }
55 return writeDocBytes(path, []byte(out))
56 }
57
58 // writeDocFile overwrites path with body, creating the parent directory and
59 // ensuring a single trailing newline. Used by Set.WriteDoc for the panel's
60 // in-place editor (path validation happens in the caller).
61 func writeDocFile(path, body string) error {
62 out := strings.TrimRight(body, "\n") + "\n"
63 return writeDocBytes(path, []byte(out))
64 }
65
66 func writeDocBytes(path string, body []byte) error {
67 if err := ensureDestinationNotSymlink(path); err != nil {
68 return err
69 }
70 if dir := filepath.Dir(path); dir != "" {
71 if err := os.MkdirAll(dir, 0o755); err != nil {
72 return err
73 }
74 }
75 if err := ensureDestinationNotSymlink(path); err != nil {
76 return err
77 }
78
79 return fileutil.AtomicWriteFile(path, body, 0o644)
80 }
81
82 // ensureDestinationNotSymlink rejects an existing final symlink. Ancestor
83 // symlinks may be legitimate platform/workspace aliases (for example macOS
84 // /var), while the sibling-temp atomic replace never follows a final link.
85 func ensureDestinationNotSymlink(path string) error {
86 info, err := os.Lstat(path)
87 if os.IsNotExist(err) {
88 return nil
89 }
90 if err != nil {
91 return err
92 }
93 if info.Mode()&os.ModeSymlink != 0 {
94 return fmt.Errorf("refusing to write %q through a symlink", path)
95 }
96 return nil
97 }
98
99 // insertUnderHeading appends bullet to the end of the section started by heading
100 // — just before the next "## "/"# " heading, or at end of file if none follows.
101 func insertUnderHeading(body, heading, bullet string) string {
102 lines := strings.Split(body, "\n")
103 start := -1
104 for i, l := range lines {
105 if strings.TrimSpace(l) == heading {
106 start = i
107 break
108 }
109 }
110 if start < 0 { // shouldn't happen (caller checked Contains), but stay safe
111 return strings.TrimRight(body, "\n") + "\n\n" + bullet + "\n"
112 }
113 end := len(lines)
114 for i := start + 1; i < len(lines); i++ {
115 if strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
116 end = i
117 break
118 }
119 }
120 // Trim trailing blank lines within the section, then place the bullet.
121 insert := end
122 for insert > start+1 && strings.TrimSpace(lines[insert-1]) == "" {
123 insert--
124 }
125 out := append([]string{}, lines[:insert]...)
126 out = append(out, bullet)
127 out = append(out, lines[insert:]...)
128 return strings.Join(out, "\n")
129 }
130
130 lines GO