package infra import ( "fmt" "os" "path/filepath" "strings" ) // LayoutReport describes what VaultLayoutEnsure did (or would do) to a vault directory. type LayoutReport struct { VaultPath string `json:"vault_path"` Created []string `json:"created"` // dirs created (relative paths) Migrated []string `json:"migrated"` // renames executed, format "src -> dst" (relative) AlreadyOK []string `json:"already_ok"` // dirs that already existed at the target location Skipped []string `json:"skipped"` // unrecognized root-level entries, left untouched DryRun bool `json:"dry_run"` } // dataBuckets are root-level directories that belong under data/. var dataBuckets = []string{"raw", "processed", "exports"} // knowledgeBuckets are root-level directories that belong under knowledge/. var knowledgeBuckets = []string{"decisions", "domains", "models", "benchmarks", "test_documents"} // knownRootFiles are root-level files that should be moved to knowledge/. var knownRootFiles = []string{"README.md", "README.txt"} // VaultLayoutEnsure ensures a vault directory uses the canonical hybrid layout: // // data/{raw,processed,exports} // knowledge/{decisions,domains,models,benchmarks,test_documents} // // Legacy vaults that have these directories at the root are migrated by renaming // (or merging when both src and dst already exist). The operation is idempotent: // a second run returns everything in AlreadyOK. // // When dryRun is true the function computes the report but does not touch the disk. func VaultLayoutEnsure(vaultPath string, dryRun bool) (LayoutReport, error) { report := LayoutReport{DryRun: dryRun} // --- resolve path --- vaultPath = strings.TrimRight(vaultPath, "/\\") var err error vaultPath, err = filepath.Abs(vaultPath) if err != nil { return report, fmt.Errorf("vault_layout_ensure: abs(%q): %w", vaultPath, err) } // Follow symlinks for the vault root itself. resolved, err := filepath.EvalSymlinks(vaultPath) if err != nil { return report, fmt.Errorf("vault_layout_ensure: eval symlinks %q: %w", vaultPath, err) } vaultPath = resolved report.VaultPath = vaultPath // --- check that vault exists and is a directory --- info, err := os.Stat(vaultPath) if err != nil { return report, fmt.Errorf("vault_layout_ensure: stat %q: %w", vaultPath, err) } if !info.IsDir() { return report, fmt.Errorf("vault_layout_ensure: %q is not a directory", vaultPath) } // --- ensure top-level containers --- for _, container := range []string{"data", "knowledge"} { dst := filepath.Join(vaultPath, container) if err := ensureDir(dst, dryRun, container, &report); err != nil { return report, err } } // --- build migration table: root name -> relative destination --- type migration struct { rootName string // name in vault root (dir or file) dstRel string // relative destination path inside vault isFile bool } var migrations []migration for _, b := range dataBuckets { migrations = append(migrations, migration{rootName: b, dstRel: filepath.Join("data", b)}) } for _, b := range knowledgeBuckets { migrations = append(migrations, migration{rootName: b, dstRel: filepath.Join("knowledge", b)}) } for _, rf := range knownRootFiles { migrations = append(migrations, migration{rootName: rf, dstRel: filepath.Join("knowledge", "README.md"), isFile: true}) } // Track which root names are "known" so we can compute Skipped. knownNames := make(map[string]struct{}) for _, m := range migrations { knownNames[strings.ToLower(m.rootName)] = struct{}{} } knownNames["data"] = struct{}{} knownNames["knowledge"] = struct{}{} // --- apply migrations --- for _, m := range migrations { src := filepath.Join(vaultPath, m.rootName) dst := filepath.Join(vaultPath, m.dstRel) srcRel := m.rootName dstRel := m.dstRel srcExists := pathExists(src) dstExists := pathExists(dst) switch { case srcExists && dstExists: // Both exist: merge if directory, error on file collision. if m.isFile { return report, fmt.Errorf("vault_layout_ensure: conflict: both %q and %q exist", srcRel, dstRel) } if err := mergeDirs(src, dst, srcRel, dstRel, dryRun, &report); err != nil { return report, err } case srcExists && !dstExists: // Only source exists: rename. report.Migrated = append(report.Migrated, fmt.Sprintf("%s -> %s", srcRel, dstRel)) if !dryRun { if err := os.Rename(src, dst); err != nil { return report, fmt.Errorf("vault_layout_ensure: rename %q -> %q: %w", src, dst, err) } } case !srcExists && dstExists: // Already migrated. report.AlreadyOK = append(report.AlreadyOK, dstRel) default: // Neither exists: create empty destination directory (skip for files). if !m.isFile { report.Created = append(report.Created, dstRel) if !dryRun { if err := os.MkdirAll(dst, 0o755); err != nil { return report, fmt.Errorf("vault_layout_ensure: mkdir %q: %w", dst, err) } } } } } // --- collect skipped (unrecognized root entries) --- entries, err := os.ReadDir(vaultPath) if err != nil { return report, fmt.Errorf("vault_layout_ensure: readdir %q: %w", vaultPath, err) } for _, e := range entries { if _, known := knownNames[strings.ToLower(e.Name())]; !known { report.Skipped = append(report.Skipped, e.Name()) } } return report, nil } // ensureDir adds the dir to Created (and creates it) if it doesn't exist, // or to AlreadyOK if it does. Used for top-level containers "data" and "knowledge". func ensureDir(path string, dryRun bool, rel string, report *LayoutReport) error { if pathExists(path) { report.AlreadyOK = append(report.AlreadyOK, rel) return nil } report.Created = append(report.Created, rel) if dryRun { return nil } if err := os.MkdirAll(path, 0o755); err != nil { return fmt.Errorf("vault_layout_ensure: mkdir %q: %w", path, err) } return nil } // mergeDirs moves the contents of src into dst, then removes src if empty. // Returns an error if any file in src already exists in dst (no overwrite policy). func mergeDirs(src, dst, srcRel, dstRel string, dryRun bool, report *LayoutReport) error { children, err := os.ReadDir(src) if err != nil { return fmt.Errorf("vault_layout_ensure: readdir %q: %w", src, err) } for _, child := range children { childDst := filepath.Join(dst, child.Name()) if pathExists(childDst) { return fmt.Errorf("vault_layout_ensure: merge conflict: %q already exists in %q (cannot overwrite %q)", child.Name(), dstRel, filepath.Join(srcRel, child.Name())) } childSrc := filepath.Join(src, child.Name()) childSrcRel := filepath.Join(srcRel, child.Name()) childDstRel := filepath.Join(dstRel, child.Name()) report.Migrated = append(report.Migrated, fmt.Sprintf("%s -> %s", childSrcRel, childDstRel)) if !dryRun { if err := os.Rename(childSrc, childDst); err != nil { return fmt.Errorf("vault_layout_ensure: rename %q -> %q: %w", childSrc, childDst, err) } } } // Remove the now-empty src directory. if !dryRun { // Re-check emptiness after renames. remaining, _ := os.ReadDir(src) if len(remaining) == 0 { if err := os.Remove(src); err != nil { return fmt.Errorf("vault_layout_ensure: remove empty src %q: %w", src, err) } } } return nil } // pathExists returns true if path exists (any type). func pathExists(path string) bool { _, err := os.Lstat(path) return err == nil } // dirIsEmpty returns true if a directory exists and has no entries. func dirIsEmpty(path string) bool { entries, err := os.ReadDir(path) if err != nil { return false } return len(entries) == 0 } // _ prevents "declared but not used" if dirIsEmpty is only used in tests. var _ = dirIsEmpty // vaultLayoutKnownNames returns the set of root-level names managed by this function. // Exported for use in tests. func vaultLayoutKnownNames() map[string]struct{} { known := make(map[string]struct{}) for _, b := range dataBuckets { known[b] = struct{}{} } for _, b := range knowledgeBuckets { known[b] = struct{}{} } for _, rf := range knownRootFiles { known[strings.ToLower(rf)] = struct{}{} } known["data"] = struct{}{} known["knowledge"] = struct{}{} return known }