Skip to content

Commit a9743ec

Browse files
committed
Conflict scan: dir flags on ScanConflict, optional source size
- `ScanConflict` gains `source_is_directory` + `dest_is_directory`; `SourceItemInfo` and the `SourceItemInput` IPC type gain `is_directory`. All four backends (`local_posix`, `smb`, `mtp`, `in_memory`) populate the flags from data already in hand — the dest listing entry and the caller-supplied source info. No new round-trips. - `WriteConflictEvent.source_size` becomes `Option<u64>`. Both emit sites (`conflict.rs::build_conflict_event`, `transfer/volume_conflict.rs`'s Stop branch) carry the optional through; `size_difference` now collapses to `None` when either side is unknown, matching the existing `destination_size` treatment. The volume path surfaces the scan hint opportunistically (`None` = unknown) instead of forcing `Some(0)`. - FE: regenerated typed bindings; `WriteConflictEvent.sourceSize` and `VolumeConflictInfo`'s new dir flags mirror the wire. `TransferProgressDialog` renders `(unknown)` for a null source size in the New slot, mirroring the destination side. - Tests (TDD where it applied): backend dir-flag population + type-mismatch flags, `build_conflict_event` source-unknown None-collapse, serde round-trips for `ScanConflict` and `WriteConflictEvent`, and a FE component test for the `(unknown)` source-size render. - No behavior change: dir-dir merge classification, dialogs, and the same-volume fast path are deferred to later milestones. `TransferDialog` sends `isDirectory: false` as a placeholder (the dialog has only paths, not per-item types) until the FE wiring lands later.
1 parent 9c62150 commit a9743ec

20 files changed

Lines changed: 327 additions & 25 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system/volume_copy.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ pub async fn scan_volume_for_conflicts(
146146
name: item.name,
147147
size: item.size,
148148
modified: item.modified,
149+
is_directory: item.is_directory,
149150
})
150151
.collect();
151152
let dest_path = PathBuf::from(dest_path);
@@ -170,4 +171,9 @@ pub struct SourceItemInput {
170171
pub size: u64,
171172
/// Modification time (Unix timestamp in seconds).
172173
pub modified: Option<i64>,
174+
/// `true` when the source item is a directory. The FE has this from the
175+
/// `FileEntry` it already holds; it lets `scan_for_conflicts` flag a
176+
/// dir-vs-dir collision the FE can classify as a silent merge.
177+
#[serde(default)]
178+
pub is_directory: bool,
173179
}

apps/desktop/src-tauri/src/file_system/volume/backends/in_memory.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,8 @@ impl Volume for InMemoryVolume {
579579
dest_size: existing.size.unwrap_or(0),
580580
source_modified: item.modified,
581581
dest_modified,
582+
source_is_directory: item.is_directory,
583+
dest_is_directory: existing.is_directory,
582584
});
583585
}
584586
}

apps/desktop/src-tauri/src/file_system/volume/backends/in_memory_test.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,7 @@ async fn test_scan_for_conflicts_no_conflicts() {
663663
name: "other.txt".to_string(),
664664
size: 100,
665665
modified: None,
666+
is_directory: false,
666667
}];
667668

668669
let conflicts = volume.scan_for_conflicts(&source_items, Path::new("/")).await.unwrap();
@@ -681,13 +682,78 @@ async fn test_scan_for_conflicts_detects_conflict() {
681682
name: "report.txt".to_string(),
682683
size: 200,
683684
modified: Some(1_700_000_000),
685+
is_directory: false,
684686
}];
685687

