diff --git a/README.md b/README.md index b1061590..2cf3e558 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,56 @@ Use --js-pipe to access the pipe data in your javascript code. --- +#### Renaming and moving files + +With `--rename` the search and replace is done on the name of each file, and the file is renamed to the result. The folder and the content are not touched. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename +docs/intro.md +docs/setup.md +``` + +With `--replacement-js` the name is the `text`: + +```bash +> rexreplace '^.*$' 'text.toLowerCase()' 'docs/*' --rename --replacement-js +docs/readme.md +``` + +With `--rename-path` it is done on the whole path, so a `/` in the replacement moves the file. Folders that are not there are made. + +```bash +> rexreplace '^' 'archive/' '*.log' --rename-path +archive/a.log +archive/b.log +``` + +See what would happen first with `--simulate`. Each old name is printed (to stderr) just before its new name (to stdout), and nothing is renamed. + +```bash +> rexreplace '\.markdown$' '.md' '**/*.markdown' --rename --simulate +docs/intro.markdown +docs/intro.md +docs/setup.markdown +docs/setup.md +``` + +If planned renames clash, the process is halted and no change is applied to any file. + +```bash +> rexreplace '^[ab]' 'c' a.md b.md --rename + ❌ Renames to the same path are not allowed. + From: a.md + From: b.md + To: c.md + + ❌ Planned renames clash, so no change is applied to any file + No further processing. +``` + +--- + ## Usage ```bash @@ -260,8 +310,11 @@ Use --js-pipe to access the pipe data in your javascript code. | `-o` | **`--output`** Output the final result instead of saving to file. Will also output content even if no replacement has taken place. [boolean] | | `-A` | **`--void-async`** Handle files in a synchronous flow. Good to limit memory usage when handling large files. [boolean] | | `-B` | **`--void-backup`** Avoid temporary backing up file. Works async (independent of -A flag) and will speed up things but at one point data lives only in memory, and you will lose the content if the process is interrupted. [boolean] | -| `-b` | **`--keep-backup`** Keep a backup file of the original content. [boolean] | +| `-b` | **`--keep-backup`** Keep a backup file of the original content. With --rename the old file is kept. [boolean] | | `-S` | **`--simulate`** Do everything but write the files. The names of the files that would be changed are printed as usual, and no backup is made. [boolean] | +| `-r` | **`--rename`** Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file. [boolean] | +| | **`--rename-path`** Like --rename, but on the whole path of each file, so a / in the replacement moves the file. Folders that are not there are made. [boolean] | +| | **`--rename-force`** With --rename or --rename-path, write over a file that is there already. [boolean] | | `-m` | **`--output-match`** Output each match on a new line. Will not replace any content but you still need to provide a dummy value (like `_`) as replacement parameter. If search pattern does not contain matching groups the full match will be outputted. If search pattern does contain matching groups only matching groups will be outputted (same line with no delimiter). [boolean] | | `-T` | **`--trim-pipe`** Trim piped data before processing. If piped data only consists of chars that can be trimmed (new line, space, tabs...) it will become an empty string. [boolean] | | `-p` | **`--pipe`** Read the piped data and make it available to --replacement-js as `pipe`. [boolean] | @@ -295,6 +348,7 @@ Files identified for potential processing are identified by: - Data can be piped in, both as the text to search and as the replacement - The names of the changed files are printed to stdout, so they can be piped on (to stderr with `--output` and `--output-match`) - Set the delimiter between names and between the content of files +- Rename files with a search and replace on their name (`--rename`), or move them with one on their path (`--rename-path`). If planned renames clash the process is halted and no change is applied to any file, and `--simulate` shows each old name next to its new one - See the [release note](https://github.com/mathiasrw/rexreplace/releases) for a log of changes. Descriptions are given in latest patch version. ### Quirks diff --git a/src/cli.ts b/src/cli.ts index adc11505..4fd55707 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -237,7 +237,7 @@ const yargs = require('yargs') .alias('B', 'void-backup') .boolean('b') - .describe('b', 'Keep a backup file of the original content.') + .describe('b', 'Keep a backup file of the original content. With --rename the old file is kept.') .alias('b', 'keep-backup') .boolean('S') @@ -247,6 +247,26 @@ const yargs = require('yargs') ) .alias('S', 'simulate') + .boolean('r') + .describe( + 'r', + 'Search and replace in the name of each file, and rename the file to the result. The folder and the content are not touched. If planned renames clash the process is halted and no change is applied to any file.' + ) + .alias('r', 'rename') + + .boolean('rename-path') + .describe( + 'rename-path', + 'Like --rename, but on the whole path of each file, so a / in the replacement moves the file. Folders that are not there are made.' + ) + .conflicts('r', 'rename-path') + + .boolean('rename-force') + .describe( + 'rename-force', + 'With --rename or --rename-path, write over a file that is there already.' + ) + .boolean('m') .describe( 'm', @@ -373,10 +393,6 @@ All variables, except from module, date objects, \`nl\` and \`_\`, has a corresp /* // Ideas - .boolean('n') - .describe('n', "Do replacement on file path/names instead of file content (rename/move the files)") - .alias('n', 'name') - // https://github.com/eugeneware/replacestream .integer('M') .describe('M', "Maximum length of match. Set this value only if any single file of your ") diff --git a/src/engine.ts b/src/engine.ts index a41b7263..08e29d9e 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -10,6 +10,8 @@ const now = new Date(); import {outputConfig, chat, info, print, printDone, warn, error, panic} from './output.ts'; +import './shim.ts'; + const re = { mctime: /[mc]time/, colon: /:/g, @@ -55,6 +57,11 @@ export async function engine(config: any = {engine: 'default'}) { function newStatus(config) { const status: any = {files: {searched: [], updated: []}, pipe: null, warnings: [], errors: []}; + // The old and the new path of each file that is renamed, or would be + if (config.rename || config.renamePath) { + status.renames = []; + } + // The status report with --verbose also tells what went into the engine and what it did. // The config is copied before it gets the status, and the piped data is told about in `pipe`. if (config.verbose && (config.status || config.statusCompact)) { @@ -130,6 +137,18 @@ async function run(config) { config.contentDelimiterTail = config.contentDelimiterTail ?? config.tail ?? !(piped && config.output); + if (config.rename && config.renamePath) { + panic('--rename can not be used with --rename-path'); + } + + if (config.renameForce && !config.rename && !config.renamePath) { + panic('--rename-force needs --rename or --rename-path'); + } + + if ((config.rename || config.renamePath) && piped) { + panic('--rename and --rename-path need files, as piped data has no name to change'); + } + if (piped) { return doReplacement('Piped data', config, config.pipedData); } @@ -151,6 +170,10 @@ async function run(config) { fs.existsSync(filepath) ? true : error('File not found:', filepath) ); + if (config.rename || config.renamePath) { + return renameFiles(files, config); + } + // A limited number of files are in work at the same time, so the memory and the open files stay within bounds. // Each worker takes the next file when it is done with one. Up to 16 files are all handled at once. const workers = config.voidAsync @@ -273,6 +296,160 @@ function updated(file, config) { info(file); } +// --rename: the name of each file is the text that is searched and replaced, and the file is renamed to the result. +// --rename-path: the same with the whole path, so the file can be moved. +function renameFiles(files, config) { + // With --rename a / in the new name would move the file + const slashed = []; + const plan = files.map(file => { + config.statusInfo.files.searched.push(file); + const text = config.renamePath ? file : path.basename(file); + chat('Work on the ' + (config.renamePath ? 'path' : 'name') + ' of: ' + file); + const replacement = + config.replacementJs && !config.outputMatch + ? dynamicReplacement(file, config, text) + : config.replacement; + const result = text.replace(config.regex, replacement); + if (result === text) { + return {old: file, new: file}; + } + if (config.renamePath) { + return {old: file, new: result}; + } + const item = {old: file, new: path.join(path.dirname(file), result)}; + if (/[\\/]/.test(result)) { + slashed.push(item); + } + return item; + }); + const renames = plan.filter(item => item.new !== item.old); + + // With --output-match the matches are printed by the replacement, and the names go to stderr. + // The replacement is not used, so nothing would be renamed: no clash check and no renames in the status. + if (config.outputMatch) { + return plan.forEach(item => + item.new !== item.old ? updated(item.old, config) : config.nameAll && info(item.old) + ); + } + + findClashes(plan, renames, slashed, config); + + plan.forEach(item => { + if (item.new === item.old) { + chat('Same name: ' + item.old); + return config.nameAll && nameRename(item, config); + } + // --simulate and --output do everything but rename + if (config.simulate || config.output) { + config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); + return nameRename(item, config); + } + renameFile(item, config); + }); +} + +// With --simulate and --output the old name goes to stderr and the new one to stdout, so they come in pairs in a terminal +function nameRename(item, config) { + if (config.simulate || config.output) { + info(item.old, process.stderr); + } + info(item.new, process.stdout); +} + +// If planned renames clash the process is halted and no change is applied to any file. +// The paths are compared as absolute paths, as two relative paths can lead to the same file, +// and the case is ignored, as `A.md` and `a.md` are the same file on a file system that is case-insensitive. +const pathKey = file => path.resolve(file).toLowerCase(); + +function findClashes(plan, renames, slashed, config) { + const byTarget = new Map(); + renames.forEach(item => { + const target = pathKey(item.new); + byTarget.set(target, (byTarget.get(target) || []).concat(item)); + }); + // All files in the run, also the ones that keep their name + const inRun = new Set(plan.map(item => pathKey(item.old))); + const inRunExact = new Set(plan.map(item => path.resolve(item.old))); + const ontoRun = new Set(byTarget.keys()).intersection(inRun); + + // Each path on its own line, so long paths can be read + const clash = (title, items) => + title + items.map(item => '\n From: ' + item.old).join('') + '\n To: ' + items[0].new; + + const clashes = slashed.map(item => + clash('"/" is not allowed in names. Use --rename-path to move files.', [item]) + ); + byTarget.forEach((items, target) => { + const [item] = items; + // The new path is another file, also when it differs from the old one only in case (on a file system that is case-sensitive) + const other = isOtherFile(item.old, item.new); + const self = pathKey(item.old) === target; + if (1 < items.length) { + clashes.push(clash('Renames to the same path are not allowed.', items)); + } else if ( + ontoRun.has(target) && + (!self || (other && inRunExact.has(path.resolve(item.new)))) + ) { + // Not even --rename-force writes over a file in the run. A file can still change the case of its own name. + clashes.push(clash('Renames clashing with other files in this run are not allowed.', items)); + } else if (other && !config.renameForce) { + clashes.push( + clash( + 'Renames clashing with files that are there already are not allowed without --rename-force.', + items + ) + ); + } + }); + + if (clashes.length) { + clashes.forEach(clash => error(clash)); + panic('Planned renames clash, so no change is applied to any file'); + } +} + +// On a file system that is case-insensitive, `A.md` is the same file as `a.md`, so a rename of only the case is not a clash +function isOtherFile(file, target) { + const stats = fs.statSync(target, {throwIfNoEntry: false}); + if (!stats) { + return false; + } + const own = fs.statSync(file); + return own.dev !== stats.dev || own.ino !== stats.ino; +} + +function renameFile(item, config) { + const target = path.resolve(item.new); + try { + const folder = path.dirname(target); + if (!fs.existsSync(folder)) { + chat('Make folder: ' + path.dirname(item.new)); + fs.mkdirSync(folder, {recursive: true}); + } + // Checked again, as the files can have changed since the clashes were found + if (isOtherFile(item.old, target)) { + if (!config.renameForce) { + return error(item.new + ' is there already, so ' + item.old + ' is not renamed'); + } + chat('Write over: ' + item.new); + } + // With --keep-backup the old file is kept, so the file is copied + if (config.keepBackup) { + chat('Copy: ' + item.old + ' to ' + item.new); + fs.copyFileSync(item.old, target); + } else { + chat('Rename: ' + item.old + ' to ' + item.new); + fs.renameSync(item.old, target); + } + } catch (e) { + return error(e); + } + config.statusInfo.files.updated.push(item.old); + config.statusInfo.renames.push(item); + info(item.new); +} + // How the piped data was used function pipeType(config, piped) { if (config.globPipe) { diff --git a/src/output.ts b/src/output.ts index 7f50bdaf..ff418041 100644 --- a/src/output.ts +++ b/src/output.ts @@ -62,12 +62,12 @@ export const printDone = function () { }; // The name of a changed file goes to stdout so the names can be piped -export const info = function (name) { +export const info = function (name, stream = null) { if (config.quiet || config.quietTotal) { return; } // stdout is taken when the result is printed, so then the names go to stderr - const stream = config.output || config.outputMatch ? process.stderr : process.stdout; + stream ??= config.output || config.outputMatch ? process.stderr : process.stdout; print(name, config.nameDelimiter, config.nameDelimiterTail, stream); }; diff --git a/src/shim.ts b/src/shim.ts new file mode 100644 index 00000000..d6142fea --- /dev/null +++ b/src/shim.ts @@ -0,0 +1,7 @@ +// Set.prototype.intersection() came in Node 22, so it is added here for older runtimes + +const proto: any = Set.prototype; + +proto.intersection ??= function (other) { + return new Set([...this].filter(item => other.has(item))); +}; diff --git a/test/cli/rename.test.js b/test/cli/rename.test.js new file mode 100644 index 00000000..3c66a01a --- /dev/null +++ b/test/cli/rename.test.js @@ -0,0 +1,30 @@ +// --rename (-r) does the search and replace on the path of each file, and renames the file to the result +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {$} from 'bun'; +import {cli, sh} from '../helper.js'; + +describe('--rename', () => { + test('gives the new names to stdout, so they can be piped on', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const result = await sh( + $`bun ${cli} md txt '*.md' -r --null-out | bun ${cli} f x --glob-pipe --null-in -A` + ); + expect(result).toEqual({out: 'a.txt\nb.txt\n', err: '', exit: 0}); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('xoo'); + }); + + test('--rename-force needs --rename or --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await sh($`bun ${cli} md txt a.md --rename-force`); + expect(result.exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); + + test('can not be used with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect((await sh($`bun ${cli} md txt a.md -r --rename-path`)).exit).toBe(3); + expect(fs.readdirSync('.')).toEqual(['a.md']); + }); +}); diff --git a/test/cli/run.sh b/test/cli/run.sh index b4ac8284..30b51a93 100644 --- a/test/cli/run.sh +++ b/test/cli/run.sh @@ -233,6 +233,26 @@ assert "rexreplace 'oo' 'x' my.file your.file --simulate" 'my.file' assert "cat my.file" 'foobar' +# --rename (-r) does the search and replace on the name, and renames the file to the result +reset +assert "rexreplace '^my' 'our' my.file --rename" 'our.file' +assert "cat our.file" 'foobar' +# --rename-path does it on the whole path, so the file can be moved +assert "rexreplace '^' 'sub/' our.file --rename-path" 'sub/our.file' +assert "cat sub/our.file" 'foobar' +rm -rf sub + +# --rename with --simulate prints the old names to stderr and the new ones to stdout +reset +assert "rexreplace '^my' 'our' my.file -r --simulate 2>/dev/null" 'our.file' +assert "rexreplace '^my' 'our' my.file -r --simulate 2>&1 >/dev/null" 'my.file' + +# If two planned renames clash, the run stops before any file is renamed +reset +assert_raises "rexreplace '^(my|your)' 'our' my.file your.file -r" 3 +assert "cat my.file" 'foobar' + + # --full-path prints the full path of each name reset assert "rexreplace 'b' 'x' my.file --full-path" "$(pwd -P)/my.file" diff --git a/test/engine/rename-force.test.js b/test/engine/rename-force.test.js new file mode 100644 index 00000000..52027eba --- /dev/null +++ b/test/engine/rename-force.test.js @@ -0,0 +1,75 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {rr} from '../helper.js'; + +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + +describe('--rename-force', () => { + test('writes over a file that is there already', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, renameForce: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Write over: a.txt\nRename: a.md to a.txt\n'); + expect(fs.readdirSync('.')).toEqual(['a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); + + test('does not write over a file that is renamed in the same run', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).exit).toBe(3); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('does not write over a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + const config = {includeGlob: ['a.md', 'b.txt'], rename: true, renameForce: true}; + expect((await rr('^a\\.md$', 'b.txt', config)).exit).toBe(3); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test.if(caseSensitive)( + 'does not write over a file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = { + includeGlob: ['a.md', 'A.md'], + rename: true, + renameForce: true, + voidIgnoreCase: true, + }; + expect((await rr('^a', 'A', config)).exit).toBe(3); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + + test('does not let two new paths differ only in case', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true, replacementJs: true}; + expect((await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); + + test('does not let two files get the same new path', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, renameForce: true}; + expect((await rr('^[ab]', 'c', config)).exit).toBe(3); + expect(fs.readdirSync('.').sort()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename-path.test.js b/test/engine/rename-path.test.js new file mode 100644 index 00000000..aa44af8d --- /dev/null +++ b/test/engine/rename-path.test.js @@ -0,0 +1,45 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import path from 'path'; +import {rr} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +describe('--rename-path', () => { + test('moves a file with a / in the replacement, and makes the folders', async () => { + fs.writeFileSync('a.md', 'foo'); + const result = await rr('^', 'docs/old/', { + includeGlob: ['a.md'], + renamePath: true, + verbose: true, + }); + expect(result.out).toBe('docs/old/a.md\n'); + expect(result.err).toContain('Make folder: docs/old\nRename: a.md to docs/old/a.md\n'); + expect(fs.readFileSync('docs/old/a.md', 'utf8')).toBe('foo'); + }); + + test('works on the full path with --full-path', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], renamePath: true, fullPath: true}; + expect((await rr('^.*/a\\.md$', path.resolve('b.md'), config)).out).toBe( + path.resolve('b.md') + '\n' + ); + expect(files()).toEqual(['b.md']); + }); +}); + +describe('--rename-path stops the run before any file is renamed, when planned renames clash', () => { + const stopped = + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; + + test('when the same new path is written in two ways', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['a.md', './b.md'], renamePath: true}; + expect((await rr('[ab]\\.md$', 'c.md', config)).err).toBe( + ' ❌ Renames to the same path are not allowed.\n From: ./b.md\n From: a.md\n To: ./c.md\n\n' + + stopped + ); + expect(files()).toEqual(['a.md', 'b.md']); + }); +}); diff --git a/test/engine/rename.test.js b/test/engine/rename.test.js new file mode 100644 index 00000000..2f7ad927 --- /dev/null +++ b/test/engine/rename.test.js @@ -0,0 +1,271 @@ +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {rr, rrStatus} from '../helper.js'; + +const files = () => fs.readdirSync('.', {recursive: true}).sort(); + +// On a file system that is case-sensitive (like on Linux), `a.md` and `A.md` can be two files +const caseSensitive = (() => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rexreplace-case-')); + fs.writeFileSync(path.join(dir, 'a'), ''); + const result = !fs.existsSync(path.join(dir, 'A')); + fs.rmSync(dir, {recursive: true}); + return result; +})(); + +describe('--rename', () => { + test('renames each file to the result of the search and replace on its name', async () => { + fs.writeFileSync('a.markdown', 'foo'); + fs.writeFileSync('b.markdown', 'bar'); + const config = {includeGlob: ['*.markdown'], rename: true}; + expect(await rr('\\.markdown$', '.md', config)).toEqual({ + out: 'a.md\nb.md\n', + err: '', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('leaves a file where the pattern changes nothing, and names it with --name-all', async () => { + fs.writeFileSync('a.txt', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.*'], rename: true}; + expect((await rr('txt', 'text', config)).out).toBe('a.text\n'); + fs.renameSync('a.text', 'a.txt'); + expect((await rr('txt', 'text', {...config, nameAll: true})).out).toBe('a.text\nb.md\n'); + expect(files()).toEqual(['a.text', 'b.md']); + }); + + test('leaves the folder as it is', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/docs.md', 'foo'); + expect((await rr('^docs', 'notes', {includeGlob: ['docs/*'], rename: true})).out).toBe( + 'docs/notes.md\n' + ); + expect(files()).toEqual(['docs', 'docs/notes.md']); + }); + + test('a / in the new name stops the run, as that is --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect(await rr('^', 'sub/', {includeGlob: ['a.md'], rename: true})).toEqual({ + out: '', + err: + ' ❌ "/" is not allowed in names. Use --rename-path to move files.\n From: a.md\n To: sub/a.md\n\n' + + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n', + exit: 3, + }); + expect(files()).toEqual(['a.md']); + }); + + test('runs the code of --replacement-js with the name as the text', async () => { + fs.mkdirSync('docs'); + fs.writeFileSync('docs/Intro.md', 'foo'); + const config = {includeGlob: ['docs/*'], rename: true, replacementJs: true}; + expect((await rr('^.*$', 'text.toLowerCase()', config)).out).toBe('docs/intro.md\n'); + }); + + test('renames only the case of a name', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect(await rr('a', 'A', config)).toEqual({out: 'A.md\n', err: '', exit: null}); + expect(files()).toEqual(['A.md']); + }); + + test('tells the old and the new paths in the status report', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'foo'); + const status = await rrStatus('md', 'txt', {includeGlob: ['a.md', 'b.txt'], rename: true}); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.files).toEqual({searched: ['a.md', 'b.txt'], updated: ['a.md']}); + }); + + test('tells in the status report only the renames that were done', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.mkdirSync('locked'); + fs.writeFileSync('locked/b.md', 'foo'); + fs.chmodSync('locked', 0o555); + try { + const status = await rrStatus('md', 'txt', { + includeGlob: ['a.md', 'locked/b.md'], + rename: true, + }); + expect(status.renames).toEqual([{old: 'a.md', new: 'a.txt'}]); + expect(status.errors[0]).toContain('EACCES'); + } finally { + fs.chmodSync('locked', 0o755); + } + }); + + describe.each([['simulate'], ['output']])('with --%s', flag => { + test('the old names go to stderr and the new ones to stdout, and nothing is renamed', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + const config = {includeGlob: ['*.md'], rename: true, [flag]: true}; + expect(await rr('md', 'txt', config)).toEqual({ + out: 'a.txt\nb.txt\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect(files()).toEqual(['a.md', 'b.md']); + expect((await rrStatus('md', 'txt', config)).renames).toEqual([ + {old: 'a.md', new: 'a.txt'}, + {old: 'b.md', new: 'b.txt'}, + ]); + }); + }); + + test('with --output-match the matches go to stdout and the names to stderr, and nothing would be renamed', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + // The replacement is not used, so `_` gives no clash + const config = {includeGlob: ['*.md'], rename: true, outputMatch: true}; + expect(await rr('^[ab]', '_', config)).toEqual({ + out: 'a\nb\n', + err: 'a.md\nb.md\n', + exit: null, + }); + expect((await rrStatus('^[ab]', '_', config)).renames).toEqual([]); + expect(files()).toEqual(['a.md', 'b.md']); + }); +}); + +describe('--rename with --keep-backup', () => { + test('keeps the old file', async () => { + fs.writeFileSync('a.md', 'foo'); + const config = {includeGlob: ['a.md'], rename: true, keepBackup: true, verbose: true}; + const result = await rr('md', 'txt', config); + expect(result.out).toBe('a.txt\n'); + expect(result.err).toContain('Copy: a.md to a.txt\n'); + expect(files()).toEqual(['a.md', 'a.txt']); + expect(fs.readFileSync('a.txt', 'utf8')).toBe('foo'); + }); +}); + +describe('--rename stops the run before any file is renamed, when planned renames clash', () => { + const stopped = + ' ❌ Planned renames clash, so no change is applied to any file\n No further processing.\n\n'; + + test('when two files would get the same new path', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'foo'); + expect(await rr('^[ab]', 'c', {includeGlob: ['*.md'], rename: true})).toEqual({ + out: '', + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: c.md\n\n' + + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + + test('when two files would swap names', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "b" : "a"', config)).toEqual({ + out: '', + err: + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.md\n\n' + + ' ❌ Renames clashing with other files in this run are not allowed.\n From: b.md\n To: a.md\n\n' + + stopped, + exit: 3, + }); + expect(fs.readFileSync('a.md', 'utf8')).toBe('foo'); + }); + + test('when the new path is a file in the run that keeps its name', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.txt', 'bar'); + expect(await rr('^a\\.md$', 'b.txt', {includeGlob: ['a.md', 'b.txt'], rename: true})).toEqual({ + out: '', + err: + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: b.txt\n\n' + + stopped, + exit: 3, + }); + expect(fs.readFileSync('b.txt', 'utf8')).toBe('bar'); + }); + + test('when two new paths differ only in case, as they are the same file on some file systems', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('b.md', 'bar'); + const config = {includeGlob: ['*.md'], rename: true, replacementJs: true}; + expect(await rr('^[ab]', '$0 == "a" ? "X" : "x"', config)).toEqual({ + out: '', + err: + ' ❌ Renames to the same path are not allowed.\n From: a.md\n From: b.md\n To: X.md\n\n' + + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'b.md']); + }); + + test('when the new path is a file already, and other files are fine', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + fs.writeFileSync('b.md', 'foo'); + expect(await rr('md', 'txt', {includeGlob: ['*.md'], rename: true})).toEqual({ + out: '', + err: + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: a.txt\n\n' + + stopped, + exit: 3, + }); + expect(files()).toEqual(['a.md', 'a.txt', 'b.md']); + }); + + test.if(caseSensitive)( + 'when the new path is another file in the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md', 'A.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with other files in this run are not allowed.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + + test.if(caseSensitive)( + 'when the new path is a file outside the run that differs only in case', + async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('A.md', 'bar'); + const config = {includeGlob: ['a.md'], rename: true, voidIgnoreCase: true}; + expect((await rr('^a', 'A', config)).err).toBe( + ' ❌ Renames clashing with files that are there already are not allowed without --rename-force.\n From: a.md\n To: A.md\n\n' + + stopped + ); + expect(fs.readFileSync('A.md', 'utf8')).toBe('bar'); + } + ); + + test('with --simulate too', async () => { + fs.writeFileSync('a.md', 'foo'); + fs.writeFileSync('a.txt', 'bar'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, simulate: true})).exit + ).toBe(3); + }); +}); + +describe('--rename can not be used', () => { + test('with --rename-path', async () => { + fs.writeFileSync('a.md', 'foo'); + expect( + (await rr('md', 'txt', {includeGlob: ['a.md'], rename: true, renamePath: true})).exit + ).toBe(3); + expect(files()).toEqual(['a.md']); + }); + + test('with piped data, as it has no name', async () => { + const result = await rr('o', 'x', {pipedData: 'foo', rename: true}); + expect(result.err).toStartWith(' ❌ --rename and --rename-path need files'); + expect(result.exit).toBe(3); + }); +});