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.
@@ -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
- raise BrandNotFoundError.new(brand, available)
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 '-' * 200
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 %-10s %-35s %-18s %-18s %-30s',
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 %-10s',
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
- # Check if project has s3-staging folder
395
- s3_sync = if Dir.exist?(File.join(project_path, 's3-staging'))
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}"
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Appydave
4
4
  module Tools
5
- VERSION = '0.67.0'
5
+ VERSION = '0.68.0'
6
6
  end
7
7
  end
@@ -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'