686688
let conflicts = volume.scan_for_conflicts(&source_items, Path::new("/")).await.unwrap();
687689
assert_eq!(conflicts.len(), 1);
688690
assert_eq!(conflicts[0].source_path, "report.txt");
689691
assert_eq!(conflicts[0].source_size, 200);
690692
assert_eq!(conflicts[0].dest_size, 11); // "old content"
693+
// File vs file: both flags false.
694+
assert!(!conflicts[0].source_is_directory);
695+
assert!(!conflicts[0].dest_is_directory);
696+
}
697+
698+
#[tokio::test]
699+
async fn test_scan_for_conflicts_populates_directory_flags() {
700+
let volume = InMemoryVolume::new("Test");
701+
// Dest holds a directory `photos` and a file `notes.txt`.
702+
volume.create_directory(Path::new("/photos")).await.unwrap();
703+
volume.create_file(Path::new("/notes.txt"), b"hello").await.unwrap();
704+
705+
// Source offers: a dir `photos` (→ dir-vs-dir merge), a file `notes.txt`
706+
// (→ file-vs-file), and a file `photos.zip` that doesn't clash.
707+
let source_items = vec![
708+
SourceItemInfo {
709+
name: "photos".to_string(),
710+
size: 0,
711+
modified: None,
712+
is_directory: true,
713+
},
714+
SourceItemInfo {
715+
name: "notes.txt".to_string(),
716+
size: 99,
717+
modified: None,
718+
is_directory: false,
719+
},
720+
SourceItemInfo {
721+
name: "photos.zip".to_string(),
722+
size: 12,
723+
modified: None,
724+
is_directory: false,
725+
},
726+
];
727+
728+
let conflicts = volume.scan_for_conflicts(&source_items, Path::new("/")).await.unwrap();
729+
assert_eq!(conflicts.len(), 2);
730+
731+
let dir_conflict = conflicts.iter().find(|c| c.source_path == "photos").unwrap();
732+
assert!(dir_conflict.source_is_directory, "source dir flag");
733+
assert!(dir_conflict.dest_is_directory, "dest dir flag");
734+
735+
let file_conflict = conflicts.iter().find(|c| c.source_path == "notes.txt").unwrap();
736+
assert!(!file_conflict.source_is_directory);
737+
assert!(!file_conflict.dest_is_directory);
738+
}
739+
740+
#[tokio::test]
741+
async fn test_scan_for_conflicts_type_mismatch_flags() {
742+
let volume = InMemoryVolume::new("Test");
743+
// Dest `data` is a file; source `data` is a directory.
744+
volume.create_file(Path::new("/data"), b"x").await.unwrap();
745+
746+
let source_items = vec![SourceItemInfo {
747+
name: "data".to_string(),
748+
size: 0,
749+
modified: None,
750+
is_directory: true,
751+
}];
752+
753+
let conflicts = volume.scan_for_conflicts(&source_items, Path::new("/")).await.unwrap();
754+
assert_eq!(conflicts.len(), 1);
755+
assert!(conflicts[0].source_is_directory, "source is a dir");
756+
assert!(!conflicts[0].dest_is_directory, "dest is a file");
691757
}
692758

693759
#[test]

apps/desktop/src-tauri/src/file_system/volume/backends/local_posix.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,8 @@ impl Volume for LocalPosixVolume {
665665
dest_size: meta.len(),
666666
source_modified: item.modified,
667667
dest_modified,
668+
source_is_directory: item.is_directory,
669+
dest_is_directory: meta.is_dir(),
668670
});
669671
}
670672
}

apps/desktop/src-tauri/src/file_system/volume/backends/local_posix_test.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,11 +551,13 @@ async fn test_scan_for_conflicts_no_conflicts() {
551551
name: "newfile.txt".to_string(),
552552
size: 100,
553553
modified: None,
554+
is_directory: false,
554555
},
555556
SourceItemInfo {
556557
name: "another.txt".to_string(),
557558
size: 200,
558559
modified: None,
560+
is_directory: false,
559561
},
560562
];
561563

@@ -584,16 +586,19 @@ async fn test_scan_for_conflicts_with_conflicts() {
584586
name: "existing.txt".to_string(),
585587
size: 100,
586588
modified: Some(1_700_000_000),
589+
is_directory: false,
587590
},
588591
SourceItemInfo {
589592
name: "newfile.txt".to_string(),
590593
size: 200,
591594
modified: None,
595+
is_directory: false,
592596
},
593597
SourceItemInfo {
594598
name: "another.txt".to_string(),
595599
size: 300,
596600
modified: Some(1_700_000_000),
601+
is_directory: false,
597602
},
598603
];
599604

apps/desktop/src-tauri/src/file_system/volume/backends/mtp.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,8 @@ impl Volume for MtpVolume {
731731
dest_size: existing.size.unwrap_or(0),
732732
source_modified: item.modified,
733733
dest_modified,
734+
source_is_directory: item.is_directory,
735+
dest_is_directory: existing.is_directory,
734736
});
735737
}
736738
}

