commit ec965c776c93720048a0cca1b9620e8b314a5324
parent 823a0659215cc8f1b1277dab06161462457fdc31
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 25 Aug 2026 21:32:28 -0700
Implement configuration and domain helpers
Diffstat:
4 files changed, 813 insertions(+), 0 deletions(-)
diff --git a/internal/config/config.go b/internal/config/config.go
@@ -0,0 +1,178 @@
+package config
+
+import (
+ "encoding/json"
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+const DefaultURL = "https://textlog.cc"
+
+type Theme string
+
+const (
+ ThemeAuto Theme = "auto"
+ ThemeLight Theme = "light"
+ ThemeDark Theme = "dark"
+)
+
+type FeedKind string
+
+const (
+ FeedForYou FeedKind = "for-you"
+ FeedToMe FeedKind = "to-me"
+ FeedHot FeedKind = "hot"
+ FeedLatest FeedKind = "latest"
+)
+
+type Config struct {
+ BaseURL string `json:"baseUrl"`
+ Theme Theme `json:"theme"`
+ Token string `json:"token,omitempty"`
+ LastTab FeedKind `json:"lastTab,omitempty"`
+}
+
+func NormalizeBaseURL(value string) (string, error) {
+ value = strings.TrimSpace(value)
+ u, err := url.Parse(value)
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return "", errors.New("Textlog URL must be a valid HTTP(S) URL")
+ }
+ u.Scheme = strings.ToLower(u.Scheme)
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return "", errors.New("Textlog URL must use HTTP or HTTPS")
+ }
+ if u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || u.RawFragment != "" {
+ return "", errors.New("Textlog URL cannot include a query or fragment")
+ }
+ u.Host = strings.ToLower(u.Host)
+ u.Path = strings.TrimRight(u.Path, "/")
+ u.RawPath = strings.TrimRight(u.RawPath, "/")
+ return strings.TrimSuffix(u.String(), "/"), nil
+}
+
+func ConfigPath(env map[string]string) string {
+ if env == nil {
+ env = Environment()
+ }
+ home := env["HOME"]
+ if home == "" {
+ home, _ = os.UserHomeDir()
+ }
+ return configPathFor(runtime.GOOS, home, env)
+}
+func configPathFor(goos, home string, env map[string]string) string {
+ switch goos {
+ case "windows":
+ root := env["APPDATA"]
+ if root == "" {
+ root = filepath.Join(home, "AppData", "Roaming")
+ }
+ return filepath.Join(root, "textlog", "config.json")
+ case "darwin":
+ return filepath.Join(home, "Library", "Application Support", "textlog", "config.json")
+ default:
+ root := env["XDG_CONFIG_HOME"]
+ if root == "" {
+ root = filepath.Join(home, ".config")
+ }
+ return filepath.Join(root, "textlog", "config.json")
+ }
+}
+func validTheme(t Theme) bool { return t == ThemeAuto || t == ThemeLight || t == ThemeDark }
+func validTab(t FeedKind) bool {
+ return t == FeedForYou || t == FeedToMe || t == FeedHot || t == FeedLatest
+}
+func Load(env map[string]string) (Config, error) {
+ if env == nil {
+ env = Environment()
+ }
+ var stored Config
+ if data, err := os.ReadFile(ConfigPath(env)); err == nil {
+ var decoded Config
+ if json.Unmarshal(data, &decoded) == nil {
+ stored = decoded
+ }
+ }
+ base := stored.BaseURL
+ if base == "" {
+ base = DefaultURL
+ }
+ if env["TEXTLOG_URL"] != "" {
+ base = env["TEXTLOG_URL"]
+ }
+ var err error
+ base, err = NormalizeBaseURL(base)
+ if err != nil {
+ return Config{}, err
+ }
+ theme := stored.Theme
+ if theme == "" {
+ theme = ThemeAuto
+ }
+ if env["TEXTLOG_THEME"] != "" {
+ theme = Theme(env["TEXTLOG_THEME"])
+ }
+ if _, ok := env["NO_COLOR"]; ok {
+ theme = ThemeLight
+ }
+ if !validTheme(theme) {
+ theme = ThemeAuto
+ }
+ token := stored.Token
+ if env["TEXTLOG_TOKEN"] != "" {
+ token = env["TEXTLOG_TOKEN"]
+ }
+ last := stored.LastTab
+ if !validTab(last) {
+ last = ""
+ }
+ return Config{BaseURL: base, Theme: theme, Token: token, LastTab: last}, nil
+}
+func Save(cfg Config, env map[string]string) error {
+ path := ConfigPath(env)
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ tmp, err := os.CreateTemp(filepath.Dir(path), "config-*.tmp")
+ if err != nil {
+ return err
+ }
+ name := tmp.Name()
+ defer os.Remove(name)
+ if err := tmp.Chmod(0600); err != nil {
+ tmp.Close()
+ return err
+ }
+ if _, err = tmp.Write(data); err == nil {
+ err = tmp.Sync()
+ }
+ if closeErr := tmp.Close(); err == nil {
+ err = closeErr
+ }
+ if err != nil {
+ return err
+ }
+ if err = os.Rename(name, path); err != nil {
+ return err
+ }
+ return os.Chmod(path, 0600)
+}
+func Environment() map[string]string {
+ out := map[string]string{}
+ for _, item := range os.Environ() {
+ if k, v, ok := strings.Cut(item, "="); ok {
+ out[k] = v
+ }
+ }
+ return out
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
@@ -0,0 +1,197 @@
+package config
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+func TestConfigLifecycleAndEnvironmentPrecedence(t *testing.T) {
+ home := t.TempDir()
+ env := map[string]string{"HOME": home}
+ if runtime.GOOS == "windows" {
+ env["APPDATA"] = filepath.Join(home, "roaming")
+ } else if runtime.GOOS != "darwin" {
+ env["XDG_CONFIG_HOME"] = filepath.Join(home, "xdg")
+ }
+
+ stored := Config{
+ BaseURL: "https://stored.example",
+ Theme: ThemeDark,
+ Token: "stored-secret",
+ LastTab: FeedToMe,
+ }
+ if err := Save(stored, env); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ path := ConfigPath(env)
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("Stat(%q): %v", path, err)
+ }
+ if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
+ t.Fatalf("config mode = %04o, want 0600", info.Mode().Perm())
+ }
+
+ loaded, err := Load(map[string]string{
+ "HOME": home,
+ "APPDATA": env["APPDATA"],
+ "XDG_CONFIG_HOME": env["XDG_CONFIG_HOME"],
+ "TEXTLOG_URL": "https://override.example/",
+ "TEXTLOG_TOKEN": "environment-secret",
+ "TEXTLOG_THEME": "light",
+ })
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ want := Config{
+ BaseURL: "https://override.example",
+ Theme: ThemeLight,
+ Token: "environment-secret",
+ LastTab: FeedToMe,
+ }
+ if loaded != want {
+ t.Fatalf("Load = %#v, want %#v", loaded, want)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var unchanged Config
+ if err := json.Unmarshal(data, &unchanged); err != nil {
+ t.Fatalf("saved JSON: %v", err)
+ }
+ if unchanged != stored {
+ t.Fatalf("environment changed stored config to %#v", unchanged)
+ }
+ if !strings.HasSuffix(string(data), "\n") {
+ t.Fatalf("saved JSON has no trailing newline: %q", data)
+ }
+}
+
+func TestLoadDefaultsAndSanitizesStoredSelections(t *testing.T) {
+ home := t.TempDir()
+ env := isolatedEnv(home)
+ path := ConfigPath(env)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(`{"baseUrl":"https://stored.example/","theme":"sepia","token":"saved","lastTab":"live"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := Load(env)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.BaseURL != "https://stored.example" || got.Theme != ThemeAuto || got.Token != "saved" || got.LastTab != "" {
+ t.Fatalf("Load invalid selections = %#v", got)
+ }
+
+ env["NO_COLOR"] = ""
+ env["TEXTLOG_THEME"] = "dark"
+ got, err = Load(env)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Theme != ThemeLight {
+ t.Fatalf("NO_COLOR theme = %q, want %q", got.Theme, ThemeLight)
+ }
+
+ missing, err := Load(isolatedEnv(t.TempDir()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if missing != (Config{BaseURL: DefaultURL, Theme: ThemeAuto}) {
+ t.Fatalf("missing config = %#v", missing)
+ }
+
+ malformedEnv := isolatedEnv(t.TempDir())
+ malformedPath := ConfigPath(malformedEnv)
+ if err := os.MkdirAll(filepath.Dir(malformedPath), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(malformedPath, []byte(`{"baseUrl":"https://partial.example",`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ malformed, err := Load(malformedEnv)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if malformed != (Config{BaseURL: DefaultURL, Theme: ThemeAuto}) {
+ t.Fatalf("partially decoded malformed config = %#v", malformed)
+ }
+}
+
+func TestSavePersistsTheProvidedConfigWithoutApplyingEnvironment(t *testing.T) {
+ env := isolatedEnv(t.TempDir())
+ env["TEXTLOG_URL"] = "https://override.example"
+ env["TEXTLOG_THEME"] = "light"
+ want := Config{BaseURL: "https://stored.example/", Theme: ThemeDark, Token: "stored"}
+ if err := Save(want, env); err != nil {
+ t.Fatal(err)
+ }
+ data, err := os.ReadFile(ConfigPath(env))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got Config
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got != want {
+ t.Fatalf("saved config = %#v, want %#v", got, want)
+ }
+}
+
+func TestNormalizeBaseURL(t *testing.T) {
+ valid := map[string]string{
+ "https://textlog.cc/": "https://textlog.cc",
+ "http://localhost:3000/api///": "http://localhost:3000/api",
+ " https://EXAMPLE.test/a/ ": "https://example.test/a",
+ }
+ for input, want := range valid {
+ got, err := NormalizeBaseURL(input)
+ if err != nil || got != want {
+ t.Errorf("NormalizeBaseURL(%q) = %q, %v; want %q", input, got, err, want)
+ }
+ }
+
+ for _, input := range []string{"wat", "file:///tmp/no", "https://example.test?q=one", "https://example.test/#frag", "https:///missing-host"} {
+ if _, err := NormalizeBaseURL(input); err == nil {
+ t.Errorf("NormalizeBaseURL(%q) unexpectedly succeeded", input)
+ }
+ }
+}
+
+func TestPlatformConfigPaths(t *testing.T) {
+ env := map[string]string{
+ "APPDATA": filepath.FromSlash("/roaming"),
+ "XDG_CONFIG_HOME": filepath.FromSlash("/xdg"),
+ }
+ tests := map[string]string{
+ "windows": filepath.Join(env["APPDATA"], "textlog", "config.json"),
+ "darwin": filepath.Join("/home/alice", "Library", "Application Support", "textlog", "config.json"),
+ "linux": filepath.Join(env["XDG_CONFIG_HOME"], "textlog", "config.json"),
+ }
+ for goos, want := range tests {
+ if got := configPathFor(goos, "/home/alice", env); got != want {
+ t.Errorf("configPathFor(%q) = %q, want %q", goos, got, want)
+ }
+ }
+}
+
+func isolatedEnv(home string) map[string]string {
+ env := map[string]string{"HOME": home}
+ if runtime.GOOS == "windows" {
+ env["APPDATA"] = filepath.Join(home, "roaming")
+ } else if runtime.GOOS != "darwin" {
+ env["XDG_CONFIG_HOME"] = filepath.Join(home, "xdg")
+ }
+ return env
+}
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
@@ -0,0 +1,287 @@
+// Package domain contains pure application behavior shared by the TUI screens.
+package domain
+
+import (
+ "regexp"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+const (
+ MaxPostCharacters = 280
+ MaxPostLines = 10
+)
+
+// CodePointLength follows JavaScript's Array.from string length semantics for
+// valid UTF-8: a Unicode code point counts as one character.
+func CodePointLength(value string) int {
+ return utf8.RuneCountInString(value)
+}
+
+// ValidatePost returns a user-facing problem, or an empty string when valid.
+func ValidatePost(value string) string {
+ if strings.TrimFunc(value, func(character rune) bool {
+ return unicode.IsSpace(character) || character == '\uFEFF'
+ }) == "" {
+ return "Write something first"
+ }
+ if CodePointLength(value) > MaxPostCharacters {
+ return "Notes can be at most 280 characters"
+ }
+ if strings.Count(value, "\n")+1 > MaxPostLines {
+ return "Notes can be at most 10 lines"
+ }
+ return ""
+}
+
+// RelativeTime formats an RFC 3339 timestamp relative to now.
+func RelativeTime(value string, now time.Time) string {
+ created, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return ""
+ }
+ seconds := int64(now.Sub(created).Seconds())
+ if seconds < 0 {
+ seconds = 0
+ }
+ switch {
+ case seconds < 60:
+ return formatInteger(seconds) + "s"
+ case seconds < 60*60:
+ return formatInteger(seconds/60) + "m"
+ case seconds < 24*60*60:
+ return formatInteger(seconds/(60*60)) + "h"
+ case seconds < 30*24*60*60:
+ return formatInteger(seconds/(24*60*60)) + "d"
+ default:
+ return created.UTC().Format("2006-01-02")
+ }
+}
+
+func formatInteger(value int64) string {
+ if value == 0 {
+ return "0"
+ }
+ var digits [20]byte
+ index := len(digits)
+ for value > 0 {
+ index--
+ digits[index] = byte(value%10) + '0'
+ value /= 10
+ }
+ return string(digits[index:])
+}
+
+// Redact replaces every occurrence of token without changing tokenless text.
+func Redact(value, token string) string {
+ if token == "" {
+ return value
+ }
+ return strings.ReplaceAll(value, token, "[redacted]")
+}
+
+type TokenKind string
+
+const (
+ TokenText TokenKind = "text"
+ TokenCode TokenKind = "code"
+ TokenLink TokenKind = "link"
+ TokenReference TokenKind = "reference"
+)
+
+type RichToken struct {
+ Kind TokenKind
+ Text string
+ URL string
+}
+
+const (
+ inlineCodePattern = "`[^`\n]+`"
+ javascriptWhitespace = `\t\n\v\f\r \x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}`
+ markdownPattern = `\[(?:\\[^\n]|[^\]\\\n])+\]\((?:https?://)?[^` + javascriptWhitespace + `)]+\)`
+ webURLPattern = `https?://[^` + javascriptWhitespace + `]+`
+ domainPattern = `(?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z]{2,})(?:/[^` + javascriptWhitespace + `]*)?`
+ referencePattern = `[#@][A-Za-z0-9_]+`
+)
+
+var (
+ richTextPattern = regexp.MustCompile("(?i)(" + strings.Join([]string{
+ inlineCodePattern,
+ markdownPattern,
+ webURLPattern,
+ domainPattern,
+ referencePattern,
+ }, "|") + ")")
+ markdownLinkPattern = regexp.MustCompile(`^\[((?:\\[^\n]|[^\]\\\n])+)\]\(([^)]+)\)$`)
+ asciiArtBodyPattern = regexp.MustCompile(`(?i)(^|[` + javascriptWhitespace + `])#(?:ascii|ascii_art)\b`)
+ markdownUnescaper = strings.NewReplacer(`\\`, `\`, `\[`, `[`, `\]`, `]`)
+)
+
+// TokenizeRichText recognizes inline code, markdown links, bare web URLs,
+// domain names, mentions, and hashtags. Inline code wins over link parsing.
+func TokenizeRichText(body string) []RichToken {
+ matches := richTextPattern.FindAllStringIndex(body, -1)
+ if len(matches) == 0 {
+ if body == "" {
+ return []RichToken{}
+ }
+ return []RichToken{{Kind: TokenText, Text: body}}
+ }
+
+ tokens := make([]RichToken, 0, len(matches)*2+1)
+ offset := 0
+ for _, match := range matches {
+ if match[0] > offset {
+ tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:match[0]]})
+ }
+ value := body[match[0]:match[1]]
+ switch {
+ case strings.HasPrefix(value, "`"):
+ tokens = append(tokens, RichToken{Kind: TokenCode, Text: value})
+ case strings.HasPrefix(value, "["):
+ parts := markdownLinkPattern.FindStringSubmatch(value)
+ if len(parts) != 3 {
+ tokens = append(tokens, RichToken{Kind: TokenText, Text: value})
+ break
+ }
+ tokens = append(tokens, RichToken{
+ Kind: TokenLink,
+ Text: markdownUnescaper.Replace(parts[1]),
+ URL: absoluteWebURL(parts[2]),
+ })
+ case value[0] == '#' || value[0] == '@':
+ tokens = append(tokens, RichToken{Kind: TokenReference, Text: value})
+ default:
+ tokens = append(tokens, RichToken{Kind: TokenLink, Text: value, URL: absoluteWebURL(value)})
+ }
+ offset = match[1]
+ }
+ if offset < len(body) {
+ tokens = append(tokens, RichToken{Kind: TokenText, Text: body[offset:]})
+ }
+ return tokens
+}
+
+func absoluteWebURL(value string) string {
+ lower := strings.ToLower(value)
+ if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
+ return value
+ }
+ return "https://" + value
+}
+
+// TerminalSafeText normalizes line endings and tab stops and removes emoji
+// skin-tone modifiers that some terminals incorrectly render as wide glyphs.
+func TerminalSafeText(value string) string {
+ value = strings.ReplaceAll(value, "\r\n", "\n")
+ value = strings.ReplaceAll(value, "\r", "\n")
+
+ const tabWidth = 4
+ var safe strings.Builder
+ safe.Grow(len(value))
+ column := 0
+ for _, character := range value {
+ switch {
+ case character == '\n':
+ safe.WriteRune(character)
+ column = 0
+ case character >= '\U0001F3FB' && character <= '\U0001F3FF':
+ // Intentionally omitted from terminal output.
+ case character == '\t':
+ spaces := tabWidth - column%tabWidth
+ safe.WriteString(strings.Repeat(" ", spaces))
+ column += spaces
+ default:
+ safe.WriteRune(character)
+ column++
+ }
+ }
+ return safe.String()
+}
+
+// IsASCIIArt reports whether tags or an in-body hashtag request literal mode.
+func IsASCIIArt(body string, tags []string) bool {
+ for _, tag := range tags {
+ tag = strings.ToLower(strings.TrimPrefix(tag, "#"))
+ if tag == "ascii" || tag == "ascii_art" {
+ return true
+ }
+ }
+ return asciiArtBodyPattern.MatchString(body)
+}
+
+func IsASCIIArtPost(post textlog.Post) bool {
+ return IsASCIIArt(post.Body, post.Tags)
+}
+
+// ClampSelection keeps a list selection in range, with zero representing an
+// empty list as well as its first item.
+func ClampSelection(current, length int) int {
+ if current < 0 || length <= 0 {
+ return 0
+ }
+ if current >= length {
+ return length - 1
+ }
+ return current
+}
+
+type ThreadItem struct {
+ Post textlog.Post
+ Depth int
+ MoreCount int
+}
+
+// BuildReplyThread orders replies depth-first. Orphans remain visible, while
+// duplicate IDs and cycles cannot duplicate posts or recurse indefinitely.
+func BuildReplyThread(root textlog.Post, replies []textlog.Reply) []ThreadItem {
+ children := make(map[int][]textlog.Reply)
+ for _, reply := range replies {
+ if reply.ParentID != nil {
+ children[*reply.ParentID] = append(children[*reply.ParentID], reply)
+ }
+ }
+
+ thread := make([]ThreadItem, 0, len(replies)+1)
+ thread = append(thread, ThreadItem{Post: root})
+ visited := map[int]bool{root.ID: true}
+
+ var appendChildren func(parentID, depth int)
+ appendChildren = func(parentID, depth int) {
+ for _, reply := range children[parentID] {
+ if visited[reply.ID] {
+ continue
+ }
+ visited[reply.ID] = true
+ moreCount := 0
+ if len(children[reply.ID]) == 0 {
+ moreCount = reply.ReplyCount
+ }
+ thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount})
+ appendChildren(reply.ID, depth+1)
+ }
+ }
+
+ appendChildren(root.ID, 1)
+ for _, reply := range replies {
+ if visited[reply.ID] {
+ continue
+ }
+ visited[reply.ID] = true
+ depth := reply.Depth
+ if depth < 1 {
+ depth = 1
+ }
+ moreCount := 0
+ if len(children[reply.ID]) == 0 {
+ moreCount = reply.ReplyCount
+ }
+ thread = append(thread, ThreadItem{Post: reply.Post, Depth: depth, MoreCount: moreCount})
+ appendChildren(reply.ID, depth+1)
+ }
+ return thread
+}
diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go
@@ -0,0 +1,151 @@
+package domain
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/ryan/gotextlog/internal/textlog"
+)
+
+func TestNoteValidationTimeAndRedaction(t *testing.T) {
+ if got := CodePointLength("🌱"); got != 1 {
+ t.Fatalf("CodePointLength = %d, want 1", got)
+ }
+ tests := []struct {
+ body string
+ want string
+ }{
+ {"", "Write something first"},
+ {" \t\n", "Write something first"},
+ {"\uFEFF", "Write something first"},
+ {strings.Repeat("🌱", 281), "Notes can be at most 280 characters"},
+ {strings.Repeat("x\n", 10) + "x", "Notes can be at most 10 lines"},
+ {"hello", ""},
+ }
+ for _, test := range tests {
+ if got := ValidatePost(test.body); got != test.want {
+ t.Errorf("ValidatePost(%q) = %q, want %q", test.body, got, test.want)
+ }
+ }
+
+ now := time.Date(2026, 1, 31, 0, 2, 0, 0, time.UTC)
+ for input, want := range map[string]string{
+ "2026-01-31T00:01:45Z": "15s",
+ "2026-01-30T23:00:00Z": "1h",
+ "2026-01-29T00:02:00Z": "2d",
+ "2026-01-01T00:00:00Z": "2026-01-01",
+ "2026-02-01T00:00:00Z": "0s",
+ } {
+ if got := RelativeTime(input, now); got != want {
+ t.Errorf("RelativeTime(%q) = %q, want %q", input, got, want)
+ }
+ }
+ if got := Redact("secret + secret", "secret"); got != "[redacted] + [redacted]" {
+ t.Fatalf("Redact = %q", got)
+ }
+ if got := Redact("unchanged", ""); got != "unchanged" {
+ t.Fatalf("Redact without token = %q", got)
+ }
+}
+
+func TestRichTextAndTerminalSafety(t *testing.T) {
+ body := "See [small PRs](getsmall.xyz/post/one), [Anthropic Risk August 2026 \\[pdf\\]](www-cdn.anthropic.com/report.pdf), https://textlog.cc and `example.com` with @david and #small_web."
+ tokens := TokenizeRichText(body)
+ wantInteractive := []RichToken{
+ {Kind: TokenLink, Text: "small PRs", URL: "https://getsmall.xyz/post/one"},
+ {Kind: TokenLink, Text: "Anthropic Risk August 2026 [pdf]", URL: "https://www-cdn.anthropic.com/report.pdf"},
+ {Kind: TokenLink, Text: "https://textlog.cc", URL: "https://textlog.cc"},
+ {Kind: TokenCode, Text: "`example.com`"},
+ {Kind: TokenReference, Text: "@david"},
+ {Kind: TokenReference, Text: "#small_web"},
+ }
+ var interactive []RichToken
+ for _, token := range tokens {
+ if token.Kind != TokenText {
+ interactive = append(interactive, token)
+ }
+ }
+ if len(interactive) != len(wantInteractive) {
+ t.Fatalf("tokens = %#v", tokens)
+ }
+ for i := range wantInteractive {
+ if interactive[i] != wantInteractive[i] {
+ t.Errorf("interactive token %d = %#v, want %#v", i, interactive[i], wantInteractive[i])
+ }
+ }
+ var rebuilt strings.Builder
+ for _, token := range tokens {
+ rebuilt.WriteString(token.Text)
+ }
+ if strings.Contains(rebuilt.String(), "\\[") || !strings.Contains(rebuilt.String(), "[pdf]") {
+ t.Fatalf("rendered token text did not unescape markdown label: %q", rebuilt.String())
+ }
+
+ if got, want := TerminalSafeText("A\tB\r\n /\\\r | |\nyes 🙌🏻!"), "A B\n /\\\n | |\nyes 🙌!"; got != want {
+ t.Fatalf("TerminalSafeText = %q, want %q", got, want)
+ }
+ if got, want := TerminalSafeText("123\tX\n1234\tX"), "123 X\n1234 X"; got != want {
+ t.Fatalf("TerminalSafeText tab stops = %q, want %q", got, want)
+ }
+ if got := TokenizeRichText("https://one.example\u00a0https://two.example"); len(got) != 3 || got[1].Kind != TokenText {
+ t.Fatalf("TokenizeRichText did not stop links at JavaScript whitespace: %#v", got)
+ }
+
+ if !IsASCIIArt("`literal` example.com", []string{"#ASCII"}) ||
+ !IsASCIIArt("art\n#ascii_art", nil) ||
+ IsASCIIArt("ordinary text", []string{"design"}) {
+ t.Fatal("IsASCIIArt did not recognize supported tags")
+ }
+
+ for _, test := range []struct{ current, length, want int }{
+ {-2, 4, 0}, {2, 4, 2}, {8, 4, 3}, {1, 0, 0},
+ } {
+ if got := ClampSelection(test.current, test.length); got != test.want {
+ t.Errorf("ClampSelection(%d, %d) = %d, want %d", test.current, test.length, got, test.want)
+ }
+ }
+}
+
+func TestBuildReplyThreadDepthFirstOrphansAndCycles(t *testing.T) {
+ root := post(1, nil, 0)
+ replies := []textlog.Reply{
+ reply(2, intPtr(1), 3, 1),
+ reply(3, intPtr(1), 0, 1),
+ reply(4, intPtr(2), 2, 2),
+ }
+ got := BuildReplyThread(root, replies)
+ assertThread(t, got, []int{1, 2, 4, 3}, []int{0, 1, 2, 1}, []int{0, 0, 2, 0})
+
+ malformed := []textlog.Reply{
+ reply(7, intPtr(8), 0, 0),
+ reply(8, intPtr(7), 0, 2),
+ reply(9, intPtr(99), 4, 0),
+ reply(7, intPtr(1), 0, 1), // duplicate ID must not be emitted again.
+ }
+ got = BuildReplyThread(root, malformed)
+ assertThread(t, got, []int{1, 7, 8, 9}, []int{0, 1, 2, 1}, []int{0, 0, 0, 4})
+}
+
+func post(id int, parentID *int, replyCount int) textlog.Post {
+ return textlog.Post{ID: id, ParentID: parentID, Body: string(rune('0' + id)), ReplyCount: replyCount}
+}
+
+func reply(id int, parentID *int, replyCount, depth int) textlog.Reply {
+ return textlog.Reply{Post: post(id, parentID, replyCount), Depth: depth}
+}
+
+func intPtr(value int) *int { return &value }
+
+func assertThread(t *testing.T, got []ThreadItem, ids, depths, more []int) {
+ t.Helper()
+ if len(got) != len(ids) {
+ t.Fatalf("thread length = %d, want %d: %#v", len(got), len(ids), got)
+ }
+ for i := range got {
+ if got[i].Post.ID != ids[i] || got[i].Depth != depths[i] || got[i].MoreCount != more[i] {
+ t.Errorf("thread[%d] = {id:%d depth:%d more:%d}, want {id:%d depth:%d more:%d}",
+ i, got[i].Post.ID, got[i].Depth, got[i].MoreCount, ids[i], depths[i], more[i])
+ }
+ }
+}