Repository navigation
Expand file tree
/
Copy pathReplacer.cs
More file actions
748 lines (672 loc) · 30.7 KB
/
Copy pathReplacer.cs
File metadata and controls
748 lines (672 loc) · 30.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
// Replace in files: the one button here that can damage a directory.
//
// The whole design is arranged so that nothing is written that the person did
// not look at first, and so that anything written can be put back.
//
// - It never rescans. It works from the result set already on screen, so
// "eyeball the Find results before replacing" is not a habit anyone has to
// remember; it is the only path through the feature.
// - It verifies twice. Once to build the preview, and again immediately
// before the write, because minutes can pass while someone reads it.
// Verification is not a timestamp check alone: every line being edited
// must still be character-for-character what the search reported.
// - It preserves the case of what it replaced, which is what makes a
// British-to-American pass over a documentation folder produce a clean
// diff instead of a hundred new sentence-case mistakes.
// - It writes through a temporary file and keeps the original, so a whole
// run reverts as a unit.
//
// C# 5 only (in-box csc).
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace RSFind
{
public class ReplaceOptions
{
public string Replacement = "";
public bool PreserveCase = true;
}
public class ReplaceChange
{
public int LineNumber;
public string Before;
public string After;
public bool Selected = true;
// The exact span being replaced, carried rather than re-derived by
// diffing Before against After. A minimal diff of the two spellings
// below is the single letter u, which is accurate and unreadable: the
// preview would show a struck-out "u" where the reader needs to see
// one word becoming another.
//
// charcheck:spelling-off
// Colour -> Color diffs to u -> (nothing)
// charcheck:spelling-on
public int MatchStart;
public string OldText;
public string NewText;
// Index into the file's Hits. The offsets it implies are resolved at
// plan time and resolved again at write time, never trusted across
// the gap between them.
public int HitIndex;
}
public class ReplacePlan
{
public FileHits File;
public List<ReplaceChange> Changes = new List<ReplaceChange>();
// Non-null means nothing will be written to this file, and this is the
// sentence explaining why. Refusals are shown, never hidden: a file
// that silently declines to change is the worst outcome here.
public string Refusal;
public bool CanApply { get { return Refusal == null && SelectedCount > 0; } }
public int SelectedCount
{
get
{
int n = 0;
for (int i = 0; i < Changes.Count; i++) if (Changes[i].Selected) n++;
return n;
}
}
}
public class ReplaceResult
{
public int FilesWritten;
public int ChangesWritten;
public List<string> Failures = new List<string>();
public string UndoDirectory;
}
public static class Replacer
{
// ---- case preservation -------------------------------------------
// Gives the replacement the shape of what it replaced. The examples
// are the reason the feature exists, so they are spelled out with the
// spelling scan suspended for four lines.
//
// charcheck:spelling-off
// colour -> color, Colour -> Color, COLOUR -> COLOR
// charcheck:spelling-on
//
// Only when the replacement was typed in lower case. A replacement
// carrying its own capitals - RSFind, GmbH, macOS - was written that
// way deliberately, and reshaping it would be the tool overruling the
// person. A mixed-case match is left alone too: there is no shape
// there to copy.
public static string PreserveCase(string matched, string replacement)
{
if (string.IsNullOrEmpty(matched) || string.IsNullOrEmpty(replacement))
return replacement;
if (HasUpper(replacement)) return replacement;
if (IsAllUpper(matched)) return replacement.ToUpperInvariant();
if (IsTitleCase(matched))
return char.ToUpperInvariant(replacement[0]) + replacement.Substring(1);
return replacement;
}
static bool HasUpper(string s)
{
for (int i = 0; i < s.Length; i++) if (char.IsUpper(s[i])) return true;
return false;
}
static bool IsAllUpper(string s)
{
bool sawLetter = false;
for (int i = 0; i < s.Length; i++)
{
if (!char.IsLetter(s[i])) continue;
sawLetter = true;
if (!char.IsUpper(s[i])) return false;
}
return sawLetter;
}
static bool IsTitleCase(string s)
{
if (!char.IsUpper(s[0])) return false;
for (int i = 1; i < s.Length; i++)
if (char.IsLetter(s[i]) && char.IsUpper(s[i])) return false;
return true;
}
// ---- planning -------------------------------------------------------
public static ReplacePlan Plan(FileHits file, Matcher matcher, ReplaceOptions options)
{
ReplacePlan plan = new ReplacePlan();
plan.File = file;
// Refusals appear on one row in the preview, so they are written
// to be read at a glance: what is wrong, then what to do about it.
if (file.Extracted)
{
plan.Refusal = "text extracted from an Office file is not what is on disk";
return plan;
}
if (file.Transformed)
{
plan.Refusal = "escapes were stripped for the search; "
+ "turn off Strip ANSI escapes and search again";
return plan;
}
// The search stopped counting before it reached the end of this
// file, so replacing what it did find would leave the rest behind
// and report a number that sounds complete.
//
// This is the one place the project's own rule about caps was being
// broken. Everywhere else a cap that bites says so; here it bit
// during a WRITE and nothing said anything - a file with 7,000
// occurrences came back "Replaced 5,000 matches" with 2,000 still
// in it. Worse, the per-file cap and the preview cap are the same
// number, so a capped file lands exactly on the limit and slips
// under a strict greater-than every time.
if (file.Truncated)
{
plan.Refusal = "the search stopped at " +
file.Hits.Count.ToString("N0", CultureInfo.InvariantCulture) +
" matches in this file, so it holds more than were found; "
+ "narrow the search and replace again";
return plan;
}
string text;
Encoding encoding;
bool hasBom;
string refusal = ReadVerified(file, out text, out encoding, out hasBom);
if (refusal != null)
{
plan.Refusal = refusal;
return plan;
}
int[] starts = LineStarts(text);
for (int i = 0; i < file.Hits.Count; i++)
{
Hit hit = file.Hits[i];
string reason = VerifyHit(text, starts, hit);
if (reason != null)
{
plan.Refusal = reason;
plan.Changes.Clear();
return plan;
}
string matched = hit.Line.Substring(hit.MatchStart, hit.MatchLength);
string replacement;
try
{
replacement = matcher.Expand(hit.Line, hit.MatchStart, options.Replacement);
}
catch (PatternError ex)
{
plan.Refusal = ex.Message;
plan.Changes.Clear();
return plan;
}
if (options.PreserveCase) replacement = PreserveCase(matched, replacement);
ReplaceChange change = new ReplaceChange();
change.LineNumber = hit.LineNumber;
change.HitIndex = i;
change.MatchStart = hit.MatchStart;
change.OldText = matched;
change.NewText = replacement;
change.Before = hit.Line;
change.After = hit.Line.Substring(0, hit.MatchStart)
+ replacement
+ hit.Line.Substring(hit.MatchStart + hit.MatchLength);
plan.Changes.Add(change);
}
if (plan.Changes.Count == 0) plan.Refusal = "nothing to replace in this file";
return plan;
}
// ---- verification ----------------------------------------------------
// Re-reads the file and checks it is the one the search saw. Size and
// timestamp are the cheap half; the caller then checks every line it
// intends to touch, which is the half that catches an edit that
// happened to land on the same size.
static string ReadVerified(FileHits file, out string text,
out Encoding encoding, out bool hasBom)
{
text = null;
encoding = null;
hasBom = false;
FileInfo info;
try
{
info = new FileInfo(file.Path);
if (!info.Exists) return "the file no longer exists";
if (info.Length != file.Length)
return "the file has changed size since the search";
if (info.LastWriteTimeUtc != file.LastWriteUtc)
return "the file has been modified since the search";
}
catch (IOException ex) { return ex.Message; }
catch (UnauthorizedAccessException ex) { return ex.Message; }
byte[] bytes;
try
{
bytes = TextFiles.ReadAllBytesShared(file.Path);
}
catch (IOException ex) { return ex.Message; }
catch (UnauthorizedAccessException ex) { return ex.Message; }
// A binary is refused whatever the search options said. Someone
// who unchecked exclude-binary to find a string inside a firmware
// image did not thereby ask to have it rewritten.
if (TextFiles.LooksBinary(bytes))
return "this file looks binary, and RSFind will not rewrite binaries";
text = TextFiles.Decode(bytes, out encoding, out hasBom);
return null;
}
static string VerifyHit(string text, int[] starts, Hit hit)
{
int index = hit.LineNumber - 1;
if (index < 0 || index >= starts.Length)
return "the file has fewer lines than it did during the search";
// The line as stored, minus its terminator, which is what the
// search reported.
int from = starts[index];
int end = index + 1 < starts.Length ? starts[index + 1] : text.Length;
if (end > from && text[end - 1] == '\n') end--;
if (end > from && text[end - 1] == '\r') end--;
string current = text.Substring(from, end - from);
if (!string.Equals(current, hit.Line, StringComparison.Ordinal))
return "the text at line " + hit.LineNumber.ToString(CultureInfo.InvariantCulture)
+ " has changed since the search";
return null;
}
static int[] LineStarts(string text)
{
List<int> starts = new List<int>();
starts.Add(0);
for (int i = 0; i < text.Length; i++)
if (text[i] == '\n' && i + 1 <= text.Length) starts.Add(i + 1);
// A file ending in a newline has no line after it, matching how
// SplitLines refuses to invent a trailing empty line.
if (starts.Count > 1 && starts[starts.Count - 1] == text.Length)
starts.RemoveAt(starts.Count - 1);
return starts.ToArray();
}
// ---- writing ----------------------------------------------------------
// Applies the selected changes. Everything is verified again first,
// because the preview the person approved may be minutes old.
public static ReplaceResult Apply(IList<ReplacePlan> plans, string undoRoot)
{
ReplaceResult result = new ReplaceResult();
List<string> manifest = new List<string>();
string undoDir = null;
for (int p = 0; p < plans.Count; p++)
{
ReplacePlan plan = plans[p];
if (!plan.CanApply) continue;
string text;
Encoding encoding;
bool hasBom;
string refusal = ReadVerified(plan.File, out text, out encoding, out hasBom);
if (refusal != null)
{
result.Failures.Add(plan.File.RelativePath + ": " + refusal);
continue;
}
int[] starts = LineStarts(text);
string rebuilt;
refusal = Rebuild(plan, text, starts, out rebuilt);
if (refusal != null)
{
result.Failures.Add(plan.File.RelativePath + ": " + refusal);
continue;
}
byte[] outBytes;
refusal = Encode(rebuilt, encoding, hasBom, out outBytes);
if (refusal != null)
{
result.Failures.Add(plan.File.RelativePath + ": " + refusal);
continue;
}
if (undoDir == null)
{
undoDir = NewUndoDirectory(undoRoot);
if (undoDir == null)
{
result.Failures.Add("could not create the undo folder, so nothing was written");
return result;
}
}
string backup = Path.Combine(undoDir,
result.FilesWritten.ToString("D4", CultureInfo.InvariantCulture) + ".bak");
try
{
File.Copy(plan.File.Path, backup, true);
}
catch (IOException ex)
{
result.Failures.Add(plan.File.RelativePath + ": could not back up (" + ex.Message + ")");
continue;
}
catch (UnauthorizedAccessException ex)
{
result.Failures.Add(plan.File.RelativePath + ": could not back up (" + ex.Message + ")");
continue;
}
refusal = WriteThroughTemp(plan.File.Path, outBytes);
if (refusal != null)
{
result.Failures.Add(plan.File.RelativePath + ": " + refusal);
// The backup is kept, deliberately.
//
// It used to be deleted here as tidy-up, which is exactly
// backwards: a failed write is the one case where the copy
// might be the only one left. A stray .bak in the undo
// folder costs nothing, and the manifest names which run it
// came from.
result.Failures.Add(plan.File.RelativePath
+ ": a copy of the original was kept at " + backup);
continue;
}
// What was written, so Undo can refuse to clobber a later edit.
FileInfo after = new FileInfo(plan.File.Path);
manifest.Add(string.Join("|", new string[] {
Path.GetFileName(backup),
after.Length.ToString(CultureInfo.InvariantCulture),
after.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
plan.File.Path
}));
result.FilesWritten++;
result.ChangesWritten += plan.SelectedCount;
}
if (undoDir != null && manifest.Count > 0)
{
try
{
File.WriteAllLines(Path.Combine(undoDir, "manifest.txt"),
manifest.ToArray(), new UTF8Encoding(false));
result.UndoDirectory = undoDir;
// After the manifest, never before: a run is only prunable
// once it is complete, and pruning first would count this
// half-written one among the keepers.
PruneUndo(undoRoot, MaxUndoRuns);
}
catch (IOException ex)
{
result.Failures.Add("the undo record could not be written (" + ex.Message + ")");
}
}
return result;
}
// Rebuilds the whole file from the original text plus the selected
// edits, working on character offsets rather than by splitting and
// rejoining lines. Rejoining would normalize line endings, drop or add
// a final newline, and quietly rewrite every line in a file where only
// one was meant to change.
static string Rebuild(ReplacePlan plan, string text, int[] starts, out string rebuilt)
{
rebuilt = null;
List<int> offsets = new List<int>();
List<int> lengths = new List<int>();
List<string> inserts = new List<string>();
for (int i = 0; i < plan.Changes.Count; i++)
{
ReplaceChange change = plan.Changes[i];
if (!change.Selected) continue;
Hit hit = plan.File.Hits[change.HitIndex];
string reason = VerifyHit(text, starts, hit);
if (reason != null) return reason;
int abs = starts[hit.LineNumber - 1] + hit.MatchStart;
if (abs < 0 || abs + hit.MatchLength > text.Length)
return "a match now falls outside the file";
string onDisk = text.Substring(abs, hit.MatchLength);
string expected = hit.Line.Substring(hit.MatchStart, hit.MatchLength);
if (!string.Equals(onDisk, expected, StringComparison.Ordinal))
return "the matched text has changed since the search";
// The replacement is recovered from the previewed line, so
// what lands on disk is exactly what was on screen.
int tail = hit.Line.Length - (hit.MatchStart + hit.MatchLength);
string replacement = change.After.Substring(
hit.MatchStart, change.After.Length - tail - hit.MatchStart);
offsets.Add(abs);
lengths.Add(hit.MatchLength);
inserts.Add(replacement);
}
if (offsets.Count == 0) return "nothing was selected";
// Ascending order, skipping any that overlap one already taken.
int[] order = new int[offsets.Count];
for (int i = 0; i < order.Length; i++) order[i] = i;
Array.Sort(order, delegate(int a, int b) { return offsets[a].CompareTo(offsets[b]); });
StringBuilder sb = new StringBuilder(text.Length);
int cursor = 0;
for (int k = 0; k < order.Length; k++)
{
int i = order[k];
if (offsets[i] < cursor) continue;
sb.Append(text, cursor, offsets[i] - cursor);
sb.Append(inserts[i]);
cursor = offsets[i] + lengths[i];
}
sb.Append(text, cursor, text.Length - cursor);
rebuilt = sb.ToString();
return null;
}
// Encodes and then proves the encoding round-trips.
//
// This is the guard that stops an ANSI file quietly losing characters.
// Encoding.Default maps anything outside its codepage to a question
// mark and reports success, so replacing a word in a Windows-1252 file
// with one containing an em dash or a Greek letter would silently
// destroy it. Better to refuse the file and say so.
static string Encode(string text, Encoding encoding, bool hasBom, out byte[] bytes)
{
bytes = null;
try
{
byte[] body = encoding.GetBytes(text);
string roundTrip = encoding.GetString(body);
if (!string.Equals(roundTrip, text, StringComparison.Ordinal))
return "the replacement uses characters this file's encoding ("
+ encoding.WebName + ") cannot store";
if (!hasBom) { bytes = body; return null; }
byte[] preamble = encoding.GetPreamble();
byte[] all = new byte[preamble.Length + body.Length];
Buffer.BlockCopy(preamble, 0, all, 0, preamble.Length);
Buffer.BlockCopy(body, 0, all, preamble.Length, body.Length);
bytes = all;
return null;
}
catch (EncoderFallbackException)
{
return "the replacement cannot be encoded in this file's encoding";
}
}
// Writes beside the target and swaps, so a failure part-way through
// leaves the original intact rather than a half-written file.
static string WriteThroughTemp(string path, byte[] bytes)
{
string temp = path + ".rsfind-tmp";
try
{
File.WriteAllBytes(temp, bytes);
}
catch (IOException ex) { return ex.Message; }
catch (UnauthorizedAccessException ex) { return ex.Message; }
try
{
// Replace keeps the destination's attributes and access
// control, which a delete-then-move would throw away.
File.Replace(temp, path, null);
return null;
}
catch (IOException)
{
// Replace refuses across some filesystems and on files with
// certain attributes, so a fallback is worth having. The
// fallback must not be delete-then-move.
//
// Deleting first opens a window in which the original is gone
// and the new content is not yet in place. A scanner or an
// indexer grabbing the path in that window - the ordinary way
// this happens - fails the move, and the old code then deleted
// the temp as tidy-up while the caller deleted the backup. All
// three copies gone, from a design whose whole promise is that
// an interrupted write leaves the original intact.
//
// Renaming aside instead means there is never a moment with no
// copy of the file, and a failed move can be undone.
string aside = path + ".rsfind-old";
try
{
Cleanup(aside);
File.Move(path, aside);
}
catch (IOException ex) { Cleanup(temp); return ex.Message; }
catch (UnauthorizedAccessException ex) { Cleanup(temp); return ex.Message; }
try
{
File.Move(temp, path);
}
catch (Exception ex)
{
// Put the original back and leave the temp alone. A stray
// .rsfind-tmp beside the file is a far better outcome than
// a missing file, and the caller keeps its backup.
try { File.Move(aside, path); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
return ex.Message;
}
Cleanup(aside);
return null;
}
catch (UnauthorizedAccessException ex)
{
Cleanup(temp);
return ex.Message;
}
}
static void Cleanup(string temp)
{
try { if (File.Exists(temp)) File.Delete(temp); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
// ---- undo -------------------------------------------------------------
static string NewUndoDirectory(string undoRoot)
{
try
{
// The caller supplies the stamp so this stays testable and so
// two runs in the same second cannot collide.
string dir = Path.Combine(undoRoot,
DateTime.Now.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture));
int suffix = 1;
string candidate = dir;
while (Directory.Exists(candidate))
candidate = dir + "-" + (++suffix).ToString(CultureInfo.InvariantCulture);
Directory.CreateDirectory(candidate);
return candidate;
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
// Puts back every file in a run, refusing any that has been edited
// since. A file someone has worked on since the replace is not one to
// silently overwrite in the name of undoing.
public static ReplaceResult Undo(string undoDir)
{
ReplaceResult result = new ReplaceResult();
result.UndoDirectory = undoDir;
string[] lines;
try
{
lines = File.ReadAllLines(Path.Combine(undoDir, "manifest.txt"));
}
catch (IOException ex)
{
result.Failures.Add("the undo record could not be read (" + ex.Message + ")");
return result;
}
foreach (string line in lines)
{
string[] parts = line.Split(new char[] { '|' }, 4);
if (parts.Length != 4) continue;
string backup = Path.Combine(undoDir, parts[0]);
long expectedLength;
long expectedTicks;
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out expectedLength)) continue;
if (!long.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out expectedTicks)) continue;
string target = parts[3];
try
{
FileInfo now = new FileInfo(target);
if (!now.Exists)
{
result.Failures.Add(target + ": no longer exists");
continue;
}
if (now.Length != expectedLength || now.LastWriteTimeUtc.Ticks != expectedTicks)
{
result.Failures.Add(target + ": edited since the replace, left alone");
continue;
}
File.Copy(backup, target, true);
result.FilesWritten++;
}
catch (IOException ex) { result.Failures.Add(target + ": " + ex.Message); }
catch (UnauthorizedAccessException ex) { result.Failures.Add(target + ": " + ex.Message); }
}
return result;
}
// How many replace runs are kept.
//
// Ten is generous for the thing undo is actually for, which is almost
// always the run you just did. It is also the number that stops this
// folder being a growing archive of file contents nobody asked for -
// and that matters more than the disk space. RSFind makes a deliberate
// point of never writing down a search query, on the grounds that one
// is as likely to be a serial number or a password as a word; complete
// copies of the files those queries ran against are more sensitive than
// the queries, so keeping them forever, in a place the user was never
// told about, was the louder inconsistency.
public const int MaxUndoRuns = 10;
// Keeps the newest runs and removes the rest. Returns how many went.
//
// Only directories holding a manifest are touched - the ones this class
// wrote. Anything else a person has put under the undo folder is theirs
// and is left alone, which is the same rule the test suite follows about
// its own scratch directory.
public static int PruneUndo(string undoRoot, int keep)
{
if (keep < 0) keep = 0;
List<string> runs = new List<string>();
try
{
if (!Directory.Exists(undoRoot)) return 0;
foreach (string dir in Directory.GetDirectories(undoRoot))
if (File.Exists(Path.Combine(dir, "manifest.txt"))) runs.Add(dir);
}
catch (IOException) { return 0; }
catch (UnauthorizedAccessException) { return 0; }
// The run directories are named yyyyMMdd-HHmmss, so sorting by name
// is sorting by time.
runs.Sort(StringComparer.OrdinalIgnoreCase);
int removed = 0;
for (int i = 0; i < runs.Count - keep; i++)
{
try
{
Directory.Delete(runs[i], true);
removed++;
}
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
return removed;
}
// The most recent run, or null. Used to offer Undo without making the
// person find a folder.
public static string LatestUndoDirectory(string undoRoot)
{
try
{
if (!Directory.Exists(undoRoot)) return null;
string[] dirs = Directory.GetDirectories(undoRoot);
Array.Sort(dirs, StringComparer.OrdinalIgnoreCase);
for (int i = dirs.Length - 1; i >= 0; i--)
if (File.Exists(Path.Combine(dirs[i], "manifest.txt"))) return dirs[i];
return null;
}
catch (IOException) { return null; }
catch (UnauthorizedAccessException) { return null; }
}
}
}