package admin import ( "fmt" "os" "path/filepath" "strings" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/persistence" ) func readYAMLFile(dataDir, subdir, id string) ([]byte, string, error) { path := filepath.Join(dataDir, subdir, id+".yaml") data, err := os.ReadFile(path) return data, path, err } func marshalYAML(data any) ([]byte, error) { return behavior.MarshalYAML(data) } func writeYAMLFile(path string, data any) ([]byte, error) { content, err := marshalYAML(data) if err != nil { return nil, err } if err := persistence.WriteFile(path, content, 0644); err != nil { return nil, err } return content, nil } func findYAMLFileInSubdirs(dataDir, subdir, id string) ([]byte, string, error) { base := filepath.Join(dataDir, subdir) rootPath := filepath.Join(base, id+".yaml") if data, err := os.ReadFile(rootPath); err == nil { return data, rootPath, nil } entries, err := os.ReadDir(base) if err != nil { return nil, "", err } for _, e := range entries { fullPath := filepath.Join(base, e.Name(), id+".yaml") if data, err := os.ReadFile(fullPath); err == nil { return data, fullPath, nil } subPath := filepath.Join(base, e.Name()) subEntries, err := os.ReadDir(subPath) if err != nil { continue } for _, se := range subEntries { fullPath := filepath.Join(subPath, se.Name(), id+".yaml") if data, err := os.ReadFile(fullPath); err == nil { return data, fullPath, nil } } } return nil, "", os.ErrNotExist } func listYAMLFiles(dataDir, subdir string) ([]string, error) { var ids []string base := filepath.Join(dataDir, subdir) entries, err := os.ReadDir(base) if err != nil { return nil, err } for _, e := range entries { if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { ids = append(ids, e.Name()[:len(e.Name())-5]) } } for _, e := range entries { if e.IsDir() { subPath := filepath.Join(base, e.Name()) subEntries, err := os.ReadDir(subPath) if err != nil { continue } for _, se := range subEntries { if !se.IsDir() && filepath.Ext(se.Name()) == ".yaml" { ids = append(ids, se.Name()[:len(se.Name())-5]) } } } } return ids, nil } type YAMLTree struct { Root []string `json:"root"` Dirs map[string][]string `json:"dirs"` } func listYAMLTree(dataDir, subdir string) (*YAMLTree, error) { tree := &YAMLTree{Root: []string{}, Dirs: map[string][]string{}} base := filepath.Join(dataDir, subdir) entries, err := os.ReadDir(base) if err != nil { return nil, err } for _, e := range entries { if !e.IsDir() && filepath.Ext(e.Name()) == ".yaml" { tree.Root = append(tree.Root, e.Name()[:len(e.Name())-5]) } } for _, e := range entries { if e.IsDir() { subPath := filepath.Join(base, e.Name()) subEntries, err := os.ReadDir(subPath) if err != nil { continue } var ids []string for _, se := range subEntries { if !se.IsDir() && filepath.Ext(se.Name()) == ".yaml" { ids = append(ids, se.Name()[:len(se.Name())-5]) } } if len(ids) > 0 { tree.Dirs[e.Name()] = ids } else { tree.Dirs[e.Name()] = []string{} } } } return tree, nil } func snapshotFile(path string) ([]byte, error) { return os.ReadFile(path) } // snapshotBefore reads a file's content for an undo record and reports // whether it existed. A missing file returns (nil, false, nil) — a create. A // read failure on an EXISTING file returns an error: treating it as a create // would make the undo record delete the real file, so the caller must abort. func snapshotBefore(path string) ([]byte, bool, error) { data, err := os.ReadFile(path) if err == nil { return data, true, nil } if os.IsNotExist(err) { return nil, false, nil } return nil, false, err } func findEntityPath(dataDir, subdir, id string) (string, error) { _, path, err := readYAMLFile(dataDir, subdir, id) if err == nil { return path, nil } _, path, err = findYAMLFileInSubdirs(dataDir, subdir, id) return path, err } func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []byte, error) { if !validEntityID(id) { return "", "", nil, fmt.Errorf("invalid id: %q", id) } if targetDir != "" && !validEntityID(targetDir) { return "", "", nil, fmt.Errorf("invalid directory: %q", targetDir) } oldPath, err := findEntityPath(dataDir, subdir, id) if err != nil { return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, id) } data, err := os.ReadFile(oldPath) if err != nil { return "", "", nil, fmt.Errorf("read: %w", err) } var newPath string if targetDir == "" { newPath = filepath.Join(dataDir, subdir, id+".yaml") } else { destDir := filepath.Join(dataDir, subdir, targetDir) if _, err := os.Stat(destDir); os.IsNotExist(err) { return "", "", nil, fmt.Errorf("directory does not exist: %s", targetDir) } newPath = filepath.Join(destDir, id+".yaml") } if oldPath == newPath { return "", "", nil, fmt.Errorf("already in target directory") } if _, err := os.Stat(newPath); err == nil { return "", "", nil, fmt.Errorf("entity already exists in target directory") } // Move in one journaled transaction: a failed write or source removal // must not leave the entity in two directories. txn := persistence.NewTransaction(persistence.JournalDir(dataDir)) if err := txn.Move(oldPath, newPath); err != nil { return "", "", nil, fmt.Errorf("move: %w", err) } if err := txn.Commit(); err != nil { return "", "", nil, fmt.Errorf("move: %w", err) } return oldPath, newPath, data, nil } func createEntityDir(dataDir, subdir, name string) error { name = strings.TrimSpace(name) if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { return fmt.Errorf("invalid directory name") } dirPath := filepath.Join(dataDir, subdir, name) if _, err := os.Stat(dirPath); err == nil { return fmt.Errorf("directory already exists") } return os.Mkdir(dirPath, 0755) } func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) { if !validEntityID(oldID) { return "", "", nil, fmt.Errorf("invalid id: %q", oldID) } if !validEntityID(newID) { return "", "", nil, fmt.Errorf("invalid new id: %q", newID) } oldPath, err := findEntityPath(dataDir, subdir, oldID) if err != nil { return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, oldID) } dir := filepath.Dir(oldPath) newPath := filepath.Join(dir, newID+".yaml") if oldPath == newPath { return "", "", nil, fmt.Errorf("new name is same as current") } // The ID namespace is global across the entity type's subdirectory tree // (findEntityPath and the runtime path indexes resolve by filename // anywhere under data/), so the collision check must cover the // whole tree, not just the file's own directory — otherwise renaming // into an ID that exists in ANOTHER directory creates a duplicate ID // shadowed by whichever file the index happens to find first. target := newID + ".yaml" var clash string filepath.WalkDir(filepath.Join(dataDir, subdir), func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if d.Name() == target { clash = path return filepath.SkipAll } return nil }) if clash != "" { return "", "", nil, fmt.Errorf("entity %s already exists at %s", newID, clash) } data, err := os.ReadFile(oldPath) if err != nil { return "", "", nil, fmt.Errorf("read: %w", err) } // Rename in one journaled transaction: a failed write or source removal // must not leave the entity in two directories. txn := persistence.NewTransaction(persistence.JournalDir(dataDir)) if err := txn.Move(oldPath, newPath); err != nil { return "", "", nil, fmt.Errorf("rename: %w", err) } if err := txn.Commit(); err != nil { return "", "", nil, fmt.Errorf("rename: %w", err) } return oldPath, newPath, data, nil } func renameEntityDir(dataDir, subdir, oldName, newName string) error { oldName = strings.TrimSpace(oldName) newName = strings.TrimSpace(newName) if oldName == "" || newName == "" || strings.ContainsAny(oldName, "/\\") || oldName == "." || oldName == ".." { return fmt.Errorf("invalid old directory name") } if strings.ContainsAny(newName, "/\\") || newName == "." || newName == ".." { return fmt.Errorf("invalid new directory name") } oldPath := filepath.Join(dataDir, subdir, oldName) if _, err := os.Stat(oldPath); os.IsNotExist(err) { return fmt.Errorf("directory %s does not exist", oldName) } newPath := filepath.Join(dataDir, subdir, newName) if _, err := os.Stat(newPath); err == nil { return fmt.Errorf("directory %s already exists", newName) } return os.Rename(oldPath, newPath) } func deleteEntityDir(dataDir, subdir, dir string) ([]ChangeDesc, error) { dir = strings.TrimSpace(dir) if dir == "" || strings.ContainsAny(dir, "/\\") || dir == "." || dir == ".." { return nil, fmt.Errorf("invalid directory") } srcDir := filepath.Join(dataDir, subdir, dir) entries, err := os.ReadDir(srcDir) if err != nil { return nil, fmt.Errorf("directory not found: %s", dir) } rootDir := filepath.Join(dataDir, subdir) var clashes []string for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { continue } if _, err := os.Stat(filepath.Join(rootDir, e.Name())); err == nil { clashes = append(clashes, e.Name()) } } if len(clashes) > 0 { return nil, fmt.Errorf("files already exist in root: %s", strings.Join(clashes, ", ")) } var changes []ChangeDesc txn := persistence.NewTransaction(persistence.JournalDir(dataDir)) for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { continue } oldPath := filepath.Join(srcDir, e.Name()) newPath := filepath.Join(rootDir, e.Name()) data, err := os.ReadFile(oldPath) if err != nil { return nil, fmt.Errorf("read %s: %w", e.Name(), err) } if err := txn.Move(oldPath, newPath); err != nil { return nil, fmt.Errorf("plan move %s: %w", e.Name(), err) } changes = append(changes, ChangeDesc{ Description: fmt.Sprintf("move %s %s to root", subdir, e.Name()), FilePath: oldPath, NewFilePath: newPath, OldContent: data, NewContent: data, }) } // Move every file in one journaled transaction so a failure cannot leave // the directory half-emptied (some files moved, some not). if err := txn.Commit(); err != nil { return nil, fmt.Errorf("move files: %w", err) } if err := os.Remove(srcDir); err != nil { return nil, fmt.Errorf("remove directory: %w", err) } changes = append(changes, ChangeDesc{ Description: fmt.Sprintf("delete directory %s/%s", subdir, dir), FilePath: srcDir, IsDelete: true, IsDirDelete: true, }) return changes, nil }