1 /**
2 Copyright: Copyright (c) 2021, Joakim Brännström. All rights reserved.
3 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost Software License 1.0)
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 */
6 module watchexec_internal.oneshot;
7 
8 import logger = std.experimental.logger;
9 import std.algorithm : map;
10 import std.array : empty, array;
11 import std.conv : to;
12 import std.exception : collectException;
13 import std.json : JSONValue;
14 
15 import colorlog;
16 import my.named_type;
17 import my.hash;
18 import my.path : AbsolutePath, Path;
19 import my.filter : GlobFilter;
20 import my.optional;
21 
22 alias FileSize = NamedType!(ulong, Tag!"FileSize", ulong.init, TagStringable, Comparable);
23 alias TimeStamp = NamedType!(long, Tag!"TimeStamp", long.init, TagStringable, Comparable);
24 
25 struct OneShotFile {
26     AbsolutePath path;
27 
28     /// unix time in seconds
29     TimeStamp timeStamp;
30 
31     FileSize size;
32 
33     bool hasChecksum;
34 
35     private {
36         Checksum64 checksum_;
37     }
38 
39     this(AbsolutePath path, TimeStamp timeStamp, FileSize size) {
40         this.path = path;
41         this.timeStamp = timeStamp;
42         this.size = size;
43     }
44 
45     this(AbsolutePath path, TimeStamp timeStamp, FileSize size, Checksum64 cs) {
46         this.path = path;
47         this.timeStamp = timeStamp;
48         this.size = size;
49         this.checksum_ = cs;
50         this.hasChecksum = true;
51     }
52 
53     Checksum64 checksum() nothrow {
54         if (hasChecksum || size.get == 0)
55             return checksum_;
56 
57         checksum_ = () {
58             try {
59                 return my.hash.checksum!makeChecksum64(path);
60             } catch (Exception e) {
61                 logger.trace(e.msg).collectException;
62             }
63             return Checksum64.init; // an empty file
64         }();
65         hasChecksum = true;
66         return checksum_;
67     }
68 }
69 
70 struct OneShotRange {
71     import std.file : dirEntries, SpanMode, timeLastModified, getSize, isFile;
72     import std.traits : ReturnType;
73     import my.file : followSymlink, existsAnd;
74 
75     private {
76         ReturnType!dirEntries entries;
77         GlobFilter gf;
78         Optional!OneShotFile front_;
79         bool followSymlink_;
80     }
81 
82     this(AbsolutePath root, GlobFilter gf, bool followSymlink) nothrow {
83         try {
84             this.entries = dirEntries(root, SpanMode.depth);
85             this.gf = gf;
86             this.followSymlink_ = followSymlink;
87         } catch (Exception e) {
88             logger.trace(e.msg).collectException;
89         }
90     }
91 
92     Optional!OneShotFile front() nothrow {
93         assert(!empty, "Can't get front of an empty range");
94 
95         if (front_.hasValue)
96             return front_;
97 
98         () {
99             try {
100                 auto f = () {
101                     if (entries.front.isSymlink && followSymlink_)
102                         return followSymlink(Path(entries.front.name)).orElse(Path.init).toString;
103                     return entries.front.name;
104                 }();
105 
106                 if (f.empty)
107                     return;
108 
109                 if (Path(f).existsAnd!isFile && gf.match(f)) {
110                     front_ = OneShotFile(AbsolutePath(f),
111                             f.timeLastModified.toUnixTime.TimeStamp, f.getSize.FileSize).some;
112                 }
113             } catch (Exception e) {
114                 logger.trace(e.msg).collectException;
115                 front_ = none!OneShotFile;
116             }
117         }();
118 
119         return front_;
120     }
121 
122     void popFront() @trusted nothrow {
123         assert(!empty, "Can't pop front of an empty range");
124 
125         front_ = none!OneShotFile;
126 
127         try {
128             entries.popFront;
129         } catch (Exception e) {
130             logger.trace(e.msg).collectException;
131         }
132     }
133 
134     bool empty() @safe nothrow {
135         try {
136             return entries.empty;
137         } catch (Exception e) {
138             logger.trace(e.msg).collectException;
139         }
140         return true;
141     }
142 }
143 
144 struct FileDb {
145     OneShotFile[AbsolutePath] files;
146     string[] command;
147 
148     void add(OneShotFile fc) {
149         files[fc.path] = fc;
150     }
151 
152     bool isChanged(ref OneShotFile fc) {
153         if (auto v = fc.path in files) {
154             if (v.size != fc.size)
155                 return true;
156             if (v.timeStamp == fc.timeStamp && v.size == fc.size)
157                 return false;
158             return v.checksum != fc.checksum;
159         }
160         return true;
161     }
162 }
163 
164 FileDb fromJson(string txt) nothrow {
165     import std.json : parseJSON;
166 
167     FileDb rval;
168 
169     auto json = () {
170         try {
171             return parseJSON(txt);
172         } catch (Exception e) {
173             logger.info(e.msg).collectException;
174         }
175         return JSONValue.init;
176     }();
177 
178     try {
179         foreach (a; json["files"].array) {
180             try {
181                 rval.add(OneShotFile(AbsolutePath(a["p"].str),
182                         a["t"].str.to!long.TimeStamp,
183                         a["s"].str.to!ulong.FileSize, Checksum64(a["c"].str.to!ulong)));
184             } catch (Exception e) {
185                 logger.trace(e.msg).collectException;
186             }
187         }
188     } catch (Exception e) {
189         logger.info(e.msg).collectException;
190     }
191     try {
192         rval.command = json["cmd"].array.map!(a => a.str).array;
193     } catch (Exception e) {
194         logger.info(e.msg).collectException;
195     }
196 
197     return rval;
198 }
199 
200 @("shall parse to the file database")
201 unittest {
202     auto txt = `[{"p": "foo/bar", "c": "1234", "t": "42"}]`;
203     auto db = fromJson(txt);
204     assert(AbsolutePath("foo/bar") in db.files);
205     assert(db.files[AbsolutePath("foo/bar")].checksum == Checksum64(1234));
206 }
207 
208 JSONValue toJson(ref FileDb db) {
209     import std.array : appender;
210     import std.path : relativePath;
211 
212     auto app = appender!(JSONValue[])();
213     foreach (fc; db.files.byValue) {
214         try {
215             JSONValue v;
216             v["p"] = relativePath(fc.path.toString);
217             v["c"] = fc.hasChecksum ? fc.checksum.c0.to!string : "0";
218             v["t"] = fc.timeStamp.get.to!string;
219             v["s"] = fc.size.get.to!string;
220             app.put(v);
221         } catch (Exception e) {
222         }
223     }
224     JSONValue rval;
225     rval["files"] = app.data;
226     rval["cmd"] = db.command;
227 
228     return rval;
229 }