mf4
1package extractor
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io/fs"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "slices"
12
13 "github.com/aymanbagabas/go-udiff"
14)
15
16var (
17 ErrFileModified error = errors.New("dst file modified")
18)
19
20const (
21 _statusWalkError = "walk_error"
22 _statusDir = "directory"
23 _statusFailed = "failed"
24 _statusCreated = "created"
25 _statusUnchanged = "unchanged"
26 _statusModified = "modified"
27)
28
29type ExtractResult struct {
30 Src string
31 Dst string
32 Status string
33 Details string
34 Err error
35}
36
37type Extractor struct {
38 dst string // target dir
39 fs fs.FS // source fs (e.g. embed.FS)
40 mode os.FileMode // apply to all files
41 Results []ExtractResult // per-file results
42}
43
44// TODO: Options
45func New(
46 fs fs.FS,
47 dst string,
48) (*Extractor, error) {
49
50 mode := os.FileMode(0o700)
51 return &Extractor{
52 dst: dst,
53 fs: fs,
54 mode: mode,
55 }, nil
56}
57
58func (e *Extractor) Deploy() error {
59 return fs.WalkDir(e.fs, ".", e.deployFile)
60}
61
62// Deploy extracts an the embedded extractor.fs filesystem into the exractor.dst
63// directory. Embeded paths will be preserved.
64// - implements the fs.WalkDirFunc interface
65func (e *Extractor) deployFile(path string, d fs.DirEntry, err error) error {
66
67 dir, _ := filepath.Split(path)
68 dstPath := filepath.Join(e.dst, path)
69 dstDir := filepath.Join(e.dst, dir)
70 result := ExtractResult{
71 Src: path,
72 Dst: dstPath,
73 }
74
75 defer func() {
76 e.Results = append(e.Results, result)
77 }()
78
79 if err != nil {
80 result.Err = err
81 result.Status = _statusWalkError
82 return nil
83 }
84
85 if d.IsDir() {
86 result.Status = _statusDir
87 return nil
88 }
89
90 dstExists := true
91 stat, err := os.Stat(dstPath)
92 if err != nil {
93 if !errors.Is(err, os.ErrNotExist) {
94 result.Err = fmt.Errorf("failed to stat file: %w", err)
95 return nil
96 }
97 dstExists = false
98 }
99 if stat != nil && stat.IsDir() {
100 result.Status = _statusDir
101 return nil
102 }
103
104 err = os.MkdirAll(dstDir, e.mode)
105 if err != nil {
106 result.Status = _statusFailed
107 result.Err = fmt.Errorf("failed to create dir: %w", err)
108 return nil
109 }
110
111 // TODO: Reader / Writer for big files
112 srcData, err := fs.ReadFile(e.fs, path)
113 if err != nil {
114 result.Status = _statusFailed
115 result.Err = fmt.Errorf("failed to read src file: %w", err)
116 return nil
117 }
118
119 if dstExists {
120 dstData, err := os.ReadFile(dstPath)
121 if err != nil {
122 result.Status = _statusFailed
123 result.Err = fmt.Errorf("failed to read dst file: %w", err)
124 return nil
125 }
126
127 if slices.Equal(srcData, dstData) {
128 result.Status = _statusUnchanged
129 return nil
130 }
131
132 diff := udiff.Unified(path, dstPath, string(srcData), string(dstData))
133 result.Status = _statusModified
134 result.Details = diff
135 result.Err = ErrFileModified
136 return nil
137 }
138
139 err = os.WriteFile(dstPath, srcData, e.mode)
140 if err != nil {
141 result.Status = _statusFailed
142 result.Err = fmt.Errorf("failed to write dst: %w", err)
143 return nil
144 }
145 return nil
146}
147
148func (e *Extractor) LogResults(logger *slog.Logger, ctx context.Context) {
149 for _, r := range e.Results {
150 level := slog.LevelInfo
151 if r.Status == _statusDir || r.Status == _statusUnchanged {
152 level = slog.LevelDebug
153 }
154 attrs := []slog.Attr{
155 slog.String("status", r.Status),
156 slog.String("dst", r.Dst),
157 }
158 if r.Err != nil {
159 level = slog.LevelError
160 if errors.Is(r.Err, ErrFileModified) {
161 level = slog.LevelWarn
162 }
163 attrs = append(attrs, slog.Any("err", r.Err))
164 }
165 logger.LogAttrs(ctx, level, r.Status, attrs...)
166 }
167}