@@ -210,6 +210,11 @@ impl SmbVolume {
210210 handle. block_on ( client. read_file_pipelined ( tree, & sp) )
211211 } ) ?;
212212
213+ // Ensure parent directory exists
214+ if let Some ( parent) = local_dest. parent ( ) {
215+ std:: fs:: create_dir_all ( parent) . map_err ( |e| VolumeError :: IoError ( e. to_string ( ) ) ) ?;
216+ }
217+
213218 let len = data. len ( ) as u64 ;
214219 std:: fs:: write ( local_dest, & data) . map_err ( |e| VolumeError :: IoError ( e. to_string ( ) ) ) ?;
215220 Ok ( len)
@@ -1037,4 +1042,359 @@ mod tests {
10371042 } ;
10381043 ( vol, rt)
10391044 }
1045+
1046+ // ── Integration tests (require Docker SMB containers) ──────────
1047+ //
1048+ // Run with: cargo nextest run smb_integration --run-ignored all
1049+ // Prerequisites: ./apps/desktop/test/smb-servers/start.sh
1050+
1051+ /// Connects to the Docker smb-guest container (127.0.0.1:9445, share "public").
1052+ ///
1053+ /// Uses a multi-threaded runtime because `SmbVolume` methods call `Handle::block_on`
1054+ /// internally (from `with_smb`). A single-threaded runtime would deadlock since
1055+ /// the test thread is already inside `rt.block_on`.
1056+ fn make_docker_volume ( ) -> ( SmbVolume , tokio:: runtime:: Runtime ) {
1057+ let rt = tokio:: runtime:: Builder :: new_multi_thread ( )
1058+ . worker_threads ( 2 )
1059+ . enable_all ( )
1060+ . build ( )
1061+ . unwrap ( ) ;
1062+ let vol = rt. block_on ( async {
1063+ connect_smb_volume ( "public" , "/tmp/smb-test-mount" , "127.0.0.1" , "public" , None , None , 9445 )
1064+ . await
1065+ . expect ( "Failed to connect to Docker SMB container at 127.0.0.1:9445. Is it running?" )
1066+ } ) ;
1067+ ( vol, rt)
1068+ }
1069+
1070+ /// Unique directory name for test isolation.
1071+ fn test_dir_name ( ) -> String {
1072+ use std:: time:: { SystemTime , UNIX_EPOCH } ;
1073+ let ts = SystemTime :: now ( ) . duration_since ( UNIX_EPOCH ) . unwrap ( ) . as_nanos ( ) ;
1074+ format ! ( "cmdr-test-{}" , ts)
1075+ }
1076+
1077+ /// Ensures a test directory is clean before use (deletes recursively if it exists).
1078+ fn ensure_clean ( vol : & SmbVolume , dir : & str ) {
1079+ if vol. exists ( Path :: new ( dir) ) {
1080+ // Delete contents recursively
1081+ if let Ok ( entries) = vol. list_directory ( Path :: new ( dir) ) {
1082+ for entry in entries {
1083+ let child = format ! ( "{}/{}" , dir, entry. name) ;
1084+ if entry. is_directory {
1085+ ensure_clean ( vol, & child) ;
1086+ } else {
1087+ let _ = vol. delete ( Path :: new ( & child) ) ;
1088+ }
1089+ }
1090+ }
1091+ let _ = vol. delete ( Path :: new ( dir) ) ;
1092+ }
1093+ }
1094+
1095+ #[ test]
1096+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1097+ fn smb_integration_list_directory ( ) {
1098+ let ( vol, _rt) = make_docker_volume ( ) ;
1099+ let entries = vol. list_directory ( Path :: new ( "" ) ) . unwrap ( ) ;
1100+ // The public share should be listable (may have files from other tests)
1101+ assert ! ( entries. iter( ) . all( |e| e. name != "." && e. name != ".." ) ) ;
1102+ }
1103+
1104+ #[ test]
1105+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1106+ fn smb_integration_create_and_read_file ( ) {
1107+ let ( vol, _rt) = make_docker_volume ( ) ;
1108+ let dir = test_dir_name ( ) ;
1109+ ensure_clean ( & vol, & dir) ;
1110+
1111+ // Create a directory
1112+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1113+
1114+ // Create a file inside it
1115+ let file_path = format ! ( "{}/test.txt" , dir) ;
1116+ let content = b"hello from cmdr integration test" ;
1117+ vol. create_file ( Path :: new ( & file_path) , content) . unwrap ( ) ;
1118+
1119+ // Verify it exists
1120+ assert ! ( vol. exists( Path :: new( & file_path) ) ) ;
1121+ assert ! ( !vol. is_directory( Path :: new( & file_path) ) . unwrap( ) ) ;
1122+
1123+ // Verify metadata
1124+ let meta = vol. get_metadata ( Path :: new ( & file_path) ) . unwrap ( ) ;
1125+ assert_eq ! ( meta. name, "test.txt" ) ;
1126+ assert_eq ! ( meta. size, Some ( content. len( ) as u64 ) ) ;
1127+ assert ! ( !meta. is_directory) ;
1128+
1129+ // List the directory and verify the file is there
1130+ let entries = vol. list_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1131+ assert_eq ! ( entries. len( ) , 1 ) ;
1132+ assert_eq ! ( entries[ 0 ] . name, "test.txt" ) ;
1133+
1134+ // Clean up
1135+ vol. delete ( Path :: new ( & file_path) ) . unwrap ( ) ;
1136+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1137+ }
1138+
1139+ #[ test]
1140+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1141+ fn smb_integration_rename ( ) {
1142+ let ( vol, _rt) = make_docker_volume ( ) ;
1143+ let dir = test_dir_name ( ) ;
1144+
1145+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1146+ let old_path = format ! ( "{}/old.txt" , dir) ;
1147+ let new_path = format ! ( "{}/new.txt" , dir) ;
1148+
1149+ vol. create_file ( Path :: new ( & old_path) , b"rename me" ) . unwrap ( ) ;
1150+
1151+ // Rename
1152+ vol. rename ( Path :: new ( & old_path) , Path :: new ( & new_path) , false ) . unwrap ( ) ;
1153+
1154+ // Old is gone, new exists
1155+ assert ! ( !vol. exists( Path :: new( & old_path) ) ) ;
1156+ assert ! ( vol. exists( Path :: new( & new_path) ) ) ;
1157+
1158+ // Clean up
1159+ vol. delete ( Path :: new ( & new_path) ) . unwrap ( ) ;
1160+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1161+ }
1162+
1163+ #[ test]
1164+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1165+ fn smb_integration_rename_force_overwrites ( ) {
1166+ let ( vol, _rt) = make_docker_volume ( ) ;
1167+ let dir = test_dir_name ( ) ;
1168+
1169+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1170+ let src = format ! ( "{}/src.txt" , dir) ;
1171+ let dst = format ! ( "{}/dst.txt" , dir) ;
1172+
1173+ vol. create_file ( Path :: new ( & src) , b"source content" ) . unwrap ( ) ;
1174+ vol. create_file ( Path :: new ( & dst) , b"will be overwritten" ) . unwrap ( ) ;
1175+
1176+ // Non-force should fail
1177+ let err = vol. rename ( Path :: new ( & src) , Path :: new ( & dst) , false ) ;
1178+ assert ! ( matches!( err, Err ( VolumeError :: AlreadyExists ( _) ) ) ) ;
1179+
1180+ // Force should succeed
1181+ vol. rename ( Path :: new ( & src) , Path :: new ( & dst) , true ) . unwrap ( ) ;
1182+ assert ! ( !vol. exists( Path :: new( & src) ) ) ;
1183+ assert ! ( vol. exists( Path :: new( & dst) ) ) ;
1184+
1185+ // Clean up
1186+ vol. delete ( Path :: new ( & dst) ) . unwrap ( ) ;
1187+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1188+ }
1189+
1190+ #[ test]
1191+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1192+ fn smb_integration_delete_directory ( ) {
1193+ let ( vol, _rt) = make_docker_volume ( ) ;
1194+ let dir = test_dir_name ( ) ;
1195+
1196+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1197+ assert ! ( vol. exists( Path :: new( & dir) ) ) ;
1198+ assert ! ( vol. is_directory( Path :: new( & dir) ) . unwrap( ) ) ;
1199+
1200+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1201+ assert ! ( !vol. exists( Path :: new( & dir) ) ) ;
1202+ }
1203+
1204+ #[ test]
1205+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1206+ fn smb_integration_export_to_local ( ) {
1207+ let ( vol, _rt) = make_docker_volume ( ) ;
1208+ let dir = test_dir_name ( ) ;
1209+ let local_tmp = std:: env:: temp_dir ( ) . join ( & dir) ;
1210+
1211+ // Create a file on the SMB share
1212+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1213+ let smb_file = format ! ( "{}/export-test.txt" , dir) ;
1214+ let content = b"exported content" ;
1215+ vol. create_file ( Path :: new ( & smb_file) , content) . unwrap ( ) ;
1216+
1217+ // Export to local
1218+ let bytes = vol
1219+ . export_to_local ( Path :: new ( & smb_file) , & local_tmp. join ( "export-test.txt" ) )
1220+ . unwrap ( ) ;
1221+ assert_eq ! ( bytes, content. len( ) as u64 ) ;
1222+
1223+ // Verify local file
1224+ let local_content = std:: fs:: read ( local_tmp. join ( "export-test.txt" ) ) . unwrap ( ) ;
1225+ assert_eq ! ( local_content, content) ;
1226+
1227+ // Clean up
1228+ let _ = std:: fs:: remove_dir_all ( & local_tmp) ;
1229+ vol. delete ( Path :: new ( & smb_file) ) . unwrap ( ) ;
1230+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1231+ }
1232+
1233+ #[ test]
1234+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1235+ fn smb_integration_import_from_local ( ) {
1236+ let ( vol, _rt) = make_docker_volume ( ) ;
1237+ let dir = test_dir_name ( ) ;
1238+ let local_tmp = std:: env:: temp_dir ( ) . join ( format ! ( "{}-import" , dir) ) ;
1239+
1240+ // Create a local file
1241+ std:: fs:: create_dir_all ( & local_tmp) . unwrap ( ) ;
1242+ let local_file = local_tmp. join ( "import-test.txt" ) ;
1243+ let content = b"imported content" ;
1244+ std:: fs:: write ( & local_file, content) . unwrap ( ) ;
1245+
1246+ // Create target dir on SMB
1247+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1248+
1249+ // Import to SMB
1250+ let smb_file = format ! ( "{}/import-test.txt" , dir) ;
1251+ let bytes = vol. import_from_local ( & local_file, Path :: new ( & smb_file) ) . unwrap ( ) ;
1252+ assert_eq ! ( bytes, content. len( ) as u64 ) ;
1253+
1254+ // Verify on SMB
1255+ assert ! ( vol. exists( Path :: new( & smb_file) ) ) ;
1256+ let meta = vol. get_metadata ( Path :: new ( & smb_file) ) . unwrap ( ) ;
1257+ assert_eq ! ( meta. size, Some ( content. len( ) as u64 ) ) ;
1258+
1259+ // Clean up
1260+ let _ = std:: fs:: remove_dir_all ( & local_tmp) ;
1261+ vol. delete ( Path :: new ( & smb_file) ) . unwrap ( ) ;
1262+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1263+ }
1264+
1265+ #[ test]
1266+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1267+ fn smb_integration_export_directory_recursive ( ) {
1268+ let ( vol, _rt) = make_docker_volume ( ) ;
1269+ let dir = test_dir_name ( ) ;
1270+ let local_tmp = std:: env:: temp_dir ( ) . join ( format ! ( "{}-export-dir" , dir) ) ;
1271+
1272+ // Create a directory tree on SMB
1273+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1274+ let sub = format ! ( "{}/subdir" , dir) ;
1275+ vol. create_directory ( Path :: new ( & sub) ) . unwrap ( ) ;
1276+ vol. create_file ( Path :: new ( & format ! ( "{}/a.txt" , dir) ) , b"file a" )
1277+ . unwrap ( ) ;
1278+ vol. create_file ( Path :: new ( & format ! ( "{}/subdir/b.txt" , dir) ) , b"file b" )
1279+ . unwrap ( ) ;
1280+
1281+ // Export entire directory
1282+ let bytes = vol. export_to_local ( Path :: new ( & dir) , & local_tmp) . unwrap ( ) ;
1283+ assert_eq ! ( bytes, 12 ) ; // "file a" (6) + "file b" (6)
1284+
1285+ // Verify local structure
1286+ assert ! ( local_tmp. join( "a.txt" ) . exists( ) ) ;
1287+ assert ! ( local_tmp. join( "subdir" ) . is_dir( ) ) ;
1288+ assert ! ( local_tmp. join( "subdir/b.txt" ) . exists( ) ) ;
1289+ assert_eq ! ( std:: fs:: read_to_string( local_tmp. join( "a.txt" ) ) . unwrap( ) , "file a" ) ;
1290+ assert_eq ! (
1291+ std:: fs:: read_to_string( local_tmp. join( "subdir/b.txt" ) ) . unwrap( ) ,
1292+ "file b"
1293+ ) ;
1294+
1295+ // Clean up
1296+ let _ = std:: fs:: remove_dir_all ( & local_tmp) ;
1297+ vol. delete ( Path :: new ( & format ! ( "{}/subdir/b.txt" , dir) ) ) . unwrap ( ) ;
1298+ vol. delete ( Path :: new ( & format ! ( "{}/a.txt" , dir) ) ) . unwrap ( ) ;
1299+ vol. delete ( Path :: new ( & sub) ) . unwrap ( ) ;
1300+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1301+ }
1302+
1303+ #[ test]
1304+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1305+ fn smb_integration_import_directory_recursive ( ) {
1306+ let ( vol, _rt) = make_docker_volume ( ) ;
1307+ let dir = test_dir_name ( ) ;
1308+ let local_tmp = std:: env:: temp_dir ( ) . join ( format ! ( "{}-import-dir" , dir) ) ;
1309+
1310+ // Create a local directory tree
1311+ std:: fs:: create_dir_all ( local_tmp. join ( "subdir" ) ) . unwrap ( ) ;
1312+ std:: fs:: write ( local_tmp. join ( "x.txt" ) , "file x" ) . unwrap ( ) ;
1313+ std:: fs:: write ( local_tmp. join ( "subdir/y.txt" ) , "file y" ) . unwrap ( ) ;
1314+
1315+ // Import to SMB
1316+ let bytes = vol. import_from_local ( & local_tmp, Path :: new ( & dir) ) . unwrap ( ) ;
1317+ assert_eq ! ( bytes, 12 ) ; // "file x" (6) + "file y" (6)
1318+
1319+ // Verify on SMB
1320+ assert ! ( vol. is_directory( Path :: new( & dir) ) . unwrap( ) ) ;
1321+ assert ! ( vol. exists( Path :: new( & format!( "{}/x.txt" , dir) ) ) ) ;
1322+ assert ! ( vol. is_directory( Path :: new( & format!( "{}/subdir" , dir) ) ) . unwrap( ) ) ;
1323+ assert ! ( vol. exists( Path :: new( & format!( "{}/subdir/y.txt" , dir) ) ) ) ;
1324+
1325+ // Clean up
1326+ let _ = std:: fs:: remove_dir_all ( & local_tmp) ;
1327+ vol. delete ( Path :: new ( & format ! ( "{}/subdir/y.txt" , dir) ) ) . unwrap ( ) ;
1328+ vol. delete ( Path :: new ( & format ! ( "{}/x.txt" , dir) ) ) . unwrap ( ) ;
1329+ vol. delete ( Path :: new ( & format ! ( "{}/subdir" , dir) ) ) . unwrap ( ) ;
1330+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1331+ }
1332+
1333+ #[ test]
1334+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1335+ fn smb_integration_scan_for_copy ( ) {
1336+ let ( vol, _rt) = make_docker_volume ( ) ;
1337+ let dir = test_dir_name ( ) ;
1338+
1339+ // Create a small tree
1340+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1341+ let sub = format ! ( "{}/inner" , dir) ;
1342+ vol. create_directory ( Path :: new ( & sub) ) . unwrap ( ) ;
1343+ vol. create_file ( Path :: new ( & format ! ( "{}/f1.txt" , dir) ) , b"aaa" ) . unwrap ( ) ;
1344+ vol. create_file ( Path :: new ( & format ! ( "{}/inner/f2.txt" , dir) ) , b"bbbbbb" )
1345+ . unwrap ( ) ;
1346+
1347+ let result = vol. scan_for_copy ( Path :: new ( & dir) ) . unwrap ( ) ;
1348+ assert_eq ! ( result. file_count, 2 ) ;
1349+ assert_eq ! ( result. dir_count, 2 ) ; // dir + inner
1350+ assert_eq ! ( result. total_bytes, 9 ) ; // 3 + 6
1351+
1352+ // Clean up
1353+ vol. delete ( Path :: new ( & format ! ( "{}/inner/f2.txt" , dir) ) ) . unwrap ( ) ;
1354+ vol. delete ( Path :: new ( & format ! ( "{}/f1.txt" , dir) ) ) . unwrap ( ) ;
1355+ vol. delete ( Path :: new ( & sub) ) . unwrap ( ) ;
1356+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1357+ }
1358+
1359+ #[ test]
1360+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1361+ fn smb_integration_scan_for_conflicts ( ) {
1362+ let ( vol, _rt) = make_docker_volume ( ) ;
1363+ let dir = test_dir_name ( ) ;
1364+
1365+ vol. create_directory ( Path :: new ( & dir) ) . unwrap ( ) ;
1366+ vol. create_file ( Path :: new ( & format ! ( "{}/exists.txt" , dir) ) , b"data" )
1367+ . unwrap ( ) ;
1368+
1369+ let source_items = vec ! [
1370+ SourceItemInfo {
1371+ name: "exists.txt" . to_string( ) ,
1372+ size: 100 ,
1373+ modified: Some ( 0 ) ,
1374+ } ,
1375+ SourceItemInfo {
1376+ name: "missing.txt" . to_string( ) ,
1377+ size: 200 ,
1378+ modified: Some ( 0 ) ,
1379+ } ,
1380+ ] ;
1381+
1382+ let conflicts = vol. scan_for_conflicts ( & source_items, Path :: new ( & dir) ) . unwrap ( ) ;
1383+ assert_eq ! ( conflicts. len( ) , 1 ) ;
1384+ assert_eq ! ( conflicts[ 0 ] . source_path, "exists.txt" ) ;
1385+
1386+ // Clean up
1387+ vol. delete ( Path :: new ( & format ! ( "{}/exists.txt" , dir) ) ) . unwrap ( ) ;
1388+ vol. delete ( Path :: new ( & dir) ) . unwrap ( ) ;
1389+ }
1390+
1391+ #[ test]
1392+ #[ ignore = "Requires Docker SMB containers (./apps/desktop/test/smb-servers/start.sh)" ]
1393+ fn smb_integration_space_info ( ) {
1394+ let ( vol, _rt) = make_docker_volume ( ) ;
1395+ let space = vol. get_space_info ( ) . unwrap ( ) ;
1396+ assert ! ( space. total_bytes > 0 ) ;
1397+ assert ! ( space. available_bytes > 0 ) ;
1398+ assert ! ( space. used_bytes <= space. total_bytes) ;
1399+ }
10401400}
0 commit comments