apps/desktop/src-tauri/src/file_system/volume/backends/smb.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1830,6 +1830,8 @@ impl Volume for SmbVolume {
18301830
dest_size: existing.size.unwrap_or(0),
18311831
source_modified: item.modified,
18321832
dest_modified,
1833+
source_is_directory: item.is_directory,
1834+
dest_is_directory: existing.is_directory,
18331835
});
18341836
}
18351837
}

apps/desktop/src-tauri/src/file_system/volume/backends/smb_integration_test.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,11 +542,13 @@ async fn smb_integration_scan_for_conflicts() {
542542
name: "exists.txt".to_string(),
543543
size: 100,
544544
modified: Some(0),
545+
is_directory: false,
545546
},
546547
SourceItemInfo {
547548
name: "missing.txt".to_string(),
548549
size: 200,
549550
modified: Some(0),
551+
is_directory: false,
550552
},
551553
];
552554

apps/desktop/src-tauri/src/file_system/volume/mod.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ pub struct ScanConflict {
198198
pub source_modified: Option<i64>,
199199
/// Unix timestamp in seconds.
200200
pub dest_modified: Option<i64>,
201+
/// `true` when the source item is a directory (from the caller-supplied
202+
/// `SourceItemInfo`). Lets the FE classify a dir-vs-dir collision as a
203+
/// silent merge ("will merge") instead of a conflict.
204+
pub source_is_directory: bool,
205+
/// `true` when the destination item is a directory (from the dest listing
206+
/// entry). See `source_is_directory`.
207+
pub dest_is_directory: bool,
201208
}
202209

203210
/// Space information for a volume.
@@ -220,6 +227,10 @@ pub struct SourceItemInfo {
220227
pub size: u64,
221228
/// Unix timestamp in seconds.
222229
pub modified: Option<i64>,
230+
/// `true` when the source item is a directory. The caller knows this from
231+
/// the `FileEntry` it already has in hand; backends copy it straight onto
232+
/// the resulting `ScanConflict::source_is_directory`.
233+
pub is_directory: bool,
223234
}
224235

225236
/// Error type for volume operations.
@@ -1027,3 +1038,49 @@ mod id_tests {
10271038
assert_eq!(path_to_id("/Volumes/External"), "volumesexternal");
10281039
}
10291040
}
1041+
1042+
#[cfg(test)]
1043+
mod scan_conflict_serde_tests {
1044+
use super::*;
1045+
1046+
#[test]
1047+
fn scan_conflict_round_trips_directory_flags() {
1048+
let conflict = ScanConflict {
1049+
source_path: "photos".to_string(),
1050+
dest_path: "/dst/photos".to_string(),
1051+
source_size: 0,
1052+
dest_size: 4_096,
1053+
source_modified: Some(1_700_000_000),
1054+
dest_modified: Some(1_700_000_001),
1055+
source_is_directory: true,
1056+
dest_is_directory: true,
1057+
};
1058+
1059+
let json = serde_json::to_string(&conflict).unwrap();
1060+
// camelCase on the wire (matches the FE binding).
1061+
assert!(json.contains("\"sourceIsDirectory\":true"), "json was: {json}");
1062+
assert!(json.contains("\"destIsDirectory\":true"), "json was: {json}");
1063+
1064+
let back: ScanConflict = serde_json::from_str(&json).unwrap();
1065+
assert!(back.source_is_directory);
1066+
assert!(back.dest_is_directory);
1067+
}
1068+
1069+
#[test]
1070+
fn scan_conflict_round_trips_type_mismatch_flags() {
1071+
let conflict = ScanConflict {
1072+
source_path: "data".to_string(),
1073+
dest_path: "/dst/data".to_string(),
1074+
source_size: 10,
1075+
dest_size: 20,
1076+
source_modified: None,
1077+
dest_modified: None,
1078+
source_is_directory: true,
1079+
dest_is_directory: false,
1080+
};
1081+
1082+
let back: ScanConflict = serde_json::from_str(&serde_json::to_string(&conflict).unwrap()).unwrap();
1083+
assert!(back.source_is_directory);
1084+
assert!(!back.dest_is_directory);
1085+
}
1086+
}

