| 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 |