Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f3fd3c5e | ||
|
|
0582dfdf6b |
+1
-1
@@ -33,7 +33,7 @@ deployment:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
|
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
|
||||||
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
||||||
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
|
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`, which the deployment injects as a bare list of client names (`acme|globex`): it is then matched as whole names, with word edges decided by Unicode letters and digits (an underscore or punctuation is a boundary, so `trucker_ecommerce` matches `trucker`; `acmecorp`, `acme2` and plurals do not), and a list that is not a valid regexp, has an empty alternative, or could break out of its group stops the server at startup. A full pattern that matches the empty string is refused at startup too. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
|
||||||
|
|
||||||
A bad default or pattern stops the server at startup. A wing created while the
|
A bad default or pattern stops the server at startup. A wing created while the
|
||||||
server runs is usable without a restart.
|
server runs is usable without a restart.
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
// Command legacymove clears the leftovers of the retired layout (hyperguild
|
|
||||||
// #209): wiki/sources/ (videos and articles move to the default wing, the
|
|
||||||
// auto-tunnel review logs are deleted) and raw/ (originals archived before inbox
|
|
||||||
// zero, their markers, orphan markers). Default is a dry run that prints the
|
|
||||||
// plan; -apply performs it after archiving every page it touches; -rollback
|
|
||||||
// restores from an archive.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/legacymove"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
brain := flag.String("brain", "/var/lib/supervisor/brain", "brain directory")
|
|
||||||
wing := flag.String("wing", "web-clip", "wing that receives video and article sources")
|
|
||||||
apply := flag.Bool("apply", false, "perform the plan (default: dry run)")
|
|
||||||
rollback := flag.String("rollback", "", "restore from this archive dir (brain-relative, e.g. archive/wing-migration-2026-10-20)")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
if err := run(*brain, *wing, *apply, *rollback); err != nil {
|
|
||||||
slog.Error("legacymove", "err", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run(brain, wing string, apply bool, rollback string) error {
|
|
||||||
if rollback != "" {
|
|
||||||
if err := legacymove.Rollback(brain, rollback); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println("rolled back from", rollback)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sources, err := legacymove.Plan(brain, wing)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rawLeft, err := legacymove.PlanRaw(brain)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items := append(sources, rawLeft...)
|
|
||||||
counts := map[legacymove.Action]int{}
|
|
||||||
for _, it := range items {
|
|
||||||
counts[it.Action]++
|
|
||||||
}
|
|
||||||
fmt.Printf("# legacy plan\n\n%d entries: %d move, %d delete, %d left\n\n", len(items), counts[legacymove.Move], counts[legacymove.Delete], counts[legacymove.Skip])
|
|
||||||
fmt.Println("| action | from | to | why |")
|
|
||||||
fmt.Println("|---|---|---|---|")
|
|
||||||
for _, it := range items {
|
|
||||||
fmt.Printf("| %s | %s | %s | %s |\n", it.Action, it.From, it.To, strings.ReplaceAll(it.Reason, "|", "/"))
|
|
||||||
}
|
|
||||||
if !apply {
|
|
||||||
fmt.Println("\n_dry run: nothing was changed_")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
res, err := legacymove.Apply(brain, items, time.Now())
|
|
||||||
fmt.Printf("\nmoved %d, deleted %d; archive: %s\n", res.Moved, res.Deleted, res.ArchiveDir)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -150,6 +150,22 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ingestClientPattern picks the client-content pattern for raw/ routing.
|
||||||
|
// INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the
|
||||||
|
// shared CLAUDE_INGEST_CLIENT_BLOCK is used: the deployment injects it as a bare
|
||||||
|
// list of client names (regexp alternation), which wing.WrapNames bounds so a
|
||||||
|
// short name does not match inside ordinary words, and which it refuses if it
|
||||||
|
// would break out of its group or match the empty string.
|
||||||
|
func ingestClientPattern(getenv func(string) string) (string, error) {
|
||||||
|
if p := getenv("INGEST_CLIENT_BLOCK"); p != "" {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" {
|
||||||
|
return wing.WrapNames(names)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
||||||
// not understand is an error, so a typo ("ture") cannot silently leave a
|
// not understand is an error, so a typo ("ture") cannot silently leave a
|
||||||
// safety-relevant switch off.
|
// safety-relevant switch off.
|
||||||
@@ -426,9 +442,10 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if routing {
|
if routing {
|
||||||
pattern := os.Getenv("INGEST_CLIENT_BLOCK")
|
pattern, perr := ingestClientPattern(os.Getenv)
|
||||||
if pattern == "" {
|
if perr != nil {
|
||||||
pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK")
|
logger.Error("client-content pattern", "err", perr)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
|
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
|
|||||||
@@ -129,3 +129,30 @@ func TestEnvFlag(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare list of client
|
||||||
|
// names (regexp alternation). Used raw, a short name would match inside ordinary
|
||||||
|
// words; wrapped with \b, a name with a non-ASCII edge letter would never match.
|
||||||
|
func TestIngestClientPattern(t *testing.T) {
|
||||||
|
get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
t.Run("nothing set", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, p)
|
||||||
|
})
|
||||||
|
t.Run("explicit pattern is a full regexp, used as is", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, `acme\s+corp`, p)
|
||||||
|
})
|
||||||
|
t.Run("the shared names are wrapped", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, p, "dif|trucker")
|
||||||
|
assert.NotEqual(t, "dif|trucker", p)
|
||||||
|
})
|
||||||
|
t.Run("a shared value that would break out of the group is a startup error", func(t *testing.T) {
|
||||||
|
_, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "x)|.*("}))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
// Package legacymove clears wiki/sources/ of the retired layout (hyperguild
|
|
||||||
// #209). It does the one thing that is safe to do mechanically: video and
|
|
||||||
// article source pages move to the default wing, and the auto-tunnel review
|
|
||||||
// logs that were once ingested as "sources" are deleted. Notes, projects and
|
|
||||||
// untyped pages stay where they are, and wiki/concepts and wiki/entities are
|
|
||||||
// not touched at all (they are the shared layer).
|
|
||||||
//
|
|
||||||
// Plan only reads. Apply copies every page it touches into
|
|
||||||
// archive/wing-migration-<date>/ first, so Rollback can restore the brain
|
|
||||||
// byte for byte. The archive is outside raw/, so the watcher never ingests it.
|
|
||||||
package legacymove
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"path"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Action is what the plan does with one page.
|
|
||||||
type Action string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Move Action = "move"
|
|
||||||
Delete Action = "delete"
|
|
||||||
Skip Action = "skip"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Item is one planned operation.
|
|
||||||
type Item struct {
|
|
||||||
From string
|
|
||||||
To string // Move only
|
|
||||||
Slug string
|
|
||||||
Action Action
|
|
||||||
Wing string // Move only
|
|
||||||
Reason string
|
|
||||||
// SHA is the SHA-256 (hex) of the bytes the plan was decided on. Apply
|
|
||||||
// re-reads the page and does nothing if the bytes differ: a page changed
|
|
||||||
// between planning and applying is not the page that was judged safe.
|
|
||||||
SHA string
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxArchiveName is NAME_MAX. The archive key (the whole path, flattened) is
|
|
||||||
// longer than the page's own name, so a long name must be refused at planning
|
|
||||||
// time rather than wedge Apply halfway.
|
|
||||||
const maxArchiveName = 255
|
|
||||||
|
|
||||||
const legacyDir = "wiki/sources"
|
|
||||||
|
|
||||||
// Plan reads the retired source dir and decides. It writes nothing.
|
|
||||||
func Plan(brainDir, wing string) ([]Item, error) {
|
|
||||||
if wing == "" || brain.Sanitise(wing) != wing {
|
|
||||||
return nil, fmt.Errorf("wing %q is not a plain wing name", wing)
|
|
||||||
}
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
names, err := bfs.ReadDirRegular(legacyDir)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", legacyDir, err)
|
|
||||||
}
|
|
||||||
var items []Item
|
|
||||||
for _, name := range names {
|
|
||||||
if !strings.HasSuffix(name, ".md") || name == "_index.md" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
from := legacyDir + "/" + name
|
|
||||||
slug := strings.TrimSuffix(name, ".md")
|
|
||||||
it := Item{From: from, Slug: slug}
|
|
||||||
if len(archiveKey(from)) > maxArchiveName {
|
|
||||||
it.Action, it.Reason = Skip, "name too long to archive; left in place"
|
|
||||||
items = append(items, it)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content, err := bfs.ReadRegular(from)
|
|
||||||
if err != nil {
|
|
||||||
it.Action, it.Reason = Skip, fmt.Sprintf("unreadable: %v", err)
|
|
||||||
items = append(items, it)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
it.SHA = sum(content)
|
|
||||||
if strings.HasPrefix(name, "tunnel-candidates-") {
|
|
||||||
it.Action, it.Reason = Delete, "auto-tunnel review log ingested as a source; not a source"
|
|
||||||
items = append(items, it)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if has := topLevelKeys(string(content)); has["wing"] || has["hall"] {
|
|
||||||
it.Action, it.Reason = Skip, "already has wing/hall in its frontmatter; left in place"
|
|
||||||
items = append(items, it)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch typ := frontmatterValue(string(content), "type"); typ {
|
|
||||||
case "video", "article":
|
|
||||||
to := "wiki/" + wing + "/sources/" + name
|
|
||||||
it.Action, it.To, it.Wing = Move, to, wing
|
|
||||||
existing, ferr := wiki.FindSlugs(brainDir, slug)
|
|
||||||
if ferr != nil {
|
|
||||||
it.Action, it.To, it.Wing, it.Reason = Skip, "", "", fmt.Sprintf("cannot check slug: %v", ferr)
|
|
||||||
}
|
|
||||||
for _, p := range existing {
|
|
||||||
if p != from {
|
|
||||||
it.Action, it.To, it.Wing, it.Reason = Skip, "", "", fmt.Sprintf("slug %q already used by %s", slug, p)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
it.Action, it.Reason = Skip, fmt.Sprintf("type %q: left in place", typ)
|
|
||||||
}
|
|
||||||
items = append(items, it)
|
|
||||||
}
|
|
||||||
sort.SliceStable(items, func(i, j int) bool { return items[i].From < items[j].From })
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Result reports what Apply did and where the archive is (brain-relative).
|
|
||||||
type Result struct {
|
|
||||||
Moved, Deleted int
|
|
||||||
ArchiveDir string
|
|
||||||
// Changed lists planned pages that were NOT touched because their bytes no
|
|
||||||
// longer match what the plan was decided on.
|
|
||||||
Changed []string
|
|
||||||
}
|
|
||||||
|
|
||||||
type manifest struct {
|
|
||||||
Moves []moveRec `json:"moves"`
|
|
||||||
Deletes []string `json:"deletes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type moveRec struct {
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
Wing string `json:"wing"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply performs the Move and Delete items, one at a time, stopping at the
|
|
||||||
// first failure. For each page it re-reads the bytes and compares them with the
|
|
||||||
// plan's hash: a page that changed since planning is left alone and reported in
|
|
||||||
// Result.Changed. Each original is archived (O_EXCL, into an archive directory
|
|
||||||
// no earlier run has used) before anything changes; a move writes its
|
|
||||||
// destination O_EXCL before the source is removed, so a failure never loses a
|
|
||||||
// page. The manifest is rewritten after every step so a partial run can still
|
|
||||||
// be rolled back. A swap between the read and the remove is a residual window
|
|
||||||
// that only a locking watcher could close.
|
|
||||||
func Apply(brainDir string, items []Item, now time.Time) (Result, error) {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
|
|
||||||
res := Result{ArchiveDir: freshArchiveDir(bfs, "archive/wing-migration-"+now.UTC().Format("2006-01-02"))}
|
|
||||||
var man manifest
|
|
||||||
save := func() error {
|
|
||||||
b, err := json.MarshalIndent(man, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return bfs.PutRegular(res.ArchiveDir+"/manifest.json", b)
|
|
||||||
}
|
|
||||||
for _, it := range items {
|
|
||||||
if it.Action != Move && it.Action != Delete {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content, err := bfs.ReadRegular(it.From)
|
|
||||||
if err != nil {
|
|
||||||
return res, fmt.Errorf("read %s: %w", it.From, err)
|
|
||||||
}
|
|
||||||
if it.SHA == "" || sum(content) != it.SHA {
|
|
||||||
res.Changed = append(res.Changed, it.From)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := bfs.CreateRegular(res.ArchiveDir+"/originals/"+archiveKey(it.From), content); err != nil {
|
|
||||||
return res, fmt.Errorf("archive %s: %w", it.From, err)
|
|
||||||
}
|
|
||||||
switch it.Action {
|
|
||||||
case Move:
|
|
||||||
if err := bfs.CreateRegular(it.To, []byte(stamp(string(content), it.Wing))); err != nil {
|
|
||||||
return res, fmt.Errorf("write %s: %w", it.To, err)
|
|
||||||
}
|
|
||||||
man.Moves = append(man.Moves, moveRec{From: it.From, To: it.To, Wing: it.Wing})
|
|
||||||
res.Moved++
|
|
||||||
case Delete:
|
|
||||||
man.Deletes = append(man.Deletes, it.From)
|
|
||||||
res.Deleted++
|
|
||||||
}
|
|
||||||
if err := save(); err != nil {
|
|
||||||
return res, fmt.Errorf("write manifest: %w", err)
|
|
||||||
}
|
|
||||||
if err := bfs.Remove(it.From); err != nil {
|
|
||||||
return res, fmt.Errorf("handled %s but removing it failed: %w", it.From, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for w := range wingsOf(items) {
|
|
||||||
_ = brain.BuildWingIndex(brainDir, w) // best-effort upkeep; the pages are already moved
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// freshArchiveDir returns base, or base-2, base-3, ... : the first that does not
|
|
||||||
// exist, so a second run never reuses (and never overwrites) an earlier archive.
|
|
||||||
func freshArchiveDir(bfs *brainfs.Brain, base string) string {
|
|
||||||
dir := base
|
|
||||||
for n := 2; ; n++ {
|
|
||||||
if _, err := bfs.Lstat(dir); err != nil {
|
|
||||||
return dir
|
|
||||||
}
|
|
||||||
dir = fmt.Sprintf("%s-%d", base, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sum(b []byte) string {
|
|
||||||
h := sha256.Sum256(b)
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func wingsOf(items []Item) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
for _, it := range items {
|
|
||||||
if it.Action == Move {
|
|
||||||
out[it.Wing] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rollback restores every page named in the archive's manifest from its
|
|
||||||
// archived original and removes the moved copies. It checks everything first
|
|
||||||
// and changes nothing if anything is in the way: a restore target that exists
|
|
||||||
// with different bytes, or a moved copy that was edited since the move (which
|
|
||||||
// is never deleted). Pages already restored are recognised by their bytes, so a
|
|
||||||
// rollback can be run again. mtime and mode of the restored pages are new.
|
|
||||||
func Rollback(brainDir, archiveDir string) error {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
raw, err := bfs.ReadRegular(archiveDir + "/manifest.json")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read manifest: %w", err)
|
|
||||||
}
|
|
||||||
var man manifest
|
|
||||||
if err := json.Unmarshal(raw, &man); err != nil {
|
|
||||||
return fmt.Errorf("parse manifest: %w", err)
|
|
||||||
}
|
|
||||||
original := func(from string) ([]byte, error) {
|
|
||||||
orig, err := bfs.ReadRegular(archiveDir + "/originals/" + archiveKey(from))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read archived %s: %w", from, err)
|
|
||||||
}
|
|
||||||
return orig, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type step struct {
|
|
||||||
from string
|
|
||||||
orig []byte
|
|
||||||
restore bool // From is missing and must be written
|
|
||||||
remove string // moved copy to delete, if any
|
|
||||||
}
|
|
||||||
var steps []step
|
|
||||||
var problems []string
|
|
||||||
plan := func(from string, orig []byte, moved string, expectMoved []byte) {
|
|
||||||
st := step{from: from, orig: orig}
|
|
||||||
cur, err := bfs.ReadRegular(from)
|
|
||||||
switch {
|
|
||||||
case err == nil && bytes.Equal(cur, orig):
|
|
||||||
// already restored
|
|
||||||
case err == nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s exists with different content", from))
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
st.restore = true
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v", from, err))
|
|
||||||
}
|
|
||||||
if moved != "" {
|
|
||||||
cur, err := bfs.ReadRegular(moved)
|
|
||||||
switch {
|
|
||||||
case err == nil && bytes.Equal(cur, expectMoved):
|
|
||||||
st.remove = moved
|
|
||||||
case err == nil:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s was edited since the move; not deleting it", moved))
|
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
|
||||||
default:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s: %v", moved, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
steps = append(steps, st)
|
|
||||||
}
|
|
||||||
for _, m := range man.Moves {
|
|
||||||
orig, err := original(m.From)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
plan(m.From, orig, m.To, []byte(stamp(string(orig), m.Wing)))
|
|
||||||
}
|
|
||||||
for _, d := range man.Deletes {
|
|
||||||
orig, err := original(d)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
plan(d, orig, "", nil)
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return fmt.Errorf("rollback refused, nothing changed: %s", strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
for _, st := range steps {
|
|
||||||
if st.restore {
|
|
||||||
if err := bfs.CreateRegular(st.from, st.orig); err != nil {
|
|
||||||
return fmt.Errorf("restore %s: %w", st.from, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if st.remove != "" {
|
|
||||||
if err := bfs.Remove(st.remove); err != nil {
|
|
||||||
return fmt.Errorf("remove moved copy %s: %w", st.remove, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// archiveKey flattens a brain path into one file name, so two pages with the
|
|
||||||
// same basename in different places (raw/x.md and wiki/sources/x.md) never
|
|
||||||
// overwrite each other in the archive.
|
|
||||||
func archiveKey(p string) string { return strings.ReplaceAll(p, "/", "__") }
|
|
||||||
|
|
||||||
// stamp inserts wing/hall after the opening fence, leaving every other byte
|
|
||||||
// alone. A page without frontmatter gets a block.
|
|
||||||
func stamp(content, wing string) string {
|
|
||||||
extra := "wing: " + wing + "\nhall: sources\n"
|
|
||||||
if strings.HasPrefix(content, "---\n") {
|
|
||||||
return "---\n" + extra + content[4:]
|
|
||||||
}
|
|
||||||
return "---\n" + extra + "---\n" + content
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterBlock returns the text between the opening fence and a real
|
|
||||||
// closing fence (a line that is exactly "---"), or "" when there is none.
|
|
||||||
func frontmatterBlock(content string) (string, bool) {
|
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
rest := content[4:]
|
|
||||||
if i := strings.Index(rest, "\n---\n"); i >= 0 {
|
|
||||||
return rest[:i], true
|
|
||||||
}
|
|
||||||
if strings.HasSuffix(rest, "\n---") {
|
|
||||||
return rest[:len(rest)-4], true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// topLevelKeys is the set of unindented `key:` names in the frontmatter block.
|
|
||||||
func topLevelKeys(content string) map[string]bool {
|
|
||||||
out := map[string]bool{}
|
|
||||||
block, ok := frontmatterBlock(content)
|
|
||||||
if !ok {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(block, "\n") {
|
|
||||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if k, _, found := strings.Cut(line, ":"); found {
|
|
||||||
out[strings.TrimSpace(k)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// frontmatterValue reads a top-level key from a CLOSED frontmatter block. A
|
|
||||||
// nested or block-scalar line that merely looks like `type: video` does not
|
|
||||||
// count, and neither does an unterminated block.
|
|
||||||
func frontmatterValue(content, key string) string {
|
|
||||||
block, ok := frontmatterBlock(content)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(block, "\n") {
|
|
||||||
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if k, v, found := strings.Cut(line, ":"); found && strings.TrimSpace(k) == key {
|
|
||||||
return strings.Trim(strings.TrimSpace(v), `"'`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawDir = "raw"
|
|
||||||
|
|
||||||
// PlanRaw finds what the watcher left in raw/ before inbox zero: originals that
|
|
||||||
// were archived but never removed, with their .processed markers, and markers
|
|
||||||
// whose note is gone. An original is deleted only when a byte-identical copy
|
|
||||||
// exists under raw/processed/ (so nothing is lost), and only when it has a
|
|
||||||
// marker (an unmarked note is still in the inbox). tunnel-candidates-* are
|
|
||||||
// AutoTunnel's own review queue and are never touched. Every Delete carries the
|
|
||||||
// SHA-256 it was decided on, which Apply re-checks.
|
|
||||||
func PlanRaw(brainDir string) ([]Item, error) {
|
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = bfs.Close() }()
|
|
||||||
names, err := bfs.ReadDirRegular(rawDir)
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", rawDir, err)
|
|
||||||
}
|
|
||||||
present := map[string]bool{}
|
|
||||||
for _, n := range names {
|
|
||||||
present[n] = true
|
|
||||||
}
|
|
||||||
archived, err := archivedCopies(bfs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
del := func(name, why string) Item {
|
|
||||||
it := Item{From: rawDir + "/" + name, Slug: name, Action: Delete, Reason: why}
|
|
||||||
b, err := bfs.ReadRegular(it.From)
|
|
||||||
if err != nil {
|
|
||||||
return Item{From: it.From, Slug: name, Action: Skip, Reason: fmt.Sprintf("unreadable: %v", err)}
|
|
||||||
}
|
|
||||||
it.SHA = sum(b)
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
var items []Item
|
|
||||||
for _, n := range names {
|
|
||||||
if strings.HasPrefix(n, "tunnel-candidates-") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(n, ".processed"):
|
|
||||||
note := strings.TrimSuffix(n, ".processed")
|
|
||||||
if !present[note] {
|
|
||||||
items = append(items, tooLongOr(rawDir+"/"+n, func() Item { return del(n, "marker without a note") }))
|
|
||||||
}
|
|
||||||
case present[n+".processed"]:
|
|
||||||
content, rerr := bfs.ReadRegular(rawDir + "/" + n)
|
|
||||||
if rerr != nil {
|
|
||||||
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: fmt.Sprintf("unreadable: %v", rerr)})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !archived[sha256.Sum256(content)] {
|
|
||||||
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: "marked processed but no identical copy under raw/processed/; kept"})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Both or neither: the archive keys of the note and its (longer)
|
|
||||||
// marker must fit, or a run would delete the note and wedge on the marker.
|
|
||||||
if len(archiveKey(rawDir+"/"+n+".processed")) > maxArchiveName {
|
|
||||||
items = append(items, Item{From: rawDir + "/" + n, Slug: n, Action: Skip, Reason: "name too long to archive; kept"})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, del(n, "archived copy exists under raw/processed/"), del(n+".processed", "marker of an archived note"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.SliceStable(items, func(i, j int) bool { return items[i].From < items[j].From })
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// tooLongOr returns a Skip when the page's archive key cannot fit in a file
|
|
||||||
// name, else whatever mk builds.
|
|
||||||
func tooLongOr(from string, mk func() Item) Item {
|
|
||||||
if len(archiveKey(from)) > maxArchiveName {
|
|
||||||
return Item{From: from, Slug: path.Base(from), Action: Skip, Reason: "name too long to archive; kept"}
|
|
||||||
}
|
|
||||||
return mk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// archivedCopies returns the SHA-256 of every file under raw/processed/, as a
|
|
||||||
// set, so "is there an identical copy" is one lookup.
|
|
||||||
func archivedCopies(bfs *brainfs.Brain) (map[[32]byte]bool, error) {
|
|
||||||
out := map[[32]byte]bool{}
|
|
||||||
days, err := bfs.ReadDirDirs(rawDir + "/processed")
|
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list %s/processed: %w", rawDir, err)
|
|
||||||
}
|
|
||||||
for _, d := range days {
|
|
||||||
files, err := bfs.ReadDirRegular(rawDir + "/processed/" + d)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
b, err := bfs.ReadRegular(rawDir + "/processed/" + d + "/" + f)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out[sha256.Sum256(b)] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
package legacymove_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/legacymove"
|
|
||||||
)
|
|
||||||
|
|
||||||
func page(t *testing.T, dir, rel, body string) {
|
|
||||||
t.Helper()
|
|
||||||
full := filepath.Join(dir, rel)
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
func src(typ string) string {
|
|
||||||
return "---\ntitle: T\ntype: " + typ + "\n---\n\n## Summary\n\nBody with [[a-link|A]] kept as is.\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
func byFrom(items []legacymove.Item) map[string]legacymove.Item {
|
|
||||||
m := map[string]legacymove.Item{}
|
|
||||||
for _, it := range items {
|
|
||||||
m[it.From] = it
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_VideosAndArticlesMoveToTheDefaultWing(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
page(t, dir, "wiki/sources/an-article.md", src("article"))
|
|
||||||
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
assert.Equal(t, legacymove.Move, m["wiki/sources/a-talk.md"].Action)
|
|
||||||
assert.Equal(t, "wiki/web-clip/sources/a-talk.md", m["wiki/sources/a-talk.md"].To)
|
|
||||||
assert.Equal(t, legacymove.Move, m["wiki/sources/an-article.md"].Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_NotesProjectsAndUntypedPagesStayWhereTheyAre(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-note.md", src("note"))
|
|
||||||
page(t, dir, "wiki/sources/a-project.md", src("project"))
|
|
||||||
page(t, dir, "wiki/sources/untyped.md", "---\ntitle: T\n---\nbody")
|
|
||||||
page(t, dir, "wiki/sources/_index.md", "index")
|
|
||||||
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
for _, f := range []string{"a-note", "a-project", "untyped"} {
|
|
||||||
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
|
|
||||||
}
|
|
||||||
_, listed := m["wiki/sources/_index.md"]
|
|
||||||
assert.False(t, listed, "the index is not a page")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_AutoTunnelLogsAreDeleted(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("video"))
|
|
||||||
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, legacymove.Delete, byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"].Action,
|
|
||||||
"never moved, whatever the type says")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_ASlugAlreadyUsedElsewhereIsSkippedNotOverwritten(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
page(t, dir, "wiki/entities/a-talk.md", "an entity with the same slug")
|
|
||||||
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
it := byFrom(items)["wiki/sources/a-talk.md"]
|
|
||||||
assert.Equal(t, legacymove.Skip, it.Action)
|
|
||||||
assert.Contains(t, it.Reason, "already used")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_ADefaultWingThatIsNotAPlainNameIsRefused(t *testing.T) {
|
|
||||||
_, err := legacymove.Plan(t.TempDir(), "../x")
|
|
||||||
assert.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApply_MovesStampsAndArchivesEverythingItTouches(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
talk := src("video")
|
|
||||||
junk := "tunnel log"
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", talk)
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
|
|
||||||
page(t, dir, "wiki/sources/a-note.md", src("note"))
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 1, res.Moved)
|
|
||||||
assert.Equal(t, 1, res.Deleted)
|
|
||||||
|
|
||||||
moved, err := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, string(moved), "wing: web-clip")
|
|
||||||
assert.Contains(t, string(moved), "hall: sources")
|
|
||||||
assert.Contains(t, string(moved), "Body with [[a-link|A]] kept as is.", "body and links are byte-preserved")
|
|
||||||
|
|
||||||
for _, gone := range []string{"wiki/sources/a-talk.md", "wiki/sources/tunnel-candidates-2026-06-14.md"} {
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, gone))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
|
|
||||||
}
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-note.md"))
|
|
||||||
assert.NoError(t, statErr, "a skipped page is left exactly where it is")
|
|
||||||
|
|
||||||
again, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, it := range again {
|
|
||||||
assert.Equal(t, legacymove.Skip, it.Action, "nothing is left to do: %s", it.From)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollback_RestoresEveryTouchedPageByteForByte(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
talk := src("video")
|
|
||||||
junk := "tunnel log"
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", talk)
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", junk)
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, talk, string(got))
|
|
||||||
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, junk, string(got))
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, "the moved copy is removed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApply_NeverOverwritesAndKeepsTheSourceIfTheDestinationAppearsLate(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
page(t, dir, "wiki/web-clip/sources/a-talk.md", "appeared after the plan")
|
|
||||||
|
|
||||||
_, err = legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
|
|
||||||
require.Error(t, err)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
|
|
||||||
assert.Equal(t, "appeared after the plan", string(got))
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.NoError(t, statErr, "the source survives")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApply_TheArchiveIsOutsideRawSoTheWatcherNeverIngestsIt(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", "x")
|
|
||||||
items, _ := legacymove.Plan(dir, "web-clip")
|
|
||||||
res, err := legacymove.Apply(dir, items, time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, strings.Contains(res.ArchiveDir, "/raw"), res.ArchiveDir)
|
|
||||||
assert.True(t, strings.HasPrefix(res.ArchiveDir, "archive/"), res.ArchiveDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- raw/ leftovers from before inbox zero ---
|
|
||||||
|
|
||||||
func rawFile(t *testing.T, dir, rel, body string) { page(t, dir, rel, body) }
|
|
||||||
|
|
||||||
func TestPlanRaw_AnArchivedOriginalAndItsMarkerAreDeleted(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/clip.md", "the clip text")
|
|
||||||
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
assert.Equal(t, legacymove.Delete, m["raw/clip.md"].Action)
|
|
||||||
assert.Equal(t, legacymove.Delete, m["raw/clip.md.processed"].Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanRaw_AnOriginalWhoseArchiveCopyDiffersIsLeftAlone(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/clip.md", "the clip text, edited since")
|
|
||||||
rawFile(t, dir, "raw/clip.md.processed", "2026-10-19")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/clip.md", "the clip text")
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
assert.Equal(t, legacymove.Skip, m["raw/clip.md"].Action, "no identical archive copy: not ours to delete")
|
|
||||||
assert.Contains(t, m["raw/clip.md"].Reason, "no identical copy")
|
|
||||||
_, hasMarker := m["raw/clip.md.processed"]
|
|
||||||
assert.False(t, hasMarker, "the marker of a note we keep is kept")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanRaw_ACopyUnderATimestampedNameCounts(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/clip.md", "same text")
|
|
||||||
rawFile(t, dir, "raw/clip.md.processed", "d")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/clip-180200.md", "same text")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/clip-2.md", "other text")
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/clip.md"].Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanRaw_AnOrphanMarkerIsDeleted(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/gone.md.processed", "2026-06-16")
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, legacymove.Delete, byFrom(items)["raw/gone.md.processed"].Action)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlanRaw_TheAutoTunnelReviewQueueAndANoteWithoutAMarkerAreNotTouched(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md", "AutoTunnel's own queue")
|
|
||||||
rawFile(t, dir, "raw/tunnel-candidates-2026-06-14.md.processed", "x")
|
|
||||||
rawFile(t, dir, "raw/fresh.md", "an unprocessed clip")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/fresh.md", "an unprocessed clip") // same bytes, but no marker: still in the inbox
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
for _, f := range []string{"raw/tunnel-candidates-2026-06-14.md", "raw/tunnel-candidates-2026-06-14.md.processed", "raw/fresh.md"} {
|
|
||||||
_, listed := m[f]
|
|
||||||
assert.False(t, listed, "%s is not a leftover", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyAndRollback_HandleRawLeftoversAndNeverCollideWithSourcesOfTheSameName(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/same.md", "raw text")
|
|
||||||
rawFile(t, dir, "raw/same.md.processed", "d")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/same.md", "raw text")
|
|
||||||
page(t, dir, "wiki/sources/same.md", src("video")) // a wiki source with the same basename
|
|
||||||
rawItems, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
srcItems, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
res, err := legacymove.Apply(dir, append(rawItems, srcItems...), time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC))
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, gone := range []string{"raw/same.md", "raw/same.md.processed", "wiki/sources/same.md"} {
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, gone))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, gone)
|
|
||||||
}
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw/processed/2026-10-19/same.md"))
|
|
||||||
assert.NoError(t, statErr, "the archive copy in raw/processed is never touched")
|
|
||||||
|
|
||||||
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "raw/same.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "raw text", string(got), "the raw note and the wiki source of one name did not overwrite each other in the archive")
|
|
||||||
got, err = os.ReadFile(filepath.Join(dir, "wiki/sources/same.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, src("video"), string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- review of #213 ---
|
|
||||||
|
|
||||||
var day = time.Date(2026, 10, 20, 0, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
// F1: a second Apply on the same day must not touch the first run's archive.
|
|
||||||
func TestApply_ASecondRunTheSameDayGetsItsOwnArchiveAndKeepsTheFirst(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "FIRST tunnel bytes")
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
first, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
page(t, dir, "wiki/sources/tunnel-candidates-1.md", "SECOND tunnel bytes") // same name reappears
|
|
||||||
items2, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
second, err := legacymove.Apply(dir, items2, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotEqual(t, first.ArchiveDir, second.ArchiveDir)
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, first.ArchiveDir, "originals", "wiki__sources__tunnel-candidates-1.md"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "FIRST tunnel bytes", string(got), "the first run's archived original is intact")
|
|
||||||
|
|
||||||
require.NoError(t, legacymove.Rollback(dir, first.ArchiveDir))
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.NoError(t, statErr, "the first run's move is still rolled back by its own manifest")
|
|
||||||
}
|
|
||||||
|
|
||||||
// F2: Plan decided on the bytes it saw. Apply must not delete different bytes.
|
|
||||||
func TestApply_ARawNoteThatChangedSincePlanningIsNotDeleted(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
rawFile(t, dir, "raw/a.md", "bytes that have an archive copy")
|
|
||||||
rawFile(t, dir, "raw/a.md.processed", "d")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/a.md", "bytes that have an archive copy")
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rawFile(t, dir, "raw/a.md", "NEW UNPROCESSED BYTES") // dropped again before Apply runs
|
|
||||||
res, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
got, err := os.ReadFile(filepath.Join(dir, "raw/a.md"))
|
|
||||||
require.NoError(t, err, "the changed note is still in the inbox")
|
|
||||||
assert.Equal(t, "NEW UNPROCESSED BYTES", string(got))
|
|
||||||
assert.Contains(t, strings.Join(res.Changed, ","), "raw/a.md")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApply_AWikiSourceThatChangedSincePlanningIsNotMoved(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video")+"\nan edit made after the plan\n")
|
|
||||||
|
|
||||||
res, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Zero(t, res.Moved)
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.Contains(t, string(got), "an edit made after the plan")
|
|
||||||
assert.NotEmpty(t, res.Changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// F3: rollback checks everything first, changes nothing on a conflict, can be
|
|
||||||
// repeated, and never throws away an edit.
|
|
||||||
func TestRollback_RefusesBeforeChangingAnythingWhenATargetExists(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
page(t, dir, "wiki/sources/b-talk.md", src("video"))
|
|
||||||
items, _ := legacymove.Plan(dir, "web-clip")
|
|
||||||
res, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
page(t, dir, "wiki/sources/b-talk.md", "someone put a different b here")
|
|
||||||
err = legacymove.Rollback(dir, res.ArchiveDir)
|
|
||||||
require.Error(t, err)
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was restored: a was not touched either")
|
|
||||||
_, statErr = os.Stat(filepath.Join(dir, "wiki/web-clip/sources/a-talk.md"))
|
|
||||||
assert.NoError(t, statErr, "and the moved copies are still there")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollback_CanBeRunTwice(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
items, _ := legacymove.Plan(dir, "web-clip")
|
|
||||||
res, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir))
|
|
||||||
require.NoError(t, legacymove.Rollback(dir, res.ArchiveDir), "a repeat finds everything already restored")
|
|
||||||
got, _ := os.ReadFile(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.Equal(t, src("video"), string(got))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRollback_DoesNotDeleteAMovedPageThatWasEditedSinceTheMove(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/a-talk.md", src("video"))
|
|
||||||
items, _ := legacymove.Plan(dir, "web-clip")
|
|
||||||
res, err := legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err)
|
|
||||||
moved := filepath.Join(dir, "wiki/web-clip/sources/a-talk.md")
|
|
||||||
b, _ := os.ReadFile(moved)
|
|
||||||
require.NoError(t, os.WriteFile(moved, append(b, []byte("\nmy edit\n")...), 0o644))
|
|
||||||
|
|
||||||
err = legacymove.Rollback(dir, res.ArchiveDir)
|
|
||||||
require.Error(t, err)
|
|
||||||
got, readErr := os.ReadFile(moved)
|
|
||||||
require.NoError(t, readErr)
|
|
||||||
assert.Contains(t, string(got), "my edit", "the edit survives")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "wiki/sources/a-talk.md"))
|
|
||||||
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was changed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// F4: a name whose archive key cannot fit must be skipped at planning time.
|
|
||||||
func TestPlanRaw_ANameTooLongToArchiveIsSkippedNotAbortedOn(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
long := strings.Repeat("a", 240) + ".md"
|
|
||||||
rawFile(t, dir, "raw/"+long, "text")
|
|
||||||
rawFile(t, dir, "raw/"+long+".processed", "d")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/"+long, "text")
|
|
||||||
rawFile(t, dir, "raw/ok.md", "fine")
|
|
||||||
rawFile(t, dir, "raw/ok.md.processed", "d")
|
|
||||||
rawFile(t, dir, "raw/processed/2026-10-19/ok.md", "fine")
|
|
||||||
|
|
||||||
items, err := legacymove.PlanRaw(dir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
assert.Equal(t, legacymove.Skip, m["raw/"+long].Action)
|
|
||||||
assert.Contains(t, m["raw/"+long].Reason, "too long")
|
|
||||||
assert.Equal(t, legacymove.Delete, m["raw/ok.md"].Action)
|
|
||||||
|
|
||||||
_, err = legacymove.Apply(dir, items, day)
|
|
||||||
require.NoError(t, err, "the long name does not wedge the run")
|
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "raw/"+long))
|
|
||||||
assert.NoError(t, statErr, "and it is left where it is")
|
|
||||||
}
|
|
||||||
|
|
||||||
// F6: do not stamp a page that already carries a wing or hall; only a top-level
|
|
||||||
// type inside a closed frontmatter block decides.
|
|
||||||
func TestPlan_AMovedPageIsNeverGivenDuplicateWingKeys(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/has-wing.md", "---\ntitle: T\ntype: video\nwing: old\nhall: facts\n---\nbody")
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
it := byFrom(items)["wiki/sources/has-wing.md"]
|
|
||||||
assert.Equal(t, legacymove.Skip, it.Action)
|
|
||||||
assert.Contains(t, it.Reason, "already has")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_OnlyATopLevelTypeInAClosedBlockClassifiesAPage(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
page(t, dir, "wiki/sources/nested.md", "---\ntitle: T\nmeta:\n type: video\n---\nbody")
|
|
||||||
page(t, dir, "wiki/sources/blockscalar.md", "---\ntitle: T\ndesc: |\n type: article\n---\nbody")
|
|
||||||
page(t, dir, "wiki/sources/unterminated.md", "---\ntitle: T\ntype: video\nbody with no closing fence")
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
m := byFrom(items)
|
|
||||||
for _, f := range []string{"nested", "blockscalar", "unterminated"} {
|
|
||||||
assert.Equal(t, legacymove.Skip, m["wiki/sources/"+f+".md"].Action, f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPlan_AWikiSourceWithATooLongNameIsSkipped(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
long := strings.Repeat("a", 240) + ".md"
|
|
||||||
page(t, dir, "wiki/sources/"+long, src("video"))
|
|
||||||
items, err := legacymove.Plan(dir, "web-clip")
|
|
||||||
require.NoError(t, err)
|
|
||||||
it := byFrom(items)["wiki/sources/"+long]
|
|
||||||
assert.Equal(t, legacymove.Skip, it.Action)
|
|
||||||
assert.Contains(t, it.Reason, "too long")
|
|
||||||
}
|
|
||||||
@@ -94,6 +94,9 @@ func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Router{}, fmt.Errorf("client-content pattern: %w", err)
|
return Router{}, fmt.Errorf("client-content pattern: %w", err)
|
||||||
}
|
}
|
||||||
|
if re.MatchString("x") || re.MatchString("") {
|
||||||
|
return Router{}, fmt.Errorf("client-content pattern %q matches text that names no client; it would park every note", clientPattern)
|
||||||
|
}
|
||||||
r.ClientBlock = re
|
r.ClientBlock = re
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -218,3 +221,28 @@ var latinLookalike = map[rune]rune{
|
|||||||
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
|
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
|
||||||
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
|
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WrapNames turns a bare list of client names (a regexp alternation such as
|
||||||
|
// "acme|globex") into a pattern that matches a name only as a whole word, with
|
||||||
|
// word edges decided by Unicode letters and digits: Go's \b is ASCII-only, so
|
||||||
|
// with it a name that begins or ends in a letter like Å, é or Ø would never
|
||||||
|
// match. An underscore or punctuation is a boundary (trucker_ecommerce matches
|
||||||
|
// "trucker"), a letter or digit glued on is not (acmecorp, acme2).
|
||||||
|
//
|
||||||
|
// The list is validated first, on its own: it must compile as a regexp, must not
|
||||||
|
// match the empty string (an empty alternative such as "a||b" would match
|
||||||
|
// everywhere), and must be self-contained: a value like "x)|.*(" compiles alone
|
||||||
|
// only if it is balanced, so it cannot break out of the group it is put in.
|
||||||
|
func WrapNames(names string) (string, error) {
|
||||||
|
if strings.TrimSpace(names) == "" {
|
||||||
|
return "", fmt.Errorf("client names list is empty")
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile("(?i)" + names)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("client names are not a valid regexp alternation: %w", err)
|
||||||
|
}
|
||||||
|
if re.MatchString("") {
|
||||||
|
return "", fmt.Errorf("client names contain an empty alternative; it would match everywhere")
|
||||||
|
}
|
||||||
|
return `(?:^|[^\p{L}\p{N}])(?:` + names + `)(?:[^\p{L}\p{N}]|$)`, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,3 +234,59 @@ func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
|
|||||||
clean := strings.Repeat("\u00e9", 300)
|
clean := strings.Repeat("\u00e9", 300)
|
||||||
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// From the review of #214: the shared secret is a bare list of client names.
|
||||||
|
func TestWrapNames(t *testing.T) {
|
||||||
|
park := func(t *testing.T, names, text string) bool {
|
||||||
|
t.Helper()
|
||||||
|
pat, err := wing.WrapNames(names)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", pat)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return r.Resolve(context.Background(), wing.Note{Text: text}).Source == wing.Exception
|
||||||
|
}
|
||||||
|
t.Run("a short name does not match inside ordinary words", func(t *testing.T) {
|
||||||
|
assert.False(t, park(t, "dif|trucker", "a different approach to modifying prompts"))
|
||||||
|
assert.True(t, park(t, "dif|trucker", "kickoff for the DIF project"))
|
||||||
|
})
|
||||||
|
t.Run("names with a non-ASCII letter at the edge still match (Go's \\b would never)", func(t *testing.T) {
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "Åke called"))
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "la Société wrote"))
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "Ørsted wind"))
|
||||||
|
})
|
||||||
|
t.Run("identifiers with underscores and punctuation count as boundaries", func(t *testing.T) {
|
||||||
|
assert.True(t, park(t, "trucker", "repo trucker_ecommerce was cloned"))
|
||||||
|
assert.True(t, park(t, "trucker", "(Trucker)"))
|
||||||
|
})
|
||||||
|
t.Run("a name inside a longer word or with digits glued on is not a boundary", func(t *testing.T) {
|
||||||
|
assert.False(t, park(t, "acme", "acmecorp"))
|
||||||
|
assert.False(t, park(t, "acme", "acme2"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapNames_RefusesWhatWouldBreakOrBlockEverything(t *testing.T) {
|
||||||
|
for _, bad := range []string{
|
||||||
|
"x)|.*(", // breaks out of the group
|
||||||
|
"acme)|(b", // changes the structure
|
||||||
|
"a||b", // empty alternative matches everywhere
|
||||||
|
"acme|", // trailing empty alternative
|
||||||
|
"|acme", // leading empty alternative
|
||||||
|
"", // nothing
|
||||||
|
"c++ corp", // not a valid regexp
|
||||||
|
"([unclosed", // not a valid regexp
|
||||||
|
} {
|
||||||
|
t.Run(bad, func(t *testing.T) {
|
||||||
|
_, err := wing.WrapNames(bad)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter_RefusesAPatternThatMatchesTheEmptyString(t *testing.T) {
|
||||||
|
for _, p := range []string{`.*`, `x*`, `^`, `\b`, `(a|)`} {
|
||||||
|
t.Run(p, func(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(routerBrain(t), "web-clip", p)
|
||||||
|
assert.Error(t, err, "it would park every note")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user