From 5a0105f1c0630f1ddffcb96f5f61e9d8089338b5 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:13:07 +0200 Subject: [PATCH] fix(terminal): cp/mv into a directory, and Windows paths PowerShell accepts (THI-353) cp / mv (GNU coreutils semantics) - A destination that is an existing directory receives the source inside it. `mv documents/notes.txt .` used to replace the home directory with the file; `cp file dir` / `Move-Item file dir` overwrote the directory. - Several sources need a directory; no directory into itself; same-file check; -v, -n, -i (answers "n" in the simulator, and says so), -f; invalid options rejected like GNU; moving the directory you stand in takes you along; `cp -rn` keeps existing files at any depth. Windows - PowerShell paths: `\` separators, `.\`, `C:\Users\user\...` (= /home/user); `C:\` shows `Users`, `cd ..` from the home reaches `C:\Users`. Bash is unchanged (a backslash is not a separator there). - Remove-Item / Copy-Item -Recurse; -Path / -Destination in any order; mkdir creates missing parents like New-Item -ItemType Directory. - Exercises accept `\` on Windows (exerciseAccepts); Tab completes after `\`. Lessons - mv, rm and cat examples run as shown, with PowerShell variants and Windows versions of the rm warning and tip. Theory gaps 174 -> 162, lessons showing bash to Windows learners 39 -> 37. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++ src/app/components/LessonPage.tsx | 3 +- src/app/components/TerminalEmulator.tsx | 2 +- src/app/data/commands/git.ts | 3 +- src/app/data/commands/windows.ts | 33 ++- src/app/data/curriculum.ts | 33 ++- src/app/data/terminalEngine.ts | 268 ++++++++++++++++++------ src/app/data/validators.ts | 9 + src/test/lessonFidelity.test.ts | 40 ++++ src/test/lessonTheoryGaps.ts | 17 +- src/test/terminalEngine.test.ts | 170 +++++++++++++++ 11 files changed, 499 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbfbf6b..be8e56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ --- +## 📁 24 septembre 2026 — Déplacer un fichier ne détruit plus rien, et Windows comprend ses propres chemins (THI-353) + +*PR #392 · écarts théorie ↔ terminal : 174 → 162 · leçons montrant du bash sur Windows : 39 → 37* + +Le rejeu de la théorie avait trouvé le bug le plus grave de la journée, dans le module 2. + +- **`mv` et `cp` vers un dossier existant.** `mv documents/notes.txt .` remplaçait le dossier personnel entier par le fichier : l'élève perdait toute son arborescence. `cp fichier dossier` et `Move-Item fichier dossier` écrasaient de même le dossier visé. Désormais, comme dans un vrai shell, le fichier va *dans* le dossier. Plusieurs fichiers peuvent être déplacés d'un coup vers un dossier, `cp -r documents projets/` crée `projets/documents`, et un dossier ne peut pas être déplacé dans lui-même. Les options `-v`, `-n` et `-i` sont comprises. Comme le simulateur ne peut pas poser de question, `-i` répond « non » et le dit. Une option inconnue est refusée avec le message de GNU. +- **Windows comprend ses propres chemins.** `documents\notes.txt`, `.\documents\rapport.md` et `C:\Users\user\projets` échouaient tous : seul le `/` fonctionnait. PowerShell accepte les deux séparateurs, le simulateur aussi. `cd ..` depuis le dossier personnel mène à `C:\Users`, et non plus à un `C:\home` qui n'existe pas sous Windows. Les exercices acceptent les deux écritures : `Get-Content documents\notes.txt` valide l'exercice au même titre que `documents/notes.txt`. Un test le vérifie pour chaque exercice Windows qui prend un chemin. La touche Tab complète aussi après un `\`. +- **Les autres commandes Windows touchées.** `Remove-Item -Recurse` supprime un dossier avec son contenu (l'option était ignorée). `Copy-Item -Recurse` copie un dossier complet. `mkdir archives\2025` crée aussi le dossier parent, comme le fait PowerShell. +- **Des exemples qui fonctionnent tels quels.** Les leçons `mv`, `rm` et `cat` montraient des fichiers qui n'existent pas (`fichier.txt`, `[contenu de fichier1]`). Chaque exemple crée maintenant ce qu'il utilise, et l'élève Windows voit la version PowerShell. + +--- + ## 🐚 24 septembre 2026 — Un vrai shell, et une théorie qui dit ce que le terminal répond (THI-353) *PR #389 · #390 · #391 · 198/198 exercices sans ligne rouge (ordinateur et mobile) · nouveau test permanent `lessonTheory` : 754 commandes de théorie rejouées* diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index a5f7e51..ffd4ce5 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -15,6 +15,7 @@ import { useLessonSEO } from '../hooks/useLessonSEO'; import { toUnixUsername } from '../../lib/username'; import { renderInlineMarkdown, stripInlineMarkdown } from '../../lib/renderInlineMarkdown'; import { TerminalState, createInitialState } from '../data/terminalEngine'; +import { exerciseAccepts } from '../data/validators'; import { TerminalEmulator } from './TerminalEmulator'; import { Button } from './ui/button'; import { AiTutorPanel } from './ai/AiTutorPanel'; @@ -179,7 +180,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { const handleCommand = useCallback( (command: string, _state: TerminalState) => { if (!lesson.exercise || exerciseCompleted) return; - if (lesson.exercise.validate(command, selectedEnv)) { + if (exerciseAccepts(lesson.exercise.validate, command, selectedEnv)) { completeLesson(moduleId, lessonId); setExerciseMessage(lesson.exercise.successMessage); setJustCompleted(true); diff --git a/src/app/components/TerminalEmulator.tsx b/src/app/components/TerminalEmulator.tsx index aa6be9a..5d7de26 100644 --- a/src/app/components/TerminalEmulator.tsx +++ b/src/app/components/TerminalEmulator.tsx @@ -264,7 +264,7 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us // Tab autocompletion. Shared by the keyboard handler and the mobile key bar. const triggerTabCompletion = useCallback(() => { - const completions = getTabCompletions(input, activeState); + const completions = getTabCompletions(input, activeState, environment); if (completions.length === 1) { setInput(completions[0]); } else if (completions.length > 1) { diff --git a/src/app/data/commands/git.ts b/src/app/data/commands/git.ts index 464169c..ac2a339 100644 --- a/src/app/data/commands/git.ts +++ b/src/app/data/commands/git.ts @@ -9,8 +9,7 @@ function displayPath(cwd: string[]): string { /** Git for Windows writes `C:/Users/user/…` (drive letter, forward slashes). */ function gitPath(cwd: string[], env: TerminalEnv): string { if (env !== 'windows') return displayPath(cwd); - const home = cwd[0] === 'home' && cwd[1] === 'user'; - return (home ? ['C:', 'Users', 'user', ...cwd.slice(2)] : ['C:', ...cwd]).join('/'); + return ['C:', ...(cwd[0] === 'home' ? ['Users', ...cwd.slice(1)] : cwd)].join('/'); } function getNode(root: DirectoryNode, path: string[]): FSNode | null { diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 12a382e..0f9988a 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -9,13 +9,16 @@ export interface WindowsCmdDeps { cmdMkdir: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; cmdTouch: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; cmdCp: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; - cmdMv: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; + cmdMv: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode; newCwd?: string[] }; cmdRm: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; cmdEcho: (args: string[], envVars?: Record) => OutputLine[]; cmdGrep: (state: TerminalState, args: string[]) => OutputLine[]; cmdEnv: (state: TerminalState) => OutputLine[]; } +/** `-Recurse`, or any prefix PowerShell accepts for it (`-r`, `-rec`…). */ +const RECURSE_PARAM = /^-r(e(c(u(r(se?)?)?)?)?)?$/i; + const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default']; /** Value that follows a PowerShell parameter (`-Verb RunAs`), case-insensitive. */ @@ -30,6 +33,19 @@ function psPath(args: string[]): string | undefined { return i >= 0 ? args[i + 1] : args.find((a) => !a.startsWith('-')); } +/** `Copy-Item a b`, or `-Path a -Destination b` in any order → `[a, b]`. */ +function psSourceAndDestination(args: string[]): string[] { + const NAMED = ['-path', '-literalpath', '-destination']; + const valueOf = (names: string[]) => { + const i = args.findIndex((a) => names.includes(a.toLowerCase())); + return i >= 0 ? args[i + 1] : undefined; + }; + const positional = args.filter((a, i) => !a.startsWith('-') && !(i > 0 && NAMED.includes(args[i - 1].toLowerCase()))); + const source = valueOf(['-path', '-literalpath']) ?? positional.shift(); + const destination = valueOf(['-destination']) ?? positional.shift(); + return [source, destination].filter((a): a is string => a !== undefined); +} + /** * Handles PowerShell aliases and Windows/macOS-specific commands. * Returns null if the command is not handled by this module (caller falls through to default). @@ -128,8 +144,10 @@ export function handleWindows( case 'copy-item': case 'cpi': case 'copy': { - const cpArgs = args.filter((a) => !a.startsWith('-')); - const { lines, newRoot } = deps.cmdCp(newState, cpArgs); + const cpArgs = psSourceAndDestination(args); + // -Recurse copies a folder with its content, like `cp -r`. + const recurse = args.some((a) => RECURSE_PARAM.test(a)); + const { lines, newRoot } = deps.cmdCp(newState, [...(recurse ? ['-r'] : []), '--', ...cpArgs]); if (newRoot) newState = { ...newState, root: newRoot }; return { lines, newState }; } @@ -138,9 +156,10 @@ export function handleWindows( case 'move-item': case 'mi': case 'move': { - const mvArgs = args.filter((a) => !a.startsWith('-')); - const { lines, newRoot } = deps.cmdMv(newState, mvArgs); + const mvArgs = psSourceAndDestination(args); + const { lines, newRoot, newCwd } = deps.cmdMv(newState, ['--', ...mvArgs]); if (newRoot) newState = { ...newState, root: newRoot }; + if (newCwd) newState = { ...newState, cwd: newCwd }; return { lines, newState }; } @@ -150,7 +169,9 @@ export function handleWindows( case 'del': case 'erase': { const rmArgs = args.filter((a) => !a.startsWith('-')); - const { lines, newRoot } = deps.cmdRm(newState, rmArgs); + // -Recurse removes a folder with its content, like `rm -r`. + const recurse = args.some((a) => RECURSE_PARAM.test(a)); + const { lines, newRoot } = deps.cmdRm(newState, recurse ? ['-r', ...rmArgs] : rmArgs); if (newRoot) newState = { ...newState, root: newRoot }; return { lines, newState }; } diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index cfd0922..acdddd0 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -544,11 +544,17 @@ export const curriculum: Module[] = [ type: 'code', content: '$ mv documents/notes.txt .\n$ ls\ndocuments downloads notes.txt projets', label: 'Déplacer un fichier', + contentByEnv: { + windows: 'PS> Move-Item documents\\notes.txt .\n# notes.txt est maintenant dans C:\\Users\\user (le point « . » = ici)', + }, }, { type: 'code', - content: '$ mv notes.txt mes-notes.txt\n$ ls\ndocuments downloads mes-notes.txt projets', + content: '$ mv documents/notes.txt documents/mes-notes.txt\n$ ls documents\nmes-notes.txt rapport.md', label: 'Renommer un fichier', + contentByEnv: { + windows: 'PS> Move-Item documents\\notes.txt documents\\mes-notes.txt\n# Même dossier, nouveau nom : le fichier est renommé', + }, }, { type: 'info', @@ -586,23 +592,40 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ rm fichier.txt\n$ ls\ndocuments downloads projets', + content: '$ touch brouillon.txt\n$ rm brouillon.txt\n$ ls\ndocuments downloads projets', label: 'Supprimer un fichier', + contentByEnv: { + windows: 'PS> New-Item brouillon.txt\n# PowerShell affiche les détails du fichier créé\nPS> Remove-Item brouillon.txt\n# Aucun message : le fichier est supprimé, sans passer par la corbeille', + }, }, { type: 'code', - content: '$ rm -r dossier\n$ ls\ndocuments downloads projets', + content: '$ mkdir -p archives/2025\n$ rm -r archives\n$ ls\ndocuments downloads projets', label: 'Supprimer un répertoire (-r)', + labelByEnv: { + windows: 'Supprimer un dossier (-Recurse)', + }, + contentByEnv: { + windows: 'PS> mkdir archives\\2025\n# PowerShell affiche les détails du dossier créé\nPS> Remove-Item -Recurse archives\n# Le dossier et tout son contenu sont supprimés', + }, }, { type: 'warning', content: 'La commande `rm -rf /` ou `rm -rf *` peut détruire votre système entier ! Ne l\'utilisez jamais sans être absolument certain de ce que vous faites.', + contentByEnv: { + windows: + '`Remove-Item -Recurse -Force *` supprime tout le dossier courant, sans confirmation ni corbeille. Lancé depuis `C:\\` avec des droits d\'administrateur, il peut rendre Windows inutilisable. Ne l\'utilisez jamais sans être absolument certain de ce que vous faites.', + }, }, { type: 'tip', content: 'Utilisez `rm -i` pour demander une confirmation avant chaque suppression. C\'est une bonne habitude de sécurité.', + contentByEnv: { + windows: + 'Ajoutez `-Confirm` (`Remove-Item brouillon.txt -Confirm`) pour que PowerShell demande une confirmation avant de supprimer. C\'est une bonne habitude de sécurité.', + }, }, ], exercise: { @@ -642,12 +665,12 @@ export const curriculum: Module[] = [ }, { type: 'code', - content: '$ cat documents/notes.txt\nMes notes importantes\nTâches du jour:\n1. Apprendre les commandes bash\n2. Pratiquer la navigation', + content: '$ cat documents/notes.txt\nMes notes importantes\nTâches du jour:\n1. Apprendre les commandes bash\n2. Pratiquer la navigation\n3. Maîtriser les permissions\nFin du fichier', label: 'Afficher un fichier', }, { type: 'code', - content: '$ cat fichier1.txt fichier2.txt\n[contenu de fichier1]\n[contenu de fichier2]', + content: '$ cat documents/notes.txt documents/rapport.md\n# Affiche notes.txt, puis rapport.md juste en dessous, sans séparation', label: 'Concaténer plusieurs fichiers', }, { diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 3e8a07e..82d57dd 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -113,7 +113,32 @@ function getNode(root: DirectoryNode, path: string[]): FSNode | null { } -function resolvePath(state: TerminalState, input: string): string[] { +/** True while a command runs in the Windows environment (see processCommand). */ +let windowsPaths = false; + +/** + * PowerShell accepts `\` as a separator and drive paths: + * `documents\notes.txt`, `.\script.ps1`, `C:\Users\user\projets` (= /home/user/projets). + */ +function fromWindowsPath(input: string): string { + const path = input.replace(/\\/g, '/'); + const drive = path.match(/^[A-Za-z]:(\/.*)?$/); + return drive ? drive[1] ?? '/' : path; +} + +/** The simulated /home is shown as C:\Users on Windows (see displayPathForEnv). */ +function windowsName(parentPath: string[], name: string, windows: boolean): string { + return windows && parentPath.length === 0 && name === 'home' ? 'Users' : name; +} + +function resolvePath(state: TerminalState, rawInput: string): string[] { + const path = resolveSegments(state, windowsPaths ? fromWindowsPath(rawInput) : rawInput); + // `C:\Users`, `\Users` or `Users` from `C:\` all mean the simulated /home. + if (windowsPaths && path[0]?.toLowerCase() === 'users') path[0] = 'home'; + return path; +} + +function resolveSegments(state: TerminalState, input: string): string[] { if (!input || input === '~') return ['home', 'user']; if (input.startsWith('~/')) { const rest = input.slice(2).split('/').filter(Boolean); @@ -150,12 +175,9 @@ function displayPath(cwd: string[]): string { */ export function displayPathForEnv(cwd: string[], env: TerminalEnv = 'linux'): string { if (env === 'windows') { - if (cwd.length >= 2 && cwd[0] === 'home' && cwd[1] === 'user') { - const rest = cwd.slice(2); - const base = 'C:\\Users\\user'; - return rest.length === 0 ? base : base + '\\' + rest.join('\\'); - } - return 'C:\\' + cwd.join('\\'); + // The simulated /home is C:\Users on Windows (so /home/user is C:\Users\user). + const parts = cwd[0] === 'home' ? ['Users', ...cwd.slice(1)] : cwd; + return 'C:\\' + parts.join('\\'); } return displayPath(cwd); } @@ -188,7 +210,7 @@ const COMPLETION_COMMANDS = [ * - No space: completes command names * - With space: completes filesystem paths (relative or absolute) */ -export function getTabCompletions(input: string, state: TerminalState): string[] { +export function getTabCompletions(input: string, state: TerminalState, env: TerminalEnv = 'linux'): string[] { const firstSpaceIdx = input.indexOf(' '); // No space yet → complete the command name @@ -205,12 +227,23 @@ export function getTabCompletions(input: string, state: TerminalState): string[] let namePrefix: string; let pathPrefix: string; - if (partial.includes('/')) { - const slashIdx = partial.lastIndexOf('/'); + // PowerShell also separates with `\` (`documents\n` → `documents\notes.txt`). + const slashIdx = env === 'windows' + ? Math.max(partial.lastIndexOf('/'), partial.lastIndexOf('\\')) + : partial.lastIndexOf('/'); + const separator = slashIdx >= 0 ? partial[slashIdx] : '/'; + + if (slashIdx >= 0) { pathPrefix = partial.slice(0, slashIdx + 1); // e.g. "documents/" namePrefix = partial.slice(slashIdx + 1); // e.g. "n" const dirPart = pathPrefix.length > 1 ? pathPrefix.slice(0, -1) : '/'; - parentPath = resolvePath(state, dirPart); + const wasWindows = windowsPaths; + windowsPaths = env === 'windows'; + try { + parentPath = resolvePath(state, dirPart); + } finally { + windowsPaths = wasWindows; + } } else { pathPrefix = ''; namePrefix = partial; @@ -220,12 +253,13 @@ export function getTabCompletions(input: string, state: TerminalState): string[] const parentNode = getNode(state.root, parentPath); if (!parentNode || parentNode.type !== 'directory') return []; - const matches = Object.keys(parentNode.children).filter((n) => n.startsWith(namePrefix)); + const shown = (name: string) => windowsName(parentPath, name, env === 'windows'); + const matches = Object.keys(parentNode.children).filter((n) => shown(n).startsWith(namePrefix)); return matches.map((name) => { const node = (parentNode as DirectoryNode).children[name]; - const suffix = node.type === 'directory' ? '/' : ''; - return inputPrefix + pathPrefix + name + suffix; + const suffix = node.type === 'directory' ? separator : ''; + return inputPrefix + pathPrefix + shown(name) + suffix; }); } @@ -331,6 +365,7 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { // Like `ls` in the C locale: one alphabetical list, hidden files first, no directories-first. const entries = Object.entries(node.children) + .map(([name, child]): [string, FSNode] => [windowsName(targetPath, name, windowsPaths), child]) .filter(([name]) => showAll || !name.startsWith('.')) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); @@ -413,6 +448,16 @@ function cmdMkdir(state: TerminalState, args: string[]): { lines: OutputLine[]; } else { const parentPath = resolved.slice(0, -1); const name = resolved[resolved.length - 1]; + // PowerShell's mkdir (New-Item -ItemType Directory) creates missing parents. + if (windowsPaths) { + let cur: DirectoryNode = newRoot; + for (const seg of parentPath) { + if (!cur.children[seg]) cur.children[seg] = makeDir({}); + const next = cur.children[seg]; + if (next.type !== 'directory') break; + cur = next; + } + } const parent = getNode(newRoot, parentPath) as DirectoryNode; if (!parent || parent.type !== 'directory') { return { lines: [{ text: `mkdir: cannot create directory '${p}': No such file or directory`, type: 'error' }] }; @@ -513,63 +558,156 @@ function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; new return { lines: [], newRoot }; } -function cmdCp(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { - const flags = args.filter((a) => a.startsWith('-')); - const paths = args.filter((a) => !a.startsWith('-')); - const recursive = flags.some((f) => f.includes('r') || f.includes('R')); +// ─── cp / mv ────────────────────────────────────────────────────────────────── +// GNU coreutils semantics: when the destination is an existing directory, each +// source goes INSIDE it (`mv notes.txt .`, `cp -r docs backup/`); several +// sources need a directory destination; a directory never lands inside itself. - if (paths.length < 2) return { lines: [{ text: 'cp: missing destination file operand', type: 'error' }] }; +type TransferResult = { lines: OutputLine[]; newRoot?: DirectoryNode; newCwd?: string[] }; - const src = paths[0]; - const dst = paths[1]; - const newRoot = deepCloneRoot(state.root); +const TRANSFER_LONG_OPTIONS: Record = { + '--recursive': 'r', '--verbose': 'v', '--no-clobber': 'n', '--interactive': 'i', '--force': 'f', +}; - const srcResolved = resolvePath(state, src); - const srcNode = getNode(newRoot, srcResolved); - if (!srcNode) return { lines: [{ text: `cp: cannot stat '${src}': No such file or directory`, type: 'error' }] }; - if (srcNode.type === 'directory' && !recursive) { - return { lines: [{ text: `cp: -r not specified; omitting directory '${src}'`, type: 'error' }] }; - } +function isInside(inner: string[], outer: string[]): boolean { + return inner.length > outer.length && outer.every((seg, i) => inner[i] === seg); +} - const dstResolved = resolvePath(state, dst); - const dstParentPath = dstResolved.slice(0, -1); - const dstName = dstResolved[dstResolved.length - 1]; - const dstParent = getNode(newRoot, dstParentPath) as DirectoryNode; - if (!dstParent || dstParent.type !== 'directory') { - return { lines: [{ text: `cp: cannot create file '${dst}': No such file or directory`, type: 'error' }] }; +/** + * Copies every entry of `src` into `dst`, merging sub-directories like `cp -r` + * does. With `noClobber` (`-n` / `-i`), files already there are kept, at any depth. + */ +function mergeDirInto(dst: DirectoryNode, src: DirectoryNode, noClobber: boolean): void { + for (const [name, child] of Object.entries(src.children)) { + const existing = dst.children[name]; + if (existing?.type === 'directory' && child.type === 'directory') mergeDirInto(existing, child, noClobber); + else if (!existing || (existing.type === child.type && !noClobber)) dst.children[name] = cloneFSNode(child, { n: 0 }); + } +} + +function transfer(cmd: 'cp' | 'mv', state: TerminalState, args: string[]): TransferResult { + const err = (text: string): OutputLine => ({ text: `${cmd}: ${text}`, type: 'error' }); + const flags = new Set(); + const paths: string[] = []; + let endOfOptions = false; + for (const a of args) { + if (!endOfOptions && a === '--') { endOfOptions = true; continue; } + if (!endOfOptions && a.startsWith('--')) { + const short = TRANSFER_LONG_OPTIONS[a]; + if (!short || (cmd === 'mv' && short === 'r')) { + return { lines: [err(`unrecognized option '${a}'`), { text: `Try '${cmd} --help' for more information.`, type: 'error' }] }; + } + flags.add(short); + continue; + } + if (!endOfOptions && a.startsWith('-') && a.length > 1) { + for (const f of a.slice(1)) { + if (!(cmd === 'cp' ? 'rRavinf' : 'vinf').includes(f)) { + return { lines: [err(`invalid option -- '${f}'`), { text: `Try '${cmd} --help' for more information.`, type: 'error' }] }; + } + flags.add(f); + } + continue; + } + paths.push(a); } + if (paths.length === 0) return { lines: [err('missing file operand')] }; + if (paths.length === 1) return { lines: [err(`missing destination file operand after '${paths[0]}'`)] }; - dstParent.children[dstName] = cloneFSNode(srcNode, { n: 0 }); - return { lines: [], newRoot }; -} - -function cmdMv(state: TerminalState, args: string[]): { lines: OutputLine[]; newRoot?: DirectoryNode } { - if (args.length < 2) return { lines: [{ text: 'mv: missing destination file operand', type: 'error' }] }; - const src = args[0]; - const dst = args[1]; + const recursive = cmd === 'mv' || flags.has('r') || flags.has('R') || flags.has('a'); + // -f overrides an earlier -i / -n, as in coreutils when it comes last; kept simple: -f wins. + const noClobber = !flags.has('f') && (flags.has('n') || flags.has('i')); + const dst = paths[paths.length - 1]; + const sources = paths.slice(0, -1); const newRoot = deepCloneRoot(state.root); + const dstPath = resolvePath(state, dst); + const intoDir = getNode(newRoot, dstPath)?.type === 'directory'; + if (sources.length > 1 && !intoDir) return { lines: [err(`target '${dst}' is not a directory`)] }; - const srcResolved = resolvePath(state, src); - const srcParentPath = srcResolved.slice(0, -1); - const srcName = srcResolved[srcResolved.length - 1]; - const srcParent = getNode(newRoot, srcParentPath) as DirectoryNode; - - if (!srcParent || !srcParent.children[srcName]) { - return { lines: [{ text: `mv: cannot stat '${src}': No such file or directory`, type: 'error' }] }; - } + const lines: OutputLine[] = []; + let changed = false; + let newCwd: string[] | undefined; + for (const src of sources) { + const srcPath = resolvePath(state, src); + const typedName = src.replace(/\/+$/, '').split('/').pop() ?? src; + const isDotName = typedName === '.' || typedName === '..' || srcPath.length === 0; + const srcNode = getNode(newRoot, srcPath); + if (!srcNode) { lines.push(err(`cannot stat '${src}': No such file or directory`)); continue; } + if (srcNode.type === 'directory' && !recursive) { lines.push(err(`-r not specified; omitting directory '${src}'`)); continue; } + + // `cp -r . dir` copies the contents of `.` into `dir`; `mv .` is refused by the kernel. + const name = srcPath[srcPath.length - 1]; + const targetPath = intoDir && !isDotName ? [...dstPath, name] : dstPath; + const shown = intoDir && !isDotName ? `${dst.replace(/\/+$/, '')}/${name}` : dst; + if (cmd === 'mv' && isDotName) { lines.push(err(`cannot move '${src}' to '${shown}': Device or resource busy`)); continue; } + if (targetPath.join('/') === srcPath.join('/')) { lines.push(err(`'${src}' and '${shown}' are the same file`)); continue; } + if (srcNode.type === 'directory' && isInside(targetPath, srcPath)) { + lines.push(err(cmd === 'mv' + ? `cannot move '${src}' to a subdirectory of itself, '${shown}'` + : `cannot copy a directory, '${src}', into itself, '${shown}'`)); + continue; + } + const parent = getNode(newRoot, targetPath.slice(0, -1)); + const trailingSlashOnFile = !intoDir && dst.endsWith('/'); + if (!parent || parent.type !== 'directory' || trailingSlashOnFile) { + const reason = trailingSlashOnFile && parent ? 'Not a directory' : 'No such file or directory'; + lines.push(err(cmd === 'mv' + ? `cannot move '${src}' to '${shown}': ${reason}` + : `cannot create ${srcNode.type === 'directory' ? 'directory' : 'regular file'} '${shown}': ${reason}`)); + continue; + } - const dstResolved = resolvePath(state, dst); - const dstParentPath = dstResolved.slice(0, -1); - const dstName = dstResolved[dstResolved.length - 1]; - const dstParent = getNode(newRoot, dstParentPath) as DirectoryNode; + const targetName = targetPath[targetPath.length - 1]; + const existing = targetPath.length ? parent.children[targetName] : newRoot; + if (existing) { + if (noClobber && !(cmd === 'cp' && existing.type === 'directory' && srcNode.type === 'directory')) { + if (flags.has('i')) { + lines.push({ text: `${cmd}: overwrite '${shown}'? n`, type: 'output' }); + lines.push({ text: '(simulateur : la réponse est « n », rien n\'est écrasé)', type: 'info' }); + } + continue; + } + if (existing.type === 'directory' && srcNode.type === 'file') { lines.push(err(`cannot overwrite directory '${shown}' with non-directory`)); continue; } + if (existing.type === 'file' && srcNode.type === 'directory') { lines.push(err(`cannot overwrite non-directory '${shown}' with directory '${src}'`)); continue; } + if (existing.type === 'directory' && srcNode.type === 'directory') { + if (cmd === 'mv' && Object.keys(existing.children).length) { + lines.push(err(`cannot move '${src}' to '${shown}': Directory not empty`)); + continue; + } + if (cmd === 'cp') { + mergeDirInto(existing, srcNode, noClobber); + changed = true; + if (flags.has('v')) lines.push({ text: `'${src}' -> '${shown}'`, type: 'output' }); + continue; + } + } + } - if (!dstParent || dstParent.type !== 'directory') { - return { lines: [{ text: `mv: cannot move '${src}' to '${dst}': No such file or directory`, type: 'error' }] }; + if (cmd === 'mv') { + const srcParent = getNode(newRoot, srcPath.slice(0, -1)) as DirectoryNode; + delete srcParent.children[name]; + parent.children[targetName] = srcNode; + // Moving the directory you stand in (or one of its parents) takes you along. + const cwd = newCwd ?? state.cwd; + if (cwd.length >= srcPath.length && srcPath.every((seg, i) => cwd[i] === seg)) { + newCwd = [...targetPath, ...cwd.slice(srcPath.length)]; + } + if (flags.has('v')) lines.push({ text: `renamed '${src}' -> '${shown}'`, type: 'output' }); + } else { + parent.children[targetName] = cloneFSNode(srcNode, { n: 0 }); + if (flags.has('v')) lines.push({ text: `'${src}' -> '${shown}'`, type: 'output' }); + } + changed = true; } + return { lines, newRoot: changed ? newRoot : undefined, newCwd }; +} - dstParent.children[dstName] = srcParent.children[srcName]; - delete srcParent.children[srcName]; - return { lines: [], newRoot }; +function cmdCp(state: TerminalState, args: string[]): TransferResult { + return transfer('cp', state, args); +} + +function cmdMv(state: TerminalState, args: string[]): TransferResult { + return transfer('mv', state, args); } const GREP_PATTERN_MAX_LEN = 200; @@ -1821,7 +1959,14 @@ export function processCommand(state: TerminalState, input: string, env: Termina const newState: TerminalState = { ...state, commandHistory: newHistory }; if (!trimmed) return { lines: [], newState }; - const result = runLine(newState, trimmed, env); + const wasWindows = windowsPaths; + windowsPaths = env === 'windows'; + let result: CommandOutput; + try { + result = runLine(newState, trimmed, env); + } finally { + windowsPaths = wasWindows; + } return expandedFrom && !result.clear ? { ...result, lines: [expandedFrom, ...result.lines] } : result; } @@ -2024,8 +2169,9 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } case 'mv': { - const { lines, newRoot } = cmdMv(newState, args); + const { lines, newRoot, newCwd } = cmdMv(newState, args); if (newRoot) newState = { ...newState, root: newRoot }; + if (newCwd) newState = { ...newState, cwd: newCwd }; return { lines, newState }; } diff --git a/src/app/data/validators.ts b/src/app/data/validators.ts index 15bcfa1..251e5cd 100644 --- a/src/app/data/validators.ts +++ b/src/app/data/validators.ts @@ -2,6 +2,15 @@ import type { EnvId } from './curriculum'; export type ValidateFn = (command: string, env?: EnvId) => boolean; +/** + * Runs an exercise validator on what the learner typed. PowerShell accepts `\` + * and `/` as path separators, so on Windows `documents\notes.txt` counts the + * same as the `documents/notes.txt` the validators are written with. + */ +export function exerciseAccepts(validate: ValidateFn, command: string, env: EnvId): boolean { + return validate(env === 'windows' ? command.replace(/\\/g, '/') : command, env); +} + export const validateOrientation: ValidateFn = (cmd) => { const c = cmd.trim().toLowerCase(); // The lesson teaches "how to find help", not one exact command. Accept the diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts index d339d2b..98d363f 100644 --- a/src/test/lessonFidelity.test.ts +++ b/src/test/lessonFidelity.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect } from 'vitest'; import { curriculum, type EnvId } from '../app/data/curriculum'; import { createInitialState, processCommand, type TerminalState } from '../app/data/terminalEngine'; +import { exerciseAccepts } from '../app/data/validators'; import { LESSON_SOLUTIONS } from './lessonSolutions'; const ENVS: EnvId[] = ['linux', 'macos', 'windows']; @@ -76,3 +77,42 @@ describe('lesson fidelity — the lesson command validates and prints no error', }); } }); + +/** + * `documents/notes.txt` → `documents\notes.txt`; URLs and options are left alone. + * Git commands too: `feature/ma-feature` is a branch name, not a path, and real + * Git on Windows does not accept `feature\ma-feature` for it either. + */ +const withBackslashes = (cmd: string) => + /^git\s/.test(cmd) + ? cmd + : cmd.split(' ').map((t) => (t.includes('://') || t.startsWith('-') ? t : t.replace(/\//g, '\\'))).join(' '); + +describe('lesson fidelity — Windows paths written with backslashes', () => { + const windowsCases = cases.filter(({ key, env }) => { + if (env !== 'windows') return false; + const solution = LESSON_SOLUTIONS[key].windows ?? LESSON_SOLUTIONS[key].all ?? []; + return solution.some((cmd) => withBackslashes(cmd) !== cmd); + }); + + it('covers the Windows exercises that take a path', () => { + // Floor = today's count (cp, mv, rm, cat, head-tail, grep, wc, permissions ×2, scp). + expect(windowsCases.length).toBeGreaterThanOrEqual(10); + }); + + for (const { key, env, exercise } of windowsCases) { + it(`${key} [${env}] — same result with \\ as separator`, () => { + const solution = (LESSON_SOLUTIONS[key].windows ?? LESSON_SOLUTIONS[key].all ?? []).map(withBackslashes); + let state: TerminalState = createInitialState(); + if (exercise.setup) state = exercise.setup.apply(state); + const errors: string[] = []; + for (const cmd of solution) { + const out = processCommand(state, cmd, env); + state = out.newState; + for (const line of out.lines) if (line.type === 'error') errors.push(`${cmd} → ${line.text}`); + } + expect(errors).toEqual([]); + expect(exerciseAccepts(exercise.validate, solution[solution.length - 1], env)).toBe(true); + }); + } +}); diff --git a/src/test/lessonTheoryGaps.ts b/src/test/lessonTheoryGaps.ts index d7dba99..ebfa2a4 100644 --- a/src/test/lessonTheoryGaps.ts +++ b/src/test/lessonTheoryGaps.ts @@ -18,21 +18,6 @@ export const KNOWN_THEORY_GAPS = new Set([ "navigation/orientation b2 [linux] help", "navigation/orientation b2 [macos] help", "navigation/orientation b2 [windows] help", - // fichiers/mv - "fichiers/mv b1 [linux] ls", - "fichiers/mv b1 [macos] ls", - "fichiers/mv b2 [linux] mv notes.txt mes-notes.txt", - "fichiers/mv b2 [linux] ls", - "fichiers/mv b2 [macos] mv notes.txt mes-notes.txt", - "fichiers/mv b2 [macos] ls", - // fichiers/rm - "fichiers/rm b1 [linux] rm fichier.txt", - "fichiers/rm b1 [macos] rm fichier.txt", - "fichiers/rm b2 [linux] rm -r dossier", - "fichiers/rm b2 [macos] rm -r dossier", - // lecture/cat - "lecture/cat b2 [linux] cat fichier1.txt fichier2.txt", - "lecture/cat b2 [macos] cat fichier1.txt fichier2.txt", // permissions/chmod "permissions/chmod b2 [linux] chmod +x script.sh", "permissions/chmod b2 [linux] chmod u+x script.sh", @@ -221,4 +206,4 @@ export const KNOWN_THEORY_GAPS = new Set([ ]); /** Lessons whose code blocks show bash to a Windows learner (no Windows variant). */ -export const BASH_SHOWN_ON_WINDOWS_MAX = 39; +export const BASH_SHOWN_ON_WINDOWS_MAX = 37; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index f304665..202901b 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -1506,6 +1506,176 @@ describe('mv', () => { }); }); +// ─── Windows paths ──────────────────────────────────────────────────────────── +// PowerShell accepts `\` and `/` as separators and drive paths; the simulated +// /home is C:\Users. Bash treats `\` as an escape, so Linux must not accept them. + +describe('Windows paths in PowerShell', () => { + const run = (env: 'linux' | 'windows', ...cmds: string[]) => { + let state = createInitialState(); + let lines: string[] = []; + for (const c of cmds) { + const r = processCommand(state, c, env); + state = r.newState; + lines = r.lines.map((l) => l.text); + } + return lines; + }; + + it('accepts backslash separators and .\\', () => { + expect(run('windows', 'Get-Content documents\\notes.txt')[0]).toBe('Mes notes importantes'); + expect(run('windows', 'cat .\\documents\\rapport.md')[0]).toBe('# Rapport Mensuel'); + expect(run('windows', 'cd documents\\', 'pwd')).toEqual(['C:\\Users\\user\\documents']); + }); + + it('accepts drive paths, C:\\Users is the parent of the home', () => { + expect(run('windows', 'ls C:\\Users\\user\\projets')).toEqual(['README.md script.sh']); + expect(run('windows', 'cd C:\\Users\\user\\downloads', 'pwd')).toEqual(['C:\\Users\\user\\downloads']); + expect(run('windows', 'cd ..', 'pwd')).toEqual(['C:\\Users']); + expect(run('windows', 'cd \\', 'pwd')).toEqual(['C:\\']); + }); + + it('mkdir creates missing parent folders, like New-Item -ItemType Directory', () => { + expect(run('windows', 'mkdir archives\\2025', 'ls archives')).toEqual(['2025']); + expect(run('linux', 'mkdir archives/2025')[0]).toContain('No such file or directory'); + }); + + it('Tab completes after a backslash on Windows, keeping the backslash', () => { + const state = createInitialState(); + expect(getTabCompletions('cat documents\\n', state, 'windows')).toEqual(['cat documents\\notes.txt']); + expect(getTabCompletions('cd C:\\Users\\user\\doc', state, 'windows')).toEqual(['cd C:\\Users\\user\\documents\\']); + expect(getTabCompletions('cat documents\\n', state, 'linux')).toEqual([]); + }); + + it('C:\\ shows Users (never the internal home) for ls, cd and Tab', () => { + expect(run('windows', 'ls C:\\')).toEqual(['Users tmp']); + expect(run('windows', 'cd \\', 'cd users', 'pwd')).toEqual(['C:\\Users']); + expect(getTabCompletions('cd C:\\U', createInitialState(), 'windows')).toEqual(['cd C:\\Users\\']); + expect(run('linux', 'ls /')).toEqual(['home tmp']); + }); + + it('Copy-Item and Move-Item read -Path / -Destination in any order', () => { + expect(run('windows', 'Copy-Item -Destination downloads -Path documents/notes.txt', 'ls downloads')).toEqual(['notes.txt']); + expect(run('windows', 'Move-Item -Destination projets documents/rapport.md', 'ls projets')).toEqual(['README.md rapport.md script.sh']); + }); + + it('bash does not treat a backslash as a separator', () => { + expect(run('linux', 'cat documents\\notes.txt')[0]).toContain('No such file or directory'); + }); +}); + +// ─── cp / mv — GNU coreutils semantics ─────────────────────────────────────── +// Expected values are what GNU cp/mv print and do, not what the engine used to do: +// a destination that is an existing directory receives the source INSIDE it. + +describe('cp / mv — destination directory (GNU semantics)', () => { + function session(env: 'linux' | 'windows', ...cmds: string[]) { + let state = createInitialState(); + for (const c of cmds) state = processCommand(state, c, env).newState; + return { out: (c: string) => processCommand(state, c, env).lines.map((l) => l.text) }; + } + + it('mv file . moves it into the current directory — the home is not replaced', () => { + const { out } = session('linux', 'mv documents/notes.txt .'); + expect(out('ls')).toEqual(['documents downloads notes.txt projets']); + expect(out('ls documents')).toEqual(['rapport.md']); + }); + + it('mv into an existing directory keeps the name', () => { + const { out } = session('linux', 'mv documents/notes.txt projets'); + expect(out('ls projets')).toEqual(['README.md notes.txt script.sh']); + expect(out('ls -F')).toEqual(['documents/ downloads/ projets/']); + }); + + it('mv several files needs a directory as last argument', () => { + const { out } = session('linux', 'mv documents/notes.txt documents/rapport.md downloads'); + expect(out('ls downloads')).toEqual(['notes.txt rapport.md']); + expect(out('mv projets/README.md projets/script.sh nouveau.txt')).toEqual(["mv: target 'nouveau.txt' is not a directory"]); + }); + + it('mv refuses to put a directory inside itself', () => { + const { out } = session('linux'); + expect(out('mv documents documents/archive')).toEqual(["mv: cannot move 'documents' to a subdirectory of itself, 'documents/archive'"]); + }); + + it('mv of a directory you stand in takes you along', () => { + const { out } = session('linux', 'cd documents', 'mv ../documents ../docs'); + expect(out('pwd')).toEqual(['/home/user/docs']); + expect(out('ls')).toEqual(['notes.txt rapport.md']); + }); + + it('mv . is refused, like the kernel does', () => { + const { out } = session('linux'); + expect(out('mv . ailleurs')).toEqual(["mv: cannot move '.' to 'ailleurs': Device or resource busy"]); + }); + + it('mv -v reports the rename; unknown options are rejected', () => { + const { out } = session('linux'); + expect(out('mv -v documents/notes.txt documents/mes-notes.txt')).toEqual(["renamed 'documents/notes.txt' -> 'documents/mes-notes.txt'"]); + expect(out('mv -z a b')).toEqual(["mv: invalid option -- 'z'", "Try 'mv --help' for more information."]); + }); + + it('mv onto the same place is "the same file"', () => { + const { out } = session('linux'); + expect(out('mv documents/notes.txt documents')).toEqual(["mv: 'documents/notes.txt' and 'documents/notes.txt' are the same file"]); + }); + + it('cp file dir copies inside the directory, the directory survives', () => { + const { out } = session('linux', 'cp projets/script.sh downloads'); + expect(out('ls -F')).toEqual(['documents/ downloads/ projets/']); + expect(out('ls downloads')).toEqual(['script.sh']); + expect(out('ls projets')).toEqual(['README.md script.sh']); + }); + + it('cp -r dir existing-dir/ creates existing-dir/dir', () => { + const { out } = session('linux', 'cp -r documents projets/'); + expect(out('ls projets')).toEqual(['README.md documents script.sh']); + expect(out('ls projets/documents')).toEqual(['notes.txt rapport.md']); + }); + + it('cp -r dir new-name creates a copy under the new name', () => { + const { out } = session('linux', 'cp -r documents sauvegarde'); + expect(out('ls sauvegarde')).toEqual(['notes.txt rapport.md']); + expect(out('ls documents')).toEqual(['notes.txt rapport.md']); + }); + + it('cp refuses to copy a directory into itself', () => { + const { out } = session('linux'); + expect(out('cp -r documents documents/copie')).toEqual(["cp: cannot copy a directory, 'documents', into itself, 'documents/copie'"]); + }); + + it('cp -i never overwrites in the simulator, and says so', () => { + const { out } = session('linux', 'cp projets/script.sh downloads'); + const lines = out('cp -i documents/notes.txt downloads/script.sh'); + expect(lines[0]).toBe("cp: overwrite 'downloads/script.sh'? n"); + }); + + it('cp -rn into an existing directory keeps files already there, at any depth', () => { + const setup = ['mkdir backup', 'cp -r documents backup', 'echo modifié > documents/notes.txt']; + const kept = session('linux', ...setup, 'cp -rn documents backup'); + expect(kept.out('cat backup/documents/notes.txt')[0]).toBe('Mes notes importantes'); + const replaced = session('linux', ...setup, 'cp -r documents backup'); + expect(replaced.out('cat backup/documents/notes.txt')).toEqual(['modifié']); + }); + + it('cp a file onto an existing directory name inside the target is refused', () => { + const { out } = session('linux', 'mkdir -p boite/notes.txt'); + expect(out('cp documents/notes.txt boite')).toEqual(["cp: cannot overwrite directory 'boite/notes.txt' with non-directory"]); + }); + + it('Remove-Item -Recurse removes a folder with its content', () => { + const { out } = session('windows', 'mkdir archives', 'Remove-Item -Recurse archives'); + expect(out('ls')).toEqual(['documents downloads projets']); + }); + + it('Move-Item and Copy-Item -Recurse follow the same rules on Windows', () => { + const { out } = session('windows', 'Move-Item documents/notes.txt projets', 'Copy-Item -Recurse documents downloads'); + expect(out('ls projets').join('\n')).toContain('notes.txt'); + expect(out('ls downloads').join('\n')).toContain('documents'); + expect(out('ls').join('\n')).toContain('projets'); + }); +}); + // ─── grep ───────────────────────────────────────────────────────────────────── describe('grep', () => {