返回 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, err := fileencoding.ReadFileUTF8(path)
41 if err != nil && !os.IsNotExist(err) {
42 // An existing but unreadable file must not be treated as empty: that
43 // would rebuild the document from scratch and destroy its content.
44 return fmt.Errorf("read %q before appending: %w", path, err)
45 }
46 body := string(existing)
47 bullet := "- " + note
48
49 var out string
50 switch {
51 case strings.TrimSpace(body) == "":
52 out = "# Project memory\n\n" + quickAddHeading + "\n\n" + bullet + "\n"
53 case strings.Contains(body, quickAddHeading):
54 // Insert the bullet at the end of the existing Notes section (before the
55 // next heading, or at EOF), keeping additions chronological.
56 out = insertUnderHeading(body, quickAddHeading, bullet)
57 default:
58 out = strings.TrimRight(body, "\n") + "\n\n" + quickAddHeading + "\n\n" + bullet + "\n"
59 }
60 return writeDocBytes(path, []byte(out))
61 }
62
63 // writeDocFile overwrites path with body, creating the parent directory and
64 // ensuring a single trailing newline. Used by Set.WriteDoc for the panel's
65 // in-place editor (path validation happens in the caller).
66 func writeDocFile(path, body string) error {
67 out := strings.TrimRight(body, "\n") + "\n"
68 return writeDocBytes(path, []byte(out))
69 }
70
71 func writeDocBytes(path string, body []byte) error {
72 if err := ensureDestinationNotSymlink(path); err != nil {
73 return err
74 }
75 if dir := filepath.Dir(path); dir != "" {
76 if err := os.MkdirAll(dir, 0o755); err != nil {
77 return err
78 }
79 }
80 if err := ensureDestinationNotSymlink(path); err != nil {
81 return err
82 }
83
84 return fileutil.AtomicWriteFile(path, body, 0o644)
85 }
86
87 // ensureDestinationNotSymlink rejects an existing final symlink. Ancestor
88 // symlinks may be legitimate platform/workspace aliases (for example macOS
89 // /var), while the sibling-temp atomic replace never follows a final link.
90 func ensureDestinationNotSymlink(path string) error {
91 info, err := os.Lstat(path)
92 if os.IsNotExist(err) {
93 return nil
94 }
95 if err != nil {
96 return err
97 }
98 if info.Mode()&os.ModeSymlink != 0 {
99 return fmt.Errorf("refusing to write %q through a symlink", path)
100 }
101 return nil
102 }
103
104 // insertUnderHeading appends bullet to the end of the section started by heading
105 // — just before the next "## "/"# " heading, or at end of file if none follows.
106 func insertUnderHeading(body, heading, bullet string) string {
107 lines := strings.Split(body, "\n")
108 start := -1
109 for i, l := range lines {
110 if strings.TrimSpace(l) == heading {
111 start = i
112 break
113 }
114 }
115 if start < 0 { // shouldn't happen (caller checked Contains), but stay safe
116 return strings.TrimRight(body, "\n") + "\n\n" + bullet + "\n"
117 }
118 end := len(lines)
119 for i := start + 1; i < len(lines); i++ {
120 if strings.HasPrefix(strings.TrimSpace(lines[i]), "#") {
121 end = i
122 break
123 }
124 }
125 // Trim trailing blank lines within the section, then place the bullet.
126 insert := end
127 for insert > start+1 && strings.TrimSpace(lines[insert-1]) == "" {
128 insert--
129 }
130 out := append([]string{}, lines[:insert]...)
131 out = append(out, bullet)
132 out = append(out, lines[insert:]...)
133 return strings.Join(out, "\n")
134 }
135
135 lines GO