Skip to content

Commit 5905a99

Browse files
committed
plan 52: reuse archetypes.DefaultRoot + surface List errors
- Drop the duplicate DefaultArchetypeRoot constant in the required-structure rule and reuse archetypes.DefaultRoot. Single source of truth for the default root name. - Add Resolver.ListWithErrors that returns both the successfully discovered archetypes and the non-ErrNotExist errors from each readDir. List keeps its old signature for callers that do not care about errors. - mdsmith archetypes list now surfaces read errors on stderr and exits 2 when a root errored with no discoverable archetypes. - Tests: injected fs.FS that errors on ReadDir for a specific root to verify both the error-surfacing and not-exist-is-silent branches.
1 parent d6ec7ff commit 5905a99

5 files changed

Lines changed: 78 additions & 13 deletions

File tree

cmd/mdsmith/archetypes.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,14 @@ func runArchetypesList(args []string) int {
204204
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
205205
return 2
206206
}
207-
entries := resolver.List()
207+
entries, listErrs := resolver.ListWithErrors()
208+
for _, err := range listErrs {
209+
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
210+
}
208211
if len(entries) == 0 {
212+
if len(listErrs) > 0 {
213+
return 2
214+
}
209215
fmt.Fprintf(os.Stderr,
210216
"mdsmith: no archetypes found under roots %v\n",
211217
resolver.EffectiveRoots())

internal/archetypes/archetypes.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,36 @@ func (r *Resolver) osJoin(p string) string {
139139
// archetype names (see isArchetypeName) are skipped — this keeps
140140
// README.md, dotfiles, and underscore-prefixed scratch files out of
141141
// the archetype namespace.
142+
//
143+
// List silently ignores every readDir error so it can never return
144+
// a partial result with an error set. Use ListWithErrors when
145+
// callers need to distinguish "root does not exist" from "root
146+
// could not be read".
142147
func (r *Resolver) List() []Entry {
148+
entries, _ := r.ListWithErrors()
149+
return entries
150+
}
151+
152+
// ListWithErrors is like List but also returns every non-ErrNotExist
153+
// error encountered while reading a root. The entry slice is always
154+
// populated with the archetypes that were successfully discovered;
155+
// errors are returned alongside so callers can decide whether to
156+
// surface them as warnings or fail fast.
157+
func (r *Resolver) ListWithErrors() ([]Entry, []error) {
143158
seen := map[string]bool{}
144159
var out []Entry
160+
var errs []error
145161
for _, root := range r.roots() {
146162
cleanRoot := filepath.ToSlash(filepath.Clean(root))
147-
entries, err := r.readDir(cleanRoot)
163+
dirEntries, err := r.readDir(cleanRoot)
148164
if err != nil {
165+
if !errors.Is(err, fs.ErrNotExist) {
166+
errs = append(errs, fmt.Errorf(
167+
"reading archetype root %q: %w", root, err))
168+
}
149169
continue
150170
}
151-
for _, e := range entries {
171+
for _, e := range dirEntries {
152172
if e.IsDir() {
153173
continue
154174
}
@@ -172,7 +192,7 @@ func (r *Resolver) List() []Entry {
172192
}
173193
}
174194
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
175-
return out
195+
return out, errs
176196
}
177197

178198
// isArchetypeName reports whether basename (without the ".md"

internal/archetypes/archetypes_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,48 @@ func TestValidateRoots_EmptySliceOK(t *testing.T) {
282282
assert.NoError(t, ValidateRoots(nil))
283283
}
284284

285+
// readDirErrFS returns a non-ErrNotExist error from ReadDir for a
286+
// specific root, exercising ListWithErrors' error-surfacing branch.
287+
type readDirErrFS struct {
288+
fs fs.FS
289+
errRoot string
290+
err error
291+
}
292+
293+
func (s readDirErrFS) Open(name string) (fs.File, error) {
294+
return s.fs.Open(name)
295+
}
296+
297+
func (s readDirErrFS) ReadDir(name string) ([]fs.DirEntry, error) {
298+
if name == s.errRoot {
299+
return nil, s.err
300+
}
301+
return fs.ReadDir(s.fs, name)
302+
}
303+
304+
func TestResolver_ListWithErrors_ReturnsNonNotExistErrors(t *testing.T) {
305+
boom := errors.New("io boom")
306+
r := &Resolver{FS: readDirErrFS{
307+
fs: fsWith(map[string]string{"good/story.md": "# ?"}),
308+
errRoot: "bad",
309+
err: boom,
310+
}, Roots: []string{"bad", "good"}}
311+
entries, errs := r.ListWithErrors()
312+
require.Len(t, entries, 1)
313+
assert.Equal(t, "story", entries[0].Name)
314+
require.Len(t, errs, 1)
315+
assert.Contains(t, errs[0].Error(), "reading archetype root")
316+
assert.True(t, errors.Is(errs[0], boom))
317+
}
318+
319+
func TestResolver_ListWithErrors_SilentOnNotExist(t *testing.T) {
320+
// Missing root is fine — the resolver tolerates empty directories.
321+
r := &Resolver{Roots: []string{"missing"}, FS: fsWith(nil)}
322+
entries, errs := r.ListWithErrors()
323+
assert.Empty(t, entries)
324+
assert.Empty(t, errs)
325+
}
326+
285327
func TestResolver_LookupSkipsDirectoryMatch(t *testing.T) {
286328
// A directory named "story.md" under the root must not be treated
287329
// as the archetype file.

internal/rules/requiredstructure/rule.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,9 @@ func init() {
2828
type Rule struct {
2929
Schema string // path to schema file
3030
Archetype string // name of an archetype schema discovered under ArchetypeRoots
31-
ArchetypeRoots []string // directories searched for archetypes; defaults to [DefaultArchetypeRoot]
31+
ArchetypeRoots []string // directories searched for archetypes; defaults to [archetypes.DefaultRoot]
3232
}
3333

34-
// DefaultArchetypeRoot is the directory used when no archetype roots
35-
// are configured for the rule.
36-
const DefaultArchetypeRoot = "archetypes"
37-
3834
// ID implements rule.Rule.
3935
func (r *Rule) ID() string { return "MDS020" }
4036

@@ -107,7 +103,7 @@ func (r *Rule) DefaultSettings() map[string]any {
107103
return map[string]any{
108104
"schema": "",
109105
"archetype": "",
110-
"archetype-roots": []string{DefaultArchetypeRoot},
106+
"archetype-roots": []string{archetypes.DefaultRoot},
111107
}
112108
}
113109

@@ -237,7 +233,7 @@ func (r *Rule) isSchemaOrArchetypeFile(f *lint.File) bool {
237233
}
238234
roots := r.ArchetypeRoots
239235
if len(roots) == 0 {
240-
roots = []string{DefaultArchetypeRoot}
236+
roots = []string{archetypes.DefaultRoot}
241237
}
242238
// Try a plain relative-path match (covers common layouts where
243239
// mdsmith runs from the project root) and an absolute-to-RootDir
@@ -282,7 +278,7 @@ func (r *Rule) isSchemaOrArchetypeFile(f *lint.File) bool {
282278
// the default when the rule's setting is empty.
283279
func (r *Rule) archetypeRoots() []string {
284280
if len(r.ArchetypeRoots) == 0 {
285-
return []string{DefaultArchetypeRoot}
281+
return []string{archetypes.DefaultRoot}
286282
}
287283
return r.ArchetypeRoots
288284
}

internal/rules/requiredstructure/rule_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"strings"
99
"testing"
1010

11+
"github.com/jeduden/mdsmith/internal/archetypes"
1112
"github.com/jeduden/mdsmith/internal/lint"
1213

1314
"github.com/stretchr/testify/assert"
@@ -255,7 +256,7 @@ func TestApplySettings_ArchetypeRootsTypedStringSlice(t *testing.T) {
255256
func TestDefaultSettings_ArchetypeRoots(t *testing.T) {
256257
r := &Rule{}
257258
assert.Equal(t,
258-
[]string{DefaultArchetypeRoot},
259+
[]string{archetypes.DefaultRoot},
259260
r.DefaultSettings()["archetype-roots"])
260261
}
261262

0 commit comments

Comments
 (0)