package issue import ( "errors" "fmt" "os" "path/filepath" "sort" "strings" "git.noodles.cam/claude-skills/marketplace/cli/internal/project" ) // The store holds two kinds of file, and only one of them is a store. // // An issue whose origin is Local lives here and nowhere else — that file IS the // issue, and losing it loses the work. Anything with a tracker origin is a // cache: the tracker has it, this copy is a working copy, and it is deleted the // moment a push confirms the tracker is up to date. // Root resolves the issue store for the current project. An explicit out // overrides it and is used exactly as typed: a relative out stays relative to // the working directory, because that is what the operator asked for. func Root(out string) string { if out != "" { return out } return project.StoreRoot("") } // ErrStoreMissing marks the "the store directory is not there" failure. // // Deliberately a different answer from "the store is empty". One is a path that // does not exist, the other is a repository with no issues filed yet, and // conflating the two is exactly what made a missed directory look like an empty // backlog. var ErrStoreMissing = errors.New("store missing") // StoreExists reports whether root is a directory that can be read as a store. func StoreExists(root string) bool { if root == "" { return false } fi, err := os.Stat(root) return err == nil && fi.IsDir() } // RequireStore asserts the store is there before reading or writing it. // // An empty root means no project was found at all — a different failure from a // project whose store has not been created yet, and the message says so. func RequireStore(root string) error { if root == "" { return fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError("")) } if !StoreExists(root) { return fmt.Errorf("%w: store %s does not exist", ErrStoreMissing, root) } return nil } // CreateStore creates the store, reporting whether it made the directory. // // Only the commands that legitimately bootstrap a store call this — `new` and // `pull` — and both announce it. Nothing creates a store as a side effect of a // write: a missing directory is something to report, not something to conjure. // An unresolved root is never conjured either — without a marker there is no // project to create a store IN, and guessing one is how a store once ended up // inside the plugin. func CreateStore(root string) (bool, error) { if root == "" { return false, fmt.Errorf("%w: %s", ErrStoreMissing, project.NotFoundError("")) } if StoreExists(root) { return false, nil } if err := os.MkdirAll(root, 0o755); err != nil { return false, err } return true, nil } // StoreError says why root cannot be read as a store, or nil when it holds // issues. // // The three messages are distinct on purpose — no project at all, a project // with no store, and a store with nothing in it are three different things to // do next. func StoreError(root string) error { switch { case root == "": return project.NotFoundError("") case !StoreExists(root): return fmt.Errorf("store %s does not exist — nothing was created; pass --out to point elsewhere", root) case len(AllIDs(root)) == 0: return fmt.Errorf("store %s exists but is empty", root) } return nil } // PathOf is where the issue with this id lives. func PathOf(root, id string) string { return filepath.Join(root, id+".md") } // AllIDs lists every issue in the store, by slug. // // An issue file is named by its slug and a slug has no dot in it, so // `.comments.md` — the thread the sync layer parks beside an issue — is not // one, and neither is anything else that grew a second extension. Without that // rule `wire-sqlc.comments` reads as an issue called `wire-sqlc.comments`, and // a bare push tries to file the comment thread as a unit of work. func AllIDs(root string) []string { if !StoreExists(root) { return nil } entries, err := os.ReadDir(root) if err != nil { return nil } var out []string for _, e := range entries { name := e.Name() if !strings.HasSuffix(name, ".md") { continue } if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "INDEX") || strings.HasPrefix(name, "tree-") { continue } id := name[:len(name)-3] if strings.Contains(id, ".") { continue } out = append(out, id) } sort.Strings(out) return out } // SlugFiles lists every file the store holds under one slug — the issue and its // sidecars. // // `.md` is the issue. Anything named `.` beside it is a // companion another layer parked there (`.comments.md` is the one that // exists today). AllIDs already refuses to read those as issues because a slug // has no dot in it; this is the same rule read the other way round. // // Which is how the domain can remove an issue completely without learning what // any of those companions are: it does not need to know that a comment thread // exists to know that a file named after this issue belongs to it and goes when // it goes. The issue's own file comes first — it is the headline of any receipt // printed from this list. // // A missing store is an empty list, not an error: nothing is there to remove. func SlugFiles(root, id string) []string { entries, err := os.ReadDir(root) if err != nil { return nil } prefix, own := id+".", id+".md" var self, sidecars []string for _, e := range entries { name := e.Name() if !strings.HasPrefix(name, prefix) || e.IsDir() { continue } p := filepath.Join(root, name) if name == own { self = append(self, p) } else { sidecars = append(sidecars, p) } } sort.Strings(sidecars) return append(self, sidecars...) } // Load reads one issue. The file name wins over the id in the metadata block. func Load(root, id string) (*Issue, error) { raw, err := os.ReadFile(PathOf(root, id)) if err != nil { return nil, err } return FromText(string(raw), id), nil } // LoadAll reads the whole store. func LoadAll(root string) (map[string]*Issue, error) { out := map[string]*Issue{} for _, id := range AllIDs(root) { i, err := Load(root, id) if err != nil { return nil, err } out[id] = i } return out, nil } // Save writes an issue to the store, which must already exist. func Save(root string, i *Issue) (string, error) { if err := RequireStore(root); err != nil { return "", err } p := PathOf(root, i.ID) if err := os.WriteFile(p, []byte(i.Text()), 0o644); err != nil { return "", err } return p, nil }