@@ -164,15 +164,21 @@ pub struct JobConfig {
164164 pub target : String ,
165165 #[ serde( default = "default_true" ) ]
166166 pub mirror_deletes : bool ,
167+ #[ serde( default = "default_true" ) ]
168+ pub use_shadow_cache : bool ,
169+ #[ serde( default ) ]
170+ pub shadow_root : Option < String > ,
167171}
168172
169173impl Default for JobConfig {
170174 fn default ( ) -> Self {
171175 Self {
172176 name : "Documents" . to_string ( ) ,
173- source : default_job_source ( ) . to_string ( ) ,
177+ source : default_job_usb_source ( ) . display ( ) . to_string ( ) ,
174178 target : default_job_target ( ) ,
175179 mirror_deletes : true ,
180+ use_shadow_cache : true ,
181+ shadow_root : None ,
176182 }
177183 }
178184}
@@ -210,15 +216,16 @@ pub struct ResolvedCacheConfig {
210216#[ derive( Debug , Clone ) ]
211217pub struct ResolvedJob {
212218 pub name : String ,
213- pub usb_source_relative : PathBuf ,
219+ pub usb_source_root : PathBuf ,
214220 pub local_target : PathBuf ,
215221 pub mirror_deletes : bool ,
216- pub shadow_dir : PathBuf ,
222+ pub use_shadow_cache : bool ,
223+ pub shadow_dir : Option < PathBuf > ,
217224}
218225
219226impl ResolvedJob {
220- pub fn usb_source_root ( & self , drive_root : & Path ) -> PathBuf {
221- drive_root . join ( & self . usb_source_relative )
227+ pub fn usb_source_root ( & self ) -> & Path {
228+ & self . usb_source_root
222229 }
223230}
224231
@@ -269,8 +276,6 @@ fn validate_config(config: AppConfig, paths: &AppPaths) -> Result<ResolvedConfig
269276 "config.json must contain at least one sync job"
270277 ) ;
271278
272- let ( drive_label, drive_root) = resolve_drive_location ( & config. drive ) ?;
273-
274279 let poll_interval_seconds = config. app . poll_interval_seconds . clamp ( 1 , 60 ) ;
275280 let app = AppBehavior {
276281 sync_on_insert : config. app . sync_on_insert ,
@@ -293,8 +298,14 @@ fn validate_config(config: AppConfig, paths: &AppPaths) -> Result<ResolvedConfig
293298 clear_shadow_on_eject : config. cache . clear_shadow_on_eject ,
294299 } ;
295300
301+ let configured_drive_root = resolve_drive_location ( & config. drive ) . ok ( ) . map ( |( _, root) | root) ;
302+ let configured_drive_label = configured_drive_root
303+ . as_ref ( )
304+ . map ( |root| describe_drive_root ( root) ) ;
305+
296306 let mut names = HashSet :: new ( ) ;
297307 let mut jobs = Vec :: with_capacity ( config. jobs . len ( ) ) ;
308+ let mut inferred_drive_root: Option < PathBuf > = configured_drive_root. clone ( ) ;
298309 for job in config. jobs {
299310 ensure ! (
300311 names. insert( job. name. clone( ) ) ,
@@ -306,31 +317,63 @@ fn validate_config(config: AppConfig, paths: &AppPaths) -> Result<ResolvedConfig
306317 "job names must not be empty"
307318 ) ;
308319
309- let usb_source_relative = normalize_relative_target ( & job. source )
310- . with_context ( || format ! ( "job '{}' source must be a relative path on the USB drive" , job. name) ) ?;
320+ let usb_source_root = resolve_job_usb_source_root ( & job. source , configured_drive_root. as_deref ( ) )
321+ . with_context ( || format ! ( "job '{}' source must be a valid USB folder" , job. name) ) ?;
322+ let job_drive_root = infer_drive_root ( & usb_source_root)
323+ . ok_or_else ( || anyhow ! ( "job '{}' source must point at a mounted USB location" , job. name) ) ?;
324+ if let Some ( existing_root) = inferred_drive_root. as_ref ( ) {
325+ ensure ! (
326+ paths_equivalent_for_drive( existing_root, & job_drive_root) ,
327+ "all jobs must point at the same USB drive or mount root"
328+ ) ;
329+ } else {
330+ inferred_drive_root = Some ( job_drive_root) ;
331+ }
311332 let local_target = PathBuf :: from ( job. target . trim ( ) ) ;
312333 ensure ! (
313334 local_target. is_absolute( ) ,
314335 "job '{}' target must be an absolute local path" ,
315336 job. name
316337 ) ;
317- let shadow_dir = cache. shadow_root . join ( sanitize_name ( & job. name ) ) ;
338+ let use_shadow_cache = job. use_shadow_cache ;
339+ let shadow_dir = if use_shadow_cache {
340+ let job_shadow_root = job
341+ . shadow_root
342+ . as_deref ( )
343+ . map ( str:: trim)
344+ . filter ( |value| !value. is_empty ( ) )
345+ . map ( PathBuf :: from)
346+ . unwrap_or_else ( || cache. shadow_root . clone ( ) ) ;
347+ Some ( normalize_cache_root ( job_shadow_root, & paths. app_dir ) ?. join ( sanitize_name ( & job. name ) ) )
348+ } else {
349+ None
350+ } ;
318351
319352 jobs. push ( ResolvedJob {
320353 name : job. name ,
321- usb_source_relative ,
354+ usb_source_root ,
322355 local_target,
323356 mirror_deletes : job. mirror_deletes ,
357+ use_shadow_cache,
324358 shadow_dir,
325359 } ) ;
326360 }
327361
362+ let drive_root = inferred_drive_root
363+ . or ( configured_drive_root)
364+ . ok_or_else ( || anyhow ! ( "set a USB source folder before saving" ) ) ?;
365+ let drive_label = configured_drive_label. unwrap_or_else ( || describe_drive_root ( & drive_root) ) ;
366+ let any_shadow_cache = jobs. iter ( ) . any ( |job| job. use_shadow_cache ) ;
367+
328368 Ok ( ResolvedConfig {
329369 drive_label,
330370 drive_root,
331371 eject_after_sync : config. drive . eject_after_sync ,
332372 app,
333- cache,
373+ cache : ResolvedCacheConfig {
374+ shadow_copy : any_shadow_cache,
375+ ..cache
376+ } ,
334377 compare : config. compare ,
335378 jobs,
336379 } )
@@ -436,6 +479,82 @@ fn looks_like_windows_absolute(value: &str) -> bool {
436479 bytes. len ( ) >= 2 && bytes[ 0 ] . is_ascii_alphabetic ( ) && bytes[ 1 ] == b':'
437480}
438481
482+ fn resolve_job_usb_source_root ( value : & str , drive_root : Option < & Path > ) -> Result < PathBuf > {
483+ let trimmed = value. trim ( ) ;
484+ ensure ! ( !trimmed. is_empty( ) , "source path must not be empty" ) ;
485+
486+ let candidate = PathBuf :: from ( trimmed) ;
487+ if candidate. is_absolute ( ) {
488+ return Ok ( candidate) ;
489+ }
490+
491+ let drive_root =
492+ drive_root. ok_or_else ( || anyhow ! ( "source must be an absolute USB folder path" ) ) ?;
493+ let relative = normalize_relative_target ( trimmed) ?;
494+ Ok ( drive_root. join ( relative) )
495+ }
496+
497+ fn infer_drive_root ( path : & Path ) -> Option < PathBuf > {
498+ #[ cfg( target_os = "windows" ) ]
499+ {
500+ let mut root = PathBuf :: new ( ) ;
501+ for component in path. components ( ) {
502+ match component {
503+ Component :: Prefix ( prefix) => root. push ( prefix. as_os_str ( ) ) ,
504+ Component :: RootDir => {
505+ root. push ( std:: path:: MAIN_SEPARATOR . to_string ( ) ) ;
506+ return Some ( root) ;
507+ }
508+ Component :: Normal ( _) => break ,
509+ _ => { }
510+ }
511+ }
512+ return None ;
513+ }
514+
515+ #[ cfg( not( target_os = "windows" ) ) ]
516+ {
517+ let mut components = path. components ( ) ;
518+ match components. next ( ) {
519+ Some ( Component :: RootDir ) => { }
520+ _ => return None ,
521+ }
522+ let mut root = PathBuf :: from ( std:: path:: MAIN_SEPARATOR . to_string ( ) ) ;
523+ let first = components. next ( ) ?;
524+ root. push ( component_as_os_str ( first) ?) ;
525+ let second = components. next ( ) ;
526+ if let Some ( component) = second {
527+ root. push ( component_as_os_str ( component) ?) ;
528+ }
529+ Some ( root)
530+ }
531+ }
532+
533+ #[ cfg( not( target_os = "windows" ) ) ]
534+ fn component_as_os_str ( component : Component < ' _ > ) -> Option < & std:: ffi:: OsStr > {
535+ match component {
536+ Component :: Normal ( value) => Some ( value) ,
537+ _ => None ,
538+ }
539+ }
540+
541+ fn describe_drive_root ( root : & Path ) -> String {
542+ root. display ( ) . to_string ( )
543+ }
544+
545+ fn paths_equivalent_for_drive ( left : & Path , right : & Path ) -> bool {
546+ #[ cfg( target_os = "windows" ) ]
547+ {
548+ left. display ( )
549+ . to_string ( )
550+ . eq_ignore_ascii_case ( & right. display ( ) . to_string ( ) )
551+ }
552+ #[ cfg( not( target_os = "windows" ) ) ]
553+ {
554+ left == right
555+ }
556+ }
557+
439558pub fn rel_path_string ( path : & Path ) -> Result < String > {
440559 let mut parts = Vec :: new ( ) ;
441560 for component in path. components ( ) {
@@ -556,14 +675,14 @@ fn default_mount_path() -> &'static str {
556675 }
557676}
558677
559- fn default_job_source ( ) -> & ' static str {
678+ fn default_job_usb_source ( ) -> PathBuf {
560679 #[ cfg( target_os = "windows" ) ]
561680 {
562- " Backups\\ Documents"
681+ PathBuf :: from ( r"E:\ Backups\Documents")
563682 }
564683 #[ cfg( not( target_os = "windows" ) ) ]
565684 {
566- " Backups/Documents"
685+ PathBuf :: from ( "/Volumes/USB/ Backups/Documents")
567686 }
568687}
569688
0 commit comments