diff --git a/mmv.go b/mmv.go index 5cf5731..ac52403 100644 --- a/mmv.go +++ b/mmv.go @@ -5,6 +5,7 @@ import ( "math/rand" "os" "path/filepath" + "strings" ) // Rename multiple files. @@ -74,6 +75,14 @@ func (err *sameDestinationError) Error() string { return fmt.Sprintf("duplicate destination: %s", err.path) } +type invalidRenameError struct { + src, dst string +} + +func (err *invalidRenameError) Error() string { + return fmt.Sprintf("invalid rename: %s, %s", err.src, err.dst) +} + func buildRenames(files map[string]string) ([]rename, error) { revs := make(map[string]string, len(files)) // reverse of files @@ -104,66 +113,121 @@ func buildRenames(files map[string]string) ([]rename, error) { if _, ok := revs[dst]; ok { return nil, &sameDestinationError{dst} } + if k, l := len(src), len(dst); k > l && src[l] == filepath.Separator && src[:l] == dst || + k < l && dst[k] == filepath.Separator && dst[:k] == src { + return nil, &invalidRenameError{src, dst} + } revs[dst] = src } - // remove source == destination + // group directories by directory depth + srcdepths := make([][]string, 1) + dstdepths := make([][]string, 1) for src, dst := range files { - if src == dst { - delete(files, src) - delete(revs, dst) + // group source paths by directory depth + i := strings.Count(src, string(filepath.Separator)) + if len(srcdepths) <= i { + xs := make([][]string, i*2) + copy(xs, srcdepths) + srcdepths = xs + } + srcdepths[i] = append(srcdepths[i], src) + // group destination paths by directory depth + i = strings.Count(dst, string(filepath.Separator)) + if len(dstdepths) <= i { + xs := make([][]string, i*2) + copy(xs, dstdepths) + dstdepths = xs + } + dstdepths[i] = append(dstdepths[i], dst) + } + + // result renames + count := len(files) + rs := make([]rename, 0, 2*count) + + // check if any parent directory will be moved + for i := len(srcdepths) - 1; i >= 0; i-- { + L: + for _, src := range srcdepths[i] { + for j := 0; j < i; j++ { + for _, s := range srcdepths[j] { + if k := len(s); len(src) > k && src[k] == filepath.Separator && src[:k] == s { + if d := files[s]; s != d { + if dst, l := files[src], len(d); i == j+1 && len(dst) > l && dst[:l] == d && dst[l:] == src[k:] { + // skip moving a file when it moves along with the closest parent directory + delete(files, src) + delete(revs, dst) + } else { + // move to a temporary path before any parent directory is moved + tmp := randomPath(filepath.Dir(s)) + rs = append(rs, rename{src, tmp}) + files[tmp] = files[dst] + delete(files, src) + revs[dst] = tmp + } + continue L + } + } + } + } + // remove source == destination + if dst := files[src]; src == dst { + delete(files, src) + delete(revs, dst) + } } } - // list the renames - var i int - rs := make([]rename, 0, 2*len(files)) - vs := make(map[string]int, len(files)) - for _, dst := range files { - if vs[dst] > 0 { - continue - } - i++ // connected component identifier + // list renames in increasing destination directory depth order + i, vs := 0, make(map[string]int, count) + for _, dsts := range dstdepths { + for _, dst := range dsts { + if vs[dst] > 0 { + continue + } + i++ // connected component identifier - // mark the nodes in the connected component and check cycle - var cycle bool - for { - vs[dst] = i - if x, ok := files[dst]; ok { - dst = x - if vs[x] > 0 { - cycle = vs[x] == i + // mark the nodes in the connected component and check cycle + var cycle bool + for { + vs[dst] = i + if x, ok := files[dst]; ok { + dst = x + if vs[x] > 0 { + cycle = vs[x] == i + break + } + } else { break } - } else { - break } - } - // if there is a cycle, rename to a temporary file - var tmp string - if cycle { - tmp = randomPath(filepath.Dir(dst)) - rs = append(rs, rename{dst, tmp}) - vs[dst]-- - } + // if there is a cycle, rename to a temporary file + var tmp string + if cycle { + tmp = randomPath(filepath.Dir(dst)) + rs = append(rs, rename{dst, tmp}) + vs[dst]-- + } - // rename from the leaf node - for { - if src, ok := revs[dst]; ok && (!cycle || vs[src] == i) { - rs = append(rs, rename{src, dst}) - if !cycle { - vs[dst] = i + // rename from the leaf node + for { + if src, ok := revs[dst]; ok && (!cycle || vs[src] == i) { + rs = append(rs, rename{src, dst}) + if !cycle { + vs[dst] = i + } + dst = src + } else { + break } - dst = src - } else { - break } - } - // if there is a cycle, rename the temporary file - if cycle { - rs = append(rs, rename{tmp, dst}) + // if there is a cycle, rename the temporary file + if cycle { + rs = append(rs, rename{tmp, dst}) + } } } return rs, nil diff --git a/mmv_test.go b/mmv_test.go index 2e73e47..8f0177c 100644 --- a/mmv_test.go +++ b/mmv_test.go @@ -296,6 +296,92 @@ func TestRename(t *testing.T) { "a/b/c/baz": "2", }, }, + { + name: "invalid rename error", + files: map[string]string{ + "x/y": "x", + }, + contents: map[string]string{ + "x/y": "0", + }, + expected: map[string]string{ + "x/y": "0", + }, + err: "invalid rename: x", + }, + { + name: "invalid rename error", + files: map[string]string{ + "x/y": "x/y/z", + }, + contents: map[string]string{ + "x/y": "0", + }, + expected: map[string]string{ + "x/y": "0", + }, + err: "invalid rename: x", + }, + { + name: "directory renames", + files: map[string]string{ + "x/foo": "y/bar", + "x/bar": "z/baz", + "x/qux": "z/qux", + "x/quy": "z/baz/qux", + "x/": "z/", + "y/bar": "x/foo", + "y/qux": "x/qux", + "y/": "w/", + "w/": "y/", + "w/x/": "y/y/", + "w/x/x/": "x/z/", + "w/x/y": "y/x/y", + "w/x/z": "w/x/z", + "w/x/w": "x/x/w", + "v/": "v/", + "v/x": "v/y", + "v/x/x": "v/y/x", + "xxxxx": "yyyyy", + }, + count: 26, + contents: map[string]string{ + "x/foo": "0", + "x/bar/a": "1", + "x/qux": "2", + "x/quy": "3", + "x/quz": "4", + "y/bar": "5", + "y/baz": "6", + "y/qux": "7", + "w/a": "8", + "w/x/a": "9", + "w/x/x/a": "10", + "w/x/y": "11", + "w/x/z": "12", + "w/x/w": "13", + "v/x/x": "14", + "xxxxx": "15", + }, + expected: map[string]string{ + "y/bar": "0", + "z/baz/a": "1", + "z/qux": "2", + "z/baz/qux": "3", + "z/quz": "4", + "x/foo": "5", + "w/baz": "6", + "x/qux": "7", + "y/a": "8", + "y/y/a": "9", + "x/z/a": "10", + "y/x/y": "11", + "w/x/z": "12", + "x/x/w": "13", + "v/y/x": "14", + "yyyyy": "15", + }, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -331,6 +417,12 @@ func TestRename(t *testing.T) { func setupFiles(contents map[string]string) error { for f, cnt := range contents { + dir := filepath.Dir(f) + if dir != "." { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + } if err := ioutil.WriteFile(f, []byte(cnt), 0o600); err != nil { return err }