apps/desktop/src-tauri/src/file_system/write_operations/conflict.rs

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -397,18 +397,26 @@ fn build_conflict_event(
397397
let destination_is_directory = dest_meta.map(|m| m.is_dir()).unwrap_or(false);
398398

399399
// Files: use `metadata.len()` directly. Directories: use the caller-
400-
// supplied recursive total (the BE never walks a destination tree).
401-
let source_size = if source_is_directory {
402-
source_size_for_dir.unwrap_or(0)
400+
// supplied recursive total (the BE never walks a destination tree). On the
401+
// local-FS path the source is always stat-able, so a file source is always
402+
// `Some`; a folder source is `Some` post-preflight and `None` only on the
403+
// rare skip-preflight path.
404+
let source_size: Option<u64> = if source_is_directory {
405+
source_size_for_dir
403406
} else {
404-
source_meta.map(|m| m.len()).unwrap_or(0)
407+
source_meta.map(|m| m.len())
405408
};
406409
let destination_size = if destination_is_directory {
407410
destination_size_for_dir
408411
} else {
409412
dest_meta.map(|m| m.len())
410413
};
411-
let size_difference = destination_size.map(|d| d as i64 - source_size as i64);
414+
// Collapse to `None` when either side is unknown — the FE can't render a
415+
// meaningful "(larger)" annotation without both numbers.
416+
let size_difference = match (destination_size, source_size) {
417+
(Some(d), Some(s)) => Some(d as i64 - s as i64),
418+
_ => None,
419+
};
412420

413421
let unix_secs = |m: Option<&fs::Metadata>| -> Option<i64> {
414422
m?.modified()
@@ -968,7 +976,7 @@ mod build_conflict_event_tests {
968976
Some(99999),
969977
);
970978

971-
assert_eq!(event.source_size, 5);
979+
assert_eq!(event.source_size, Some(5));
972980
assert_eq!(event.destination_size, Some(6));
973981
assert_eq!(event.size_difference, Some(1));
974982
}
@@ -997,7 +1005,7 @@ mod build_conflict_event_tests {
9971005
Some(4_096_000),
9981006
);
9991007

1000-
assert_eq!(event.source_size, 1);
1008+
assert_eq!(event.source_size, Some(1));
10011009
assert_eq!(event.destination_size, Some(4_096_000));
10021010
assert_eq!(event.size_difference, Some(4_095_999));
10031011
}
@@ -1018,7 +1026,7 @@ mod build_conflict_event_tests {
10181026

10191027
let event = build_conflict_event("op", &source, &dest, Some(&source_meta), Some(&dest_meta), None, None);
10201028

1021-
assert_eq!(event.source_size, 1);
1029+
assert_eq!(event.source_size, Some(1));
10221030
assert_eq!(event.destination_size, None);
10231031
assert_eq!(event.size_difference, None);
10241032
}
@@ -1046,10 +1054,31 @@ mod build_conflict_event_tests {
10461054
None,
10471055
);
10481056

1049-
assert_eq!(event.source_size, 123_456);
1057+
assert_eq!(event.source_size, Some(123_456));
10501058
assert_eq!(event.destination_size, Some(2));
10511059
assert_eq!(event.size_difference, Some(2 - 123_456));
10521060
}
1061+
1062+
#[test]
1063+
fn folder_source_with_unknown_size_surfaces_none() {
1064+
// A folder source with no pre-flight scan total (the skip-preflight /
1065+
// fast-path case) surfaces `source_size: None`, and `size_difference`
1066+
// collapses to `None` just as it does when the destination is unknown.
1067+
let temp = TempDir::new().unwrap();
1068+
let source = temp.path().join("payload");
1069+
let dest = temp.path().join("notes.txt");
1070+
fs::create_dir(&source).unwrap();
1071+
fs::write(&dest, b"hi").unwrap();
1072+
1073+
let source_meta = fs::metadata(&source).unwrap();
1074+
let dest_meta = fs::metadata(&dest).unwrap();
1075+
1076+
let event = build_conflict_event("op", &source, &dest, Some(&source_meta), Some(&dest_meta), None, None);
1077+
1078+
assert_eq!(event.source_size, None);
1079+
assert_eq!(event.destination_size, Some(2));
1080+
assert_eq!(event.size_difference, None);
1081+
}
10531082
}
10541083

10551084
#[cfg(test)]

0 commit comments

Comments
 (0)