diff --git a/README.md b/README.md index 3ba1b06..06f9fec 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,37 @@ -# collector — 收集端骨架(rag-wave1 T3+T4) +# collector + +> ⚠️ **本目錄有兩代 collector 並存(2026-07-19)**: +> +> - **新(現役開發線)= Go 版 hash 偵測 collector**(`*.go`,SDD `ingest-hash-trigger` task 2)—— +> 變更偵測回歸檔案系統,manifest+content hash,**不經 git、不經 Gitea**。見下方「Go 版」段。 +> - **舊(legacy,待 SDD task 4 拆除)= Node 版 Gitea push 鏈**(`index.js`/`transform.js`/`git-sync.js`/`config.js`, +> rag-wave1 T3/T4 產物)——watch → git commit/push → Gitea webhook。這正是 de-Gitea 要取代的鏈, +> 保留只為現行本機 demo 鏈還在用;新功能一律做在 Go 版。 + +## Go 版:hash 偵測 collector(SDD ingest-hash-trigger) + +``` +collector scan --root <知識資料夾> --manifest [--max-removed-ratio 0.4] [--dry-run] +``` + +一次掃描:走訪資料夾(先只認 .md/.markdown/.txt/.docx/.pptx/.pdf)→ mtime+size fast-path +(沒變→沿用 manifest hash;變了才算 sha256)→ 對照 manifest 產出事件 → 事件 JSON +(符合 `schemas/collector-trigger.v1.schema.json`)輸出 stdout → 更新 manifest(`--dry-run` 不寫)。 + +- **事件分類順序(design §3)**:先把本輪 removed×added 以 content_hash 配對成 `renamed` + (只更新路徑映射,不 retire、不重萃);再分 added / modified / removed。 +- **大量刪除防呆(R6)**:removed 數 > manifest 條目 × 40%(`--max-removed-ratio` 可調)→ + removed 全部不執行、manifest 條目保留、輸出 `mass_delete_guard` 警告。 +- **重試語意**:`ingested_hash` 只會在(未來的)上傳/ingest 成功後回寫;本階段永不寫它, + 所以「偵測過但未成功 ingest」的檔每輪都會重發 added/modified——這是設計(design §2),不是 bug。 +- **本階段不接網路**:daemon 常駐(launchd)、R2 上傳、打 arcrun named-webhook=之後的 task + (SDD task 3/4、journeys/user-onboarding 環 6)。 + +測試:`go test ./...`(added / modified / removed / renamed / 大量刪除防呆五情境+fast-path+manifest 往返)。 + +--- + +# (legacy)Node 版收集端骨架(rag-wave1 T3+T4) > design.md §4;跑在**客戶端機器**(NAS/VM,或導入者代管的 VPS),不是 arcrun workflow(arcrun 零件禁檔案系統,見 CLAUDE.md 紅線)。 > ⚠️ **本骨架端到端(真實 NAS/VM + 真實 Gitea remote + systemd 常駐)需在本機/客戶環境驗,雲端 sandbox 沒有這些條件**——這裡只做得到:程式邏輯本身可跑、對本機臨時目錄的煙測(見下方「已驗證」)。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..884034b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module arcrun-rag/collector + +go 1.22 diff --git a/main.go b/main.go new file mode 100644 index 0000000..6faddc1 --- /dev/null +++ b/main.go @@ -0,0 +1,74 @@ +// collector — hash 差異偵測 collector(SDD ingest-hash-trigger,Go 版骨架)。 +// +// 用法: +// +// collector scan --root <知識資料夾> --manifest \ +// [--max-removed-ratio 0.4] [--dry-run] +// +// 一次掃描:走訪 root、對照 manifest、把事件(符合 +// schemas/collector-trigger.v1.schema.json)以 JSON 輸出到 stdout,並更新 manifest +// (--dry-run 不寫)。daemon 常駐/launchd/R2 上傳/打 named-webhook=之後的 task, +// 本階段不接網路。 +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" +) + +func main() { + if len(os.Args) < 2 || os.Args[1] != "scan" { + fmt.Fprintln(os.Stderr, "用法: collector scan --root --manifest [--max-removed-ratio 0.4] [--dry-run]") + os.Exit(2) + } + fs := flag.NewFlagSet("scan", flag.ExitOnError) + root := fs.String("root", "", "知識資料夾根路徑(必填)") + manifestPath := fs.String("manifest", "", "manifest JSON 檔路徑(必填;不存在會建新)") + ratio := fs.Float64("max-removed-ratio", DefaultMaxRemovedRatio, "大量刪除防呆門檻(removed 數 > manifest 條目 × 本值 → 全部不下架、只發警告)") + dryRun := fs.Bool("dry-run", false, "只輸出事件,不更新 manifest") + if err := fs.Parse(os.Args[2:]); err != nil { + os.Exit(2) + } + if *root == "" || *manifestPath == "" { + fmt.Fprintln(os.Stderr, "錯誤:--root 與 --manifest 皆為必填") + os.Exit(2) + } + absRoot, err := filepath.Abs(*root) + if err != nil { + fatal(err) + } + absManifest, err := filepath.Abs(*manifestPath) + if err != nil { + fatal(err) + } + + m, err := LoadManifest(absManifest, absRoot) + if err != nil { + fatal(err) + } + payload, err := Scan(absRoot, m, ScanOptions{ + MaxRemovedRatio: *ratio, + SkipPaths: map[string]bool{absManifest: true}, // manifest 若住在 root 底下,不掃自己 + }) + if err != nil { + fatal(err) + } + if !*dryRun { + if err := m.Save(absManifest); err != nil { + fatal(err) + } + } + out, err := json.MarshalIndent(payload, "", " ") + if err != nil { + fatal(err) + } + fmt.Println(string(out)) +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "collector:", err) + os.Exit(1) +} diff --git a/manifest.go b/manifest.go new file mode 100644 index 0000000..4bf7e0a --- /dev/null +++ b/manifest.go @@ -0,0 +1,103 @@ +// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。 +// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。 +package main + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +// ManifestEntry 是單一檔案在 manifest 裡的狀態。 +// - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。 +// - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」; +// 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash, +// 留給之後的上傳/webhook task 在成功後回寫。 +type ManifestEntry struct { + ContentHash string `json:"content_hash"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` + IngestedHash string `json:"ingested_hash,omitempty"` + IngestedAt int64 `json:"ingested_at,omitempty"` +} + +// Manifest 對應一個被勾選的資料夾。 +type Manifest struct { + FolderID string `json:"folder_id"` + Root string `json:"root"` + Entries map[string]*ManifestEntry `json:"entries"` +} + +// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。 +func newUUID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} + +// LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。 +func LoadManifest(path, root string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + id, uerr := newUUID() + if uerr != nil { + return nil, uerr + } + return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil + } + return nil, err + } + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err) + } + if m.Entries == nil { + m.Entries = map[string]*ManifestEntry{} + } + if m.FolderID == "" { + id, uerr := newUUID() + if uerr != nil { + return nil, uerr + } + m.FolderID = id + } + if root != "" { + m.Root = root + } + return &m, nil +} + +// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。 +func (m *Manifest) Save(path string) error { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".manifest-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + return os.Rename(tmpName, path) +} diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..710a465 --- /dev/null +++ b/scan.go @@ -0,0 +1,289 @@ +// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。 +// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射), +// 再分類其餘 added/modified/removed;removed 數 > manifest 條目 × 門檻(預設 40%) +// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。 +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// 先只認 .md 與常見文件檔(transform/其他格式是之後的 task)。 +var allowedExt = map[string]bool{ + ".md": true, + ".markdown": true, + ".txt": true, + ".docx": true, + ".pptx": true, + ".pdf": true, +} + +// ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json)---- + +type Event struct { + Type string `json:"type"` + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` + SourceHash string `json:"source_hash"` + Size *int64 `json:"size,omitempty"` + R2Key string `json:"r2_key,omitempty"` +} + +type Warning struct { + Code string `json:"code"` + Message string `json:"message"` + RemovedCount int `json:"removed_count,omitempty"` + ManifestCount int `json:"manifest_count,omitempty"` + ThresholdRatio float64 `json:"threshold_ratio,omitempty"` +} + +type TriggerPayload struct { + SchemaVersion int `json:"schema_version"` + FolderID string `json:"folder_id"` + Root string `json:"root,omitempty"` + GeneratedAt int64 `json:"generated_at,omitempty"` + Events []Event `json:"events"` + Warnings []Warning `json:"warnings,omitempty"` +} + +type ScanOptions struct { + // MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。 + MaxRemovedRatio float64 + // SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。 + SkipPaths map[string]bool +} + +const DefaultMaxRemovedRatio = 0.4 + +type fileState struct { + hash string // sha256: + size int64 + mtime int64 +} + +func hashFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil +} + +func r2KeyOf(sourceHash string) string { + return "raw/" + strings.TrimPrefix(sourceHash, "sha256:") +} + +// Scan 走訪 root、對照並更新 manifest、產出一輪事件。 +// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at +// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。 +func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) { + if opts.MaxRemovedRatio <= 0 { + opts.MaxRemovedRatio = DefaultMaxRemovedRatio + } + orig := m.Entries + manifestCountBefore := len(orig) + + // 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。 + current := map[string]fileState{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + name := d.Name() + if d.IsDir() { + if p != root && strings.HasPrefix(name, ".") { + return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過 + } + return nil + } + if strings.HasPrefix(name, ".") { + return nil + } + if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] { + return nil + } + if !allowedExt[strings.ToLower(filepath.Ext(name))] { + return nil + } + info, ierr := d.Info() + if ierr != nil { + return ierr + } + rel, rerr := filepath.Rel(root, p) + if rerr != nil { + return rerr + } + rel = filepath.ToSlash(rel) + st := fileState{size: info.Size(), mtime: info.ModTime().Unix()} + if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size { + st.hash = e.ContentHash // fast-path:mtime+size 沒變,跳過重算 + } else { + h, herr := hashFile(p) + if herr != nil { + return herr + } + st.hash = h + } + current[rel] = st + return nil + }) + if err != nil { + return nil, err + } + + // 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。 + var addedPaths, removedPaths []string + for p := range current { + if _, ok := orig[p]; !ok { + addedPaths = append(addedPaths, p) + } + } + for p := range orig { + if _, ok := current[p]; !ok { + removedPaths = append(removedPaths, p) + } + } + sort.Strings(addedPaths) + sort.Strings(removedPaths) + + // 3) 先配對 renamed(design §3 順序 1):removed×added 以 content_hash 配對, + // 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。 + removedByHash := map[string][]string{} + for _, p := range removedPaths { + h := orig[p].ContentHash + removedByHash[h] = append(removedByHash[h], p) + } + renamedOldOf := map[string]string{} // newPath -> oldPath + pairedOld := map[string]bool{} + var events []Event + for _, np := range addedPaths { + h := current[np].hash + cands := removedByHash[h] + if len(cands) == 0 { + continue + } + op := cands[0] + removedByHash[h] = cands[1:] + pairedOld[op] = true + renamedOldOf[np] = op + events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h}) + } + + // 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。 + sortedCurrent := make([]string, 0, len(current)) + for p := range current { + sortedCurrent = append(sortedCurrent, p) + } + sort.Strings(sortedCurrent) + addedEvent := func(p string) Event { + st := current[p] + size := st.size + return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)} + } + for _, p := range sortedCurrent { + if op, isRenamed := renamedOldOf[p]; isRenamed { + if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added + events = append(events, addedEvent(p)) + } + continue + } + if _, existed := orig[p]; !existed { + events = append(events, addedEvent(p)) // 真新檔 + } else if orig[p].IngestedHash == "" { + events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試 + } + } + + // 5) modified:manifest 有、現況有、content_hash != ingested_hash(design §3 順序 3)。 + for _, p := range sortedCurrent { + e, existed := orig[p] + if !existed { + continue + } + if _, isRenamed := renamedOldOf[p]; isRenamed { + continue + } + if e.IngestedHash != "" && current[p].hash != e.IngestedHash { + st := current[p] + size := st.size + events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}) + } + } + + // 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。 + var finalRemoved []string + for _, p := range removedPaths { + if !pairedOld[p] { + finalRemoved = append(finalRemoved, p) + } + } + var warnings []Warning + guardTripped := manifestCountBefore > 0 && + float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore) + if guardTripped { + warnings = append(warnings, Warning{ + Code: "mass_delete_guard", + Message: fmt.Sprintf( + "本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。", + len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100), + RemovedCount: len(finalRemoved), + ManifestCount: manifestCountBefore, + ThresholdRatio: opts.MaxRemovedRatio, + }) + } else { + for _, p := range finalRemoved { + events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash}) + } + } + + // 7) 更新 manifest(rebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。 + // 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。 + newEntries := make(map[string]*ManifestEntry, len(current)) + for p, st := range current { + ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime} + var carry *ManifestEntry + if op, isRenamed := renamedOldOf[p]; isRenamed { + carry = orig[op] + } else if e, ok := orig[p]; ok { + carry = e + } + if carry != nil { + ne.IngestedHash = carry.IngestedHash + ne.IngestedAt = carry.IngestedAt + } + newEntries[p] = ne + } + if guardTripped { + for _, p := range finalRemoved { + newEntries[p] = orig[p] + } + } + m.Entries = newEntries + m.Root = root + + if events == nil { + events = []Event{} + } + return &TriggerPayload{ + SchemaVersion: 1, + FolderID: m.FolderID, + Root: root, + GeneratedAt: time.Now().Unix(), + Events: events, + Warnings: warnings, + }, nil +} diff --git a/scan_test.go b/scan_test.go new file mode 100644 index 0000000..335099e --- /dev/null +++ b/scan_test.go @@ -0,0 +1,287 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + "time" +) + +var baseTime = time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) + +func writeFile(t *testing.T, root, rel, content string, mtime time.Time) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, mtime, mtime); err != nil { + t.Fatal(err) + } +} + +func hashOf(content string) string { + sum := sha256.Sum256([]byte(content)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func newTestManifest() *Manifest { + return &Manifest{FolderID: "test-folder-id", Entries: map[string]*ManifestEntry{}} +} + +// markIngested 模擬「上一輪 ingest 全部成功」:ingested_hash 補成 content_hash。 +func markIngested(m *Manifest) { + for _, e := range m.Entries { + e.IngestedHash = e.ContentHash + e.IngestedAt = 1 + } +} + +func mustScan(t *testing.T, root string, m *Manifest) *TriggerPayload { + t.Helper() + p, err := Scan(root, m, ScanOptions{}) + if err != nil { + t.Fatal(err) + } + return p +} + +func eventsOfType(p *TriggerPayload, typ string) []Event { + var out []Event + for _, e := range p.Events { + if e.Type == typ { + out = append(out, e) + } + } + return out +} + +// 情境 1:added——新檔進來,事件帶 source_hash / size / r2_key,manifest 記錄但不標已 ingest。 +func TestAdded(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "hello world\n", baseTime) + m := newTestManifest() + + p := mustScan(t, root, m) + + if p.SchemaVersion != 1 || p.FolderID != "test-folder-id" { + t.Fatalf("payload 頭部不對: %+v", p) + } + if len(p.Events) != 1 { + t.Fatalf("要 1 個事件,得到 %d: %+v", len(p.Events), p.Events) + } + ev := p.Events[0] + wantHash := hashOf("hello world\n") + if ev.Type != "added" || ev.Path != "a.md" || ev.SourceHash != wantHash { + t.Fatalf("added 事件不對: %+v", ev) + } + if ev.R2Key != "raw/"+wantHash[len("sha256:"):] { + t.Fatalf("r2_key 不對: %s", ev.R2Key) + } + if ev.Size == nil || *ev.Size != int64(len("hello world\n")) { + t.Fatalf("size 不對: %+v", ev.Size) + } + e := m.Entries["a.md"] + if e == nil || e.ContentHash != wantHash || e.IngestedHash != "" { + t.Fatalf("manifest 記錄不對: %+v", e) + } + + // R3:模擬 ingest 成功後重掃(=daemon 重啟),未變更檔案零事件。 + markIngested(m) + p2 := mustScan(t, root, m) + if len(p2.Events) != 0 { + t.Fatalf("未變更檔案不得重送,得到: %+v", p2.Events) + } +} + +// 情境 2:modified——內容變(mtime 也變)→ modified 事件帶新 hash;ingested_hash 保留舊值(可重試語意)。 +func TestModified(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "v1\n", baseTime) + m := newTestManifest() + mustScan(t, root, m) + markIngested(m) + oldIngested := m.Entries["a.md"].IngestedHash + + writeFile(t, root, "a.md", "v2 changed\n", baseTime.Add(time.Hour)) + p := mustScan(t, root, m) + + if len(p.Events) != 1 { + t.Fatalf("要 1 個事件,得到: %+v", p.Events) + } + ev := p.Events[0] + wantHash := hashOf("v2 changed\n") + if ev.Type != "modified" || ev.Path != "a.md" || ev.SourceHash != wantHash || ev.R2Key == "" { + t.Fatalf("modified 事件不對: %+v", ev) + } + e := m.Entries["a.md"] + if e.ContentHash != wantHash { + t.Fatalf("manifest content_hash 未更新: %+v", e) + } + if e.IngestedHash != oldIngested { + t.Fatalf("ingested_hash 不該在掃描階段被動(上傳成功才回寫): %+v", e) + } +} + +// 情境 3:removed——比例低於防呆門檻時正常發下架事件(帶最後已知 hash),manifest 條目移除。 +func TestRemoved(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "aaa\n", baseTime) + writeFile(t, root, "b.md", "bbb\n", baseTime) + writeFile(t, root, "c.md", "ccc\n", baseTime) + m := newTestManifest() + mustScan(t, root, m) + markIngested(m) + + if err := os.Remove(filepath.Join(root, "b.md")); err != nil { + t.Fatal(err) + } + p := mustScan(t, root, m) // 1/3 = 33% ≤ 40% → 放行 + + if len(p.Warnings) != 0 { + t.Fatalf("不該觸發防呆: %+v", p.Warnings) + } + rm := eventsOfType(p, "removed") + if len(rm) != 1 || rm[0].Path != "b.md" || rm[0].SourceHash != hashOf("bbb\n") { + t.Fatalf("removed 事件不對: %+v", rm) + } + if len(p.Events) != 1 { + t.Fatalf("其餘檔案不該有事件: %+v", p.Events) + } + if _, ok := m.Entries["b.md"]; ok { + t.Fatal("removed 後 manifest 條目應移除") + } + if len(m.Entries) != 2 { + t.Fatalf("manifest 應剩 2 條: %+v", m.Entries) + } +} + +// 情境 4:renamed——改名/搬移以 content_hash 配對,只更新路徑映射: +// 不重萃(ingested_hash 原樣搬)、不誤刪(零 removed/added 事件)。 +func TestRenamed(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "same content\n", baseTime) + writeFile(t, root, "keep.md", "keep\n", baseTime) + m := newTestManifest() + mustScan(t, root, m) + markIngested(m) + ingestedAt := m.Entries["a.md"].IngestedAt + + // 搬進子資料夾+改名(同內容) + writeFile(t, root, "sub/b.md", "same content\n", baseTime.Add(time.Hour)) + if err := os.Remove(filepath.Join(root, "a.md")); err != nil { + t.Fatal(err) + } + p := mustScan(t, root, m) + + if len(p.Events) != 1 { + t.Fatalf("改名只該有 1 個 renamed 事件,得到: %+v", p.Events) + } + ev := p.Events[0] + if ev.Type != "renamed" || ev.Path != "sub/b.md" || ev.OldPath != "a.md" || ev.SourceHash != hashOf("same content\n") { + t.Fatalf("renamed 事件不對: %+v", ev) + } + if len(eventsOfType(p, "removed"))+len(eventsOfType(p, "added")) != 0 { + t.Fatalf("改名不得產生 removed/added(誤刪/重萃): %+v", p.Events) + } + if _, ok := m.Entries["a.md"]; ok { + t.Fatal("舊路徑條目應消失") + } + ne := m.Entries["sub/b.md"] + if ne == nil || ne.IngestedHash != hashOf("same content\n") || ne.IngestedAt != ingestedAt { + t.Fatalf("路徑映射未保留 ingest 狀態(會導致重萃): %+v", ne) + } +} + +// 情境 5:大量刪除防呆——資料夾整個清空(100% > 40%),卡片零下架+警告有發出,manifest 條目保留。 +func TestMassDeleteGuard(t *testing.T) { + root := t.TempDir() + names := []string{"a.md", "b.md", "c.md", "d.md", "e.md"} + for _, n := range names { + writeFile(t, root, n, n+" content\n", baseTime) + } + m := newTestManifest() + mustScan(t, root, m) + markIngested(m) + + for _, n := range names { // 模擬資料夾未掛載/清空 + if err := os.Remove(filepath.Join(root, n)); err != nil { + t.Fatal(err) + } + } + p := mustScan(t, root, m) + + if len(eventsOfType(p, "removed")) != 0 { + t.Fatalf("防呆觸發時 removed 必須全數壓下: %+v", p.Events) + } + if len(p.Warnings) != 1 || p.Warnings[0].Code != "mass_delete_guard" { + t.Fatalf("警告未發出: %+v", p.Warnings) + } + w := p.Warnings[0] + if w.RemovedCount != 5 || w.ManifestCount != 5 || w.ThresholdRatio != DefaultMaxRemovedRatio { + t.Fatalf("警告數字不對: %+v", w) + } + if len(m.Entries) != 5 { + t.Fatalf("防呆觸發時 manifest 條目必須保留(下輪重評): %d", len(m.Entries)) + } + + // 檔案回來(誤刪復原)→ 內容沒變、mtime 相同 → 零事件、警告消失。 + for _, n := range names { + writeFile(t, root, n, n+" content\n", baseTime) + } + p2 := mustScan(t, root, m) + if len(p2.Events) != 0 || len(p2.Warnings) != 0 { + t.Fatalf("復原後應零事件零警告: events=%+v warnings=%+v", p2.Events, p2.Warnings) + } +} + +// 附加:mtime fast-path——mtime/size 沒變不重算 hash(用「內容偷偷改但 mtime 不動」驗證 fast-path 真的生效)。 +func TestMtimeFastPath(t *testing.T) { + root := t.TempDir() + writeFile(t, root, "a.md", "AAAA\n", baseTime) // 與替換內容等長 → size 相同 + m := newTestManifest() + mustScan(t, root, m) + markIngested(m) + + // 同 size、同 mtime、內容不同:fast-path 應沿用舊 hash → 零事件(設計如此:mtime 只是 fast-path) + writeFile(t, root, "a.md", "BBBB\n", baseTime) + p := mustScan(t, root, m) + if len(p.Events) != 0 { + t.Fatalf("fast-path 未生效(mtime/size 未變不該重算 hash): %+v", p.Events) + } + // mtime 一變就會重算 → 抓到真變更 + writeFile(t, root, "a.md", "BBBB\n", baseTime.Add(time.Minute)) + p2 := mustScan(t, root, m) + if len(p2.Events) != 1 || p2.Events[0].Type != "modified" { + t.Fatalf("mtime 變更後應抓到 modified: %+v", p2.Events) + } +} + +// 附加:manifest 存讀往返。 +func TestManifestRoundTrip(t *testing.T) { + dir := t.TempDir() + mp := filepath.Join(dir, "manifest.json") + m, err := LoadManifest(mp, "/tmp/kb") + if err != nil { + t.Fatal(err) + } + if m.FolderID == "" { + t.Fatal("新 manifest 應生成 folder_id") + } + m.Entries["x.md"] = &ManifestEntry{ContentHash: hashOf("x"), Size: 1, Mtime: 2, IngestedHash: hashOf("x"), IngestedAt: 3} + if err := m.Save(mp); err != nil { + t.Fatal(err) + } + m2, err := LoadManifest(mp, "") + if err != nil { + t.Fatal(err) + } + if m2.FolderID != m.FolderID || m2.Entries["x.md"] == nil || m2.Entries["x.md"].IngestedAt != 3 { + t.Fatalf("往返不一致: %+v", m2) + } +}