appydave-tools 0.67.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/CLAUDE.md +33 -0
- data/docs/ai-instructions/behavioral-regression-audit.md +659 -0
- data/docs/code-quality/behavioral-audit-2025-01-22.md +659 -0
- data/docs/code-quality/uat-plan-2025-01-22.md +374 -0
- data/lib/appydave/tools/dam/brand_resolver.rb +7 -1
- data/lib/appydave/tools/dam/errors.rb +9 -1
- data/lib/appydave/tools/dam/fuzzy_matcher.rb +63 -0
- data/lib/appydave/tools/dam/project_listing.rb +51 -12
- data/lib/appydave/tools/dam/s3_operations.rb +95 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools.rb +1 -0
- data/package.json +1 -1
- metadata +5 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# User Acceptance Test Plan - DAM CLI Enhancements
|
|
2
|
+
|
|
3
|
+
**Test Date:** 2025-01-22
|
|
4
|
+
**Commit Range:** `9e49668` → `4228b51` (75 commits)
|
|
5
|
+
**Test Scope:** Verify core functionality intact + new features working
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Delivery Method
|
|
10
|
+
|
|
11
|
+
This test plan will be executed **interactively** using the following process:
|
|
12
|
+
|
|
13
|
+
1. **AI announces:** "Testing [feature]: [description]"
|
|
14
|
+
2. **AI runs:** `command here`
|
|
15
|
+
3. **AI shows:** Output from command
|
|
16
|
+
4. **User responds:**
|
|
17
|
+
- Type `1` to proceed to next test (affirmative)
|
|
18
|
+
- OR provide feedback/change of plan
|
|
19
|
+
|
|
20
|
+
**Important:**
|
|
21
|
+
- Tests are executed **one at a time** (not batched)
|
|
22
|
+
- Quick verification focus (not exhaustive testing)
|
|
23
|
+
- S3 operations use `--dry-run` flag (no actual uploads/downloads)
|
|
24
|
+
- User can interrupt at any point with feedback
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Test Structure
|
|
29
|
+
|
|
30
|
+
### Suite 1: Core Functionality (Baseline)
|
|
31
|
+
**Goal:** Verify nothing broke from original functionality
|
|
32
|
+
|
|
33
|
+
**Tests:**
|
|
34
|
+
1. Brand listing (default view)
|
|
35
|
+
2. Brand listing (invalid brand with error)
|
|
36
|
+
3. Project listing (specific brand)
|
|
37
|
+
4. Project listing (pattern matching)
|
|
38
|
+
5. S3 status check
|
|
39
|
+
6. Git status check
|
|
40
|
+
|
|
41
|
+
### Suite 2: New Features (Additions)
|
|
42
|
+
**Goal:** Verify new capabilities work as intended
|
|
43
|
+
|
|
44
|
+
**Tests:**
|
|
45
|
+
7. Brand listing (--detailed flag)
|
|
46
|
+
8. Project listing (--detailed flag)
|
|
47
|
+
9. Fuzzy matching (typo suggestions)
|
|
48
|
+
10. Git status column (brand level)
|
|
49
|
+
11. Git status column (project level)
|
|
50
|
+
12. S3 sync status (3-state model)
|
|
51
|
+
13. S3 timestamps (detailed view)
|
|
52
|
+
|
|
53
|
+
### Suite 3: Edge Cases (Regressions)
|
|
54
|
+
**Goal:** Catch potential issues from refactoring
|
|
55
|
+
|
|
56
|
+
**Tests:**
|
|
57
|
+
14. Case-insensitive brand resolution
|
|
58
|
+
15. Brand shortcuts (ad, voz, joy, ss)
|
|
59
|
+
16. S3 operations with no staging folder
|
|
60
|
+
17. Git operations on non-git directories
|
|
61
|
+
|
|
62
|
+
### Suite 4: Performance Check
|
|
63
|
+
**Goal:** Ensure no major slowdowns
|
|
64
|
+
|
|
65
|
+
**Tests:**
|
|
66
|
+
18. List all brands (measure response time)
|
|
67
|
+
19. List brand projects with many items
|
|
68
|
+
20. Detailed view performance
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Test Execution Plan
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
### **SUITE 1: CORE FUNCTIONALITY (BASELINE)**
|
|
77
|
+
|
|
78
|
+
#### Test 1: Brand Listing (Default View)
|
|
79
|
+
**Purpose:** Verify default brand list still works
|
|
80
|
+
**Command:** `dam list`
|
|
81
|
+
**Expected:**
|
|
82
|
+
- Shows all brands
|
|
83
|
+
- Displays: BRAND, KEY, PROJECTS, SIZE, LAST MODIFIED, GIT, S3 SYNC columns
|
|
84
|
+
- No errors
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
#### Test 2: Invalid Brand (Error Handling)
|
|
89
|
+
**Purpose:** Verify error messages work
|
|
90
|
+
**Command:** `dam list invalidbrand`
|
|
91
|
+
**Expected:**
|
|
92
|
+
- Error message: "Brand directory not found: invalidbrand"
|
|
93
|
+
- Shows available brands
|
|
94
|
+
- No crash
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
#### Test 3: Project Listing (Specific Brand)
|
|
99
|
+
**Purpose:** Verify project listing unchanged
|
|
100
|
+
**Command:** `dam list appydave`
|
|
101
|
+
**Expected:**
|
|
102
|
+
- Shows all appydave projects
|
|
103
|
+
- Displays project names, sizes, dates
|
|
104
|
+
- No errors
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
#### Test 4: Pattern Matching
|
|
109
|
+
**Purpose:** Verify pattern expansion works
|
|
110
|
+
**Command:** `dam list appydave 'b6*'`
|
|
111
|
+
**Expected:**
|
|
112
|
+
- Shows only projects matching b6* (b60-b69)
|
|
113
|
+
- Pattern correctly expands
|
|
114
|
+
- No errors
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
#### Test 5: S3 Status Check
|
|
119
|
+
**Purpose:** Verify S3 status command works
|
|
120
|
+
**Command:** `dam s3-status appydave b65`
|
|
121
|
+
**Expected:**
|
|
122
|
+
- Shows S3 sync status for b65
|
|
123
|
+
- Lists files in S3 vs local
|
|
124
|
+
- Displays sync state (upload/download/synced)
|
|
125
|
+
- No errors
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
#### Test 6: Git Status Check
|
|
130
|
+
**Purpose:** Verify git status detection works
|
|
131
|
+
**Command:** `dam status appydave`
|
|
132
|
+
**Expected:**
|
|
133
|
+
- Shows git repository status
|
|
134
|
+
- Shows branch, commits ahead/behind
|
|
135
|
+
- Shows modified/untracked files
|
|
136
|
+
- No errors
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### **SUITE 2: NEW FEATURES (ADDITIONS)**
|
|
141
|
+
|
|
142
|
+
#### Test 7: Brand Listing (--detailed Flag)
|
|
143
|
+
**Purpose:** Verify new --detailed flag for brands
|
|
144
|
+
**Command:** `dam list --detailed`
|
|
145
|
+
**Expected:**
|
|
146
|
+
- Shows extended columns: PATH, SSD BACKUP, WORKFLOW, ACTIVE PROJECTS
|
|
147
|
+
- Table width approximately 200 characters
|
|
148
|
+
- All data populated correctly
|
|
149
|
+
- No errors
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
#### Test 8: Project Listing (--detailed Flag)
|
|
154
|
+
**Purpose:** Verify new --detailed flag for projects
|
|
155
|
+
**Command:** `dam list appydave --detailed`
|
|
156
|
+
**Expected:**
|
|
157
|
+
- Shows extended columns: HEAVY/LIGHT FILES, SSD BACKUP, S3 TIMESTAMPS
|
|
158
|
+
- Additional metadata displayed
|
|
159
|
+
- No errors
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
#### Test 9: Fuzzy Matching (Typo Suggestions)
|
|
164
|
+
**Purpose:** Verify "Did you mean?" feature
|
|
165
|
+
**Command:** `dam list appydav`
|
|
166
|
+
**Expected:**
|
|
167
|
+
- Error message shows "Did you mean?"
|
|
168
|
+
- Suggests "appydave"
|
|
169
|
+
- Helpful error format
|
|
170
|
+
- No crash
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
#### Test 10: Git Status Column (Brand Level)
|
|
175
|
+
**Purpose:** Verify GIT STATUS column in brand list
|
|
176
|
+
**Command:** `dam list`
|
|
177
|
+
**Expected:**
|
|
178
|
+
- Shows "✓ clean" or "⚠️ changes" for each brand
|
|
179
|
+
- Accurate reflection of git state
|
|
180
|
+
- No errors
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
#### Test 11: Git Status Column (Project Level)
|
|
185
|
+
**Purpose:** Verify GIT column in project list
|
|
186
|
+
**Command:** `dam list appydave`
|
|
187
|
+
**Expected:**
|
|
188
|
+
- Shows git status per project (if git repo)
|
|
189
|
+
- Shows "N/A" for non-git projects
|
|
190
|
+
- No errors
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
#### Test 12: S3 Sync Status (3-State Model)
|
|
195
|
+
**Purpose:** Verify ↑ upload / ↓ download / ✓ synced states
|
|
196
|
+
**Command:** `dam list appydave`
|
|
197
|
+
**Expected:**
|
|
198
|
+
- S3 SYNC column shows one of: ↑ upload, ↓ download, ✓ synced, none, N/A
|
|
199
|
+
- Accurate reflection of sync state
|
|
200
|
+
- No errors
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
#### Test 13: S3 Timestamps (Detailed View)
|
|
205
|
+
**Purpose:** Verify last upload/download times
|
|
206
|
+
**Command:** `dam list appydave --detailed`
|
|
207
|
+
**Expected:**
|
|
208
|
+
- Shows S3 LAST UPLOAD and LAST DOWNLOAD columns
|
|
209
|
+
- Timestamps formatted correctly
|
|
210
|
+
- Shows "N/A" if no S3 configured
|
|
211
|
+
- No errors
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
### **SUITE 3: EDGE CASES (REGRESSIONS)**
|
|
216
|
+
|
|
217
|
+
#### Test 14: Case-Insensitive Brand Resolution
|
|
218
|
+
**Purpose:** Verify case handling works
|
|
219
|
+
**Commands:**
|
|
220
|
+
- `dam list appydave`
|
|
221
|
+
- `dam list APPYDAVE`
|
|
222
|
+
- `dam list AppyDave`
|
|
223
|
+
|
|
224
|
+
**Expected:**
|
|
225
|
+
- All three commands work identically
|
|
226
|
+
- Same projects listed
|
|
227
|
+
- No errors
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
#### Test 15: Brand Shortcuts
|
|
232
|
+
**Purpose:** Verify shortcuts resolve correctly
|
|
233
|
+
**Commands:**
|
|
234
|
+
- `dam list ad` (should resolve to appydave)
|
|
235
|
+
- `dam list voz` (should resolve to voz)
|
|
236
|
+
- `dam list joy` (should resolve to beauty-and-joy)
|
|
237
|
+
- `dam list ss` (should resolve to supportsignal)
|
|
238
|
+
|
|
239
|
+
**Expected:**
|
|
240
|
+
- Each shortcut resolves to correct brand
|
|
241
|
+
- Projects listed correctly
|
|
242
|
+
- No errors
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
#### Test 16: S3 Operations (No Staging Folder)
|
|
247
|
+
**Purpose:** Verify graceful handling when s3-staging/ doesn't exist
|
|
248
|
+
**Command:** `dam s3-up appydave b40 --dry-run`
|
|
249
|
+
**Expected:**
|
|
250
|
+
- Clear error message OR creates staging directory
|
|
251
|
+
- No crash
|
|
252
|
+
- Helpful guidance
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
#### Test 17: Git Operations (Non-Git Directory)
|
|
257
|
+
**Purpose:** Verify graceful handling of non-git projects
|
|
258
|
+
**Command:** `dam list appydave` (view projects with no .git)
|
|
259
|
+
**Expected:**
|
|
260
|
+
- GIT column shows "N/A" for non-git projects
|
|
261
|
+
- No errors
|
|
262
|
+
- No crash
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
### **SUITE 4: PERFORMANCE CHECK**
|
|
267
|
+
|
|
268
|
+
#### Test 18: List All Brands (Response Time)
|
|
269
|
+
**Purpose:** Ensure no major slowdown from new features
|
|
270
|
+
**Command:** `time dam list`
|
|
271
|
+
**Expected:**
|
|
272
|
+
- Completes in < 3 seconds (reasonable for filesystem scan)
|
|
273
|
+
- Output matches expected format
|
|
274
|
+
- No errors
|
|
275
|
+
|
|
276
|
+
**Baseline:** Original implementation time (if known)
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
#### Test 19: List Brand Projects (Many Items)
|
|
281
|
+
**Purpose:** Verify performance with 20+ projects
|
|
282
|
+
**Command:** `time dam list appydave`
|
|
283
|
+
**Expected:**
|
|
284
|
+
- Completes in < 5 seconds
|
|
285
|
+
- All projects displayed
|
|
286
|
+
- No errors
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
#### Test 20: Detailed View Performance
|
|
291
|
+
**Purpose:** Ensure --detailed flag doesn't cause excessive slowdown
|
|
292
|
+
**Command:** `time dam list appydave --detailed`
|
|
293
|
+
**Expected:**
|
|
294
|
+
- Completes in < 10 seconds (allows for S3 API calls)
|
|
295
|
+
- All data populated
|
|
296
|
+
- No errors
|
|
297
|
+
|
|
298
|
+
**Note:** This may be slower due to S3 API calls per project
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Success Criteria
|
|
303
|
+
|
|
304
|
+
### Must Pass (Critical)
|
|
305
|
+
- ✅ All baseline tests (Suite 1) pass
|
|
306
|
+
- ✅ No breaking changes to existing commands
|
|
307
|
+
- ✅ No crashes or unhandled exceptions
|
|
308
|
+
- ✅ Error messages are helpful
|
|
309
|
+
|
|
310
|
+
### Should Pass (Important)
|
|
311
|
+
- ✅ All new features work as designed (Suite 2)
|
|
312
|
+
- ✅ Edge cases handled gracefully (Suite 3)
|
|
313
|
+
- ✅ Performance acceptable (Suite 4)
|
|
314
|
+
|
|
315
|
+
### Nice to Have (Enhancements)
|
|
316
|
+
- ✅ Fuzzy matching suggestions accurate
|
|
317
|
+
- ✅ Git/S3 status columns informative
|
|
318
|
+
- ✅ Detailed views show useful metadata
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Failure Handling
|
|
323
|
+
|
|
324
|
+
If a test fails:
|
|
325
|
+
1. **Document the failure:**
|
|
326
|
+
- Test number
|
|
327
|
+
- Command run
|
|
328
|
+
- Expected vs actual output
|
|
329
|
+
- Error message (if any)
|
|
330
|
+
|
|
331
|
+
2. **Classify severity:**
|
|
332
|
+
- 🔴 **CRITICAL:** Core functionality broken (Suite 1)
|
|
333
|
+
- 🟡 **MODERATE:** New feature not working (Suite 2)
|
|
334
|
+
- 🟢 **MINOR:** Edge case or performance issue (Suite 3/4)
|
|
335
|
+
|
|
336
|
+
3. **Decision:**
|
|
337
|
+
- Critical failures → Stop testing, fix immediately
|
|
338
|
+
- Moderate failures → Document, continue testing
|
|
339
|
+
- Minor failures → Note for future improvement
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Test Execution Checklist
|
|
344
|
+
|
|
345
|
+
Before starting:
|
|
346
|
+
- [ ] Working directory: `/Users/davidcruwys/dev/ad/appydave-tools`
|
|
347
|
+
- [ ] Dam command available: `which dam` or `bin/dam`
|
|
348
|
+
- [ ] Configuration valid: `ad_config -l`
|
|
349
|
+
- [ ] Test brands exist: appydave, voz, etc.
|
|
350
|
+
|
|
351
|
+
During testing:
|
|
352
|
+
- [ ] Execute tests one at a time
|
|
353
|
+
- [ ] Wait for user "1" confirmation before proceeding
|
|
354
|
+
- [ ] Document any unexpected output
|
|
355
|
+
- [ ] Note performance anomalies
|
|
356
|
+
|
|
357
|
+
After testing:
|
|
358
|
+
- [ ] Summarize results (pass/fail counts)
|
|
359
|
+
- [ ] Document any critical issues
|
|
360
|
+
- [ ] Provide recommendations
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Notes
|
|
365
|
+
|
|
366
|
+
- **S3 Testing:** Uses `--dry-run` flag to avoid actual uploads/downloads
|
|
367
|
+
- **Git Testing:** Read-only operations, no repository modifications
|
|
368
|
+
- **Performance:** Measured with `time` command, baseline comparison if available
|
|
369
|
+
- **Interactive Format:** User controls pace with "1" confirmations
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
**Test plan prepared:** 2025-01-22
|
|
374
|
+
**Ready to execute:** Awaiting user confirmation to begin Suite 1
|
|
@@ -91,8 +91,14 @@ module Appydave
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
unless Dir.exist?(brand_path)
|
|
94
|
+
# Get available brands for error message
|
|
94
95
|
available = Config.available_brands_display
|
|
95
|
-
|
|
96
|
+
|
|
97
|
+
# Use fuzzy matching to suggest similar brands
|
|
98
|
+
available_list = Config.available_brands
|
|
99
|
+
suggestions = FuzzyMatcher.find_matches(brand, available_list, threshold: 3)
|
|
100
|
+
|
|
101
|
+
raise BrandNotFoundError.new(brand, available, suggestions)
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
key
|
|
@@ -8,8 +8,16 @@ module Appydave
|
|
|
8
8
|
|
|
9
9
|
# Raised when brand directory not found
|
|
10
10
|
class BrandNotFoundError < DamError
|
|
11
|
-
def initialize(brand, available_brands = nil)
|
|
11
|
+
def initialize(brand, available_brands = nil, suggestions = nil)
|
|
12
12
|
message = "Brand directory not found: #{brand}"
|
|
13
|
+
|
|
14
|
+
# Add fuzzy match suggestions if provided
|
|
15
|
+
if suggestions && !suggestions.empty?
|
|
16
|
+
message += "\n\nDid you mean?"
|
|
17
|
+
suggestions.each { |s| message += "\n - #{s}" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add full list of available brands
|
|
13
21
|
message += "\n\nAvailable brands:\n#{available_brands}" if available_brands && !available_brands.empty?
|
|
14
22
|
super(message)
|
|
15
23
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module Dam
|
|
6
|
+
# Fuzzy matching for brand names using Levenshtein distance
|
|
7
|
+
class FuzzyMatcher
|
|
8
|
+
class << self
|
|
9
|
+
# Find closest matches to input string
|
|
10
|
+
# @param input [String] Input string to match
|
|
11
|
+
# @param candidates [Array<String>] List of valid options
|
|
12
|
+
# @param threshold [Integer] Maximum distance to consider a match (default: 3)
|
|
13
|
+
# @return [Array<String>] Sorted list of closest matches
|
|
14
|
+
def find_matches(input, candidates, threshold: 3)
|
|
15
|
+
return [] if input.nil? || input.empty? || candidates.empty?
|
|
16
|
+
|
|
17
|
+
# Calculate distances and filter by threshold
|
|
18
|
+
matches = candidates.map do |candidate|
|
|
19
|
+
distance = levenshtein_distance(input.downcase, candidate.downcase)
|
|
20
|
+
{ candidate: candidate, distance: distance }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Filter by threshold
|
|
24
|
+
matches = matches.select { |m| m[:distance] <= threshold }
|
|
25
|
+
|
|
26
|
+
# Sort by distance (closest first)
|
|
27
|
+
matches.sort_by { |m| m[:distance] }.map { |m| m[:candidate] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Calculate Levenshtein distance between two strings
|
|
31
|
+
# @param str1 [String] First string
|
|
32
|
+
# @param str2 [String] Second string
|
|
33
|
+
# @return [Integer] Edit distance
|
|
34
|
+
def levenshtein_distance(str1, str2)
|
|
35
|
+
return str2.length if str1.empty?
|
|
36
|
+
return str1.length if str2.empty?
|
|
37
|
+
|
|
38
|
+
# Create distance matrix
|
|
39
|
+
matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1) }
|
|
40
|
+
|
|
41
|
+
# Initialize first row and column
|
|
42
|
+
(0..str1.length).each { |i| matrix[i][0] = i }
|
|
43
|
+
(0..str2.length).each { |j| matrix[0][j] = j }
|
|
44
|
+
|
|
45
|
+
# Calculate distances
|
|
46
|
+
(1..str1.length).each do |i|
|
|
47
|
+
(1..str2.length).each do |j|
|
|
48
|
+
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
|
49
|
+
matrix[i][j] = [
|
|
50
|
+
matrix[i - 1][j] + 1, # deletion
|
|
51
|
+
matrix[i][j - 1] + 1, # insertion
|
|
52
|
+
matrix[i - 1][j - 1] + cost # substitution
|
|
53
|
+
].min
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
matrix[str1.length][str2.length]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -121,14 +121,18 @@ module Appydave
|
|
|
121
121
|
if detailed
|
|
122
122
|
# Detailed view with additional columns
|
|
123
123
|
header = 'PROJECT SIZE AGE GIT S3 ' \
|
|
124
|
-
'PATH HEAVY FILES LIGHT FILES SSD BACKUP'
|
|
124
|
+
'PATH HEAVY FILES LIGHT FILES SSD BACKUP ' \
|
|
125
|
+
'S3 ↑ UPLOAD S3 ↓ DOWNLOAD'
|
|
125
126
|
puts header
|
|
126
|
-
puts '-' *
|
|
127
|
+
puts '-' * 250
|
|
127
128
|
|
|
128
129
|
project_data.each do |data|
|
|
129
130
|
age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
|
|
131
|
+
s3_upload = data[:s3_last_upload] ? format_age(data[:s3_last_upload]) : 'N/A'
|
|
132
|
+
s3_download = data[:s3_last_download] ? format_age(data[:s3_last_download]) : 'N/A'
|
|
133
|
+
|
|
130
134
|
puts format(
|
|
131
|
-
'%-45s %12s %15s %-15s %-
|
|
135
|
+
'%-45s %12s %15s %-15s %-12s %-35s %-18s %-18s %-30s %-15s %-15s',
|
|
132
136
|
data[:name],
|
|
133
137
|
format_size(data[:size]),
|
|
134
138
|
age_display,
|
|
@@ -137,7 +141,9 @@ module Appydave
|
|
|
137
141
|
shorten_path(data[:path]),
|
|
138
142
|
data[:heavy_files] || 'N/A',
|
|
139
143
|
data[:light_files] || 'N/A',
|
|
140
|
-
data[:ssd_backup] || 'N/A'
|
|
144
|
+
data[:ssd_backup] || 'N/A',
|
|
145
|
+
s3_upload,
|
|
146
|
+
s3_download
|
|
141
147
|
)
|
|
142
148
|
end
|
|
143
149
|
else
|
|
@@ -148,7 +154,7 @@ module Appydave
|
|
|
148
154
|
project_data.each do |data|
|
|
149
155
|
age_display = data[:stale] ? "#{data[:age]} ⚠️" : data[:age]
|
|
150
156
|
puts format(
|
|
151
|
-
'%-45s %12s %15s %-15s %-
|
|
157
|
+
'%-45s %12s %15s %-15s %-12s',
|
|
152
158
|
data[:name],
|
|
153
159
|
format_size(data[:size]),
|
|
154
160
|
age_display,
|
|
@@ -377,6 +383,38 @@ module Appydave
|
|
|
377
383
|
end
|
|
378
384
|
end
|
|
379
385
|
|
|
386
|
+
# Calculate 3-state S3 sync status for a project
|
|
387
|
+
def self.calculate_project_s3_sync_status(brand_arg, brand_info, project)
|
|
388
|
+
# Check if S3 is configured
|
|
389
|
+
s3_bucket = brand_info.aws.s3_bucket
|
|
390
|
+
return 'N/A' if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
|
|
391
|
+
|
|
392
|
+
# Use S3Operations to calculate sync status
|
|
393
|
+
begin
|
|
394
|
+
s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
|
|
395
|
+
s3_ops.calculate_sync_status
|
|
396
|
+
rescue StandardError
|
|
397
|
+
# S3 not accessible or other error
|
|
398
|
+
'N/A'
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Calculate S3 sync timestamps for a project
|
|
403
|
+
def self.calculate_s3_timestamps(brand_arg, brand_info, project)
|
|
404
|
+
# Check if S3 is configured
|
|
405
|
+
s3_bucket = brand_info.aws.s3_bucket
|
|
406
|
+
return { last_upload: nil, last_download: nil } if s3_bucket.nil? || s3_bucket.empty? || s3_bucket == 'NOT-SET'
|
|
407
|
+
|
|
408
|
+
# Use S3Operations to get timestamps
|
|
409
|
+
begin
|
|
410
|
+
s3_ops = S3Operations.new(brand_arg, project, brand_info: brand_info)
|
|
411
|
+
s3_ops.sync_timestamps
|
|
412
|
+
rescue StandardError
|
|
413
|
+
# S3 not accessible or other error
|
|
414
|
+
{ last_upload: nil, last_download: nil }
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
380
418
|
# Collect project data for display
|
|
381
419
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/ParameterLists
|
|
382
420
|
def self.collect_project_data(brand_arg, brand_path, brand_info, project, is_git_repo, detailed: false)
|
|
@@ -391,12 +429,8 @@ module Appydave
|
|
|
391
429
|
'N/A'
|
|
392
430
|
end
|
|
393
431
|
|
|
394
|
-
#
|
|
395
|
-
s3_sync =
|
|
396
|
-
'✓ staged'
|
|
397
|
-
else
|
|
398
|
-
'none'
|
|
399
|
-
end
|
|
432
|
+
# Calculate 3-state S3 sync status
|
|
433
|
+
s3_sync = calculate_project_s3_sync_status(brand_arg, brand_info, project)
|
|
400
434
|
|
|
401
435
|
result = {
|
|
402
436
|
name: project,
|
|
@@ -434,10 +468,15 @@ module Appydave
|
|
|
434
468
|
File.exist?(ssd_project_path) ? shorten_path(ssd_project_path) : nil
|
|
435
469
|
end
|
|
436
470
|
|
|
471
|
+
# S3 timestamps (last upload/download)
|
|
472
|
+
s3_timestamps = calculate_s3_timestamps(brand_arg, brand_info, project)
|
|
473
|
+
|
|
437
474
|
result.merge!(
|
|
438
475
|
heavy_files: "#{heavy_count} (#{format_size(heavy_size)})",
|
|
439
476
|
light_files: "#{light_count} (#{format_size(light_size)})",
|
|
440
|
-
ssd_backup: ssd_path
|
|
477
|
+
ssd_backup: ssd_path,
|
|
478
|
+
s3_last_upload: s3_timestamps[:last_upload],
|
|
479
|
+
s3_last_download: s3_timestamps[:last_download]
|
|
441
480
|
)
|
|
442
481
|
end
|
|
443
482
|
|
|
@@ -488,6 +488,101 @@ module Appydave
|
|
|
488
488
|
puts dry_run ? '✅ Archive dry-run complete!' : '✅ Archive complete!'
|
|
489
489
|
end
|
|
490
490
|
|
|
491
|
+
# Calculate 3-state S3 sync status
|
|
492
|
+
# @return [String] One of: '↑ upload', '↓ download', '✓ synced', 'none'
|
|
493
|
+
def calculate_sync_status
|
|
494
|
+
project_dir = project_directory_path
|
|
495
|
+
staging_dir = File.join(project_dir, 's3-staging')
|
|
496
|
+
|
|
497
|
+
# No s3-staging directory means no S3 intent
|
|
498
|
+
return 'none' unless Dir.exist?(staging_dir)
|
|
499
|
+
|
|
500
|
+
# Get S3 files (if S3 configured)
|
|
501
|
+
begin
|
|
502
|
+
s3_files = list_s3_files
|
|
503
|
+
rescue StandardError
|
|
504
|
+
# S3 not configured or not accessible
|
|
505
|
+
return 'none'
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
local_files = list_local_files(staging_dir)
|
|
509
|
+
|
|
510
|
+
# No files anywhere
|
|
511
|
+
return 'none' if s3_files.empty? && local_files.empty?
|
|
512
|
+
|
|
513
|
+
# Build S3 files map
|
|
514
|
+
s3_files_map = s3_files.each_with_object({}) do |file, hash|
|
|
515
|
+
relative_path = extract_relative_path(file['Key'])
|
|
516
|
+
hash[relative_path] = file
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Check for differences
|
|
520
|
+
needs_upload = false
|
|
521
|
+
needs_download = false
|
|
522
|
+
|
|
523
|
+
# Check all local files
|
|
524
|
+
local_files.each_key do |relative_path|
|
|
525
|
+
local_file = File.join(staging_dir, relative_path)
|
|
526
|
+
s3_file = s3_files_map[relative_path]
|
|
527
|
+
|
|
528
|
+
if s3_file
|
|
529
|
+
# Compare MD5
|
|
530
|
+
local_md5 = file_md5(local_file)
|
|
531
|
+
s3_md5 = s3_file['ETag'].gsub('"', '')
|
|
532
|
+
needs_upload = true if local_md5 != s3_md5
|
|
533
|
+
else
|
|
534
|
+
# Local file not in S3
|
|
535
|
+
needs_upload = true
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Check for S3-only files
|
|
540
|
+
s3_files_map.each_key do |relative_path|
|
|
541
|
+
local_file = File.join(staging_dir, relative_path)
|
|
542
|
+
needs_download = true unless File.exist?(local_file)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Return status based on what's needed
|
|
546
|
+
if needs_upload && needs_download
|
|
547
|
+
'⚠️ both'
|
|
548
|
+
elsif needs_upload
|
|
549
|
+
'↑ upload'
|
|
550
|
+
elsif needs_download
|
|
551
|
+
'↓ download'
|
|
552
|
+
else
|
|
553
|
+
'✓ synced'
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Calculate S3 sync timestamps (last upload/download times)
|
|
558
|
+
# @return [Hash] { last_upload: Time|nil, last_download: Time|nil }
|
|
559
|
+
def sync_timestamps
|
|
560
|
+
project_dir = project_directory_path
|
|
561
|
+
staging_dir = File.join(project_dir, 's3-staging')
|
|
562
|
+
|
|
563
|
+
# No s3-staging directory means no S3 intent
|
|
564
|
+
return { last_upload: nil, last_download: nil } unless Dir.exist?(staging_dir)
|
|
565
|
+
|
|
566
|
+
# Get S3 files (if S3 configured)
|
|
567
|
+
begin
|
|
568
|
+
s3_files = list_s3_files
|
|
569
|
+
rescue StandardError
|
|
570
|
+
# S3 not configured or not accessible
|
|
571
|
+
return { last_upload: nil, last_download: nil }
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Last upload time = most recent S3 file LastModified
|
|
575
|
+
last_upload = s3_files.map { |f| f['LastModified'] }.compact.max if s3_files.any?
|
|
576
|
+
|
|
577
|
+
# Last download time = most recent local file mtime (in s3-staging)
|
|
578
|
+
last_download = if Dir.exist?(staging_dir)
|
|
579
|
+
local_files = Dir.glob(File.join(staging_dir, '**/*')).select { |f| File.file?(f) }
|
|
580
|
+
local_files.map { |f| File.mtime(f) }.max if local_files.any?
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
{ last_upload: last_upload, last_download: last_download }
|
|
584
|
+
end
|
|
585
|
+
|
|
491
586
|
# Build S3 key for a file
|
|
492
587
|
def build_s3_key(relative_path)
|
|
493
588
|
"#{brand_info.aws.s3_prefix}#{project_id}/#{relative_path}"
|
data/lib/appydave/tools.rb
CHANGED
|
@@ -55,6 +55,7 @@ require 'appydave/tools/subtitle_processor/join'
|
|
|
55
55
|
require 'appydave/tools/dam/errors'
|
|
56
56
|
require 'appydave/tools/dam/file_helper'
|
|
57
57
|
require 'appydave/tools/dam/git_helper'
|
|
58
|
+
require 'appydave/tools/dam/fuzzy_matcher'
|
|
58
59
|
require 'appydave/tools/dam/brand_resolver'
|
|
59
60
|
require 'appydave/tools/dam/config'
|
|
60
61
|
require 'appydave/tools/dam/project_resolver'
|