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