@@ -16,6 +16,12 @@ use crate::model::{
1616 canonical_tag_key, supported_audio_extension,
1717} ;
1818
19+ #[ derive( Clone , Debug ) ]
20+ pub struct RenameRecord {
21+ pub stem : String ,
22+ pub paths : Vec < PathBuf > ,
23+ }
24+
1925pub struct Backend {
2026 db : Database ,
2127 folders : BTreeMap < Category , PathBuf > ,
@@ -151,6 +157,48 @@ impl Backend {
151157 self . db . remove_tag ( category, & stem, key, value)
152158 }
153159
160+ pub fn rename_tag (
161+ & mut self ,
162+ category : Category ,
163+ path : & Path ,
164+ key : & str ,
165+ old_value : & str ,
166+ new_value : & str ,
167+ ) -> io:: Result < ( ) > {
168+ let Some ( key) = canonical_category_key ( category, key) else {
169+ return Ok ( ( ) ) ;
170+ } ;
171+ let stem = file_stem ( path) ;
172+ self . db
173+ . rename_stem_tag_value ( category, & stem, key, old_value, new_value)
174+ }
175+
176+ pub fn rename_records (
177+ & mut self ,
178+ category : Category ,
179+ records : & [ RenameRecord ] ,
180+ new_stem : & str ,
181+ ) -> io:: Result < usize > {
182+ rename_record_files ( records, new_stem) ?;
183+ for record in records {
184+ self . db . rename_stem_tags ( category, & record. stem , new_stem) ?;
185+ }
186+ self . refresh_category ( category) ?;
187+ Ok ( records. iter ( ) . map ( |record| record. paths . len ( ) ) . sum ( ) )
188+ }
189+
190+ pub fn rename_tag_value (
191+ & mut self ,
192+ category : Category ,
193+ key : & str ,
194+ old_value : & str ,
195+ new_value : & str ,
196+ ) -> io:: Result < ( ) > {
197+ self . db
198+ . rename_tag_value ( category, key, old_value, new_value) ?;
199+ self . refresh_category ( category)
200+ }
201+
154202 pub fn folder_tag_values ( & self , category : Category ) -> io:: Result < Vec < String > > {
155203 let Some ( folder) = self . folders . get ( & category) else {
156204 return Ok ( Vec :: new ( ) ) ;
@@ -295,6 +343,77 @@ fn trash_files(paths: Vec<PathBuf>) -> io::Result<usize> {
295343 Ok ( path_count)
296344}
297345
346+ fn rename_record_files ( records : & [ RenameRecord ] , new_stem : & str ) -> io:: Result < usize > {
347+ let new_stem = valid_file_stem ( new_stem) ?;
348+ let mut source_paths = BTreeSet :: new ( ) ;
349+ let mut planned = Vec :: new ( ) ;
350+
351+ for record in records {
352+ for source in & record. paths {
353+ source_paths. insert ( source. clone ( ) ) ;
354+ let extension = source
355+ . extension ( )
356+ . and_then ( |extension| extension. to_str ( ) )
357+ . ok_or_else ( || {
358+ io:: Error :: new ( io:: ErrorKind :: InvalidInput , "file has no extension" )
359+ } ) ?;
360+ let parent = source. parent ( ) . ok_or_else ( || {
361+ io:: Error :: new ( io:: ErrorKind :: InvalidInput , "file has no parent folder" )
362+ } ) ?;
363+ planned. push ( (
364+ source. clone ( ) ,
365+ parent. join ( format ! ( "{new_stem}.{extension}" ) ) ,
366+ ) ) ;
367+ }
368+ }
369+
370+ let mut destination_paths = BTreeSet :: new ( ) ;
371+ for ( source, destination) in & planned {
372+ if source == destination {
373+ continue ;
374+ }
375+ if !destination_paths. insert ( destination. clone ( ) ) {
376+ return Err ( io:: Error :: new (
377+ io:: ErrorKind :: AlreadyExists ,
378+ format ! ( "rename would create duplicate {}" , destination. display( ) ) ,
379+ ) ) ;
380+ }
381+ if destination. exists ( ) && !source_paths. contains ( destination) {
382+ return Err ( io:: Error :: new (
383+ io:: ErrorKind :: AlreadyExists ,
384+ format ! ( "{} already exists" , destination. display( ) ) ,
385+ ) ) ;
386+ }
387+ }
388+
389+ let mut renamed = 0 ;
390+ for ( source, destination) in planned {
391+ if source == destination {
392+ continue ;
393+ }
394+ fs:: rename ( & source, & destination) ?;
395+ renamed += 1 ;
396+ }
397+ Ok ( renamed)
398+ }
399+
400+ fn valid_file_stem ( stem : & str ) -> io:: Result < & str > {
401+ let stem = stem. trim ( ) ;
402+ if stem. is_empty ( ) {
403+ return Err ( io:: Error :: new (
404+ io:: ErrorKind :: InvalidInput ,
405+ "new name cannot be empty" ,
406+ ) ) ;
407+ }
408+ if stem. contains ( '/' ) || stem. contains ( '\\' ) {
409+ return Err ( io:: Error :: new (
410+ io:: ErrorKind :: InvalidInput ,
411+ "new name cannot contain path separators" ,
412+ ) ) ;
413+ }
414+ Ok ( stem)
415+ }
416+
298417pub fn import_to_folder (
299418 folder : & Path ,
300419 source : & Path ,
@@ -833,6 +952,67 @@ mod tests {
833952 assert_eq ! ( records[ 0 ] . tags[ "TYPE" ] , vec![ "Foley" ] ) ;
834953 }
835954
955+ #[ test]
956+ fn rename_records_moves_variants_and_preserves_tags ( ) {
957+ let dir = unique_dir ( "rename-records" ) ;
958+ fs:: write ( dir. join ( "song.wav" ) , b"not actually audio" ) . unwrap ( ) ;
959+ fs:: write ( dir. join ( "song.mp3" ) , b"not actually audio" ) . unwrap ( ) ;
960+ let paths = vec ! [ dir. join( "song.wav" ) , dir. join( "song.mp3" ) ] ;
961+
962+ let mut backend = backend ( "rename-records" ) ;
963+ backend
964+ . set_category_folder ( Category :: Music , dir. clone ( ) )
965+ . unwrap ( ) ;
966+ backend
967+ . add_tag ( Category :: Music , & paths[ 0 ] , "Genre" , "Ambient" )
968+ . unwrap ( ) ;
969+
970+ backend
971+ . rename_records (
972+ Category :: Music ,
973+ & [ RenameRecord {
974+ stem : "song" . to_string ( ) ,
975+ paths,
976+ } ] ,
977+ "renamed" ,
978+ )
979+ . unwrap ( ) ;
980+
981+ assert ! ( !dir. join( "song.wav" ) . exists( ) ) ;
982+ assert ! ( !dir. join( "song.mp3" ) . exists( ) ) ;
983+ assert ! ( dir. join( "renamed.wav" ) . exists( ) ) ;
984+ assert ! ( dir. join( "renamed.mp3" ) . exists( ) ) ;
985+ let records = backend. filter ( Category :: Music , "" , & BTreeMap :: new ( ) ) ;
986+ assert_eq ! ( names( records. clone( ) ) , vec![ "renamed" ] ) ;
987+ assert_eq ! ( records[ 0 ] . tags[ "GENRE" ] , vec![ "Ambient" ] ) ;
988+ }
989+
990+ #[ test]
991+ fn rename_tag_value_updates_all_matching_stems ( ) {
992+ let dir = unique_dir ( "rename-tag-value" ) ;
993+ let first = dir. join ( "first.wav" ) ;
994+ let second = dir. join ( "second.wav" ) ;
995+ fs:: write ( & first, b"not actually audio" ) . unwrap ( ) ;
996+ fs:: write ( & second, b"not actually audio" ) . unwrap ( ) ;
997+
998+ let mut backend = backend ( "rename-tag-value" ) ;
999+ backend. set_category_folder ( Category :: Music , dir) . unwrap ( ) ;
1000+ backend
1001+ . add_tag ( Category :: Music , & first, "Genre" , "Ambient" )
1002+ . unwrap ( ) ;
1003+ backend
1004+ . add_tag ( Category :: Music , & second, "Genre" , "Ambient" )
1005+ . unwrap ( ) ;
1006+
1007+ backend
1008+ . rename_tag_value ( Category :: Music , "Genre" , "Ambient" , "Drone" )
1009+ . unwrap ( ) ;
1010+
1011+ let records = backend. filter ( Category :: Music , "" , & BTreeMap :: new ( ) ) ;
1012+ assert_eq ! ( records[ 0 ] . tags[ "GENRE" ] , vec![ "Drone" ] ) ;
1013+ assert_eq ! ( records[ 1 ] . tags[ "GENRE" ] , vec![ "Drone" ] ) ;
1014+ }
1015+
8361016 #[ test]
8371017 fn refresh_indexes_matching_extensions_without_probe ( ) {
8381018 let dir = unique_dir ( "extension-only" ) ;
0 commit comments