66import pytest
77
88from basic_memory .cli .commands .cloud .rclone_commands import (
9+ MIN_RCLONE_VERSION_EMPTY_DIRS ,
910 RcloneError ,
1011 SyncProject ,
1112 bisync_initialized ,
1213 check_rclone_installed ,
1314 get_project_bisync_state ,
1415 get_project_remote ,
16+ get_rclone_version ,
1517 project_bisync ,
1618 project_check ,
1719 project_ls ,
1820 project_sync ,
21+ supports_create_empty_src_dirs ,
1922)
2023
2124
@@ -194,10 +197,12 @@ def test_project_sync_no_local_path(mock_is_installed):
194197@patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
195198@patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
196199@patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
197- def test_project_bisync_success (mock_bisync_init , mock_run , mock_is_installed ):
200+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs" )
201+ def test_project_bisync_success (mock_supports_flag , mock_bisync_init , mock_run , mock_is_installed ):
198202 """Test successful project bisync."""
199203 mock_is_installed .return_value = True
200204 mock_bisync_init .return_value = True # Already initialized
205+ mock_supports_flag .return_value = True # Mock version check
201206 mock_run .return_value = MagicMock (returncode = 0 )
202207
203208 project = SyncProject (
@@ -221,9 +226,8 @@ def test_project_bisync_success(mock_bisync_init, mock_run, mock_is_installed):
221226
222227
223228@patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
224- @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
225229@patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
226- def test_project_bisync_requires_resync_first_time (mock_bisync_init , mock_run , mock_is_installed ):
230+ def test_project_bisync_requires_resync_first_time (mock_bisync_init , mock_is_installed ):
227231 """Test that first bisync requires --resync flag."""
228232 mock_is_installed .return_value = True
229233 mock_bisync_init .return_value = False # Not initialized
@@ -238,16 +242,19 @@ def test_project_bisync_requires_resync_first_time(mock_bisync_init, mock_run, m
238242 project_bisync (project , "my-bucket" )
239243
240244 assert "requires --resync" in str (exc_info .value )
241- mock_run .assert_not_called ()
242245
243246
244247@patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
245248@patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
246249@patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
247- def test_project_bisync_with_resync_flag (mock_bisync_init , mock_run , mock_is_installed ):
250+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs" )
251+ def test_project_bisync_with_resync_flag (
252+ mock_supports_flag , mock_bisync_init , mock_run , mock_is_installed
253+ ):
248254 """Test bisync with --resync flag for first time."""
249255 mock_is_installed .return_value = True
250256 mock_bisync_init .return_value = False # Not initialized
257+ mock_supports_flag .return_value = True # Mock version check
251258 mock_run .return_value = MagicMock (returncode = 0 )
252259
253260 project = SyncProject (
@@ -266,10 +273,14 @@ def test_project_bisync_with_resync_flag(mock_bisync_init, mock_run, mock_is_ins
266273@patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
267274@patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
268275@patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
269- def test_project_bisync_dry_run_skips_init_check (mock_bisync_init , mock_run , mock_is_installed ):
276+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs" )
277+ def test_project_bisync_dry_run_skips_init_check (
278+ mock_supports_flag , mock_bisync_init , mock_run , mock_is_installed
279+ ):
270280 """Test that dry-run skips initialization check."""
271281 mock_is_installed .return_value = True
272282 mock_bisync_init .return_value = False # Not initialized
283+ mock_supports_flag .return_value = True # Mock version check
273284 mock_run .return_value = MagicMock (returncode = 0 )
274285
275286 project = SyncProject (
@@ -479,3 +490,158 @@ def test_project_ls_checks_rclone_installed(mock_is_installed):
479490
480491 assert "rclone is not installed" in str (exc_info .value )
481492 mock_is_installed .assert_called_once ()
493+
494+
495+ # Tests for rclone version detection
496+
497+
498+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
499+ def test_get_rclone_version_parses_standard_version (mock_run ):
500+ """Test parsing standard rclone version output."""
501+ # Clear the lru_cache before test
502+ get_rclone_version .cache_clear ()
503+
504+ mock_run .return_value = MagicMock (
505+ stdout = "rclone v1.64.2\n - os/version: darwin 23.0.0\n - os/arch: arm64\n "
506+ )
507+
508+ version = get_rclone_version ()
509+
510+ assert version == (1 , 64 , 2 )
511+ mock_run .assert_called_once ()
512+
513+
514+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
515+ def test_get_rclone_version_parses_dev_version (mock_run ):
516+ """Test parsing rclone dev version output like v1.60.1-DEV."""
517+ get_rclone_version .cache_clear ()
518+
519+ mock_run .return_value = MagicMock (stdout = "rclone v1.60.1-DEV\n - os/version: linux 5.15.0\n " )
520+
521+ version = get_rclone_version ()
522+
523+ assert version == (1 , 60 , 1 )
524+
525+
526+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
527+ def test_get_rclone_version_handles_invalid_output (mock_run ):
528+ """Test handling of invalid rclone version output."""
529+ get_rclone_version .cache_clear ()
530+
531+ mock_run .return_value = MagicMock (stdout = "not a valid version string" )
532+
533+ version = get_rclone_version ()
534+
535+ assert version is None
536+
537+
538+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
539+ def test_get_rclone_version_handles_exception (mock_run ):
540+ """Test handling of subprocess exception."""
541+ get_rclone_version .cache_clear ()
542+
543+ mock_run .side_effect = Exception ("Command failed" )
544+
545+ version = get_rclone_version ()
546+
547+ assert version is None
548+
549+
550+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
551+ def test_get_rclone_version_handles_timeout (mock_run ):
552+ """Test handling of subprocess timeout."""
553+ get_rclone_version .cache_clear ()
554+ from subprocess import TimeoutExpired
555+
556+ mock_run .side_effect = TimeoutExpired (cmd = "rclone version" , timeout = 10 )
557+
558+ version = get_rclone_version ()
559+
560+ assert version is None
561+
562+
563+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version" )
564+ def test_supports_create_empty_src_dirs_true_for_new_version (mock_get_version ):
565+ """Test supports_create_empty_src_dirs returns True for v1.64+."""
566+ mock_get_version .return_value = (1 , 64 , 2 )
567+
568+ assert supports_create_empty_src_dirs () is True
569+
570+
571+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version" )
572+ def test_supports_create_empty_src_dirs_true_for_exact_min_version (mock_get_version ):
573+ """Test supports_create_empty_src_dirs returns True for exactly v1.64.0."""
574+ mock_get_version .return_value = (1 , 64 , 0 )
575+
576+ assert supports_create_empty_src_dirs () is True
577+
578+
579+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version" )
580+ def test_supports_create_empty_src_dirs_false_for_old_version (mock_get_version ):
581+ """Test supports_create_empty_src_dirs returns False for v1.60."""
582+ mock_get_version .return_value = (1 , 60 , 1 )
583+
584+ assert supports_create_empty_src_dirs () is False
585+
586+
587+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.get_rclone_version" )
588+ def test_supports_create_empty_src_dirs_false_for_unknown_version (mock_get_version ):
589+ """Test supports_create_empty_src_dirs returns False when version unknown."""
590+ mock_get_version .return_value = None
591+
592+ assert supports_create_empty_src_dirs () is False
593+
594+
595+ def test_min_rclone_version_constant ():
596+ """Test MIN_RCLONE_VERSION_EMPTY_DIRS constant is set correctly."""
597+ assert MIN_RCLONE_VERSION_EMPTY_DIRS == (1 , 64 , 0 )
598+
599+
600+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
601+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
602+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
603+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs" )
604+ def test_project_bisync_includes_empty_dirs_flag_when_supported (
605+ mock_supports_flag , mock_bisync_init , mock_run , mock_is_installed
606+ ):
607+ """Test project_bisync includes --create-empty-src-dirs when supported."""
608+ mock_is_installed .return_value = True
609+ mock_bisync_init .return_value = True
610+ mock_supports_flag .return_value = True
611+ mock_run .return_value = MagicMock (returncode = 0 )
612+
613+ project = SyncProject (
614+ name = "research" ,
615+ path = "app/data/research" ,
616+ local_sync_path = "/tmp/research" ,
617+ )
618+
619+ project_bisync (project , "my-bucket" )
620+
621+ cmd = mock_run .call_args [0 ][0 ]
622+ assert "--create-empty-src-dirs" in cmd
623+
624+
625+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.is_rclone_installed" )
626+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.subprocess.run" )
627+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.bisync_initialized" )
628+ @patch ("basic_memory.cli.commands.cloud.rclone_commands.supports_create_empty_src_dirs" )
629+ def test_project_bisync_excludes_empty_dirs_flag_when_not_supported (
630+ mock_supports_flag , mock_bisync_init , mock_run , mock_is_installed
631+ ):
632+ """Test project_bisync excludes --create-empty-src-dirs for older rclone."""
633+ mock_is_installed .return_value = True
634+ mock_bisync_init .return_value = True
635+ mock_supports_flag .return_value = False # Old rclone version
636+ mock_run .return_value = MagicMock (returncode = 0 )
637+
638+ project = SyncProject (
639+ name = "research" ,
640+ path = "app/data/research" ,
641+ local_sync_path = "/tmp/research" ,
642+ )
643+
644+ project_bisync (project , "my-bucket" )
645+
646+ cmd = mock_run .call_args [0 ][0 ]
647+ assert "--create-empty-src-dirs" not in cmd
0 commit comments