diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml
index a6f19dc..6ffaf53 100644
--- a/.github/workflows/quality-checks.yml
+++ b/.github/workflows/quality-checks.yml
@@ -52,3 +52,26 @@ jobs:
- name: Validate repository structure
run: python scripts/validate_repository_structure.py
+
+ file-organizer-windows:
+ name: File Organizer Windows
+ runs-on: windows-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ - name: Show Python version
+ run: python --version
+
+ - name: Install test dependency
+ run: python -m pip install --disable-pip-version-check "pytest>=9.1,<9.2"
+
+ - name: Run File Organizer tests on Windows
+ run: python -m pytest -q practical-projects/06-file-organizer/tests
diff --git a/docs/learning-path.en.md b/docs/learning-path.en.md
index 1d6dc7f..c8e077b 100644
--- a/docs/learning-path.en.md
+++ b/docs/learning-path.en.md
@@ -135,11 +135,11 @@ Phase 9 is complete with four reviewed chapters. The sequence moves from tabular
3. ✅ [User Registration](../practical-projects/03-user-registration/README.md)
4. ✅ [CSV Analyzer](../practical-projects/04-csv-analyzer/README.md)
5. ✅ [Report Generator](../practical-projects/05-report-generator/README.md)
-6. ⏳ File Organizer
+6. 🚧 [File Organizer](../practical-projects/06-file-organizer/README.md)
7. ⏳ Fictional Reconciliation Workflow
8. ⏳ Simulated Automation Flow
-Phase 10 is in progress. Project 01 integrates validated data modeling, exact `Decimal` money, collections, JSON persistence, CSV export, deterministic temporary-file handling, and automated pytest coverage. Project 02 adds configurable grade policies, exact weighted aggregation, explicit progress-versus-final state, structured reporting, and boundary-focused tests. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused tests without introducing authentication. Project 04 adds schema-aware CSV ingestion, typed conversion, row-level rejection diagnostics, structural failures, duplicate identifier checks, deterministic filtering, and aggregation using the standard library. Project 05 adds explicit reporting windows, deterministic summaries, exact two-decimal metrics, immutable report boundaries, TXT/Markdown rendering, format-aware escaping, and UTF-8 file output.
+Phase 10 is in progress. Project 01 integrates validated data modeling, exact `Decimal` money, collections, JSON persistence, CSV export, deterministic temporary-file handling, and automated pytest coverage. Project 02 adds configurable grade policies, exact weighted aggregation, explicit progress-versus-final state, structured reporting, and boundary-focused tests. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused tests without introducing authentication. Project 04 adds schema-aware CSV ingestion, typed conversion, row-level rejection diagnostics, structural failures, duplicate identifier checks, deterministic filtering, and aggregation using the standard library. Project 05 adds explicit reporting windows, deterministic summaries, exact two-decimal metrics, immutable report boundaries, TXT/Markdown rendering, format-aware escaping, and UTF-8 file output. Project 06 adds deterministic shallow file discovery, suffix classification, immutable dry-run planning, explicit collision policies, symlink boundaries, `(device, inode)` identity checks, source/category/root anchoring, bounded staging names, and platform-aware atomic no-replace commit behavior with Linux `renameat2(RENAME_NOREPLACE)`.
## Useful navigation
diff --git a/docs/learning-path.es.md b/docs/learning-path.es.md
index 6e6ad98..332cef3 100644
--- a/docs/learning-path.es.md
+++ b/docs/learning-path.es.md
@@ -135,11 +135,11 @@ La Fase 9 está completada con cuatro capítulos revisados. La secuencia avanza
3. ✅ [Registro de Usuarios](../practical-projects/03-user-registration/README.es.md)
4. ✅ [Analizador CSV](../practical-projects/04-csv-analyzer/README.es.md)
5. ✅ [Generador de Informes](../practical-projects/05-report-generator/README.es.md)
-6. ⏳ Organizador de Archivos
+6. 🚧 [Organizador de Archivos](../practical-projects/06-file-organizer/README.es.md)
7. ⏳ Flujo Ficticio de Conciliación
8. ⏳ Flujo Simulado de Automatización
-La Fase 10 está en progreso. El Proyecto 01 integra modelado de datos validado, dinero exacto con `Decimal`, colecciones, persistencia JSON, exportación CSV, manejo determinista de archivos temporales y cobertura automatizada con pytest. El Proyecto 02 añade políticas de calificación configurables, agregación ponderada exacta, estado de progreso frente a final explícito, informe estructurado y pruebas centradas en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y pruebas centradas en mutación sin introducir autenticación. El Proyecto 04 añade ingestión CSV consciente de schema, conversión tipada, diagnóstico de rechazos por fila, fallos estructurales, comprobación de identificadores duplicados, filtros deterministas y agregación mediante la biblioteca estándar. El Proyecto 05 añade ventanas explícitas de informe, resúmenes deterministas, métricas exactas con dos decimales, límites inmutables del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8.
+La Fase 10 está en progreso. El Proyecto 01 integra modelado de datos validado, dinero exacto con `Decimal`, colecciones, persistencia JSON, exportación CSV, manejo determinista de archivos temporales y cobertura automatizada con pytest. El Proyecto 02 añade políticas de calificación configurables, agregación ponderada exacta, estado de progreso frente a final explícito, informe estructurado y pruebas centradas en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados, transiciones explícitas del ciclo de vida y pruebas centradas en mutación sin introducir autenticación. El Proyecto 04 añade ingestión CSV consciente de schema, conversión tipada, diagnóstico de rechazos por fila, fallos estructurales, comprobación de identificadores duplicados, filtros deterministas y agregación mediante la biblioteca estándar. El Proyecto 05 añade ventanas explícitas de informe, resúmenes deterministas, métricas exactas con dos decimales, límites inmutables del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial y determinista de archivos, clasificación por sufijo, planificación dry-run inmutable, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de raíz/categorías, nombres de staging acotados y commit atómico no-replace sensible a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.
## Navegación útil
diff --git a/docs/learning-path.pt-BR.md b/docs/learning-path.pt-BR.md
index 67c6c1b..e47464f 100644
--- a/docs/learning-path.pt-BR.md
+++ b/docs/learning-path.pt-BR.md
@@ -135,11 +135,11 @@ A Fase 9 está concluída com quatro capítulos revisados. A sequência avança
3. ✅ [Cadastro de Usuários](../practical-projects/03-user-registration/README.pt-BR.md)
4. ✅ [Analisador CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)
5. ✅ [Gerador de Relatórios](../practical-projects/05-report-generator/README.pt-BR.md)
-6. ⏳ Organizador de Arquivos
+6. 🚧 [Organizador de Arquivos](../practical-projects/06-file-organizer/README.pt-BR.md)
7. ⏳ Fluxo Fictício de Conciliação
8. ⏳ Fluxo Simulado de Automação
-A Fase 10 está em andamento. O Projeto 01 integra modelagem de dados validada, dinheiro exato com `Decimal`, coleções, persistência JSON, exportação CSV, manipulação determinística de arquivos temporários e cobertura automatizada com pytest. O Projeto 02 adiciona políticas de notas configuráveis, agregação ponderada exata, estado de progresso versus final explícito, relatório estruturado e testes focados em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e testes focados em mutação sem introduzir autenticação. O Projeto 04 adiciona ingestão CSV consciente de schema, conversão tipada, diagnóstico de rejeições por linha, falhas estruturais, verificação de identificadores duplicados, filtros determinísticos e agregação usando a biblioteca padrão. O Projeto 05 adiciona janelas explícitas de relatório, resumos determinísticos, métricas exatas com duas casas decimais, fronteiras imutáveis de relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8.
+A Fase 10 está em andamento. O Projeto 01 integra modelagem de dados validada, dinheiro exato com `Decimal`, coleções, persistência JSON, exportação CSV, manipulação determinística de arquivos temporários e cobertura automatizada com pytest. O Projeto 02 adiciona políticas de notas configuráveis, agregação ponderada exata, estado de progresso versus final explícito, relatório estruturado e testes focados em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados, transições explícitas de ciclo de vida e testes focados em mutação sem introduzir autenticação. O Projeto 04 adiciona ingestão CSV consciente de schema, conversão tipada, diagnóstico de rejeições por linha, falhas estruturais, verificação de identificadores duplicados, filtros determinísticos e agregação usando a biblioteca padrão. O Projeto 05 adiciona janelas explícitas de relatório, resumos determinísticos, métricas exatas com duas casas decimais, fronteiras imutáveis de relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa e determinística de arquivos, classificação por sufixo, planejamento dry-run imutável, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de raiz/categorias, nomes de staging limitados e commit atômico no-replace sensível à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.
## Navegação útil
diff --git a/docs/project-structure.en.md b/docs/project-structure.en.md
index fcfb473..ebf6a2d 100644
--- a/docs/project-structure.en.md
+++ b/docs/project-structure.en.md
@@ -394,15 +394,25 @@ python-study-guide/
│ │ └── tests/
│ │ ├── conftest.py
│ │ └── test_csv_analyzer.py
-│ └── 05-report-generator/
+│ ├── 05-report-generator/
+│ │ ├── README.md
+│ │ ├── README.pt-BR.md
+│ │ ├── README.es.md
+│ │ ├── demo.py
+│ │ ├── report_generator.py
+│ │ └── tests/
+│ │ ├── conftest.py
+│ │ └── test_report_generator.py
+│ └── 06-file-organizer/
│ ├── README.md
│ ├── README.pt-BR.md
│ ├── README.es.md
│ ├── demo.py
-│ ├── report_generator.py
+│ ├── file_organizer.py
│ └── tests/
│ ├── conftest.py
-│ └── test_report_generator.py
+│ ├── test_atomic_move.py
+│ └── test_file_organizer.py
├── program-flow/
│ ├── README.md
│ ├── README.pt-BR.md
@@ -619,7 +629,7 @@ python-study-guide/
- `external-libraries/`: complete Phase 9 learning path for third-party packages. It contains reviewed multilingual chapters for pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x, and pytest 9.1.x, with twenty deterministic executable examples in total. The phase covers tabular transformations, Excel workbook automation, HTTP/API clients, and automated-testing contracts; Phase 10 practical projects come next.
- `functions/`: complete Phase 5 learning path. Chapters 01–09 cover defining and calling functions, required inputs, returned values, scope and name lookup, type hints for function interfaces, default values including definition-time evaluation and mutable-default safety, variable-length positional and keyword argument collection with `*args` and `**kwargs`, composition through helper and coordinating functions with explicit dependencies and simple call graphs, and explicit data-flow tracing across calls including parameter bindings, rebinding versus mutation, `None`, tuple results, and return-based handoffs, in English, Brazilian Portuguese, and Spanish with deterministic executable examples.
- `fundamentals/`: complete Phase 1 learning path. Its six chapters teach how Python runs a program, how to use `print()` and `input()`, how assignment and naming work, how to recognize and inspect common built-in data types, and how to convert compatible values deliberately, with aligned multilingual explanations and executable examples.
-- `practical-projects/`: Phase 10 practical-project workspace. Projects 01–05 are available: Expense Tracker integrates validated monetary data and persistence; Grade Calculator adds configurable grading policies and exact weighted aggregation; User Registration adds canonical identity-like data, duplicate prevention, indexed updates, and lifecycle transitions; CSV Analyzer adds strict schema-aware ingestion and partial-success validation; Report Generator adds explicit date windows, deterministic summary metrics, TXT/Markdown rendering, and UTF-8 file output.
+- `practical-projects/`: Phase 10 practical-project workspace. Projects 01–05 are complete and Project 06 File Organizer is in progress. Project 06 adds deterministic shallow discovery, immutable planning, collision policies, symlink boundaries, filesystem identity checks, descriptor-anchored directories, bounded staging names, atomic Linux no-replace commit behavior, a deterministic demo, and focused regression tests.
- `program-flow/`: complete Phase 4 learning path. Chapters 01–08 teach conditions, comparisons, truth-value testing, membership, identity, Boolean logic, conditional branching with `if`, `elif`, and `else`, structural pattern matching, iterable-driven repetition with `for`, numeric progressions with `range()`, position-aware iteration with `enumerate()`, parallel iteration with `zip()` including explicit equal-length validation with `strict=True`, state-driven repetition with `while`, deliberate loop control with `break`, `continue`, and loop `else`, and how to choose and combine program-flow tools according to intent, in English, Brazilian Portuguese, and Spanish with deterministic executable examples.
- `scripts/`: dependency-free maintenance tools used locally and by GitHub Actions.
- `standard-library/`: complete Phase 8 learning path. Chapters 01–09 cover `pathlib` filesystem boundaries, `datetime` date/time modeling, advanced `json` and `csv` contracts, `logging`, specialized `collections`, `itertools`, `decimal`, and `os`/`shutil` contracts for environment state, traversal, metadata, copy, move, recursive removal, platform capabilities, and archive safety, in English, Brazilian Portuguese, and Spanish with deterministic executable examples.
diff --git a/docs/project-structure.es.md b/docs/project-structure.es.md
index ebf5992..ace7970 100644
--- a/docs/project-structure.es.md
+++ b/docs/project-structure.es.md
@@ -394,15 +394,25 @@ python-study-guide/
│ │ └── tests/
│ │ ├── conftest.py
│ │ └── test_csv_analyzer.py
-│ └── 05-report-generator/
+│ ├── 05-report-generator/
+│ │ ├── README.md
+│ │ ├── README.pt-BR.md
+│ │ ├── README.es.md
+│ │ ├── demo.py
+│ │ ├── report_generator.py
+│ │ └── tests/
+│ │ ├── conftest.py
+│ │ └── test_report_generator.py
+│ └── 06-file-organizer/
│ ├── README.md
│ ├── README.pt-BR.md
│ ├── README.es.md
│ ├── demo.py
-│ ├── report_generator.py
+│ ├── file_organizer.py
│ └── tests/
│ ├── conftest.py
-│ └── test_report_generator.py
+│ ├── test_atomic_move.py
+│ └── test_file_organizer.py
├── program-flow/
│ ├── README.md
│ ├── README.pt-BR.md
@@ -619,7 +629,7 @@ python-study-guide/
- `external-libraries/`: ruta completa de la Fase 9 para paquetes de terceros. Contiene capítulos multilingües revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x y pytest 9.1.x, con veinte ejemplos ejecutables deterministas en total. La fase cubre transformaciones tabulares, automatización de libros de Excel, clientes HTTP/API y contratos de pruebas automatizadas; la Fase 10 de proyectos prácticos viene a continuación.
- `functions/`: ruta completa de la Fase 5. Los Capítulos 01–09 cubren definición y llamada de funciones, entradas obligatorias, valores retornados, alcance y búsqueda de nombres, type hints para interfaces de funciones, valores predeterminados incluida la evaluación al definir la función y la seguridad con valores mutables, recolección de argumentos posicionales y por palabra clave de cantidad variable con `*args` y `**kwargs`, composición mediante funciones auxiliares y coordinadoras con dependencias explícitas y grafos simples de llamadas, y seguimiento explícito del flujo de datos entre llamadas, incluidos vínculos de parámetros, reasignación frente a mutación, `None`, resultados en tupla y traspasos mediante `return`, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos.
- `fundamentals/`: ruta completa de la Fase 1. Sus seis capítulos enseñan cómo Python ejecuta un programa, cómo usar `print()` e `input()`, cómo funcionan la asignación y los nombres, cómo reconocer e inspeccionar tipos de datos incorporados comunes y cómo convertir valores compatibles de forma deliberada, con explicaciones multilingües alineadas y ejemplos ejecutables.
-- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están disponibles: Control de Gastos integra datos monetarios validados y persistencia; Calculadora de Notas añade políticas de calificación configurables y agregación ponderada exacta; Registro de Usuarios añade datos canónicos de identidad, prevención de duplicados, actualizaciones indexadas y transiciones del ciclo de vida; Analizador CSV añade ingestión estricta consciente de schema y validación con éxito parcial; Generador de Informes añade ventanas explícitas de fechas, métricas deterministas de resumen, renderización TXT/Markdown y escritura UTF-8.
+- `practical-projects/`: espacio de Proyectos Prácticos de la Fase 10. Los Proyectos 01–05 están completados y el Proyecto 06 Organizador de Archivos está en progreso. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, políticas de colisión, fronteras de symlink, verificaciones de identidad del filesystem, directorios anclados por descriptors, nombres de staging acotados, commit atómico no-replace en Linux, demo determinista y pruebas de regresión enfocadas.
- `program-flow/`: ruta completa de la Fase 4. Los Capítulos 01–08 enseñan condiciones, comparaciones, pruebas de valor de verdad, pertenencia, identidad, lógica booleana, ramificación condicional con `if`, `elif` y `else`, coincidencia de patrones estructurales, repetición guiada por iterables con `for`, progresiones numéricas con `range()`, iteración con posición usando `enumerate()`, iteración paralela con `zip()` incluida la validación explícita de longitudes iguales con `strict=True`, repetición guiada por estado con `while`, control deliberado de bucles con `break`, `continue` y `else` de bucle y cómo elegir y combinar herramientas de flujo del programa según la intención, en inglés, portugués de Brasil y español, con ejemplos ejecutables determinísticos.
- `scripts/`: herramientas de mantenimiento sin dependencias externas utilizadas localmente y por GitHub Actions.
- `standard-library/`: ruta completa de la Fase 8. Los Capítulos 01–09 cubren fronteras de filesystem con `pathlib`, modelado de fecha/hora con `datetime`, contratos avanzados de `json` y `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` y contratos de `os`/`shutil` para estado del entorno, recorrido, metadatos, copia, movimiento, eliminación recursiva, capacidades de plataforma y seguridad de archives, en inglés, portugués de Brasil y español con ejemplos ejecutables deterministas.
diff --git a/docs/project-structure.pt-BR.md b/docs/project-structure.pt-BR.md
index 03737e4..635b843 100644
--- a/docs/project-structure.pt-BR.md
+++ b/docs/project-structure.pt-BR.md
@@ -394,15 +394,25 @@ python-study-guide/
│ │ └── tests/
│ │ ├── conftest.py
│ │ └── test_csv_analyzer.py
-│ └── 05-report-generator/
+│ ├── 05-report-generator/
+│ │ ├── README.md
+│ │ ├── README.pt-BR.md
+│ │ ├── README.es.md
+│ │ ├── demo.py
+│ │ ├── report_generator.py
+│ │ └── tests/
+│ │ ├── conftest.py
+│ │ └── test_report_generator.py
+│ └── 06-file-organizer/
│ ├── README.md
│ ├── README.pt-BR.md
│ ├── README.es.md
│ ├── demo.py
-│ ├── report_generator.py
+│ ├── file_organizer.py
│ └── tests/
│ ├── conftest.py
-│ └── test_report_generator.py
+│ ├── test_atomic_move.py
+│ └── test_file_organizer.py
├── program-flow/
│ ├── README.md
│ ├── README.pt-BR.md
@@ -619,7 +629,7 @@ python-study-guide/
- `external-libraries/`: trilha completa da Fase 9 para pacotes de terceiros. Contém capítulos multilíngues revisados de pandas 3.0.x, openpyxl 3.1.x, Requests 2.34.x e pytest 9.1.x, com vinte exemplos executáveis determinísticos no total. A fase cobre transformações tabulares, automação de workbooks do Excel, clientes HTTP/API e contratos de testes automatizados; a Fase 10 de projetos práticos vem a seguir.
- `functions/`: trilha completa da Fase 5. Os Capítulos 01–09 cobrem definição e chamada de funções, entradas obrigatórias, valores retornados, escopo e busca de nomes, type hints para interfaces de funções, valores padrão incluindo avaliação no momento da definição e segurança com padrões mutáveis, coleta de argumentos posicionais e nomeados de quantidade variável com `*args` e `**kwargs`, composição por funções auxiliares e coordenadoras com dependências explícitas e grafos simples de chamadas e rastreamento explícito do fluxo de dados entre chamadas, incluindo vínculos de parâmetros, reatribuição versus mutação, `None`, resultados em tupla e passagens por `return`, em inglês, português brasileiro e espanhol com exemplos executáveis determinísticos.
- `fundamentals/`: trilha completa da Fase 1. Seus seis capítulos ensinam como o Python executa um programa, como usar `print()` e `input()`, como funcionam atribuição e nomes, como reconhecer e inspecionar tipos de dados embutidos comuns e como converter valores compatíveis de forma deliberada, com explicações multilíngues alinhadas e exemplos executáveis.
-- `practical-projects/`: espaço de Projetos Práticos da Fase 10. Os Projetos 01–05 estão disponíveis: Controle de Despesas integra dados monetários validados e persistência; Calculadora de Notas adiciona políticas de notas configuráveis e agregação ponderada exata; Cadastro de Usuários adiciona dados canônicos de identidade, prevenção de duplicidades, atualizações indexadas e transições de ciclo de vida; Analisador CSV adiciona ingestão rígida consciente de schema e validação com sucesso parcial; Gerador de Relatórios adiciona janelas explícitas de datas, métricas determinísticas de resumo, renderização TXT/Markdown e escrita UTF-8.
+- `practical-projects/`: espaço de Projetos Práticos da Fase 10. Os Projetos 01–05 estão concluídos e o Projeto 06 Organizador de Arquivos está em andamento. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, políticas de colisão, fronteiras de symlink, verificações de identidade do filesystem, diretórios ancorados por descriptors, nomes de staging limitados, commit atômico no-replace no Linux, demo determinístico e testes focados de regressão.
- `program-flow/`: trilha completa da Fase 4. Os Capítulos 01–08 ensinam condições, comparações, teste de valor de verdade, pertencimento, identidade, lógica booleana, ramificação condicional com `if`, `elif` e `else`, correspondência de padrões estruturais, repetição guiada por iteráveis com `for`, progressões numéricas com `range()`, iteração com posição usando `enumerate()`, iteração paralela com `zip()` incluindo validação explícita de comprimentos iguais com `strict=True`, repetição guiada por estado com `while`, controle deliberado de loops com `break`, `continue` e `else` de loop e como escolher e combinar ferramentas de fluxo do programa de acordo com a intenção, em inglês, português brasileiro e espanhol, com exemplos executáveis determinísticos.
- `scripts/`: ferramentas de manutenção sem dependências externas, utilizadas localmente e pelo GitHub Actions.
- `standard-library/`: trilha completa da Fase 8. Os Capítulos 01–09 cobrem fronteiras de filesystem com `pathlib`, modelagem de data/hora com `datetime`, contratos avançados de `json` e `csv`, `logging`, `collections` especializadas, `itertools`, `decimal` e contratos de `os`/`shutil` para estado do ambiente, travessia, metadados, cópia, movimentação, remoção recursiva, capacidades de plataforma e segurança de archives, em inglês, português do Brasil e espanhol com exemplos executáveis determinísticos.
diff --git a/docs/roadmap.en.md b/docs/roadmap.en.md
index 4a2c8d7..fb7b76d 100644
--- a/docs/roadmap.en.md
+++ b/docs/roadmap.en.md
@@ -24,9 +24,9 @@ This roadmap tracks both the educational curriculum and the repository foundatio
| 7. Errors, files, and modules | Complete | Five reviewed chapters cover exception handling, deliberate exception signaling, safe file I/O, TXT/CSV/JSON data formats, and imports/modules/packages |
| 8. Standard library | Complete | Nine reviewed chapters cover paths, date/time, JSON, CSV, logging, specialized collections, lazy iteration, decimal arithmetic, and OS/filesystem operations |
| 9. External libraries | Complete | Four reviewed chapters cover pandas, openpyxl, requests, and pytest with explicit dependency contracts and deterministic examples |
-| 10. Practical projects | In progress | Projects 01–05 cover validated monetary workflows, configurable grading rules, canonical user registration, schema-aware CSV ingestion, and deterministic report generation with automated tests |
+| 10. Practical projects | In progress | Projects 01–05 are complete; Project 06 File Organizer is in progress with deterministic planning, collision safety, filesystem identity checks, anchored directories, and atomic no-replace commit behavior |
-Phases 0–9 are complete. Phase 10: Practical Projects is in progress with Expense Tracker, Grade Calculator, User Registration, CSV Analyzer, and Report Generator available as Projects 01–05. The practical-project phase turns previously studied concepts into complete workflows with requirements, design decisions, implementation, validation, extension paths, and portfolio discussion.
+Phases 0–9 are complete. Phase 10: Practical Projects is in progress with Expense Tracker, Grade Calculator, User Registration, CSV Analyzer, and Report Generator complete as Projects 01–05, while File Organizer is the current Project 06. The practical-project phase turns previously studied concepts into complete workflows with requirements, design decisions, implementation, validation, extension paths, and portfolio discussion.
## Phase 0: Project foundation
@@ -172,11 +172,11 @@ See the [Practical Projects section index](../practical-projects/README.md).
- [x] [User Registration](../practical-projects/03-user-registration/README.md)
- [x] [CSV Analyzer](../practical-projects/04-csv-analyzer/README.md)
- [x] [Report Generator](../practical-projects/05-report-generator/README.md)
-- [ ] File Organizer
+- [ ] [File Organizer](../practical-projects/06-file-organizer/README.md) — current project
- [ ] Fictional Reconciliation Workflow
- [ ] Simulated Automation Flow
-Project 01 establishes the Phase 10 contract with explicit requirements, validated data modeling, exact `Decimal` money, persistence, deterministic demonstration, automated pytest coverage, extension challenges, and portfolio discussion. Project 02 extends the contract with configurable grading rules, exact weighted aggregation, explicit partial/final reporting, and boundary-focused validation. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused pytest coverage without introducing authentication. Project 04 adds strict CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate accepted identifiers, deterministic aggregation, and filtering with standard-library CSV mechanics exposed explicitly. Project 05 adds explicit inclusive date windows, source identity validation, exact deterministic summary metrics, immutable report construction, TXT/Markdown rendering, format-specific escaping, and UTF-8 file output.
+Project 01 establishes the Phase 10 contract with explicit requirements, validated data modeling, exact `Decimal` money, persistence, deterministic demonstration, automated pytest coverage, extension challenges, and portfolio discussion. Project 02 extends the contract with configurable grading rules, exact weighted aggregation, explicit partial/final reporting, and boundary-focused validation. Project 03 adds canonical identity-like data, Unicode and IDNA normalization, duplicate prevention, secondary lookup indexes, safe indexed-field updates, explicit lifecycle transitions, and mutation-focused pytest coverage without introducing authentication. Project 04 adds strict CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate accepted identifiers, deterministic aggregation, and filtering with standard-library CSV mechanics exposed explicitly. Project 05 adds explicit inclusive date windows, source identity validation, exact deterministic summary metrics, immutable report construction, TXT/Markdown rendering, format-specific escaping, and UTF-8 file output. Project 06 adds shallow deterministic discovery, immutable planning, suffix categories, explicit collision policies, symlink boundaries, `(device, inode)` identity checks, root/category descriptor anchoring, bounded staging names, and platform-aware atomic no-replace commits with Linux `renameat2(RENAME_NOREPLACE)`.
Each project should include:
diff --git a/docs/roadmap.es.md b/docs/roadmap.es.md
index d622ad1..feb7776 100644
--- a/docs/roadmap.es.md
+++ b/docs/roadmap.es.md
@@ -24,9 +24,9 @@ Este roadmap acompaña tanto la ruta educativa como la base del repositorio que
| 7. Errores, archivos y módulos | Completada | Cinco capítulos revisados cubren manejo de excepciones, señalización deliberada, I/O seguro de archivos, formatos TXT/CSV/JSON e imports/módulos/paquetes |
| 8. Biblioteca estándar | Completada | Nueve capítulos revisados cubren rutas, fecha/hora, JSON, CSV, logging, colecciones especializadas, iteración lazy, aritmética decimal y operaciones de OS/filesystem |
| 9. Bibliotecas externas | Completada | Cuatro capítulos revisados cubren pandas, openpyxl, requests y pytest con contratos explícitos de dependencias y ejemplos deterministas |
-| 10. Proyectos prácticos | En progreso | Los Proyectos 01–05 cubren flujos monetarios validados, reglas configurables de calificación, registro canónico de usuarios, ingestión CSV consciente de schema y generación determinista de informes con pruebas automatizadas |
+| 10. Proyectos prácticos | En progreso | Los Proyectos 01–05 están completados; el Proyecto 06 Organizador de Archivos está en progreso con planificación determinista, seguridad de colisiones, identidad de filesystem, directorios anclados y commit atómico no-replace |
-Las Fases 0–9 están completadas. La Fase 10: Proyectos Prácticos está en progreso con Control de Gastos, Calculadora de Notas, Registro de Usuarios, Analizador CSV y Generador de Informes disponibles como Proyectos 01–05. La fase de proyectos prácticos transforma conceptos ya estudiados en flujos completos con requisitos, decisiones de diseño, implementación, validación, caminos de extensión y discusión de portafolio.
+Las Fases 0–9 están completadas. La Fase 10: Proyectos Prácticos está en progreso con Control de Gastos, Calculadora de Notas, Registro de Usuarios, Analizador CSV y Generador de Informes completados como Proyectos 01–05, mientras que Organizador de Archivos es el Proyecto 06 actual. La fase transforma conceptos ya estudiados en flujos completos con requisitos, decisiones de diseño, implementación, validación, caminos de extensión y discusión de portafolio.
## Fase 0: Base del proyecto
@@ -172,11 +172,11 @@ Consulta el [índice de la sección Proyectos Prácticos](../practical-projects/
- [x] [Registro de Usuarios](../practical-projects/03-user-registration/README.es.md)
- [x] [Analizador de CSV](../practical-projects/04-csv-analyzer/README.es.md)
- [x] [Generador de Informes](../practical-projects/05-report-generator/README.es.md)
-- [ ] Organizador de Archivos
+- [ ] [Organizador de Archivos](../practical-projects/06-file-organizer/README.es.md) — proyecto actual
- [ ] Flujo Ficticio de Conciliación
- [ ] Flujo Simulado de Automatización
-El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8.
+El Proyecto 01 establece el contrato de la Fase 10 con requisitos explícitos, modelado de datos validado, dinero exacto con `Decimal`, persistencia, demostración determinista, cobertura automatizada con pytest, desafíos de ampliación y discusión de portafolio. El Proyecto 02 amplía el contrato con reglas de calificación configurables, agregación ponderada exacta, informe parcial/final explícito y validación centrada en límites. El Proyecto 03 añade datos de identidad canónicos, normalización Unicode e IDNA, prevención de duplicados, índices secundarios, actualizaciones seguras y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV estrictos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores aceptados duplicados, agregación determinista y filtros con la mecánica de la biblioteca estándar expuesta explícitamente. El Proyecto 05 añade ventanas inclusivas explícitas de fechas, validación de identidad del origen, métricas exactas y deterministas de resumen, construcción inmutable del informe, renderización TXT/Markdown, escape específico del formato y escritura UTF-8. El Proyecto 06 añade descubrimiento superficial determinista, planificación inmutable, categorías por sufijo, políticas explícitas de colisión, fronteras de symlink, identidad `(device, inode)`, anclaje de descriptors de raíz/categorías, nombres de staging acotados y commits atómicos no-replace sensibles a la plataforma con `renameat2(RENAME_NOREPLACE)` en Linux.
Cada proyecto debe incluir:
diff --git a/docs/roadmap.pt-BR.md b/docs/roadmap.pt-BR.md
index 7d79475..a9b332c 100644
--- a/docs/roadmap.pt-BR.md
+++ b/docs/roadmap.pt-BR.md
@@ -24,9 +24,9 @@ Este roadmap acompanha tanto a trilha educacional quanto a fundação do reposit
| 7. Erros, arquivos e módulos | Concluída | Cinco capítulos revisados cobrem tratamento de exceções, sinalização deliberada, I/O seguro de arquivos, formatos TXT/CSV/JSON e imports/módulos/pacotes |
| 8. Biblioteca padrão | Concluída | Nove capítulos revisados cobrem caminhos, data/hora, JSON, CSV, logging, coleções especializadas, iteração lazy, aritmética decimal e operações de OS/filesystem |
| 9. Bibliotecas externas | Concluída | Quatro capítulos revisados cobrem pandas, openpyxl, requests e pytest com contratos explícitos de dependências e exemplos determinísticos |
-| 10. Projetos práticos | Em andamento | Projetos 01–05 cobrem fluxos monetários validados, regras configuráveis de notas, cadastro canônico de usuários, ingestão CSV consciente de schema e geração determinística de relatórios com testes automatizados |
+| 10. Projetos práticos | Em andamento | Projetos 01–05 estão concluídos; o Projeto 06 Organizador de Arquivos está em andamento com planejamento determinístico, segurança de colisões, identidade de filesystem, diretórios ancorados e commit atômico no-replace |
-As Fases 0–9 estão concluídas. A Fase 10: Projetos Práticos está em andamento com Controle de Despesas, Calculadora de Notas, Cadastro de Usuários, Analisador CSV e Gerador de Relatórios disponíveis como Projetos 01–05. A fase de projetos práticos transforma conceitos já estudados em fluxos completos com requisitos, decisões de design, implementação, validação, caminhos de extensão e discussão de portfólio.
+As Fases 0–9 estão concluídas. A Fase 10: Projetos Práticos está em andamento com Controle de Despesas, Calculadora de Notas, Cadastro de Usuários, Analisador CSV e Gerador de Relatórios concluídos como Projetos 01–05, enquanto o Organizador de Arquivos é o atual Projeto 06. A fase transforma conceitos já estudados em fluxos completos com requisitos, decisões de design, implementação, validação, caminhos de extensão e discussão de portfólio.
## Fase 0: Fundação do projeto
@@ -172,11 +172,11 @@ Veja o [índice da seção Projetos Práticos](../practical-projects/README.pt-B
- [x] [Cadastro de Usuários](../practical-projects/03-user-registration/README.pt-BR.md)
- [x] [Analisador de CSV](../practical-projects/04-csv-analyzer/README.pt-BR.md)
- [x] [Gerador de Relatórios](../practical-projects/05-report-generator/README.pt-BR.md)
-- [ ] Organizador de Arquivos
+- [ ] [Organizador de Arquivos](../practical-projects/06-file-organizer/README.pt-BR.md) — projeto atual
- [ ] Fluxo Fictício de Conciliação
- [ ] Fluxo Simulado de Automação
-O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8.
+O Projeto 01 estabelece o contrato da Fase 10 com requisitos explícitos, modelagem de dados validada, dinheiro exato com `Decimal`, persistência, demonstração determinística, cobertura automatizada com pytest, desafios de extensão e discussão de portfólio. O Projeto 02 amplia o contrato com regras de notas configuráveis, agregação ponderada exata, relatório parcial/final explícito e validação focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, normalização Unicode e IDNA, prevenção de duplicidade, índices secundários, atualizações seguras e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV rígidos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores aceitos duplicados, agregação determinística e filtros com a mecânica da biblioteca padrão exposta explicitamente. O Projeto 05 adiciona janelas inclusivas explícitas de datas, validação da identidade da origem, métricas exatas e determinísticas de resumo, construção imutável do relatório, renderização TXT/Markdown, escape específico do formato e escrita UTF-8. O Projeto 06 adiciona descoberta rasa determinística, planejamento imutável, categorias por sufixo, políticas explícitas de colisão, fronteiras de symlink, identidade `(device, inode)`, ancoragem de descriptors de raiz/categorias, nomes de staging limitados e commits atômicos no-replace sensíveis à plataforma com `renameat2(RENAME_NOREPLACE)` no Linux.
Cada projeto deve incluir:
diff --git a/practical-projects/06-file-organizer/README.es.md b/practical-projects/06-file-organizer/README.es.md
new file mode 100644
index 0000000..2f77b2f
--- /dev/null
+++ b/practical-projects/06-file-organizer/README.es.md
@@ -0,0 +1,494 @@
+
+
+# Proyecto 06 · Organizador de Archivos
+
+[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md)
+
+
+
+[← Volver a Proyectos Prácticos](../README.es.md)
+
+> **Fase 10 · Proyectos Prácticos**
+
+Este proyecto organiza archivos hijos directos en carpetas por categoría, manteniendo descubrimiento, planificación, manejo de colisiones y mutación del filesystem explícitos y comprobables.
+
+## Objetivos de aprendizaje
+
+Al finalizar este proyecto, deberías poder:
+
+- descubrir archivos directos con `pathlib` sin recorrido recursivo;
+- clasificar nombres de archivo de forma determinista con reglas de sufijo sin distinguir mayúsculas y minúsculas;
+- modelar cambios planificados del filesystem con dataclasses inmutables;
+- separar una fase de planificación sin mutación de una fase de ejecución con efectos secundarios;
+- detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas;
+- elegir políticas de colisión explícitas en lugar de sobrescribir datos silenciosamente;
+- tratar los symlinks y archivos especiales como fronteras del filesystem;
+- razonar sobre carreras time-of-check/time-of-use;
+- comparar objetos del filesystem mediante identidad `(device, inode)`;
+- anclar directorios con file descriptors en Linux;
+- fijar orígenes sin bloquear ante sustituciones tardías por FIFO;
+- usar semántica atómica no-replace en la frontera final de commit del nombre exacto;
+- distinguir comprobaciones lógicas con `casefold()` de garantías atómicas de nombre exacto;
+- conservar estado incierto en lugar de borrar entradas a ciegas durante recuperación;
+- probar código de filesystem de forma segura con directorios temporales.
+
+## Problema
+
+Imagina un workspace ficticio con:
+
+```text
+workspace/
+├── notes.txt
+├── rows.csv
+├── photo.png
+├── backup.tar.gz
+└── script.py
+```
+
+El organizador debería producir:
+
+```text
+workspace/
+├── documents/
+│ └── notes.txt
+├── data/
+│ └── rows.csv
+├── images/
+│ └── photo.png
+├── archives/
+│ └── backup.tar.gz
+└── other/
+ └── script.py
+```
+
+El desafío importante no es solo mover archivos. El proyecto hace visibles las decisiones del filesystem antes de mutar y se niega a afirmar garantías de seguridad que la plataforma actual no puede hacer cumplir.
+
+## Requisitos
+
+La implementación debe:
+
+1. aceptar un directorio de origen existente que no sea symlink;
+2. inspeccionar únicamente hijos directos;
+3. ignorar directorios anidados;
+4. registrar symlinks hijos directos por separado sin seguirlos;
+5. clasificar archivos regulares por el sufijo del nombre;
+6. conservar exactamente los nombres de archivo;
+7. crear carpetas de destino solo cuando sean necesarias;
+8. producir un orden determinista;
+9. construir un plan inmutable antes de mutar;
+10. rechazar rutas de categoría inválidas, incluidos directorios de categoría que sean symlinks;
+11. detectar colisiones de destino exactas y sin distinción de mayúsculas/minúsculas durante planificación/preflight;
+12. ofrecer políticas explícitas `ERROR` y `SKIP` durante la planificación;
+13. ejecutar un preflight completo;
+14. vincular la identidad de cada origen cuando comienza la ejecución, no durante la planificación;
+15. nunca reemplazar silenciosamente un destino exacto;
+16. volver a comprobar nombres de destino equivalentes por `casefold()` inmediatamente antes del commit;
+17. rechazar cambios del origen después del vínculo de identidad de ejecución y supuestos obsoletos sobre raíz/categoría;
+18. nunca ejecutar `unlink()` a ciegas sobre staging o rollback cuya identidad pueda haber cambiado;
+19. devolver un resultado estructurado solo después de verificar el destino planificado.
+
+## Alcance deliberado
+
+El pipeline es:
+
+```text
+directorio de origen
+ -> descubrimiento de archivos directos
+ -> clasificación por sufijo
+ -> plan seguro contra colisiones
+ -> preflight de ejecución
+ -> carpetas de categoría ancladas
+ -> claim del origen
+ -> nueva comprobación casefold durante la mutación
+ -> commit atómico no-replace del nombre exacto en el destino
+```
+
+Este proyecto excluye intencionalmente:
+
+- organización recursiva;
+- inspección MIME o de contenido;
+- renombrado automático de duplicados;
+- hashing o deduplicación;
+- eliminación como función expuesta al usuario;
+- transacciones de rollback para el plan completo;
+- watchers de filesystem;
+- interfaz gráfica;
+- almacenamiento en la nube;
+- movimientos entre filesystems distintos.
+
+Mantener estas responsabilidades fuera de alcance hace que las reglas de seguridad sean más fáciles de inspeccionar.
+
+## Categorías
+
+`FileCategory` define cinco destinos:
+
+| Categoría | Carpeta | Sufijos representativos |
+|---|---|---|
+| Documentos | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` |
+| Datos | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` |
+| Imágenes | `images/` | `.png`, `.jpg`, `.webp`, `.svg` |
+| Archivos comprimidos | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` |
+| Otros | `other/` | todo lo que no coincida arriba |
+
+La coincidencia ignora diferencias de mayúsculas y minúsculas. La clasificación usa solo nombres de archivo y nunca abre su contenido.
+
+## Modelos centrales
+
+### `MoveAction`
+
+Representa un movimiento planificado:
+
+```text
+archivo de origen -> destino de categoría
+```
+
+Sus invariantes exigen rutas absolutas, el mismo nombre en origen y destino y una carpeta que corresponda a la categoría seleccionada.
+
+### `OrganizationPlan`
+
+Almacena:
+
+- el directorio de origen absoluto;
+- valores `MoveAction` ordenados;
+- archivos omitidos por colisión;
+- symlinks hijos directos ignorados.
+
+El plan es inmutable. Crearlo no crea directorios ni mueve archivos. Registra **intención de pathname/categoría**, no un descriptor abierto ni un snapshot duradero del objeto de filesystem detrás de cada pathname. Si un archivo regular se reemplaza en el mismo pathname planificado antes de que `execute_plan()` empiece a fijar los orígenes, el reemplazo es el objeto actual seleccionado por esa intención de pathname. La identidad fuerte del objeto comienza con el pinning de ejecución.
+
+### `OrganizationResult`
+
+Registra exactamente los destinos planificados devueltos tras una ejecución exitosa.
+
+## Descubrimiento intencionalmente superficial
+
+`discover_files()` devuelve solo archivos regulares hijos directos.
+
+El movimiento recursivo introduce contratos adicionales para rutas relativas, categorías anidadas y nombres duplicados entre directorios. Esos temas pertenecen a un proyecto mayor.
+
+## Planificar antes de mutar
+
+`plan_organization()` valida el workspace, escanea archivos directos, los clasifica y calcula destinos sin modificar el filesystem.
+
+```text
+observar -> decidir -> validar -> mutar
+```
+
+La propuesta existe como datos antes de que comiencen los efectos secundarios, lo que facilita revisión y pruebas. Esta separación deliberadamente **no** promete que un pathname siga nombrando el mismo objeto observado durante la planificación; conservar esa garantía exigiría mantener descriptores de origen vivos dentro del plan. En su lugar, la ejecución vincula el objeto regular actual en cada pathname planificado antes de crear categorías o mutar orígenes.
+
+## Políticas de colisión
+
+### `CollisionPolicy.ERROR`
+
+La planificación genera `FileExistsError` cuando ya existe un nombre de destino.
+
+### `CollisionPolicy.SKIP`
+
+Los archivos conflictivos permanecen en el origen y aparecen en `skipped_collisions`.
+
+La ejecución vuelve a comprobar colisiones después de planificar. La existencia del nombre exacto se hace cumplir de forma atómica en el commit final de Linux; los nombres equivalentes por `casefold()` se vuelven a comprobar inmediatamente antes de ese commit.
+
+## Colisiones sin distinción de mayúsculas/minúsculas
+
+Los filesystems difieren en sensibilidad de caja. El organizador compara nombres lógicos de destino usando `casefold()`.
+
+```text
+Report.TXT
+report.txt
+```
+
+Estos nombres se consideran una colisión lógica durante planificación, preflight y la comprobación inmediatamente anterior al commit, incluso en un filesystem case-sensitive.
+
+Hay una frontera importante: en un filesystem case-sensitive, la primitiva del kernel `RENAME_NOREPLACE` protege únicamente el **nombre exacto del destino**. Un proceso externo no cooperativo todavía puede crear otro nombre equivalente por `casefold()` en el pequeño intervalo posterior al último escaneo. Por ello, el proyecto no afirma unicidad atómica case-insensitive donde el filesystem no ofrece esa garantía.
+
+## Fronteras de symlink y anclaje de directorios
+
+El organizador no sigue symlinks hijos directos. También rechaza directorio de origen o carpeta de categoría que sea symlink. En Windows, tanto el directorio de origen como las carpetas de categoría se rechazan cuando son junctions NTFS: `is_dir()` sigue un junction, por lo que aceptarlo podría redirigir el descubrimiento o un movimiento fuera del workspace.
+
+En la ruta segura de Linux, la raíz y las categorías necesarias se abren con `O_DIRECTORY | O_NOFOLLOW`. Sus identidades `(device, inode)` se comparan repetidamente con las rutas que todavía deberían alcanzarlas.
+
+Esto importa porque un file descriptor permanece unido al mismo directorio aunque otro proceso renombre ese directorio. El pinning evita redirección mediante symlink; la validación del anclaje evita continuar silenciosamente dentro de un directorio que ya no es alcanzable por la ruta planificada.
+
+## Por qué el preflight no basta
+
+Una implementación ingenua podría hacer:
+
+```python
+if not destination.exists():
+ source.rename(destination)
+```
+
+La comprobación puede quedar obsoleta inmediatamente. Otro proceso puede crear el destino o sustituir un origen o directorio después de la validación.
+
+El preflight reduce estados inseguros, pero las garantías sensibles a concurrencia también deben existir en la frontera de mutación.
+
+## Identidad del filesystem
+
+La implementación representa identidad con:
+
+```text
+(st_dev, st_ino)
+```
+
+El nombre `notes.txt` es una entrada de directorio, no la identidad del objeto del filesystem.
+
+La planificación registra intención de pathname y no identidad del objeto de origen. Por ello, un archivo regular reemplazado en el mismo pathname **antes del pinning de ejecución** se acepta como el objeto actual seleccionado por el plan. Durante la ejecución segura en Linux, la identidad del origen se acepta **solo después de abrir el archivo actual** con `O_NOFOLLOW | O_NONBLOCK` cuando `O_NONBLOCK` está disponible. El `fstat()` deriva `(device, inode)` de ese descriptor ya abierto, y todos los descriptores de los orígenes planificados permanecen abiertos hasta que termina el plan. Así, un inode aceptado y luego desvinculado no puede liberarse y reutilizarse de inmediato mientras la ejecución todavía depende de su identidad. La flag nonblocking también evita que una sustitución tardía por FIFO bloquee `open()`. El pinning estabiliza la identidad del objeto, no su contenido; las escrituras concurrentes sobre el mismo inode quedan fuera de las garantías de snapshot de este proyecto.
+
+## Nombres de staging de longitud fija
+
+La ruta segura de Linux reclama temporalmente la entrada pública del origen bajo un nombre interno:
+
+```text
+.fo-stage-<32 caracteres hexadecimales>
+```
+
+El staging tiene longitud fija y nunca incorpora el nombre original. Así, un filename válido y largo no hace que el nombre interno supere un límite típico `NAME_MAX`.
+
+## Commit atómico no-replace en Linux
+
+La ruta segura de Linux usa `renameat2(..., RENAME_NOREPLACE)` mediante file descriptors anclados.
+
+Conceptualmente:
+
+```text
+1. validar rutas y ejecutar el preflight de colisiones
+2. abrir y anclar la raíz
+3. abrir el archivo regular actual en cada pathname planificado y aceptar identidad mediante `fstat()` del descriptor fijado
+4. mantener abiertos todos los descriptores aceptados hasta que termine el plan
+5. abrir y anclar las categorías necesarias
+6. reclamar origen -> staging corto con semántica no-replace
+7. verificar identidad del staging y anclajes
+8. escanear de nuevo la categoría anclada buscando un destino equivalente por casefold
+9. renombrar atómicamente staging -> destino exacto con RENAME_NOREPLACE
+10. verificar identidad del destino y anclajes
+11. informar éxito
+```
+
+`RENAME_NOREPLACE` convierte la existencia del **nombre exacto del destino** en parte de la propia operación atómica. No existe una comprobación `exists()` separada seguida de un rename que pueda reemplazar. El escaneo `casefold()` previo detecta colisiones lógicas visibles en esa frontera, pero se documenta como una nueva comprobación y no como un lock atómico case-insensitive.
+
+La ruta segura normal no finaliza el movimiento con `unlink()` del staging. Así no se traslada la misma ventana check-to-unlink del nombre público a un nombre interno.
+
+## Recuperación conservadora
+
+Los errores concurrentes pueden dejar estado incierto. La recuperación prioriza conservación frente a limpieza destructiva.
+
+Si la ejecución ya movió el origen al staging y después detecta una condición insegura, puede crear un hard link no-replace de vuelta al nombre de origen cuando sea posible. No elimina a ciegas el staging.
+
+Un pathname de staging no funciona como lock de inode. Después de reclamar el origen, cada ruta de fallo vuelve a comprobar si la entrada de staging todavía coincide con la identidad fijada del origen. Si coincide, la ejecución puede intentar recrear el nombre original mediante un hard link no-replace desde ese staging comprobado, pero la restauración solo se acepta después de volver a leer el propio pathname recreado del origen y verificar que conserva la identidad fijada. Si el link falla, sufre una carrera hacia otro objeto, deja ausente el nombre de origen o la identidad posterior al link no coincide, la ejecución deja intactas las entradas inciertas y, antes de cerrar el descriptor todavía fijado del origen, copia los bytes planificados a un archivo regular exclusivo `.fo-recovery-*`. Ese recovery no se informa como conservado solo porque su descriptor se haya escrito y `fsync()` haya terminado: la ejecución primero sincroniza el archivo de recovery y después sincroniza el directorio raíz anclado para hacer duradera ante un crash la entrada recién creada en el directorio. Cierra el descriptor de recovery antes de la comprobación final del pathname y luego vuelve a leer el pathname de recuperación a través de la raíz anclada, exigiendo que nombre el mismo archivo regular `(st_dev, st_ino)`. Si el pathname desaparece, se renombra o se reemplaza en ese punto final de verificación, la ejecución falla en lugar de afirmar falsamente que los datos quedaron retenidos, sin borrar ni sobrescribir entradas inciertas de terceros. Esta es una prueba puntual del namespace: un proceso externo no cooperativo con permiso para modificar el directorio todavía puede cambiar el pathname después de la verificación, por lo que el proyecto no afirma retención indefinida del pathname frente a cambios posteriores del namespace. Esto también cubre un fallo final de `RENAME_NOREPLACE` causado por un destino que aparece después de una carrera sobre el staging. Si un staging de reemplazo se renombra con éxito y la verificación de identidad del destino detecta la divergencia, el destino ajeno también queda intacto mientras se recuperan los bytes fijados. La recuperación solo se informa cuando el propio pathname usado para informarla queda demostrado en el punto final de verificación.
+
+Por ello, la ejecución segura en Linux exige deliberadamente permiso de lectura para cada archivo regular planificado. La legibilidad se valida antes de crear los directorios de categoría y de nuevo al fijar el inode del origen para la mutación; los fallos de permisos se informan como `PermissionError`, no como un falso cambio de identidad del origen.
+
+En escenarios raros de carrera/fallo, esto puede dejar una entrada interna de recuperación. Los prefijos `.fo-stage-*` y `.fo-recovery-*` son namespaces internos reservados y quedan fuera de descubrimientos futuros para que la evidencia de recuperación no se reorganice por accidente. Es preferible a borrar o reclasificar datos cuya identidad actual no puede demostrarse.
+
+El plan completo de varios archivos no es transaccional.
+
+## Contrato de plataforma
+
+La implementación hace explícitas las garantías por plataforma:
+
+- **Linux:** ejecución segura con FDs anclados usa `renameat2(RENAME_NOREPLACE)` cuando está disponible, con protección atómica no-replace para el nombre exacto del destino y nuevas comprobaciones `casefold()` durante la mutación;
+- **Windows:** la ruta portátil protegida usa `os.rename()` rechazando un destino existente y realiza comprobaciones best-effort de `casefold()`, redirección e identidad. **No** afirma tener la misma resistencia a carreras adversariales basada en descriptores fijados que la ruta Linux;
+- **otros POSIX:** la ejecución genera `NotImplementedError` cuando no puede aplicar de forma segura la semántica no-replace requerida.
+
+Un ejemplo orientado a seguridad debe fallar honestamente en vez de degradar su contrato de forma silenciosa.
+
+## Flujo de ejecución
+
+`execute_plan()` realiza:
+
+1. validación del tipo del plan;
+2. revalidación del directorio de origen;
+3. revalidación de rutas de categoría;
+4. preflight de colisiones;
+5. selección de capacidades de plataforma;
+6. Linux: fijar todos los orígenes antes de aceptar identidad y antes de mutar categorías;
+7. preparación de directorios anclados;
+8. claim del origen;
+9. nueva comprobación de colisión por `casefold()` durante la mutación;
+10. commit atómico no-replace del nombre exacto;
+11. verificación de destino y anclajes;
+12. construcción de `OrganizationResult`.
+
+## Determinismo
+
+Archivos y acciones se ordenan por:
+
+```python
+(path.name.casefold(), path.name)
+```
+
+Esto mantiene ejemplos, pruebas y revisión estables.
+
+## Ejecutar el demo
+
+Desde la raíz del repositorio:
+
+```bash
+python practical-projects/06-file-organizer/demo.py
+```
+
+El demo usa `TemporaryDirectory`, crea solo archivos ficticios, imprime el plan, lo ejecuta y muestra las carpetas resultantes.
+
+## Ejecutar las pruebas
+
+Suite enfocada:
+
+```bash
+python -m pytest practical-projects/06-file-organizer/tests -q
+```
+
+El capítulo evita un conteo fijo de pruebas porque la cobertura de regresión evoluciona con los reviews.
+
+La cobertura incluye:
+
+- clasificación por sufijo;
+- descubrimiento superficial y determinista;
+- manejo de symlinks;
+- invariantes de modelos inmutables;
+- colisiones exactas y sin distinción de mayúsculas/minúsculas;
+- políticas `ERROR` y `SKIP`;
+- orígenes ausentes u obsoletos;
+- destinos exactos tardíos;
+- destinos tardíos equivalentes por `casefold()` antes del commit final;
+- sustitución tardía del origen por symlink/archivo/FIFO;
+- pinning nonblocking del origen;
+- carreras de symlink y rename de categoría;
+- carreras de rename de la raíz;
+- staging de longitud fija;
+- finalización del staging sin `unlink()`;
+- verificación de identidad del destino;
+- ejecución exitosa y planes vacíos.
+
+## Rutas de fallo importantes
+
+### Directorio de origen ausente
+
+Genera `FileNotFoundError`.
+
+### Ruta de origen es archivo regular
+
+Genera `NotADirectoryError`.
+
+### Directorio de origen es symlink
+
+Se rechaza antes del escaneo.
+
+### Ruta de categoría es archivo o symlink
+
+Se rechaza antes de planificar o ejecutar.
+
+### Destino aparece después de la planificación
+
+El preflight y la nueva comprobación `casefold()` durante la mutación generan `FileExistsError` para las colisiones que observan. El `RENAME_NOREPLACE` final de Linux rechaza de forma atómica un destino con nombre exacto que aparezca en la frontera del commit.
+
+### El origen planificado se convierte en FIFO u otro archivo especial
+
+El pinning de Linux usa flags nonblocking y luego `fstat()` rechaza la sustitución por no ser un archivo regular, en lugar de bloquear la ejecución.
+
+### Origen planificado cambia
+
+Una sustitución por otro archivo regular antes del vínculo de identidad de ejecución se acepta como el objeto actual seleccionado por el plan. Los cambios posteriores a ese vínculo se rechazan en lugar de tratarse como el origen vinculado.
+
+### Raíz o categoría se renombra/sustituye
+
+La validación del anclaje genera error en vez de devolver una ruta que ya no identifica el destino comprometido.
+
+### Primitiva atómica no-replace no disponible
+
+La plataforma no compatible genera error en vez de debilitar silenciosamente el contrato.
+
+## Errores comunes
+
+### Mover mientras se escanea
+
+Mezclar descubrimiento y mutación hace difícil razonar sobre fallos parciales. Construye primero el plan.
+
+### Tratar un nombre como identidad
+
+Las entradas de directorio pueden sustituirse conservando el mismo nombre. Usa identidad del filesystem cuando esa diferencia importe.
+
+### Abrir una ruta sustituible en modo bloqueante
+
+`O_NOFOLLOW` rechaza symlinks, pero no evita que un FIFO bloquee un `open()` de solo lectura. Usa pinning nonblocking antes de validar el tipo de archivo.
+
+### Suponer que un escaneo `casefold()` es un lock atómico
+
+Un escaneo en user space puede detectar colisiones lógicas sin distinción de caja, pero en un filesystem case-sensitive no puede impedir que aparezca después otro nombre con distinta combinación de mayúsculas/minúsculas. Mantén la garantía atómica limitada al nombre exacto aplicado por la primitiva del kernel.
+
+### Comprobar inmediatamente antes de `unlink()`
+
+Sigue existiendo una ventana check-to-unlink. Cuando importa la identidad de la eliminación, reestructura la operación en vez de añadir otra comprobación.
+
+### Suponer que un FD abierto conserva el mismo pathname
+
+El descriptor sigue el inode del directorio tras un rename. Verifica su anclaje contra la ruta planificada.
+
+### Incluir el nombre completo del origen en el staging
+
+Los nombres válidos pueden estar ya cerca de `NAME_MAX`. Mantén los nombres internos acotados de forma independiente.
+
+### Limpiar a ciegas después de una carrera
+
+El cleanup también muta. Conserva entradas inciertas en vez de borrar algo que puede pertenecer a otro actor.
+
+### Tratar preflight como transacción
+
+El filesystem puede cambiar después. Un plan de varios archivos sigue siendo una secuencia de commits individualmente protegidos.
+
+## Ejercicio
+
+Extiende el organizador con un **renderizador dry-run** sin cambiar el comportamiento de ejecución.
+
+Requisitos:
+
+1. aceptar un `OrganizationPlan`;
+2. devolver texto legible y determinista;
+3. mostrar movimientos planificados, colisiones omitidas y symlinks ignorados;
+4. nunca acceder ni modificar el filesystem;
+5. añadir pruebas para planes vacíos y no vacíos.
+
+## Desafíos de extensión
+
+Considera:
+
+- mapeo configurable de sufijos;
+- categorías definidas por el usuario;
+- exportación/importación JSON con validación de plan obsoleto;
+- journal de operaciones;
+- descubrimiento recursivo con reglas explícitas de ruta relativa;
+- deduplicación por checksum;
+- herramientas de recuperación/auditoría para entradas de staging conservadas;
+- diseño transaccional para otro dominio de problema.
+
+Cada extensión introduce nuevas invariantes. Define el contrato antes de añadir código.
+
+## Discusión de portafolio
+
+Una explicación útil no es “escribí un script que mueve archivos”.
+
+Una versión más fuerte es:
+
+> Diseñé un flujo de filesystem con planificación determinista, políticas explícitas de colisión, fronteras de symlink/archivo especial, identidad por inode, directorios anclados por descriptors, nombres de staging acotados, nuevas comprobaciones `casefold()` durante la mutación y commit atómico no-replace del nombre exacto en Linux mediante `renameat2(RENAME_NOREPLACE)`. El manejo de fallos conserva estado incierto en lugar de borrar entradas a ciegas.
+
+Eso comunica decisiones de ingeniería, no solo uso de APIs.
+
+## Referencia rápida
+
+| Tarea | Función/tipo |
+|---|---|
+| Clasificar filename | `classify_path()` |
+| Descubrir archivos regulares directos | `discover_files()` |
+| Construir una propuesta segura | `plan_organization()` |
+| Elegir comportamiento de colisión | `CollisionPolicy` |
+| Describir un movimiento | `MoveAction` |
+| Mantener el plan inmutable | `OrganizationPlan` |
+| Ejecutar el plan | `execute_plan()` |
+| Mantener destinos exitosos | `OrganizationResult` |
+| Identificar objetos del filesystem | `(st_dev, st_ino)` |
+| Nueva comprobación lógica por `casefold()` | `listdir()` sobre el directorio anclado |
+| Commit seguro del nombre exacto en Linux | `renameat2(RENAME_NOREPLACE)` |
+
+## Qué sigue
+
+El Proyecto 05 generó archivos. El Proyecto 06 toma la siguiente frontera: descubrir y organizar archivos con seguridad.
+
+El Proyecto 07 vuelve a subir de nivel, combinando registros de dominio validados y estados explícitos de workflow en un **flujo ficticio de conciliación**.
diff --git a/practical-projects/06-file-organizer/README.md b/practical-projects/06-file-organizer/README.md
new file mode 100644
index 0000000..9eea00c
--- /dev/null
+++ b/practical-projects/06-file-organizer/README.md
@@ -0,0 +1,494 @@
+
+
+# Project 06 · File Organizer
+
+[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md)
+
+
+
+[← Back to Practical Projects](../README.md)
+
+> **Phase 10 · Practical Projects**
+
+This project organizes direct child files into category folders while keeping discovery, planning, collision handling, and filesystem mutation explicit and testable.
+
+## Learning objectives
+
+By the end of this project, you should be able to:
+
+- discover direct files with `pathlib` without recursive traversal;
+- classify filenames deterministically with case-insensitive suffix rules;
+- model planned filesystem changes with immutable dataclasses;
+- separate a non-mutating planning phase from a mutating execution phase;
+- detect exact and case-insensitive destination collisions;
+- choose explicit collision policies instead of silently overwriting data;
+- treat symlinks and special files as filesystem boundaries;
+- reason about time-of-check/time-of-use races;
+- compare filesystem objects by `(device, inode)` identity;
+- anchor directories with file descriptors on Linux;
+- pin sources without blocking on late FIFO replacements;
+- use atomic no-replace rename semantics at the final exact-name commit boundary;
+- distinguish logical casefold collision checks from exact-name atomic guarantees;
+- preserve uncertain state instead of blindly deleting entries during recovery;
+- test filesystem code safely with temporary directories.
+
+## Problem
+
+Imagine a fictional workspace containing:
+
+```text
+workspace/
+├── notes.txt
+├── rows.csv
+├── photo.png
+├── backup.tar.gz
+└── script.py
+```
+
+The organizer should produce:
+
+```text
+workspace/
+├── documents/
+│ └── notes.txt
+├── data/
+│ └── rows.csv
+├── images/
+│ └── photo.png
+├── archives/
+│ └── backup.tar.gz
+└── other/
+ └── script.py
+```
+
+The important challenge is not merely moving files. The project makes filesystem decisions visible before mutation and refuses to claim safety guarantees the current platform cannot enforce.
+
+## Requirements
+
+The implementation must:
+
+1. accept an existing non-symlink source directory;
+2. inspect only direct children;
+3. ignore nested directories;
+4. report direct-child symlinks separately instead of following them;
+5. classify regular files by filename suffix;
+6. preserve filenames exactly;
+7. create destination folders only when required;
+8. produce deterministic ordering;
+9. build an immutable plan before mutation;
+10. reject invalid category paths, including symlinked category directories;
+11. detect exact and case-insensitive destination collisions during planning/preflight;
+12. support explicit `ERROR` and `SKIP` planning policies;
+13. run a complete execution preflight;
+14. bind each source filesystem identity when execution begins, not during planning;
+15. never silently replace an exact destination;
+16. recheck casefold-equivalent destination names immediately before commit;
+17. reject source changes after execution-time identity binding and reject stale root/category assumptions;
+18. never blindly unlink a staging or rollback entry whose identity may have changed;
+19. return a structured result only after the planned destination is verified.
+
+## Deliberate scope
+
+The pipeline is:
+
+```text
+source directory
+ -> direct-file discovery
+ -> suffix classification
+ -> collision-safe plan
+ -> execution preflight
+ -> anchored category folders
+ -> source claim
+ -> mutation-time casefold recheck
+ -> atomic exact-name no-replace destination commit
+```
+
+This project intentionally excludes:
+
+- recursive organization;
+- MIME or content inspection;
+- automatic duplicate renaming;
+- hashing or deduplication;
+- deletion as a user-facing feature;
+- whole-plan rollback transactions;
+- filesystem watchers;
+- GUI interaction;
+- cloud storage;
+- cross-filesystem moves.
+
+Keeping these responsibilities out of scope makes the safety rules easier to inspect.
+
+## Categories
+
+`FileCategory` defines five destinations:
+
+| Category | Folder | Representative suffixes |
+|---|---|---|
+| Documents | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` |
+| Data | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` |
+| Images | `images/` | `.png`, `.jpg`, `.webp`, `.svg` |
+| Archives | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` |
+| Other | `other/` | anything not matched above |
+
+Matching is case-insensitive. Classification uses filenames only and never opens file contents.
+
+## Core models
+
+### `MoveAction`
+
+Represents one planned move:
+
+```text
+source file -> category destination
+```
+
+Its invariants require absolute paths, the same source/destination filename, and a destination folder matching the selected category.
+
+### `OrganizationPlan`
+
+Stores:
+
+- the absolute source directory;
+- sorted `MoveAction` values;
+- files skipped because of collisions;
+- ignored direct-child symlinks.
+
+The plan is immutable. Creating it does not create directories and does not move files. It records **pathname/category intent**, not an open descriptor or durable snapshot of the filesystem object behind each pathname. If a regular file is replaced at the same planned pathname before `execute_plan()` begins binding sources, the replacement is the current object selected by that pathname intent. Strong object identity starts at execution-time pinning.
+
+### `OrganizationResult`
+
+Records the exact planned destinations returned after successful execution.
+
+## Discovery is intentionally shallow
+
+`discover_files()` returns direct regular-file children only.
+
+Recursive movement introduces additional contracts for relative paths, nested category folders, and duplicate names across directories. Those belong to a larger project.
+
+## Planning before mutation
+
+`plan_organization()` validates the workspace, scans direct files, classifies them, and calculates destinations without changing the filesystem.
+
+```text
+observe -> decide -> validate -> mutate
+```
+
+The proposal exists as data before side effects begin, which makes review and testing easier. This separation deliberately does **not** promise that a pathname still names the identical filesystem object observed during planning; retaining that guarantee would require keeping live source descriptors inside the plan. Execution instead binds the current regular object at each planned pathname before any category creation or source mutation.
+
+## Collision policies
+
+### `CollisionPolicy.ERROR`
+
+Planning raises `FileExistsError` when a destination name already exists.
+
+### `CollisionPolicy.SKIP`
+
+Conflicting source files remain in the source directory and are listed in `skipped_collisions`.
+
+Execution rechecks collisions after planning. Exact-name existence is enforced atomically at the final Linux commit; casefold-equivalent names are rechecked immediately before that commit.
+
+## Case-insensitive collision checks
+
+Filesystems differ in case sensitivity. The organizer therefore compares logical destination names using `casefold()`.
+
+```text
+Report.TXT
+report.txt
+```
+
+These names are treated as a logical collision during planning, preflight, and the mutation-time recheck even on a case-sensitive filesystem.
+
+There is an important boundary: on a case-sensitive filesystem, the kernel primitive `RENAME_NOREPLACE` protects only the **exact destination name**. A non-cooperating external process could still create a different casefold-equivalent name in the tiny interval after the final casefold scan. The project therefore does not claim atomic case-insensitive uniqueness where the filesystem does not provide it.
+
+## Symlink and directory-anchor boundaries
+
+The organizer does not follow direct-child symlinks. It rejects a source directory or category folder that is a symlink. On Windows, source directories and category folders that are NTFS junctions are rejected too: `is_dir()` follows a junction, so accepting one could redirect discovery or a planned move outside the workspace.
+
+On the secure Linux path, the source root and required category directories are opened with `O_DIRECTORY | O_NOFOLLOW`. Their `(device, inode)` identities are repeatedly compared with the paths that should still reach them.
+
+This matters because a directory file descriptor remains attached to the same directory even if another process renames that directory. Descriptor pinning prevents symlink redirection, while anchor validation prevents execution from silently continuing inside a directory that is no longer reachable at the planned path.
+
+## Why preflight is not enough
+
+A naive implementation might do:
+
+```python
+if not destination.exists():
+ source.rename(destination)
+```
+
+That check can become stale immediately. Another process may create the destination or replace a source or directory after validation.
+
+Preflight reduces the number of unsafe states, but concurrency-sensitive guarantees must also exist at the mutation boundary.
+
+## Filesystem identity
+
+The implementation represents identity with:
+
+```text
+(st_dev, st_ino)
+```
+
+The filename `notes.txt` is a directory entry. It is not the identity of the underlying filesystem object.
+
+Planning records pathname intent rather than source-object identity. Therefore a regular file replaced at the same pathname **before execution-time pinning** is accepted as the current object selected by the plan. During secure Linux execution, source identity is accepted **only after the current source has been opened** with `O_NOFOLLOW | O_NONBLOCK` when `O_NONBLOCK` is available. The following `fstat()` derives `(device, inode)` from that already-open descriptor, and every planned source descriptor stays open until the plan finishes. An accepted inode that is later unlinked therefore cannot be freed and immediately reused while execution still depends on its identity. The nonblocking flag also prevents a late FIFO replacement from hanging `open()`. Descriptor pinning stabilizes object identity, not file contents; concurrent writes to the same inode are outside this project's snapshot guarantees.
+
+## Fixed-length staging names
+
+The secure Linux path temporarily claims the public source entry under an internal name:
+
+```text
+.fo-stage-<32 hexadecimal characters>
+```
+
+The stage name has fixed length and never embeds the original filename. A valid long filename therefore cannot make the internal name exceed a typical filesystem `NAME_MAX` limit.
+
+## Atomic no-replace commit on Linux
+
+The secure Linux path uses `renameat2(..., RENAME_NOREPLACE)` through pinned directory descriptors.
+
+Conceptually:
+
+```text
+1. validate paths and collision preflight
+2. open and anchor the source root
+3. open the current regular file at every planned pathname and accept identity from `fstat()` on that pinned descriptor
+4. keep all accepted source descriptors open through plan completion
+5. open and anchor required category directories
+6. claim source name -> short internal stage with no-replace semantics
+7. verify stage identity and directory anchors
+8. rescan the pinned category for a casefold-equivalent destination
+9. atomically rename stage -> exact destination with RENAME_NOREPLACE
+10. verify destination identity and anchors
+11. report success
+```
+
+`RENAME_NOREPLACE` makes **exact destination-name** existence part of the atomic filesystem operation. There is no separate `exists()` check followed by a replacing rename. The preceding casefold scan catches logical collisions visible at that boundary, but it is intentionally documented as a recheck rather than an atomic case-insensitive lock.
+
+The normal secure path does **not** finalize a move by calling `unlink()` on the staging name. This avoids transferring the same check-to-unlink race from the public source name to an internal name.
+
+## Conservative recovery
+
+Concurrency errors can leave uncertain state. Recovery therefore favors preservation over destructive cleanup.
+
+If execution has already claimed the source into a staging entry and later detects an unsafe condition, it may create a no-replace hard link back to the original source name when possible. It does not blindly delete the staging entry.
+
+A staging pathname is not an inode lock. After the source has been claimed, every failure path rechecks whether the staging entry still matches the pinned source identity. If it does, execution may attempt a no-replace hard link from that proven stage back to the original source name, but restoration is accepted only after the recreated source pathname itself is re-read and verified to have the pinned identity. If the link fails, races to a different object, leaves the source name missing, or the post-link source identity does not match, execution leaves uncertain entries untouched and, before closing the still-pinned source file descriptor, copies the planned source bytes into an exclusive `.fo-recovery-*` regular file. That recovery file is not reported as retained merely because its descriptor was written and `fsync()` completed: execution first syncs the recovery file and then syncs the anchored root directory so the newly created directory entry is crash-durable. It closes the recovery descriptor before the final pathname proof, then re-reads the recovery pathname through the anchored root and requires it to name the same regular-file `(st_dev, st_ino)`. A missing, renamed, or replaced recovery pathname at that final verification point raises instead of falsely claiming retention, and uncertain third-party entries are not deleted or overwritten. This is a point-in-time namespace proof: a non-cooperating external process with permission to mutate the directory can still change the pathname after verification, so the project does not claim indefinite pathname retention against later namespace changes. This also covers a final `RENAME_NOREPLACE` failure caused by a destination that appears after the stage was raced. If a replacement stage is successfully renamed and destination identity verification detects the mismatch, the unrelated destination is likewise left intact while the pinned bytes are recovered. Recovery is reported only when the pathname used to report that preservation is proven at the final verification point.
+
+Safe Linux execution therefore deliberately requires read access to each planned regular file. Readability is validated before category directories are created and again when the source inode is pinned for mutation; permission failures are reported as `PermissionError`, not as a false source-identity change.
+
+This can intentionally leave an internal recovery entry in unusual race/failure scenarios. The `.fo-stage-*` and `.fo-recovery-*` prefixes are reserved internal namespaces and are excluded from later discovery so recovery evidence is not accidentally reorganized. That is preferable to deleting or reclassifying uncertain data whose current identity cannot be proven.
+
+The whole multi-file plan is not transactional.
+
+## Platform contract
+
+The implementation is explicit about platform guarantees:
+
+- **Linux:** secure descriptor-anchored execution uses `renameat2(RENAME_NOREPLACE)` when available, with atomic no-replace protection for the exact destination name and mutation-time casefold rechecks;
+- **Windows:** the guarded portable path relies on Windows `os.rename()` refusing an existing destination and performs best-effort casefold, redirect, and identity checks. It does **not** claim the descriptor-pinned adversarial race resistance of the Linux path;
+- **other POSIX platforms:** execution raises `NotImplementedError` when the project cannot enforce the required no-replace semantics safely.
+
+A safety-oriented example should fail honestly instead of silently downgrading its contract.
+
+## Execution flow
+
+`execute_plan()` performs:
+
+1. plan type validation;
+2. source-directory revalidation;
+3. category-path revalidation;
+4. destination collision preflight;
+5. platform capability selection;
+6. Linux: bind the current regular file at every planned pathname by pinning it before accepting identity and before category mutation;
+7. anchored directory setup;
+8. source claim;
+9. mutation-time casefold collision recheck;
+10. atomic exact-name no-replace commit;
+11. destination/anchor verification;
+12. `OrganizationResult` construction.
+
+## Determinism
+
+Files and actions are sorted by:
+
+```python
+(path.name.casefold(), path.name)
+```
+
+This keeps examples, tests, and review output stable.
+
+## Running the demo
+
+From the repository root:
+
+```bash
+python practical-projects/06-file-organizer/demo.py
+```
+
+The demo uses `TemporaryDirectory`, creates fictional files only, prints the plan, executes it, and shows the resulting folders.
+
+## Running the tests
+
+Focused suite:
+
+```bash
+python -m pytest practical-projects/06-file-organizer/tests -q
+```
+
+The chapter intentionally avoids a fixed test count because review-driven regression coverage evolves.
+
+Coverage includes:
+
+- suffix classification;
+- deterministic shallow discovery;
+- symlink handling;
+- immutable model invariants;
+- exact and case-insensitive collisions;
+- `ERROR` and `SKIP` policies;
+- stale and missing sources;
+- late exact destinations;
+- late casefold-equivalent destinations before final commit;
+- late source symlink/file/FIFO replacement;
+- nonblocking source pinning;
+- category symlink and rename races;
+- source-root rename races;
+- fixed-length staging names;
+- staging finalization without `unlink()`;
+- destination identity verification;
+- successful execution and empty plans.
+
+## Failure paths worth studying
+
+### Missing source directory
+
+Raises `FileNotFoundError`.
+
+### Source path is a regular file
+
+Raises `NotADirectoryError`.
+
+### Source directory is a symlink
+
+Rejected before scanning.
+
+### Category path is a file or symlink
+
+Rejected before planning or execution.
+
+### Destination appears after planning
+
+Preflight and the mutation-time casefold recheck raise `FileExistsError` for collisions they observe. The final Linux `RENAME_NOREPLACE` atomically rejects an exact-name destination that appears at the commit boundary.
+
+### Planned source becomes a FIFO or another special file
+
+The Linux source pin uses nonblocking open flags, then `fstat()` rejects the replacement as non-regular instead of hanging execution.
+
+### Planned source changes
+
+A regular-file replacement before execution-time binding is accepted as the current object selected by the plan. Changes after binding are rejected instead of being treated as the bound source.
+
+### Source root or category directory is renamed/replaced
+
+Anchor verification raises instead of returning a path that no longer identifies the committed destination.
+
+### Atomic no-replace primitive is unavailable
+
+The unsupported platform path raises rather than weakening the safety contract silently.
+
+## Common mistakes
+
+### Moving while scanning
+
+Mixing discovery and mutation makes partial failure difficult to reason about. Build a plan first.
+
+### Treating a filename as object identity
+
+Directory entries can be replaced while preserving the same name. Use filesystem identity when the distinction matters.
+
+### Opening a possibly replaced path in blocking mode
+
+`O_NOFOLLOW` rejects symlinks but does not stop a FIFO from blocking a read-only `open()`. Use nonblocking pinning before validating the file type.
+
+### Assuming a casefold scan is an atomic lock
+
+A user-space directory scan can detect logical case-insensitive collisions, but on a case-sensitive filesystem it cannot make a later differently cased name impossible. Keep the atomic guarantee scoped to the exact name enforced by the kernel primitive.
+
+### Checking immediately before `unlink()`
+
+A check-to-unlink window still exists. When deletion identity matters, restructure the operation instead of adding another check.
+
+### Assuming an open directory descriptor still has the same pathname
+
+A descriptor follows the directory inode through rename. Verify its anchor against the planned path.
+
+### Embedding the full source filename in a staging name
+
+Valid source names may already be near `NAME_MAX`. Keep internal names bounded independently.
+
+### Blind cleanup after a race
+
+Cleanup is mutation too. Preserve uncertain entries rather than deleting something that may belong to another actor.
+
+### Treating preflight as a transaction
+
+The filesystem can change afterward. A multi-file plan remains a sequence of individually guarded commits.
+
+## Exercise
+
+Extend the organizer with a **dry-run renderer** without changing execution behavior.
+
+Requirements:
+
+1. accept an `OrganizationPlan`;
+2. return deterministic human-readable text;
+3. show planned moves, skipped collisions, and ignored symlinks;
+4. never access or mutate the filesystem;
+5. add tests for empty and non-empty plans.
+
+## Extension challenges
+
+Consider:
+
+- configurable suffix mappings;
+- user-defined categories;
+- JSON plan export/import with stale-plan validation;
+- operation journaling;
+- recursive discovery with explicit relative-path rules;
+- checksum-based duplicate detection;
+- richer recovery/audit tooling for preserved staging entries;
+- a transactional design for a different problem domain.
+
+Each extension introduces new invariants. Define the contract before adding code.
+
+## Portfolio discussion
+
+A useful explanation is not “I wrote a script that moves files.”
+
+A stronger version is:
+
+> I designed a filesystem workflow with deterministic planning, explicit collision policies, symlink/special-file boundaries, inode-based identity checks, descriptor-anchored directories, bounded staging names, mutation-time casefold rechecks, and an atomic Linux exact-name no-replace commit using `renameat2(RENAME_NOREPLACE)`. Failure handling preserves uncertain state instead of blindly deleting entries.
+
+That communicates engineering decisions, not just API usage.
+
+## Quick reference
+
+| Task | Function/type |
+|---|---|
+| Classify a filename | `classify_path()` |
+| Discover direct regular files | `discover_files()` |
+| Build a safe proposal | `plan_organization()` |
+| Choose collision behavior | `CollisionPolicy` |
+| Describe one move | `MoveAction` |
+| Hold the immutable plan | `OrganizationPlan` |
+| Execute the plan | `execute_plan()` |
+| Hold successful destinations | `OrganizationResult` |
+| Identify filesystem objects | `(st_dev, st_ino)` |
+| Logical casefold recheck | pinned-directory `listdir()` |
+| Secure Linux exact-name commit | `renameat2(RENAME_NOREPLACE)` |
+
+## What comes next
+
+Project 05 generated files. Project 06 owns the next boundary: discovering and organizing files safely.
+
+Project 07 moves upward again, combining validated domain records and explicit workflow states in a **fictional reconciliation workflow**.
diff --git a/practical-projects/06-file-organizer/README.pt-BR.md b/practical-projects/06-file-organizer/README.pt-BR.md
new file mode 100644
index 0000000..c38e8d6
--- /dev/null
+++ b/practical-projects/06-file-organizer/README.pt-BR.md
@@ -0,0 +1,494 @@
+
+
+# Projeto 06 · Organizador de Arquivos
+
+[🇺🇸 English](README.md) · [🇧🇷 Português](README.pt-BR.md) · [🇪🇸 Español](README.es.md)
+
+
+
+[← Voltar para Projetos Práticos](../README.pt-BR.md)
+
+> **Fase 10 · Projetos Práticos**
+
+Este projeto organiza arquivos filhos diretos em pastas por categoria, mantendo descoberta, planejamento, tratamento de colisões e mutação do filesystem explícitos e testáveis.
+
+## Objetivos de aprendizagem
+
+Ao concluir este projeto, você deverá ser capaz de:
+
+- descobrir arquivos diretos com `pathlib` sem travessia recursiva;
+- classificar nomes de arquivos de forma determinística com regras de sufixo sem diferenciar maiúsculas e minúsculas;
+- modelar mudanças planejadas no filesystem com dataclasses imutáveis;
+- separar uma fase de planejamento sem mutação de uma fase de execução com efeitos colaterais;
+- detectar colisões de destino exatas e sem diferenciação de caixa;
+- escolher políticas de colisão explícitas em vez de sobrescrever dados silenciosamente;
+- tratar symlinks e arquivos especiais como fronteiras do filesystem;
+- raciocinar sobre corridas time-of-check/time-of-use;
+- comparar objetos do filesystem pela identidade `(device, inode)`;
+- ancorar diretórios com file descriptors no Linux;
+- fixar origens sem bloquear diante de substituições tardias por FIFO;
+- usar semântica atômica no-replace na fronteira final de commit do nome exato;
+- distinguir checagens lógicas por `casefold()` de garantias atômicas de nome exato;
+- preservar estado incerto em vez de apagar entradas cegamente durante recuperação;
+- testar código de filesystem com segurança usando diretórios temporários.
+
+## Problema
+
+Imagine um workspace fictício contendo:
+
+```text
+workspace/
+├── notes.txt
+├── rows.csv
+├── photo.png
+├── backup.tar.gz
+└── script.py
+```
+
+O organizador deve produzir:
+
+```text
+workspace/
+├── documents/
+│ └── notes.txt
+├── data/
+│ └── rows.csv
+├── images/
+│ └── photo.png
+├── archives/
+│ └── backup.tar.gz
+└── other/
+ └── script.py
+```
+
+O desafio importante não é apenas mover arquivos. O projeto torna as decisões de filesystem visíveis antes da mutação e se recusa a afirmar garantias de segurança que a plataforma atual não consegue aplicar.
+
+## Requisitos
+
+A implementação deve:
+
+1. aceitar um diretório de origem existente que não seja symlink;
+2. inspecionar apenas filhos diretos;
+3. ignorar diretórios aninhados;
+4. registrar symlinks filhos diretos separadamente, sem segui-los;
+5. classificar arquivos regulares pelo sufixo do nome;
+6. preservar exatamente os nomes dos arquivos;
+7. criar pastas de destino somente quando necessárias;
+8. produzir ordenação determinística;
+9. construir um plano imutável antes da mutação;
+10. rejeitar caminhos de categoria inválidos, inclusive diretórios de categoria que sejam symlinks;
+11. detectar colisões de destino exatas e sem diferenciação de caixa durante planejamento/preflight;
+12. oferecer políticas explícitas `ERROR` e `SKIP` durante o planejamento;
+13. executar um preflight completo;
+14. vincular a identidade de cada origem quando a execução começa, e não durante o planejamento;
+15. nunca substituir silenciosamente um destino exato;
+16. revalidar nomes de destino equivalentes por `casefold()` imediatamente antes do commit;
+17. rejeitar mudanças da origem após o vínculo de identidade da execução e premissas obsoletas sobre raiz/categoria;
+18. nunca executar `unlink()` cegamente em staging ou rollback cuja identidade possa ter mudado;
+19. retornar resultado estruturado apenas após verificar o destino planejado.
+
+## Escopo deliberado
+
+O pipeline é:
+
+```text
+diretório de origem
+ -> descoberta de arquivos diretos
+ -> classificação por sufixo
+ -> plano seguro contra colisões
+ -> preflight de execução
+ -> pastas de categoria ancoradas
+ -> claim da origem
+ -> nova checagem casefold na mutação
+ -> commit atômico no-replace do nome exato no destino
+```
+
+Este projeto intencionalmente não inclui:
+
+- organização recursiva;
+- inspeção MIME ou de conteúdo;
+- renomeação automática de duplicados;
+- hashing ou deduplicação;
+- exclusão como funcionalidade exposta ao usuário;
+- transações de rollback para o plano inteiro;
+- watchers de filesystem;
+- interface gráfica;
+- armazenamento em nuvem;
+- movimentos entre filesystems diferentes.
+
+Manter essas responsabilidades fora do escopo deixa as regras de segurança mais fáceis de inspecionar.
+
+## Categorias
+
+`FileCategory` define cinco destinos:
+
+| Categoria | Pasta | Sufixos representativos |
+|---|---|---|
+| Documentos | `documents/` | `.txt`, `.md`, `.pdf`, `.docx` |
+| Dados | `data/` | `.csv`, `.json`, `.xml`, `.xlsx` |
+| Imagens | `images/` | `.png`, `.jpg`, `.webp`, `.svg` |
+| Arquivos compactados | `archives/` | `.zip`, `.7z`, `.tar.gz`, `.tar.xz` |
+| Outros | `other/` | tudo o que não corresponder acima |
+
+A correspondência ignora diferenças entre maiúsculas e minúsculas. A classificação usa apenas nomes de arquivo e nunca abre o conteúdo.
+
+## Modelos centrais
+
+### `MoveAction`
+
+Representa um movimento planejado:
+
+```text
+arquivo de origem -> destino da categoria
+```
+
+Suas invariantes exigem caminhos absolutos, o mesmo nome na origem e destino e uma pasta correspondente à categoria escolhida.
+
+### `OrganizationPlan`
+
+Armazena:
+
+- o diretório de origem absoluto;
+- valores `MoveAction` ordenados;
+- arquivos ignorados por colisão;
+- symlinks filhos diretos ignorados.
+
+O plano é imutável. Criá-lo não cria diretórios e não move arquivos. Ele registra **intenção de pathname/categoria**, e não um descriptor aberto ou snapshot durável do objeto de filesystem por trás de cada pathname. Se um arquivo regular for substituído no mesmo pathname planejado antes de `execute_plan()` começar a pinar as origens, a substituição é o objeto atual selecionado por essa intenção de pathname. A identidade forte do objeto começa no pinning da execução.
+
+### `OrganizationResult`
+
+Registra exatamente os destinos planejados retornados após execução bem-sucedida.
+
+## Descoberta intencionalmente rasa
+
+`discover_files()` retorna somente arquivos regulares filhos diretos.
+
+Movimento recursivo introduz contratos adicionais para caminhos relativos, categorias aninhadas e nomes duplicados entre diretórios. Esses temas pertencem a um projeto maior.
+
+## Planejar antes de alterar
+
+`plan_organization()` valida o workspace, varre arquivos diretos, classifica cada um e calcula destinos sem modificar o filesystem.
+
+```text
+observar -> decidir -> validar -> alterar
+```
+
+A proposta existe como dados antes de os efeitos colaterais começarem, facilitando revisão e testes. Essa separação deliberadamente **não** promete que um pathname ainda nomeie o mesmo objeto observado durante o planejamento; manter essa garantia exigiria descriptors de origem vivos dentro do plano. Em vez disso, a execução vincula o objeto regular atual em cada pathname planejado antes de criar categorias ou alterar origens.
+
+## Políticas de colisão
+
+### `CollisionPolicy.ERROR`
+
+O planejamento gera `FileExistsError` quando um nome de destino já existe.
+
+### `CollisionPolicy.SKIP`
+
+Arquivos conflitantes permanecem na origem e são listados em `skipped_collisions`.
+
+A execução revalida colisões depois do planejamento. A existência do nome exato é aplicada atomicamente no commit final do Linux; nomes equivalentes por `casefold()` são rechecados imediatamente antes desse commit.
+
+## Colisões sem diferenciação de caixa
+
+Filesystems variam quanto à sensibilidade de caixa. O organizador compara nomes lógicos de destino usando `casefold()`.
+
+```text
+Report.TXT
+report.txt
+```
+
+Esses nomes são tratados como colisão lógica durante planejamento, preflight e a checagem imediatamente anterior ao commit, mesmo em filesystem case-sensitive.
+
+Há uma fronteira importante: em um filesystem case-sensitive, a primitiva do kernel `RENAME_NOREPLACE` protege somente o **nome exato do destino**. Um processo externo que não coopere ainda pode criar outro nome equivalente por `casefold()` no pequeno intervalo após a última varredura. Por isso, o projeto não afirma unicidade atômica case-insensitive onde o filesystem não fornece essa garantia.
+
+## Fronteiras de symlink e ancoragem de diretórios
+
+O organizador não segue symlinks filhos diretos. Também rejeita diretório de origem ou pasta de categoria que seja symlink. No Windows, tanto o diretório de origem quanto as pastas de categoria são rejeitados quando são junctions NTFS: `is_dir()` segue um junction, então aceitá-lo poderia redirecionar descoberta ou movimentação para fora do workspace.
+
+No caminho seguro do Linux, a raiz e as categorias necessárias são abertas com `O_DIRECTORY | O_NOFOLLOW`. Suas identidades `(device, inode)` são comparadas repetidamente com os caminhos que ainda deveriam alcançá-las.
+
+Isso importa porque um file descriptor continua preso ao mesmo diretório mesmo quando outro processo renomeia esse diretório. O pinning impede redirecionamento por symlink; a validação de âncora impede continuar silenciosamente em um diretório que não está mais acessível pelo caminho planejado.
+
+## Por que o preflight não basta
+
+Uma implementação ingênua poderia fazer:
+
+```python
+if not destination.exists():
+ source.rename(destination)
+```
+
+Essa checagem pode ficar obsoleta imediatamente. Outro processo pode criar o destino ou substituir uma origem ou diretório depois da validação.
+
+O preflight reduz estados inseguros, mas garantias sensíveis a concorrência também precisam existir na fronteira de mutação.
+
+## Identidade no filesystem
+
+A implementação representa identidade com:
+
+```text
+(st_dev, st_ino)
+```
+
+O nome `notes.txt` é uma entrada de diretório, não a identidade do objeto do filesystem.
+
+O planejamento registra intenção de pathname, e não identidade do objeto de origem. Portanto, um arquivo regular substituído no mesmo pathname **antes do pinning da execução** é aceito como o objeto atual selecionado pelo plano. Durante a execução segura no Linux, a identidade da origem só é aceita **depois que o arquivo atual já foi aberto** com `O_NOFOLLOW | O_NONBLOCK` quando `O_NONBLOCK` está disponível. O `fstat()` deriva `(device, inode)` desse descriptor já aberto, e todos os descriptors das origens planejadas permanecem abertos até o fim do plano. Assim, um inode aceito e depois desvinculado não pode ser liberado e imediatamente reutilizado enquanto a execução ainda depende da sua identidade. A flag nonblocking também impede que uma substituição tardia por FIFO trave o `open()`. O pinning estabiliza a identidade do objeto, não o conteúdo; escritas concorrentes no mesmo inode ficam fora das garantias de snapshot deste projeto.
+
+## Nomes de staging com tamanho fixo
+
+O caminho seguro do Linux reivindica temporariamente a entrada pública da origem com um nome interno:
+
+```text
+.fo-stage-<32 caracteres hexadecimais>
+```
+
+O staging tem tamanho fixo e nunca incorpora o nome original. Assim, um filename válido e longo não faz o nome interno ultrapassar um limite típico `NAME_MAX`.
+
+## Commit atômico no-replace no Linux
+
+O caminho seguro do Linux usa `renameat2(..., RENAME_NOREPLACE)` por meio de file descriptors ancorados.
+
+Conceitualmente:
+
+```text
+1. validar caminhos e executar o preflight de colisões
+2. abrir e ancorar a raiz
+3. abrir o arquivo regular atual em cada pathname planejado e aceitar identidade pelo `fstat()` do descriptor pinado
+4. manter todos os descriptors aceitos abertos até o fim do plano
+5. abrir e ancorar as categorias necessárias
+6. reivindicar origem -> staging curto com semântica no-replace
+7. verificar identidade do staging e âncoras
+8. varrer novamente a categoria ancorada por destino equivalente via casefold
+9. renomear atomicamente staging -> destino exato com RENAME_NOREPLACE
+10. verificar identidade do destino e âncoras
+11. reportar sucesso
+```
+
+`RENAME_NOREPLACE` transforma a existência do **nome exato do destino** em parte da própria operação atômica. Não existe uma checagem `exists()` separada seguida de rename substitutivo. A varredura `casefold()` anterior captura colisões lógicas visíveis nessa fronteira, mas é documentada como rechecagem, não como lock atômico case-insensitive.
+
+O caminho seguro normal não finaliza o movimento com `unlink()` do staging. Isso evita apenas transferir a mesma janela check-to-unlink do nome público para um nome interno.
+
+## Recuperação conservadora
+
+Erros concorrentes podem deixar estado incerto. A recuperação prioriza preservação em vez de limpeza destrutiva.
+
+Se a execução já moveu a origem para staging e depois detecta condição insegura, ela pode criar um hard link no-replace de volta para o nome de origem quando possível. Ela não apaga cegamente o staging.
+
+Um pathname de staging não funciona como lock de inode. Depois que a origem foi claimada, todo caminho de falha revalida se a entrada de staging ainda corresponde à identidade pinada da origem. Se corresponder, a execução pode tentar recriar o nome original por hard link no-replace a partir desse staging comprovado, mas a restauração só é aceita depois que o próprio pathname recriado da origem é relido e verificado com a identidade pinada. Se o link falhar, sofrer corrida para outro objeto, deixar o nome de origem ausente ou a identidade pós-link não corresponder, a execução deixa entradas incertas intactas e, antes de fechar o descritor ainda pinado da origem, copia os bytes planejados para um arquivo regular exclusivo `.fo-recovery-*`. Esse recovery não é reportado como preservado apenas porque seu descritor foi gravado e o `fsync()` terminou: a execução primeiro sincroniza o arquivo de recovery e depois sincroniza o diretório raiz ancorado para tornar durável, diante de crash, a entrada recém-criada no diretório. Ela fecha o descritor do recovery antes da prova final do pathname e então relê o pathname de recuperação pelo root ancorado, exigindo que ele aponte para o mesmo arquivo regular `(st_dev, st_ino)`. Se o pathname sumir, for renomeado ou substituído nesse ponto final de verificação, a execução falha em vez de afirmar falsamente que os dados foram retidos, sem excluir nem sobrescrever entradas incertas de terceiros. Essa é uma prova pontual do namespace: um processo externo não cooperativo com permissão para alterar o diretório ainda pode mudar o pathname depois da verificação, portanto o projeto não afirma retenção indefinida do pathname contra mudanças posteriores no namespace. Isso também cobre uma falha final de `RENAME_NOREPLACE` causada por um destino que aparece depois de uma corrida sobre o staging. Se um staging substituto for renomeado com sucesso e a verificação de identidade do destino detectar a divergência, o destino alheio também permanece intacto enquanto os bytes pinados são recuperados. A recuperação só é reportada quando o próprio pathname usado para informá-la é comprovado no ponto final de verificação.
+
+Por isso, a execução segura no Linux exige deliberadamente permissão de leitura para cada arquivo regular planejado. A legibilidade é validada antes da criação das pastas de categoria e novamente ao pinar o inode da origem para a mutação; falhas de permissão são reportadas como `PermissionError`, e não como uma falsa mudança de identidade da origem.
+
+Em cenários raros de corrida/falha, isso pode deixar uma entrada interna de recuperação. Os prefixos `.fo-stage-*` e `.fo-recovery-*` são namespaces internos reservados e ficam fora de descobertas futuras, evitando que evidências de recuperação sejam reorganizadas por acidente. É preferível a excluir ou reclassificar dados cuja identidade atual não pode ser comprovada.
+
+O plano inteiro de múltiplos arquivos não é transacional.
+
+## Contrato de plataforma
+
+A implementação explicita as garantias por plataforma:
+
+- **Linux:** execução segura com FDs ancorados usa `renameat2(RENAME_NOREPLACE)` quando disponível, com proteção atômica no-replace para o nome exato do destino e rechecagens `casefold()` na mutação;
+- **Windows:** o caminho portátil protegido usa `os.rename()` recusando destino existente e realiza checagens best-effort de `casefold()`, redirecionamento e identidade. Ele **não** afirma possuir a mesma resistência a corridas adversariais baseada em descriptors pinados do caminho Linux;
+- **outros POSIX:** a execução gera `NotImplementedError` quando não consegue aplicar a semântica no-replace exigida com segurança.
+
+Um exemplo orientado a segurança deve falhar de forma honesta em vez de reduzir silenciosamente seu contrato.
+
+## Fluxo de execução
+
+`execute_plan()` realiza:
+
+1. validação do tipo do plano;
+2. revalidação do diretório de origem;
+3. revalidação dos caminhos de categoria;
+4. preflight de colisões;
+5. seleção da capacidade da plataforma;
+6. Linux: pinning de todas as origens antes de aceitar identidade e antes de mutar categorias;
+7. preparação dos diretórios ancorados;
+8. claim da origem;
+9. rechecagem de colisão por `casefold()` na mutação;
+10. commit atômico no-replace do nome exato;
+11. verificação do destino e das âncoras;
+12. construção de `OrganizationResult`.
+
+## Determinismo
+
+Arquivos e ações são ordenados por:
+
+```python
+(path.name.casefold(), path.name)
+```
+
+Isso mantém exemplos, testes e revisão estáveis.
+
+## Executando o demo
+
+A partir da raiz do repositório:
+
+```bash
+python practical-projects/06-file-organizer/demo.py
+```
+
+O demo usa `TemporaryDirectory`, cria apenas arquivos fictícios, imprime o plano, executa e mostra as pastas resultantes.
+
+## Executando os testes
+
+Suíte focada:
+
+```bash
+python -m pytest practical-projects/06-file-organizer/tests -q
+```
+
+O capítulo evita contagem fixa de testes porque a cobertura de regressão evolui conforme os reviews.
+
+A cobertura inclui:
+
+- classificação por sufixo;
+- descoberta rasa e determinística;
+- tratamento de symlinks;
+- invariantes dos modelos imutáveis;
+- colisões exatas e sem diferenciação de caixa;
+- políticas `ERROR` e `SKIP`;
+- origens ausentes ou obsoletas;
+- destinos exatos tardios;
+- destinos tardios equivalentes por `casefold()` antes do commit final;
+- substituição tardia da origem por symlink/arquivo/FIFO;
+- pinning nonblocking da origem;
+- corridas de symlink e rename da categoria;
+- corridas de rename da raiz;
+- staging com tamanho fixo;
+- finalização do staging sem `unlink()`;
+- verificação da identidade do destino;
+- execução bem-sucedida e planos vazios.
+
+## Caminhos de falha importantes
+
+### Diretório de origem ausente
+
+Gera `FileNotFoundError`.
+
+### Caminho de origem é arquivo regular
+
+Gera `NotADirectoryError`.
+
+### Diretório de origem é symlink
+
+É rejeitado antes da varredura.
+
+### Caminho de categoria é arquivo ou symlink
+
+É rejeitado antes do planejamento ou execução.
+
+### Destino aparece depois do planejamento
+
+O preflight e a rechecagem `casefold()` na mutação geram `FileExistsError` para colisões que observam. O `RENAME_NOREPLACE` final do Linux rejeita atomicamente um destino com nome exato que apareça na fronteira do commit.
+
+### Origem planejada vira FIFO ou outro arquivo especial
+
+O pinning no Linux usa flags nonblocking e depois o `fstat()` rejeita a substituição por não ser arquivo regular, em vez de travar a execução.
+
+### Origem planejada muda
+
+Uma substituição por outro arquivo regular antes do vínculo de identidade da execução é aceita como o objeto atual selecionado pelo plano. Mudanças após esse vínculo são rejeitadas em vez de serem tratadas como a origem vinculada.
+
+### Raiz ou categoria é renomeada/substituída
+
+A validação de âncora gera erro em vez de retornar um caminho que não identifica mais o destino comprometido.
+
+### Primitiva atômica no-replace indisponível
+
+A plataforma não suportada gera erro em vez de enfraquecer silenciosamente o contrato.
+
+## Erros comuns
+
+### Mover enquanto varre
+
+Misturar descoberta e mutação torna falhas parciais difíceis de raciocinar. Construa o plano primeiro.
+
+### Tratar nome como identidade
+
+Entradas de diretório podem ser substituídas mantendo o mesmo nome. Use identidade do filesystem quando essa diferença importa.
+
+### Abrir um caminho substituível em modo bloqueante
+
+`O_NOFOLLOW` rejeita symlinks, mas não impede que um FIFO bloqueie um `open()` read-only. Use pinning nonblocking antes de validar o tipo do arquivo.
+
+### Assumir que uma varredura `casefold()` é um lock atômico
+
+Uma varredura em user space detecta colisões lógicas sem diferenciação de caixa, mas em filesystem case-sensitive não consegue impedir que outro nome com caixa diferente apareça depois. Mantenha a garantia atômica limitada ao nome exato aplicado pela primitiva do kernel.
+
+### Verificar imediatamente antes de `unlink()`
+
+Ainda existe uma janela check-to-unlink. Quando a identidade da exclusão importa, reestruture a operação em vez de adicionar outra checagem.
+
+### Assumir que um FD aberto ainda tem o mesmo pathname
+
+O descriptor acompanha o inode do diretório após rename. Verifique sua âncora contra o caminho planejado.
+
+### Embutir o nome completo da origem no staging
+
+Nomes válidos podem já estar próximos do `NAME_MAX`. Mantenha nomes internos limitados independentemente.
+
+### Limpar cegamente depois de uma corrida
+
+Cleanup também é mutação. Preserve entradas incertas em vez de apagar algo que pode pertencer a outro ator.
+
+### Tratar preflight como transação
+
+O filesystem pode mudar depois. Um plano de vários arquivos continua sendo uma sequência de commits individualmente protegidos.
+
+## Exercício
+
+Estenda o organizador com um **renderizador de dry run** sem alterar o comportamento de execução.
+
+Requisitos:
+
+1. aceitar um `OrganizationPlan`;
+2. retornar texto legível e determinístico;
+3. mostrar movimentos planejados, colisões ignoradas e symlinks ignorados;
+4. nunca acessar ou modificar o filesystem;
+5. adicionar testes para planos vazios e não vazios.
+
+## Desafios de extensão
+
+Considere:
+
+- mapeamento configurável de sufixos;
+- categorias definidas pelo usuário;
+- exportação/importação JSON com validação de plano obsoleto;
+- journal de operações;
+- descoberta recursiva com regras explícitas de caminho relativo;
+- deduplicação por checksum;
+- tooling de recuperação/auditoria para entradas de staging preservadas;
+- design transacional para outro domínio de problema.
+
+Cada extensão introduz novas invariantes. Defina o contrato antes de adicionar código.
+
+## Discussão de portfólio
+
+Uma explicação útil não é “eu escrevi um script que move arquivos”.
+
+Uma versão mais forte é:
+
+> Eu projetei um fluxo de filesystem com planejamento determinístico, políticas explícitas de colisão, fronteiras de symlink/arquivo especial, identidade por inode, diretórios ancorados por descriptors, nomes de staging limitados, rechecagens `casefold()` na mutação e commit atômico no-replace do nome exato no Linux com `renameat2(RENAME_NOREPLACE)`. O tratamento de falhas preserva estado incerto em vez de apagar entradas cegamente.
+
+Isso comunica decisões de engenharia, não apenas uso de APIs.
+
+## Referência rápida
+
+| Tarefa | Função/tipo |
+|---|---|
+| Classificar filename | `classify_path()` |
+| Descobrir arquivos regulares diretos | `discover_files()` |
+| Construir uma proposta segura | `plan_organization()` |
+| Escolher comportamento de colisão | `CollisionPolicy` |
+| Descrever um movimento | `MoveAction` |
+| Manter o plano imutável | `OrganizationPlan` |
+| Executar o plano | `execute_plan()` |
+| Manter destinos bem-sucedidos | `OrganizationResult` |
+| Identificar objetos do filesystem | `(st_dev, st_ino)` |
+| Rechecagem lógica por `casefold()` | `listdir()` no diretório ancorado |
+| Commit seguro do nome exato no Linux | `renameat2(RENAME_NOREPLACE)` |
+
+## O que vem depois
+
+O Projeto 05 gerou arquivos. O Projeto 06 assume a próxima fronteira: descobrir e organizar arquivos com segurança.
+
+O Projeto 07 sobe novamente de nível, combinando registros de domínio validados e estados explícitos de workflow em um **fluxo fictício de conciliação**.
diff --git a/practical-projects/06-file-organizer/demo.py b/practical-projects/06-file-organizer/demo.py
new file mode 100644
index 0000000..ebd72b8
--- /dev/null
+++ b/practical-projects/06-file-organizer/demo.py
@@ -0,0 +1,36 @@
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from file_organizer import execute_plan, plan_organization
+
+
+def main() -> None:
+ """Run a deterministic fictional organization workflow in a temporary folder."""
+ with TemporaryDirectory() as temporary_directory:
+ workspace = Path(temporary_directory)
+ fictional_files = {
+ "meeting-notes.txt": "Agenda notes\n",
+ "orders.csv": "id,total\n101,50\n",
+ "product-photo.png": "fictional image placeholder\n",
+ "backup.zip": "fictional archive placeholder\n",
+ "automation.py": "print('fictional')\n",
+ }
+ for name, content in fictional_files.items():
+ (workspace / name).write_text(content, encoding="utf-8")
+
+ plan = plan_organization(workspace)
+ print(f"planned moves: {plan.planned_count}")
+ for action in plan.actions:
+ print(
+ f"{action.source.name} -> "
+ f"{action.destination.parent.name}/{action.destination.name}"
+ )
+
+ result = execute_plan(plan)
+ print(f"moved files: {result.moved_count}")
+ folders = sorted(path.name for path in workspace.iterdir() if path.is_dir())
+ print(f"created folders: {', '.join(folders)}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/practical-projects/06-file-organizer/file_organizer.py b/practical-projects/06-file-organizer/file_organizer.py
new file mode 100644
index 0000000..5b0c161
--- /dev/null
+++ b/practical-projects/06-file-organizer/file_organizer.py
@@ -0,0 +1,1159 @@
+from __future__ import annotations
+
+import ctypes
+import errno
+import os
+import secrets
+import stat
+import sys
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import Enum
+from os import PathLike
+from pathlib import Path
+
+
+class FileCategory(str, Enum):
+ """Destination categories supported by the organizer."""
+
+ DOCUMENTS = "documents"
+ DATA = "data"
+ IMAGES = "images"
+ ARCHIVES = "archives"
+ OTHER = "other"
+
+
+class CollisionPolicy(str, Enum):
+ """How planning handles a destination name that already exists."""
+
+ ERROR = "error"
+ SKIP = "skip"
+
+
+_DOCUMENT_SUFFIXES = frozenset({".txt", ".md", ".pdf", ".doc", ".docx", ".odt"})
+_DATA_SUFFIXES = frozenset({".csv", ".json", ".xml", ".xls", ".xlsx"})
+_IMAGE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"})
+_ARCHIVE_SUFFIXES = frozenset({".zip", ".tar", ".gz", ".bz2", ".xz", ".7z"})
+_COMPOUND_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz")
+_RENAME_NOREPLACE = 1
+_INTERNAL_PREFIXES = (".fo-stage-", ".fo-recovery-")
+
+
+def _load_renameat2() -> Callable[..., int] | None:
+ """Return Linux renameat2 when libc exposes the no-replace primitive."""
+ if not sys.platform.startswith("linux"):
+ return None
+ try:
+ libc = ctypes.CDLL(None, use_errno=True)
+ renameat2 = libc.renameat2
+ except (OSError, AttributeError):
+ return None
+
+ renameat2.argtypes = [
+ ctypes.c_int,
+ ctypes.c_char_p,
+ ctypes.c_int,
+ ctypes.c_char_p,
+ ctypes.c_uint,
+ ]
+ renameat2.restype = ctypes.c_int
+ return renameat2
+
+
+_RENAMEAT2 = _load_renameat2()
+
+
+@dataclass(frozen=True, slots=True)
+class _FileIdentity:
+ """Stable filesystem identity captured while an object is pinned."""
+
+ device: int
+ inode: int
+
+
+@dataclass(frozen=True, slots=True)
+class _PinnedSource:
+ """Descriptor and identity accepted together for one planned source."""
+
+ fd: int
+ identity: _FileIdentity
+
+
+def _coerce_path(value: str | PathLike[str], field_name: str) -> Path:
+ if isinstance(value, bool) or not isinstance(value, (str, PathLike)):
+ raise TypeError(f"{field_name} must be a path-like value")
+ try:
+ return Path(value)
+ except (TypeError, ValueError, OSError) as exc:
+ raise TypeError(f"{field_name} must be a valid path-like value") from exc
+
+
+def _require_source_directory(value: str | PathLike[str]) -> Path:
+ path = _coerce_path(value, "source_directory")
+ is_junction = getattr(path, "is_junction", None)
+ if path.is_symlink() or bool(is_junction is not None and is_junction()):
+ raise ValueError("source_directory cannot be a symlink or junction")
+ if not path.exists():
+ raise FileNotFoundError(f"source_directory does not exist: {path}")
+ if not path.is_dir():
+ raise NotADirectoryError(f"source_directory is not a directory: {path}")
+ return path.resolve()
+
+
+def _path_sort_key(path: Path) -> tuple[str, str]:
+ return path.name.casefold(), path.name
+
+
+def _identity_from_regular_stat(
+ stat_result: os.stat_result,
+ *,
+ filename: str,
+) -> _FileIdentity:
+ if not stat.S_ISREG(stat_result.st_mode):
+ raise FileNotFoundError(
+ f"planned source is no longer a regular file: {filename}"
+ )
+ return _FileIdentity(stat_result.st_dev, stat_result.st_ino)
+
+
+def _identity_from_directory_stat(
+ stat_result: os.stat_result,
+ *,
+ directory_name: str,
+) -> _FileIdentity:
+ if not stat.S_ISDIR(stat_result.st_mode):
+ raise ValueError(
+ f"directory became unsafe during execution: {directory_name}"
+ )
+ return _FileIdentity(stat_result.st_dev, stat_result.st_ino)
+
+
+def _capture_path_identity(path: Path) -> _FileIdentity:
+ try:
+ stat_result = path.lstat()
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(
+ f"planned source is no longer a regular file: {path.name}"
+ ) from exc
+ return _identity_from_regular_stat(stat_result, filename=path.name)
+
+
+def _capture_directory_identity(path: Path) -> _FileIdentity:
+ try:
+ stat_result = path.lstat()
+ except FileNotFoundError as exc:
+ raise ValueError(
+ f"directory became unsafe during execution: {path.name}"
+ ) from exc
+ return _identity_from_directory_stat(stat_result, directory_name=path.name)
+
+
+@dataclass(frozen=True, slots=True)
+class MoveAction:
+ """One planned move from the source directory into a category folder."""
+
+ source: Path
+ destination: Path
+ category: FileCategory
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.source, Path) or not isinstance(self.destination, Path):
+ raise TypeError("source and destination must be Path values")
+ if not isinstance(self.category, FileCategory):
+ raise TypeError("category must be a FileCategory")
+ if not self.source.is_absolute() or not self.destination.is_absolute():
+ raise ValueError("source and destination must be absolute paths")
+ if self.source == self.destination:
+ raise ValueError("source and destination must differ")
+ if self.source.name != self.destination.name:
+ raise ValueError("destination must preserve the source filename")
+ if self.destination.parent.name != self.category.value:
+ raise ValueError("destination directory must match the file category")
+
+
+@dataclass(frozen=True, slots=True)
+class OrganizationPlan:
+ """Immutable pathname-intent plan produced before filesystem mutation."""
+
+ source_directory: Path
+ actions: tuple[MoveAction, ...]
+ skipped_collisions: tuple[Path, ...]
+ ignored_symlinks: tuple[Path, ...]
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.source_directory, Path):
+ raise TypeError("source_directory must be a Path")
+ if not self.source_directory.is_absolute():
+ raise ValueError("source_directory must be absolute")
+ if not isinstance(self.actions, tuple) or any(
+ not isinstance(action, MoveAction) for action in self.actions
+ ):
+ raise TypeError("actions must be a tuple of MoveAction values")
+ for field_name, values in (
+ ("skipped_collisions", self.skipped_collisions),
+ ("ignored_symlinks", self.ignored_symlinks),
+ ):
+ if not isinstance(values, tuple) or any(
+ not isinstance(path, Path) for path in values
+ ):
+ raise TypeError(f"{field_name} must be a tuple of Path values")
+
+ for action in self.actions:
+ if action.source.parent != self.source_directory:
+ raise ValueError("planned sources must be direct children of source_directory")
+ if action.destination.parent.parent != self.source_directory:
+ raise ValueError(
+ "planned destinations must be category folders inside source_directory"
+ )
+
+ for path in (*self.skipped_collisions, *self.ignored_symlinks):
+ if not path.is_absolute() or path.parent != self.source_directory:
+ raise ValueError(
+ "skipped and ignored paths must be direct children of source_directory"
+ )
+
+ expected_actions = tuple(
+ sorted(self.actions, key=lambda item: _path_sort_key(item.source))
+ )
+ if self.actions != expected_actions:
+ raise ValueError("actions must be sorted by source filename")
+
+ for field_name, values in (
+ ("skipped_collisions", self.skipped_collisions),
+ ("ignored_symlinks", self.ignored_symlinks),
+ ):
+ if values != tuple(sorted(values, key=_path_sort_key)):
+ raise ValueError(f"{field_name} must be sorted by filename")
+
+ source_keys = tuple(action.source.name.casefold() for action in self.actions)
+ if len(source_keys) != len(set(source_keys)):
+ raise ValueError("planned source filenames must be unique case-insensitively")
+
+ destination_keys = tuple(
+ (action.category.value, action.destination.name.casefold())
+ for action in self.actions
+ )
+ if len(destination_keys) != len(set(destination_keys)):
+ raise ValueError("planned destinations must be unique case-insensitively")
+
+ @property
+ def planned_count(self) -> int:
+ return len(self.actions)
+
+ @property
+ def skipped_collision_count(self) -> int:
+ return len(self.skipped_collisions)
+
+ @property
+ def ignored_symlink_count(self) -> int:
+ return len(self.ignored_symlinks)
+
+
+@dataclass(frozen=True, slots=True)
+class OrganizationResult:
+ """Result of successfully executing one complete organization plan."""
+
+ plan: OrganizationPlan
+ moved_files: tuple[Path, ...]
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.plan, OrganizationPlan):
+ raise TypeError("plan must be an OrganizationPlan")
+ if not isinstance(self.moved_files, tuple) or any(
+ not isinstance(path, Path) for path in self.moved_files
+ ):
+ raise TypeError("moved_files must be a tuple of Path values")
+ expected = tuple(action.destination for action in self.plan.actions)
+ if self.moved_files != expected:
+ raise ValueError("moved_files must match the plan destinations")
+
+ @property
+ def moved_count(self) -> int:
+ return len(self.moved_files)
+
+
+def classify_path(path: str | PathLike[str]) -> FileCategory:
+ """Classify a filename by its suffix without reading file contents."""
+ value = _coerce_path(path, "path")
+ name = value.name.casefold()
+
+ if any(name.endswith(suffix) for suffix in _COMPOUND_ARCHIVE_SUFFIXES):
+ return FileCategory.ARCHIVES
+
+ suffix = value.suffix.casefold()
+ if suffix in _DOCUMENT_SUFFIXES:
+ return FileCategory.DOCUMENTS
+ if suffix in _DATA_SUFFIXES:
+ return FileCategory.DATA
+ if suffix in _IMAGE_SUFFIXES:
+ return FileCategory.IMAGES
+ if suffix in _ARCHIVE_SUFFIXES:
+ return FileCategory.ARCHIVES
+ return FileCategory.OTHER
+
+
+def _scan_source_directory(
+ source_directory: Path,
+) -> tuple[tuple[Path, ...], tuple[Path, ...]]:
+ files: list[Path] = []
+ symlinks: list[Path] = []
+
+ for child in sorted(source_directory.iterdir(), key=_path_sort_key):
+ if child.name.startswith(_INTERNAL_PREFIXES):
+ continue
+ if child.is_symlink():
+ symlinks.append(child.absolute())
+ elif child.is_file():
+ files.append(child.absolute())
+
+ return tuple(files), tuple(symlinks)
+
+
+def discover_files(source_directory: str | PathLike[str]) -> tuple[Path, ...]:
+ """Return direct regular-file children in deterministic order."""
+ root = _require_source_directory(source_directory)
+ files, _ = _scan_source_directory(root)
+ return files
+
+
+def _is_directory_redirect(path: Path) -> bool:
+ """Return whether a directory entry redirects traversal to another location."""
+ if path.is_symlink():
+ return True
+ is_junction = getattr(path, "is_junction", None)
+ return bool(is_junction is not None and is_junction())
+
+
+def _validate_category_locations(source_directory: Path) -> None:
+ for category in FileCategory:
+ target = source_directory / category.value
+ if _is_directory_redirect(target):
+ raise ValueError(
+ f"category directory cannot be a symlink or junction: {target.name}"
+ )
+ if target.exists() and not target.is_dir():
+ raise NotADirectoryError(
+ f"category path exists but is not a directory: {target.name}"
+ )
+
+
+def _existing_names_casefold(directory: Path) -> set[str]:
+ if not directory.exists():
+ return set()
+ return {child.name.casefold() for child in directory.iterdir()}
+
+
+def plan_organization(
+ source_directory: str | PathLike[str],
+ *,
+ collision_policy: CollisionPolicy = CollisionPolicy.ERROR,
+) -> OrganizationPlan:
+ """Build a deterministic, non-mutating pathname-intent plan."""
+ root = _require_source_directory(source_directory)
+ if not isinstance(collision_policy, CollisionPolicy):
+ raise TypeError("collision_policy must be a CollisionPolicy")
+
+ _validate_category_locations(root)
+ files, symlinks = _scan_source_directory(root)
+ existing_by_category = {
+ category: _existing_names_casefold(root / category.value)
+ for category in FileCategory
+ }
+
+ actions: list[MoveAction] = []
+ skipped: list[Path] = []
+ planned_keys: set[tuple[FileCategory, str]] = set()
+
+ for source in files:
+ category = classify_path(source)
+ destination = (root / category.value / source.name).absolute()
+ key = (category, source.name.casefold())
+ collides = (
+ source.name.casefold() in existing_by_category[category]
+ or key in planned_keys
+ )
+
+ if collides:
+ if collision_policy is CollisionPolicy.ERROR:
+ raise FileExistsError(
+ f"destination already exists for source file: {source.name}"
+ )
+ skipped.append(source)
+ continue
+
+ actions.append(
+ MoveAction(
+ source=source,
+ destination=destination,
+ category=category,
+ )
+ )
+ planned_keys.add(key)
+
+ return OrganizationPlan(
+ source_directory=root,
+ actions=tuple(actions),
+ skipped_collisions=tuple(skipped),
+ ignored_symlinks=symlinks,
+ )
+
+
+def _preflight_execution(plan: OrganizationPlan) -> None:
+ root = _require_source_directory(plan.source_directory)
+ if root != plan.source_directory:
+ raise ValueError("source_directory no longer resolves to the planned directory")
+
+ _validate_category_locations(root)
+
+ for action in plan.actions:
+ target_directory = action.destination.parent
+ if target_directory.exists():
+ current_names = _existing_names_casefold(target_directory)
+ if action.destination.name.casefold() in current_names:
+ raise FileExistsError(
+ f"destination appeared after planning: {action.destination.name}"
+ )
+ elif action.destination.exists() or action.destination.is_symlink():
+ raise FileExistsError(
+ f"destination appeared after planning: {action.destination.name}"
+ )
+
+
+def _capture_portable_source_identities(
+ plan: OrganizationPlan,
+) -> dict[Path, _FileIdentity]:
+ """Capture best-effort pathname identities for the guarded Windows path."""
+ return {
+ action.source: _capture_path_identity(action.source) for action in plan.actions
+ }
+
+
+def _supports_secure_directory_fds() -> bool:
+ """Return whether Linux can enforce descriptor-anchored no-replace renames."""
+ return (
+ _RENAMEAT2 is not None
+ and hasattr(os, "O_DIRECTORY")
+ and hasattr(os, "O_NOFOLLOW")
+ and os.open in os.supports_dir_fd
+ and os.mkdir in os.supports_dir_fd
+ and os.link in os.supports_dir_fd
+ and os.rename in os.supports_dir_fd
+ and os.stat in os.supports_dir_fd
+ and os.stat in os.supports_follow_symlinks
+ and os.listdir in os.supports_fd
+ )
+
+
+def _directory_open_flags() -> int:
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
+ if hasattr(os, "O_CLOEXEC"):
+ flags |= os.O_CLOEXEC
+ return flags
+
+
+def _open_source_directory_fd(source_directory: Path) -> int:
+ try:
+ return os.open(source_directory, _directory_open_flags())
+ except OSError as exc:
+ raise ValueError("source_directory became unsafe during execution") from exc
+
+
+def _verify_root_anchor_at(source_directory: Path, root_fd: int) -> None:
+ """Require the pinned root FD to remain reachable at the planned path."""
+ pinned = os.fstat(root_fd)
+ try:
+ current = source_directory.lstat()
+ except FileNotFoundError as exc:
+ raise ValueError("source_directory moved during execution") from exc
+
+ pinned_identity = _identity_from_directory_stat(
+ pinned,
+ directory_name=source_directory.name,
+ )
+ try:
+ current_identity = _identity_from_directory_stat(
+ current,
+ directory_name=source_directory.name,
+ )
+ except ValueError as exc:
+ raise ValueError("source_directory moved during execution") from exc
+ if pinned_identity != current_identity:
+ raise ValueError("source_directory moved during execution")
+
+
+def _open_category_directory_fd(root_fd: int, category_name: str) -> int:
+ """Create/open one category directory without following a late symlink."""
+ try:
+ os.mkdir(category_name, dir_fd=root_fd)
+ except FileExistsError:
+ pass
+
+ try:
+ category_fd = os.open(category_name, _directory_open_flags(), dir_fd=root_fd)
+ except OSError as exc:
+ raise ValueError(
+ f"category directory became unsafe during execution: {category_name}"
+ ) from exc
+
+ try:
+ _verify_category_anchor_at(
+ root_fd=root_fd,
+ category_name=category_name,
+ category_fd=category_fd,
+ )
+ except Exception:
+ os.close(category_fd)
+ raise
+ return category_fd
+
+
+def _regular_identity_at(filename: str, *, directory_fd: int) -> _FileIdentity:
+ try:
+ stat_result = os.stat(
+ filename,
+ dir_fd=directory_fd,
+ follow_symlinks=False,
+ )
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(
+ f"planned source is no longer a regular file: {filename}"
+ ) from exc
+ return _identity_from_regular_stat(stat_result, filename=filename)
+
+
+def _open_planned_source_fd_at(
+ source_name: str,
+ *,
+ root_fd: int,
+) -> _PinnedSource:
+ """Open first, then accept identity from the descriptor pinning the inode."""
+ flags = os.O_RDONLY | os.O_NOFOLLOW
+ if hasattr(os, "O_NONBLOCK"):
+ flags |= os.O_NONBLOCK
+ if hasattr(os, "O_CLOEXEC"):
+ flags |= os.O_CLOEXEC
+ try:
+ source_fd = os.open(source_name, flags, dir_fd=root_fd)
+ except PermissionError as exc:
+ raise PermissionError(
+ f"planned source must be readable for safe execution: {source_name}"
+ ) from exc
+ except OSError as exc:
+ raise FileNotFoundError(
+ f"planned source changed during execution: {source_name}"
+ ) from exc
+
+ try:
+ identity = _identity_from_regular_stat(
+ os.fstat(source_fd),
+ filename=source_name,
+ )
+ except Exception:
+ os.close(source_fd)
+ raise
+ return _PinnedSource(fd=source_fd, identity=identity)
+
+
+def _pin_planned_sources_at(
+ plan: OrganizationPlan,
+ *,
+ root_fd: int,
+) -> dict[Path, _PinnedSource]:
+ """Pin the current regular file at every planned pathname before mutation.
+
+ OrganizationPlan intentionally stores pathname/category intent, not live file
+ descriptors or a durable filesystem-object snapshot. Identity becomes strong
+ only when execute_plan opens each pathname and accepts fstat() on that pin.
+ """
+ pinned: dict[Path, _PinnedSource] = {}
+ try:
+ for action in plan.actions:
+ pinned[action.source] = _open_planned_source_fd_at(
+ action.source.name,
+ root_fd=root_fd,
+ )
+ except Exception:
+ for source in pinned.values():
+ os.close(source.fd)
+ raise
+ return pinned
+
+
+def _verify_destination_identity_at(
+ destination_name: str,
+ *,
+ destination_directory_fd: int,
+ expected_identity: _FileIdentity,
+) -> None:
+ try:
+ stat_result = os.stat(
+ destination_name,
+ dir_fd=destination_directory_fd,
+ follow_symlinks=False,
+ )
+ except FileNotFoundError as exc:
+ raise RuntimeError(
+ f"destination changed during execution: {destination_name}"
+ ) from exc
+
+ if not stat.S_ISREG(stat_result.st_mode):
+ raise RuntimeError(
+ f"destination does not match planned source: {destination_name}"
+ )
+ if _FileIdentity(stat_result.st_dev, stat_result.st_ino) != expected_identity:
+ raise RuntimeError(
+ f"destination does not match planned source: {destination_name}"
+ )
+
+
+def _verify_category_anchor_at(
+ *,
+ root_fd: int,
+ category_name: str,
+ category_fd: int,
+) -> None:
+ """Require the pinned category FD to remain the named child of the root."""
+ pinned = os.fstat(category_fd)
+ try:
+ current = os.stat(
+ category_name,
+ dir_fd=root_fd,
+ follow_symlinks=False,
+ )
+ except FileNotFoundError as exc:
+ raise ValueError(
+ f"category directory moved during execution: {category_name}"
+ ) from exc
+
+ pinned_identity = _identity_from_directory_stat(
+ pinned,
+ directory_name=category_name,
+ )
+ current_identity = _identity_from_directory_stat(
+ current,
+ directory_name=category_name,
+ )
+ if pinned_identity != current_identity:
+ raise ValueError(
+ f"category directory moved during execution: {category_name}"
+ )
+
+
+def _verify_no_casefold_destination_collision_at(
+ destination_name: str,
+ *,
+ destination_directory_fd: int,
+) -> None:
+ """Reject a casefold-equivalent entry visible immediately before commit."""
+ destination_key = destination_name.casefold()
+ try:
+ current_names = os.listdir(destination_directory_fd)
+ except OSError as exc:
+ raise ValueError("category directory became unsafe during execution") from exc
+
+ if any(name.casefold() == destination_key for name in current_names):
+ raise FileExistsError(
+ f"case-insensitive destination appeared during execution: {destination_name}"
+ )
+
+
+def _verify_no_casefold_destination_collision_path(destination: Path) -> None:
+ """Best-effort Windows recheck for a casefold-equivalent destination."""
+ destination_key = destination.name.casefold()
+ if any(child.name.casefold() == destination_key for child in destination.parent.iterdir()):
+ raise FileExistsError(
+ f"case-insensitive destination appeared during execution: {destination.name}"
+ )
+
+
+def _make_stage_name(source_name: str) -> str:
+ """Return a fixed-length internal name independent of the source filename."""
+ del source_name
+ return f".fo-stage-{secrets.token_hex(16)}"
+
+
+def _make_recovery_name(source_name: str) -> str:
+ """Return a bounded exclusive name for emergency source-data recovery."""
+ del source_name
+ return f".fo-recovery-{secrets.token_hex(16)}"
+
+
+def _recover_pinned_source_at(
+ source_fd: int,
+ source_name: str,
+ *,
+ root_fd: int,
+) -> str:
+ """Persist bytes from the pinned source FD into a proven recovery pathname."""
+ source_stat = os.fstat(source_fd)
+ mode = stat.S_IMODE(source_stat.st_mode)
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
+ if hasattr(os, "O_CLOEXEC"):
+ flags |= os.O_CLOEXEC
+
+ recovery_fd: int | None = None
+ recovery_name = ""
+ for _ in range(16):
+ recovery_name = _make_recovery_name(source_name)
+ try:
+ recovery_fd = os.open(
+ recovery_name,
+ flags,
+ mode,
+ dir_fd=root_fd,
+ )
+ except FileExistsError:
+ continue
+ break
+
+ if recovery_fd is None:
+ raise FileExistsError(
+ f"could not allocate recovery entry for planned source: {source_name}"
+ )
+
+ recovery_identity = _identity_from_regular_stat(
+ os.fstat(recovery_fd),
+ filename=recovery_name,
+ )
+ original_offset = os.lseek(source_fd, 0, os.SEEK_CUR)
+ try:
+ os.lseek(source_fd, 0, os.SEEK_SET)
+ while True:
+ chunk = os.read(source_fd, 1024 * 1024)
+ if not chunk:
+ break
+ view = memoryview(chunk)
+ while view:
+ written = os.write(recovery_fd, view)
+ if written <= 0:
+ raise OSError(
+ "could not persist pinned source recovery data"
+ )
+ view = view[written:]
+ os.fchmod(recovery_fd, mode)
+ os.fsync(recovery_fd)
+ os.fsync(root_fd)
+ finally:
+ os.lseek(source_fd, original_offset, os.SEEK_SET)
+ os.close(recovery_fd)
+
+ try:
+ recovery_path_identity = _regular_identity_at(
+ recovery_name,
+ directory_fd=root_fd,
+ )
+ except OSError as exc:
+ raise RuntimeError(
+ f"recovery pathname changed during execution: {recovery_name}"
+ ) from exc
+ if recovery_path_identity != recovery_identity:
+ raise RuntimeError(
+ f"recovery pathname changed during execution: {recovery_name}"
+ )
+
+ return recovery_name
+
+
+def _preserve_stage_at(
+ stage_name: str,
+ source_name: str,
+ *,
+ root_fd: int,
+ expected_identity: _FileIdentity | None = None,
+) -> bool:
+ """Best-effort restore without deleting raced entries; optionally prove result."""
+ try:
+ os.link(
+ stage_name,
+ source_name,
+ src_dir_fd=root_fd,
+ dst_dir_fd=root_fd,
+ follow_symlinks=False,
+ )
+ except OSError:
+ pass
+
+ if expected_identity is None:
+ return False
+
+ try:
+ restored_identity = _regular_identity_at(
+ source_name,
+ directory_fd=root_fd,
+ )
+ except OSError:
+ return False
+ return restored_identity == expected_identity
+
+
+def _preserve_claimed_source_after_failure_at(
+ stage_name: str,
+ source_name: str,
+ *,
+ root_fd: int,
+ source_fd: int,
+ expected_identity: _FileIdentity,
+) -> str | None:
+ """Restore a proven stage or recover pinned bytes when stage identity is uncertain."""
+ try:
+ staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd)
+ except OSError:
+ staged_identity = None
+
+ if staged_identity == expected_identity:
+ restored = _preserve_stage_at(
+ stage_name,
+ source_name,
+ root_fd=root_fd,
+ expected_identity=expected_identity,
+ )
+ if restored:
+ return None
+
+ return _recover_pinned_source_at(
+ source_fd,
+ source_name,
+ root_fd=root_fd,
+ )
+
+
+def _claim_source_at(
+ source_name: str,
+ *,
+ root_fd: int,
+ expected_identity: _FileIdentity,
+) -> str:
+ """Atomically detach the source name and verify the claimed regular file."""
+ stage_name = ""
+ for _ in range(16):
+ stage_name = _make_stage_name(source_name)
+ try:
+ _rename_no_replace_at(
+ source_name,
+ stage_name,
+ source_directory_fd=root_fd,
+ destination_directory_fd=root_fd,
+ )
+ except FileExistsError:
+ continue
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(
+ f"planned source changed during execution: {source_name}"
+ ) from exc
+ break
+ else:
+ raise FileExistsError(
+ f"could not allocate staging entry for planned source: {source_name}"
+ )
+
+ try:
+ staged_identity = _regular_identity_at(stage_name, directory_fd=root_fd)
+ except FileNotFoundError as exc:
+ _preserve_stage_at(stage_name, source_name, root_fd=root_fd)
+ raise FileNotFoundError(
+ f"planned source changed during execution: {source_name}"
+ ) from exc
+
+ if staged_identity != expected_identity:
+ _preserve_stage_at(stage_name, source_name, root_fd=root_fd)
+ raise FileNotFoundError(
+ f"planned source changed during execution: {source_name}"
+ )
+ return stage_name
+
+
+def _rename_no_replace_at(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+) -> None:
+ """Atomically rename between pinned directories without replacing destination."""
+ if _RENAMEAT2 is None:
+ raise NotImplementedError("atomic no-replace rename is unavailable")
+
+ ctypes.set_errno(0)
+ result = _RENAMEAT2(
+ source_directory_fd,
+ os.fsencode(source_name),
+ destination_directory_fd,
+ os.fsencode(destination_name),
+ _RENAME_NOREPLACE,
+ )
+ if result == 0:
+ return
+
+ error_number = ctypes.get_errno()
+ if error_number == errno.EEXIST:
+ raise FileExistsError(
+ error_number,
+ "destination appeared during execution",
+ destination_name,
+ )
+ raise OSError(error_number, os.strerror(error_number), destination_name)
+
+
+def _move_file_no_replace_at(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_path: Path,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ category_name: str,
+ source_fd: int,
+ expected_identity: _FileIdentity,
+) -> None:
+ """Commit one move using the source descriptor pinned before mutation."""
+ _verify_root_anchor_at(source_directory_path, source_directory_fd)
+ _verify_category_anchor_at(
+ root_fd=source_directory_fd,
+ category_name=category_name,
+ category_fd=destination_directory_fd,
+ )
+
+ try:
+ stage_name = _claim_source_at(
+ source_name,
+ root_fd=source_directory_fd,
+ expected_identity=expected_identity,
+ )
+ except FileNotFoundError as exc:
+ recovery_name = _recover_pinned_source_at(
+ source_fd,
+ source_name,
+ root_fd=source_directory_fd,
+ )
+ raise FileNotFoundError(
+ "planned source changed after it was pinned; "
+ f"planned source data retained as {recovery_name}: {source_name}"
+ ) from exc
+
+ try:
+ _verify_root_anchor_at(source_directory_path, source_directory_fd)
+ _verify_category_anchor_at(
+ root_fd=source_directory_fd,
+ category_name=category_name,
+ category_fd=destination_directory_fd,
+ )
+ staged_identity = _regular_identity_at(
+ stage_name,
+ directory_fd=source_directory_fd,
+ )
+ if staged_identity != expected_identity:
+ raise FileNotFoundError(
+ f"planned source changed during execution: {source_name}"
+ )
+ _verify_no_casefold_destination_collision_at(
+ destination_name,
+ destination_directory_fd=destination_directory_fd,
+ )
+ _rename_no_replace_at(
+ stage_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+ except (FileExistsError, FileNotFoundError, ValueError, OSError) as exc:
+ recovery_name = _preserve_claimed_source_after_failure_at(
+ stage_name,
+ source_name,
+ root_fd=source_directory_fd,
+ source_fd=source_fd,
+ expected_identity=expected_identity,
+ )
+ if recovery_name is not None:
+ exc.add_note(
+ "planned source data retained as "
+ f"{recovery_name}: {source_name}"
+ )
+ raise
+
+ try:
+ _verify_destination_identity_at(
+ destination_name,
+ destination_directory_fd=destination_directory_fd,
+ expected_identity=expected_identity,
+ )
+ except RuntimeError as exc:
+ recovery_name = _recover_pinned_source_at(
+ source_fd,
+ source_name,
+ root_fd=source_directory_fd,
+ )
+ raise RuntimeError(
+ "destination does not match planned source; "
+ f"planned source data retained as {recovery_name}: {destination_name}"
+ ) from exc
+ _verify_root_anchor_at(source_directory_path, source_directory_fd)
+ _verify_category_anchor_at(
+ root_fd=source_directory_fd,
+ category_name=category_name,
+ category_fd=destination_directory_fd,
+ )
+
+
+def _rename_no_replace_path(source: Path, destination: Path) -> None:
+ """Portable no-replace rename for Windows; Linux uses descriptor execution."""
+ if os.name != "nt":
+ raise NotImplementedError(
+ "safe execution requires Linux renameat2 or Windows rename semantics"
+ )
+ try:
+ os.rename(source, destination)
+ except FileExistsError as exc:
+ raise FileExistsError(
+ f"destination appeared during execution: {destination.name}"
+ ) from exc
+
+
+def _move_file_no_replace(
+ source: Path,
+ destination: Path,
+ expected_identity: _FileIdentity,
+) -> None:
+ """Windows fallback using its atomic no-replace rename behavior."""
+ _verify_path_identity(source, expected_identity)
+ category_identity = _capture_directory_identity(destination.parent)
+ _verify_no_casefold_destination_collision_path(destination)
+ _rename_no_replace_path(source, destination)
+ _verify_destination_path_identity(destination, expected_identity)
+ if _capture_directory_identity(destination.parent) != category_identity:
+ raise ValueError(
+ f"category directory moved during execution: {destination.parent.name}"
+ )
+
+
+def _verify_path_identity(path: Path, expected_identity: _FileIdentity) -> None:
+ current_identity = _capture_path_identity(path)
+ if current_identity != expected_identity:
+ raise FileNotFoundError(
+ f"planned source changed during execution: {path.name}"
+ )
+
+
+def _verify_destination_path_identity(
+ destination: Path,
+ expected_identity: _FileIdentity,
+) -> None:
+ try:
+ stat_result = destination.lstat()
+ except FileNotFoundError as exc:
+ raise RuntimeError(
+ f"destination changed during execution: {destination.name}"
+ ) from exc
+ if not stat.S_ISREG(stat_result.st_mode):
+ raise RuntimeError(
+ f"destination does not match planned source: {destination.name}"
+ )
+ if _FileIdentity(stat_result.st_dev, stat_result.st_ino) != expected_identity:
+ raise RuntimeError(
+ f"destination does not match planned source: {destination.name}"
+ )
+
+
+def _execute_plan_with_directory_fds(
+ plan: OrganizationPlan,
+) -> OrganizationResult:
+ """Execute using sources and directories pinned before Linux mutation."""
+ root_fd = _open_source_directory_fd(plan.source_directory)
+ pinned_sources: dict[Path, _PinnedSource] = {}
+ category_fds: dict[FileCategory, int] = {}
+
+ try:
+ _verify_root_anchor_at(plan.source_directory, root_fd)
+
+ # Accept identity only from already-open descriptors. Keeping every
+ # descriptor alive prevents accepted inodes from being freed/reused.
+ pinned_sources = _pin_planned_sources_at(plan, root_fd=root_fd)
+
+ for category in sorted(
+ {action.category for action in plan.actions},
+ key=lambda item: item.value,
+ ):
+ category_fds[category] = _open_category_directory_fd(
+ root_fd,
+ category.value,
+ )
+
+ moved: list[Path] = []
+ for action in plan.actions:
+ pinned_source = pinned_sources[action.source]
+ _move_file_no_replace_at(
+ action.source.name,
+ action.destination.name,
+ source_directory_path=plan.source_directory,
+ source_directory_fd=root_fd,
+ destination_directory_fd=category_fds[action.category],
+ category_name=action.category.value,
+ source_fd=pinned_source.fd,
+ expected_identity=pinned_source.identity,
+ )
+ moved.append(action.destination)
+
+ _verify_root_anchor_at(plan.source_directory, root_fd)
+ return OrganizationResult(plan=plan, moved_files=tuple(moved))
+ finally:
+ for directory_fd in category_fds.values():
+ os.close(directory_fd)
+ for source in pinned_sources.values():
+ os.close(source.fd)
+ os.close(root_fd)
+
+
+def _execute_plan_portable(
+ plan: OrganizationPlan,
+ source_identities: dict[Path, _FileIdentity],
+) -> OrganizationResult:
+ """Execute on Windows, where os.rename refuses an existing destination."""
+ if os.name != "nt":
+ raise NotImplementedError(
+ "safe execution requires Linux renameat2 or Windows rename semantics"
+ )
+
+ for directory in sorted(
+ {action.destination.parent for action in plan.actions},
+ key=lambda path: (path.name.casefold(), path.name),
+ ):
+ directory.mkdir(exist_ok=True)
+ if _is_directory_redirect(directory) or not directory.is_dir():
+ raise ValueError(
+ f"category directory became unsafe during execution: {directory.name}"
+ )
+
+ moved: list[Path] = []
+ for action in plan.actions:
+ if _is_directory_redirect(action.destination.parent):
+ raise ValueError(
+ "category directory became unsafe during execution: "
+ f"{action.destination.parent.name}"
+ )
+ _move_file_no_replace(
+ action.source,
+ action.destination,
+ source_identities[action.source],
+ )
+ moved.append(action.destination)
+ return OrganizationResult(plan=plan, moved_files=tuple(moved))
+
+
+def execute_plan(plan: OrganizationPlan) -> OrganizationResult:
+ """Execute pathname intent under the strongest supported platform contract.
+
+ A plan does not freeze source-object identity between planning and execution.
+ The current regular file at each planned pathname is bound when execution
+ starts; changes after that binding are rejected under the platform contract.
+ """
+ if not isinstance(plan, OrganizationPlan):
+ raise TypeError("plan must be an OrganizationPlan")
+
+ _preflight_execution(plan)
+ if not plan.actions:
+ return OrganizationResult(plan=plan, moved_files=())
+
+ if _supports_secure_directory_fds():
+ return _execute_plan_with_directory_fds(plan)
+
+ source_identities = _capture_portable_source_identities(plan)
+ return _execute_plan_portable(plan, source_identities)
diff --git a/practical-projects/06-file-organizer/tests/conftest.py b/practical-projects/06-file-organizer/tests/conftest.py
new file mode 100644
index 0000000..6190fbf
--- /dev/null
+++ b/practical-projects/06-file-organizer/tests/conftest.py
@@ -0,0 +1,5 @@
+from pathlib import Path
+import sys
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(PROJECT_ROOT))
diff --git a/practical-projects/06-file-organizer/tests/test_atomic_move.py b/practical-projects/06-file-organizer/tests/test_atomic_move.py
new file mode 100644
index 0000000..fdff01b
--- /dev/null
+++ b/practical-projects/06-file-organizer/tests/test_atomic_move.py
@@ -0,0 +1,886 @@
+import os
+import stat
+from pathlib import Path
+
+import pytest
+
+import file_organizer
+from file_organizer import execute_plan, plan_organization
+
+
+def test_recovery_path_removed_during_fsync_is_not_reported_as_retained(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ root_fd = file_organizer._open_source_directory_fd(tmp_path)
+ source_fd = os.open(source, os.O_RDONLY)
+ original_fsync = os.fsync
+ recovery_unlinked = False
+
+ def unlink_recovery_during_fsync(fd: int) -> None:
+ nonlocal recovery_unlinked
+ original_fsync(fd)
+ if fd == source_fd or recovery_unlinked:
+ return
+ recovery_files = [
+ child
+ for child in tmp_path.iterdir()
+ if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ recovery_files[0].unlink()
+ recovery_unlinked = True
+
+ monkeypatch.setattr(file_organizer.os, "fsync", unlink_recovery_during_fsync)
+
+ try:
+ with pytest.raises(RuntimeError, match="recovery pathname changed during execution"):
+ file_organizer._recover_pinned_source_at(
+ source_fd,
+ source.name,
+ root_fd=root_fd,
+ )
+
+ assert recovery_unlinked
+ assert not any(
+ child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir()
+ )
+ os.lseek(source_fd, 0, os.SEEK_SET)
+ assert os.read(source_fd, 1024) == b"planned source"
+ finally:
+ os.close(source_fd)
+ os.close(root_fd)
+
+
+def test_recovery_path_removed_after_descriptor_close_is_not_reported_as_retained(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ root_fd = file_organizer._open_source_directory_fd(tmp_path)
+ source_fd = os.open(source, os.O_RDONLY)
+ original_close = os.close
+ recovery_unlinked = False
+
+ def unlink_recovery_after_descriptor_close(fd: int) -> None:
+ nonlocal recovery_unlinked
+ is_recovery_fd = (
+ fd not in {source_fd, root_fd}
+ and stat.S_ISREG(os.fstat(fd).st_mode)
+ )
+ original_close(fd)
+ if not is_recovery_fd or recovery_unlinked:
+ return
+ recovery_files = [
+ child
+ for child in tmp_path.iterdir()
+ if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ recovery_files[0].unlink()
+ recovery_unlinked = True
+
+ monkeypatch.setattr(file_organizer.os, "close", unlink_recovery_after_descriptor_close)
+
+ try:
+ with pytest.raises(RuntimeError, match="recovery pathname changed during execution"):
+ file_organizer._recover_pinned_source_at(
+ source_fd,
+ source.name,
+ root_fd=root_fd,
+ )
+
+ assert recovery_unlinked
+ assert not any(
+ child.name.startswith(".fo-recovery-") for child in tmp_path.iterdir()
+ )
+ os.lseek(source_fd, 0, os.SEEK_SET)
+ assert os.read(source_fd, 1024) == b"planned source"
+ finally:
+ os.close(source_fd)
+ os.close(root_fd)
+
+
+def test_recovery_syncs_root_directory_after_recovery_file(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ root_fd = file_organizer._open_source_directory_fd(tmp_path)
+ source_fd = os.open(source, os.O_RDONLY)
+ original_fsync = os.fsync
+ sync_order: list[str] = []
+
+ def tracking_fsync(fd: int) -> None:
+ if fd == root_fd:
+ sync_order.append("root")
+ elif stat.S_ISREG(os.fstat(fd).st_mode):
+ sync_order.append("recovery")
+ original_fsync(fd)
+
+ monkeypatch.setattr(file_organizer.os, "fsync", tracking_fsync)
+
+ try:
+ recovery_name = file_organizer._recover_pinned_source_at(
+ source_fd,
+ source.name,
+ root_fd=root_fd,
+ )
+
+ assert sync_order == ["recovery", "root"]
+ recovery_path = tmp_path / recovery_name
+ assert recovery_path.read_text(encoding="utf-8") == "planned source"
+ finally:
+ os.close(source_fd)
+ os.close(root_fd)
+
+
+def test_execute_plan_never_replaces_destination_created_after_preflight(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ destination.write_text("late destination", encoding="utf-8")
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(FileExistsError, match="during execution"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "planned source"
+ assert destination.read_text(encoding="utf-8") == "late destination"
+ assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir())
+
+
+def test_execute_plan_rejects_category_symlink_created_after_preflight(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+
+ outside = tmp_path.parent / f"{tmp_path.name}-outside"
+ outside.mkdir()
+ category = tmp_path / "documents"
+ original_mkdir = os.mkdir
+ raced = False
+
+ def racing_mkdir(
+ path: str | os.PathLike[str],
+ mode: int = 0o777,
+ *,
+ dir_fd: int | None = None,
+ ) -> None:
+ nonlocal raced
+ if path == "documents" and dir_fd is not None and not raced:
+ raced = True
+ category.symlink_to(outside, target_is_directory=True)
+ raise FileExistsError
+ original_mkdir(path, mode, dir_fd=dir_fd)
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(file_organizer.os, "mkdir", racing_mkdir)
+
+ with pytest.raises(ValueError, match="became unsafe during execution"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "planned source"
+ assert category.is_symlink()
+ assert list(outside.iterdir()) == []
+
+
+def test_execute_plan_rejects_source_symlink_replacement_during_mutation(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+
+ outside = tmp_path.parent / f"{tmp_path.name}-source-target.txt"
+ outside.write_text("target data", encoding="utf-8")
+ destination = tmp_path / "documents" / "notes.txt"
+ original_move = file_organizer._move_file_no_replace_at
+ raced = False
+
+ def racing_move(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_path: Path,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ category_name: str,
+ source_fd: int,
+ expected_identity: file_organizer._FileIdentity,
+ ) -> None:
+ nonlocal raced
+ if not raced:
+ raced = True
+ source.unlink()
+ source.symlink_to(outside)
+ original_move(
+ source_name,
+ destination_name,
+ source_directory_path=source_directory_path,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ category_name=category_name,
+ source_fd=source_fd,
+ expected_identity=expected_identity,
+ )
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(file_organizer, "_move_file_no_replace_at", racing_move)
+
+ with pytest.raises(FileNotFoundError, match="planned source data retained"):
+ execute_plan(plan)
+
+ assert source.is_symlink()
+ assert outside.read_text(encoding="utf-8") == "target data"
+ assert not destination.exists()
+
+
+def test_source_replacement_during_claim_is_preserved_without_unlink(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ raced = False
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal raced
+ if (
+ source_name == source.name
+ and source_directory_fd == destination_directory_fd
+ and not raced
+ ):
+ raced = True
+ source.unlink()
+ source.write_text("third-party replacement", encoding="utf-8")
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(FileNotFoundError, match="planned source data retained"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "third-party replacement"
+ assert not destination.exists()
+ recovery_files = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ assert recovery_files[0].read_text(encoding="utf-8") == "planned source"
+ retained = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")
+ ]
+ assert len(retained) == 1
+ assert retained[0].read_text(encoding="utf-8") == "third-party replacement"
+
+
+def test_category_rename_after_fd_open_never_reports_false_destination(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ category = tmp_path / "documents"
+ detached = tmp_path / "documents-detached"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ raced = False
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal raced
+ if source_directory_fd != destination_directory_fd and not raced:
+ raced = True
+ category.rename(detached)
+ category.mkdir()
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(ValueError, match="category directory moved during execution"):
+ execute_plan(plan)
+
+ assert not (category / "notes.txt").exists()
+ assert (detached / "notes.txt").read_text(encoding="utf-8") == "planned source"
+
+
+def test_source_root_rename_after_fd_open_never_reports_false_destination(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ source = workspace / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(workspace)
+ detached = tmp_path / "workspace-detached"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ raced = False
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal raced
+ if source_directory_fd != destination_directory_fd and not raced:
+ raced = True
+ workspace.rename(detached)
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(ValueError, match="source_directory moved during execution"):
+ execute_plan(plan)
+
+ assert not workspace.exists()
+ assert (detached / "documents" / "notes.txt").read_text(encoding="utf-8") == "planned source"
+
+
+def test_stage_name_is_fixed_length_for_long_source_names() -> None:
+ short = file_organizer._make_stage_name("a.txt")
+ long = file_organizer._make_stage_name(f"{'x' * 220}.txt")
+
+ assert short.startswith(".fo-stage-")
+ assert long.startswith(".fo-stage-")
+ assert len(short.encode()) == len(long.encode()) < 64
+
+
+def test_successful_execution_never_unlinks_a_staging_entry(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ original_unlink = os.unlink
+
+ def guarded_unlink(
+ path: str | os.PathLike[str],
+ *,
+ dir_fd: int | None = None,
+ ) -> None:
+ if os.fspath(path).startswith(".fo-stage-"):
+ raise AssertionError("staging entries must not be unlinked")
+ original_unlink(path, dir_fd=dir_fd)
+
+ monkeypatch.setattr(file_organizer.os, "unlink", guarded_unlink)
+
+ result = execute_plan(plan)
+
+ assert result.moved_count == 1
+ assert not source.exists()
+ assert (tmp_path / "documents" / "notes.txt").read_text(encoding="utf-8") == "planned source"
+
+
+def test_source_pin_uses_nonblocking_open_and_rejects_late_fifo(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+ if not hasattr(os, "mkfifo") or not hasattr(os, "O_NONBLOCK"):
+ pytest.skip("FIFO or O_NONBLOCK is unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ original_open = os.open
+ raced = False
+ observed_flags: list[int] = []
+
+ def racing_open(
+ path: str | os.PathLike[str],
+ flags: int,
+ mode: int = 0o777,
+ *,
+ dir_fd: int | None = None,
+ ) -> int:
+ nonlocal raced
+ if path == source.name and dir_fd is not None and not raced:
+ raced = True
+ observed_flags.append(flags)
+ source.unlink()
+ os.mkfifo(source)
+ return original_open(path, flags, mode, dir_fd=dir_fd)
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(file_organizer.os, "open", racing_open)
+
+ with pytest.raises(FileNotFoundError, match="regular file|changed during execution"):
+ execute_plan(plan)
+
+ assert observed_flags
+ assert observed_flags[0] & os.O_NONBLOCK
+ assert stat.S_ISFIFO(source.lstat().st_mode)
+ assert not (tmp_path / "documents" / "notes.txt").exists()
+
+
+def test_execute_plan_rechecks_late_casefold_collision_before_commit(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "Report.TXT"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ category = tmp_path / "documents"
+ late_destination = category / "report.txt"
+ exact_destination = category / "Report.TXT"
+ original_claim = file_organizer._claim_source_at
+ raced = False
+
+ def racing_claim(
+ source_name: str,
+ *,
+ root_fd: int,
+ expected_identity: file_organizer._FileIdentity,
+ ) -> str:
+ nonlocal raced
+ stage_name = original_claim(
+ source_name,
+ root_fd=root_fd,
+ expected_identity=expected_identity,
+ )
+ if not raced:
+ raced = True
+ late_destination.write_text("late casefold collision", encoding="utf-8")
+ return stage_name
+
+ monkeypatch.setattr(file_organizer, "_claim_source_at", racing_claim)
+
+ with pytest.raises(FileExistsError, match="case-insensitive destination"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "planned source"
+ assert late_destination.read_text(encoding="utf-8") == "late casefold collision"
+ assert not exact_destination.exists()
+ assert any(child.name.startswith(".fo-stage-") for child in tmp_path.iterdir())
+
+
+def test_staging_replacement_before_final_rename_preserves_pinned_source_data(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ raced = False
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal raced
+ if source_name.startswith(".fo-stage-") and not raced:
+ raced = True
+ stage = tmp_path / source_name
+ assert stage.name.startswith(".fo-stage-")
+ stage.unlink()
+ stage.write_text("third-party replacement", encoding="utf-8")
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(RuntimeError, match="planned source data retained"):
+ execute_plan(plan)
+
+ assert destination.read_text(encoding="utf-8") == "third-party replacement"
+ recovery_files = [
+ child
+ for child in tmp_path.iterdir()
+ if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ assert recovery_files[0].read_text(encoding="utf-8") == "planned source"
+ assert not source.exists()
+
+
+def test_failed_final_rename_stage_changes_during_restore_recovers_pinned_source_data(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ original_link = os.link
+ final_rename_failed = False
+ restore_raced = False
+
+ def failing_final_rename(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal final_rename_failed
+ if source_name.startswith(".fo-stage-") and not final_rename_failed:
+ final_rename_failed = True
+ destination.write_text("late destination", encoding="utf-8")
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ def racing_link(
+ src: str | os.PathLike[str],
+ dst: str | os.PathLike[str],
+ *,
+ src_dir_fd: int | None = None,
+ dst_dir_fd: int | None = None,
+ follow_symlinks: bool = True,
+ ) -> None:
+ nonlocal restore_raced
+ if (
+ os.fspath(src).startswith(".fo-stage-")
+ and os.fspath(dst) == source.name
+ and src_dir_fd is not None
+ and dst_dir_fd is not None
+ and not restore_raced
+ ):
+ restore_raced = True
+ stage = tmp_path / os.fspath(src)
+ stage.unlink()
+ stage.write_text("third-party stage", encoding="utf-8")
+ original_link(
+ src,
+ dst,
+ src_dir_fd=src_dir_fd,
+ dst_dir_fd=dst_dir_fd,
+ follow_symlinks=follow_symlinks,
+ )
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(file_organizer, "_rename_no_replace_at", failing_final_rename)
+ monkeypatch.setattr(file_organizer.os, "link", racing_link)
+
+ with pytest.raises(FileExistsError, match="destination appeared during execution"):
+ execute_plan(plan)
+
+ assert final_rename_failed
+ assert restore_raced
+ assert destination.read_text(encoding="utf-8") == "late destination"
+ assert source.read_text(encoding="utf-8") == "third-party stage"
+ stage_files = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")
+ ]
+ assert len(stage_files) == 1
+ assert stage_files[0].read_text(encoding="utf-8") == "third-party stage"
+ recovery_files = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ assert recovery_files[0].read_text(encoding="utf-8") == "planned source"
+
+
+def test_failed_final_rename_after_stage_replacement_recovers_pinned_source_data(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+ original_rename_no_replace = file_organizer._rename_no_replace_at
+ raced = False
+
+ def racing_rename_no_replace(
+ source_name: str,
+ destination_name: str,
+ *,
+ source_directory_fd: int,
+ destination_directory_fd: int,
+ ) -> None:
+ nonlocal raced
+ if source_name.startswith(".fo-stage-") and not raced:
+ raced = True
+ stage = tmp_path / source_name
+ stage.unlink()
+ stage.write_text("third-party stage", encoding="utf-8")
+ destination.write_text("late destination", encoding="utf-8")
+ original_rename_no_replace(
+ source_name,
+ destination_name,
+ source_directory_fd=source_directory_fd,
+ destination_directory_fd=destination_directory_fd,
+ )
+
+ monkeypatch.setattr(
+ file_organizer,
+ "_rename_no_replace_at",
+ racing_rename_no_replace,
+ )
+
+ with pytest.raises(FileExistsError, match="destination appeared during execution"):
+ execute_plan(plan)
+
+ assert destination.read_text(encoding="utf-8") == "late destination"
+ assert not source.exists()
+ stage_files = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-stage-")
+ ]
+ assert len(stage_files) == 1
+ assert stage_files[0].read_text(encoding="utf-8") == "third-party stage"
+ recovery_files = [
+ child
+ for child in tmp_path.iterdir()
+ if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ assert recovery_files[0].read_text(encoding="utf-8") == "planned source"
+
+
+def test_secure_execution_reports_readability_precondition_before_categories(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ original_open = os.open
+
+ def permission_denied_open(
+ path: str | os.PathLike[str],
+ flags: int,
+ mode: int = 0o777,
+ *,
+ dir_fd: int | None = None,
+ ) -> int:
+ if path == source.name and dir_fd is not None:
+ raise PermissionError("simulated unreadable source")
+ return original_open(path, flags, mode, dir_fd=dir_fd)
+
+ monkeypatch.setattr(file_organizer, "_supports_secure_directory_fds", lambda: True)
+ monkeypatch.setattr(file_organizer.os, "open", permission_denied_open)
+
+ with pytest.raises(PermissionError, match="must be readable for safe execution"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "planned source"
+ assert not (tmp_path / "documents").exists()
+
+
+def test_category_fd_is_closed_when_anchor_verification_fails(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ root_fd = file_organizer._open_source_directory_fd(tmp_path)
+ original_open = os.open
+ original_close = os.close
+ opened_category_fd: int | None = None
+ closed_fds: list[int] = []
+
+ def tracking_open(
+ path: str | os.PathLike[str],
+ flags: int,
+ mode: int = 0o777,
+ *,
+ dir_fd: int | None = None,
+ ) -> int:
+ nonlocal opened_category_fd
+ fd = original_open(path, flags, mode, dir_fd=dir_fd)
+ if path == "documents" and dir_fd == root_fd:
+ opened_category_fd = fd
+ return fd
+
+ def tracking_close(fd: int) -> None:
+ closed_fds.append(fd)
+ original_close(fd)
+
+ def failing_anchor(**_: object) -> None:
+ raise ValueError("simulated category anchor race")
+
+ monkeypatch.setattr(file_organizer.os, "open", tracking_open)
+ monkeypatch.setattr(file_organizer.os, "close", tracking_close)
+ monkeypatch.setattr(file_organizer, "_verify_category_anchor_at", failing_anchor)
+
+ try:
+ with pytest.raises(ValueError, match="simulated category anchor race"):
+ file_organizer._open_category_directory_fd(root_fd, "documents")
+ finally:
+ original_close(root_fd)
+
+ assert opened_category_fd is not None
+ assert opened_category_fd in closed_fds
+
+
+def test_source_identity_is_accepted_only_after_descriptor_pin(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ if not file_organizer._supports_secure_directory_fds():
+ pytest.skip("secure directory descriptors are unavailable on this platform")
+
+ source = tmp_path / "notes.txt"
+ source.write_text("planned source", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ destination = tmp_path / "documents" / "notes.txt"
+ original_pin = file_organizer._pin_planned_sources_at
+ raced = False
+
+ def racing_pin(
+ plan_value: file_organizer.OrganizationPlan,
+ *,
+ root_fd: int,
+ ) -> dict[Path, file_organizer._PinnedSource]:
+ nonlocal raced
+ pinned = original_pin(plan_value, root_fd=root_fd)
+ if not raced:
+ raced = True
+ source.unlink()
+ source.write_text("third-party replacement", encoding="utf-8")
+ return pinned
+
+ monkeypatch.setattr(file_organizer, "_pin_planned_sources_at", racing_pin)
+
+ with pytest.raises(FileNotFoundError, match="planned source data retained"):
+ execute_plan(plan)
+
+ assert source.read_text(encoding="utf-8") == "third-party replacement"
+ assert not destination.exists()
+ recovery_files = [
+ child for child in tmp_path.iterdir() if child.name.startswith(".fo-recovery-")
+ ]
+ assert len(recovery_files) == 1
+ assert recovery_files[0].read_text(encoding="utf-8") == "planned source"
diff --git a/practical-projects/06-file-organizer/tests/test_file_organizer.py b/practical-projects/06-file-organizer/tests/test_file_organizer.py
new file mode 100644
index 0000000..b53ea37
--- /dev/null
+++ b/practical-projects/06-file-organizer/tests/test_file_organizer.py
@@ -0,0 +1,528 @@
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+import file_organizer
+from file_organizer import (
+ CollisionPolicy,
+ FileCategory,
+ MoveAction,
+ OrganizationPlan,
+ OrganizationResult,
+ classify_path,
+ discover_files,
+ execute_plan,
+ plan_organization,
+)
+
+
+@pytest.mark.parametrize(
+ ("name", "expected"),
+ [
+ ("notes.txt", FileCategory.DOCUMENTS),
+ ("README.MD", FileCategory.DOCUMENTS),
+ ("report.pdf", FileCategory.DOCUMENTS),
+ ("records.csv", FileCategory.DATA),
+ ("payload.JSON", FileCategory.DATA),
+ ("sheet.xlsx", FileCategory.DATA),
+ ("photo.png", FileCategory.IMAGES),
+ ("photo.JPEG", FileCategory.IMAGES),
+ ("vector.svg", FileCategory.IMAGES),
+ ("backup.zip", FileCategory.ARCHIVES),
+ ("backup.tar.gz", FileCategory.ARCHIVES),
+ ("backup.TAR.XZ", FileCategory.ARCHIVES),
+ ("script.py", FileCategory.OTHER),
+ ("LICENSE", FileCategory.OTHER),
+ ],
+)
+def test_classify_path_by_suffix(name: str, expected: FileCategory) -> None:
+ assert classify_path(name) is expected
+
+
+@pytest.mark.parametrize("value", [None, 42, True, 3.14])
+def test_classify_path_rejects_non_path_like_values(value: object) -> None:
+ with pytest.raises(TypeError, match="path-like"):
+ classify_path(value) # type: ignore[arg-type]
+
+
+def test_discover_files_returns_direct_regular_files_in_deterministic_order(tmp_path: Path) -> None:
+ (tmp_path / "b.txt").write_text("b", encoding="utf-8")
+ (tmp_path / "A.txt").write_text("a", encoding="utf-8")
+ (tmp_path / "nested").mkdir()
+ (tmp_path / "nested" / "ignored.txt").write_text("x", encoding="utf-8")
+
+ files = discover_files(tmp_path)
+
+ assert tuple(path.name for path in files) == ("A.txt", "b.txt")
+ assert all(path.is_absolute() for path in files)
+
+
+def test_discover_files_ignores_symlinks(tmp_path: Path) -> None:
+ target = tmp_path / "target.txt"
+ target.write_text("x", encoding="utf-8")
+ link = tmp_path / "linked.txt"
+ try:
+ link.symlink_to(target)
+ except OSError:
+ pytest.skip("symlinks are not available in this environment")
+
+ assert tuple(path.name for path in discover_files(tmp_path)) == ("target.txt",)
+
+
+def test_discover_files_accepts_string_directory(tmp_path: Path) -> None:
+ (tmp_path / "a.txt").write_text("x", encoding="utf-8")
+ assert discover_files(str(tmp_path))[0].name == "a.txt"
+
+
+def test_discover_files_rejects_missing_directory(tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ discover_files(tmp_path / "missing")
+
+
+def test_discover_files_rejects_regular_file(tmp_path: Path) -> None:
+ source = tmp_path / "file.txt"
+ source.write_text("x", encoding="utf-8")
+ with pytest.raises(NotADirectoryError):
+ discover_files(source)
+
+
+def test_discover_files_rejects_symlink_source_directory(tmp_path: Path) -> None:
+ real = tmp_path / "real"
+ real.mkdir()
+ link = tmp_path / "link"
+ try:
+ link.symlink_to(real, target_is_directory=True)
+ except OSError:
+ pytest.skip("symlinks are not available in this environment")
+
+ with pytest.raises(ValueError, match="cannot be a symlink"):
+ discover_files(link)
+
+
+def test_plan_organization_builds_expected_categories_without_mutating(tmp_path: Path) -> None:
+ for name in ("notes.txt", "rows.csv", "image.png", "backup.zip", "script.py"):
+ (tmp_path / name).write_text("x", encoding="utf-8")
+
+ plan = plan_organization(tmp_path)
+
+ assert plan.planned_count == 5
+ assert plan.skipped_collision_count == 0
+ assert tuple(action.category for action in plan.actions) == (
+ FileCategory.ARCHIVES,
+ FileCategory.IMAGES,
+ FileCategory.DOCUMENTS,
+ FileCategory.DATA,
+ FileCategory.OTHER,
+ )
+ assert all(action.source.exists() for action in plan.actions)
+ assert not any((tmp_path / category.value).exists() for category in FileCategory)
+
+
+def test_plan_organization_preserves_filenames(tmp_path: Path) -> None:
+ source = tmp_path / "Quarterly Report.PDF"
+ source.write_text("x", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ action = plan.actions[0]
+ assert action.destination.name == source.name
+ assert action.destination.parent.name == "documents"
+
+
+def test_plan_organization_reports_ignored_symlinks(tmp_path: Path) -> None:
+ target = tmp_path / "target.txt"
+ target.write_text("x", encoding="utf-8")
+ link = tmp_path / "linked.txt"
+ try:
+ link.symlink_to(target)
+ except OSError:
+ pytest.skip("symlinks are not available in this environment")
+
+ plan = plan_organization(tmp_path)
+ assert plan.ignored_symlink_count == 1
+ assert plan.ignored_symlinks[0].name == "linked.txt"
+ assert tuple(action.source.name for action in plan.actions) == ("target.txt",)
+
+
+def test_plan_organization_rejects_raw_collision_policy(tmp_path: Path) -> None:
+ with pytest.raises(TypeError, match="CollisionPolicy"):
+ plan_organization(tmp_path, collision_policy="skip") # type: ignore[arg-type]
+
+
+def test_plan_organization_errors_on_existing_destination(tmp_path: Path) -> None:
+ (tmp_path / "report.txt").write_text("new", encoding="utf-8")
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "report.txt").write_text("old", encoding="utf-8")
+
+ with pytest.raises(FileExistsError, match="report.txt"):
+ plan_organization(tmp_path)
+
+
+def test_plan_organization_detects_casefold_collision(tmp_path: Path) -> None:
+ (tmp_path / "Report.TXT").write_text("new", encoding="utf-8")
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "report.txt").write_text("old", encoding="utf-8")
+
+ with pytest.raises(FileExistsError):
+ plan_organization(tmp_path)
+
+
+def test_plan_organization_can_skip_existing_destination(tmp_path: Path) -> None:
+ (tmp_path / "report.txt").write_text("new", encoding="utf-8")
+ (tmp_path / "data.csv").write_text("data", encoding="utf-8")
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "report.txt").write_text("old", encoding="utf-8")
+
+ plan = plan_organization(tmp_path, collision_policy=CollisionPolicy.SKIP)
+
+ assert plan.planned_count == 1
+ assert plan.skipped_collision_count == 1
+ assert plan.skipped_collisions[0].name == "report.txt"
+ assert plan.actions[0].source.name == "data.csv"
+
+
+def test_plan_organization_rejects_category_path_that_is_a_file(tmp_path: Path) -> None:
+ (tmp_path / "documents").write_text("not a directory", encoding="utf-8")
+ with pytest.raises(NotADirectoryError, match="documents"):
+ plan_organization(tmp_path)
+
+
+def test_plan_organization_rejects_category_directory_symlink(tmp_path: Path) -> None:
+ real = tmp_path / "real-documents"
+ real.mkdir()
+ link = tmp_path / "documents"
+ try:
+ link.symlink_to(real, target_is_directory=True)
+ except OSError:
+ pytest.skip("symlinks are not available in this environment")
+
+ with pytest.raises(ValueError, match="category directory cannot be a symlink"):
+ plan_organization(tmp_path)
+
+
+def test_plan_organization_rejects_category_directory_junction(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ source = tmp_path / "notes.txt"
+ source.write_text("x", encoding="utf-8")
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ original_is_junction = getattr(Path, "is_junction", lambda self: False)
+
+ def fake_is_junction(path: Path) -> bool:
+ return path == documents or original_is_junction(path)
+
+ monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)
+
+ with pytest.raises(ValueError, match="symlink or junction"):
+ plan_organization(tmp_path)
+
+ assert source.read_text(encoding="utf-8") == "x"
+
+
+def test_windows_portable_execution_rejects_late_category_junction(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ source = tmp_path / "notes.txt"
+ source.write_text("planned", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ documents = tmp_path / "documents"
+ identities = {source.resolve(): file_organizer._capture_path_identity(source.resolve())}
+ original_is_junction = getattr(Path, "is_junction", lambda self: False)
+
+ def fake_is_junction(path: Path) -> bool:
+ return path == documents or original_is_junction(path)
+
+ monkeypatch.setattr(Path, "is_junction", fake_is_junction, raising=False)
+ monkeypatch.setattr(file_organizer.os, "name", "nt")
+
+ with pytest.raises(ValueError, match="category directory became unsafe"):
+ file_organizer._execute_plan_portable(plan, identities)
+
+ assert source.read_text(encoding="utf-8") == "planned"
+ assert not (documents / "notes.txt").exists()
+
+
+def test_plan_empty_directory_is_valid(tmp_path: Path) -> None:
+ plan = plan_organization(tmp_path)
+ assert plan.actions == ()
+ assert plan.skipped_collisions == ()
+ assert plan.ignored_symlinks == ()
+
+
+def test_move_action_validates_category_type(tmp_path: Path) -> None:
+ source = (tmp_path / "a.txt").absolute()
+ destination = (tmp_path / "documents" / "a.txt").absolute()
+ with pytest.raises(TypeError, match="FileCategory"):
+ MoveAction(source, destination, "documents") # type: ignore[arg-type]
+
+
+def test_move_action_requires_absolute_paths(tmp_path: Path) -> None:
+ with pytest.raises(ValueError, match="absolute"):
+ MoveAction(Path("a.txt"), Path("documents/a.txt"), FileCategory.DOCUMENTS)
+
+
+def test_move_action_requires_preserved_filename(tmp_path: Path) -> None:
+ source = (tmp_path / "a.txt").absolute()
+ destination = (tmp_path / "documents" / "b.txt").absolute()
+ with pytest.raises(ValueError, match="preserve"):
+ MoveAction(source, destination, FileCategory.DOCUMENTS)
+
+
+def test_move_action_requires_category_directory(tmp_path: Path) -> None:
+ source = (tmp_path / "a.txt").absolute()
+ destination = (tmp_path / "data" / "a.txt").absolute()
+ with pytest.raises(ValueError, match="category"):
+ MoveAction(source, destination, FileCategory.DOCUMENTS)
+
+
+def test_organization_plan_requires_sorted_actions(tmp_path: Path) -> None:
+ root = tmp_path.resolve()
+ first = MoveAction(root / "a.txt", root / "documents" / "a.txt", FileCategory.DOCUMENTS)
+ second = MoveAction(root / "b.txt", root / "documents" / "b.txt", FileCategory.DOCUMENTS)
+ with pytest.raises(ValueError, match="sorted"):
+ OrganizationPlan(root, (second, first), (), ())
+
+
+def test_organization_plan_requires_sources_inside_root(tmp_path: Path) -> None:
+ root = tmp_path.resolve()
+ outside = root.parent / "outside.txt"
+ action = MoveAction(outside, root / "documents" / "outside.txt", FileCategory.DOCUMENTS)
+ with pytest.raises(ValueError, match="direct children"):
+ OrganizationPlan(root, (action,), (), ())
+
+
+def test_organization_plan_requires_destinations_inside_root(tmp_path: Path) -> None:
+ root = tmp_path.resolve()
+ source = root / "a.txt"
+ destination = root.parent / "documents" / "a.txt"
+ action = MoveAction(source, destination, FileCategory.DOCUMENTS)
+ with pytest.raises(ValueError, match="category folders inside"):
+ OrganizationPlan(root, (action,), (), ())
+
+
+def test_execute_plan_moves_files_and_creates_only_needed_directories(tmp_path: Path) -> None:
+ (tmp_path / "notes.txt").write_text("notes", encoding="utf-8")
+ (tmp_path / "rows.csv").write_text("a,b\n1,2\n", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+
+ result = execute_plan(plan)
+
+ assert result.moved_count == 2
+ assert (tmp_path / "documents" / "notes.txt").read_text(encoding="utf-8") == "notes"
+ assert (tmp_path / "data" / "rows.csv").exists()
+ assert not (tmp_path / "images").exists()
+ assert not (tmp_path / "archives").exists()
+ assert not (tmp_path / "other").exists()
+
+
+def test_execute_plan_preserves_skipped_collision_source(tmp_path: Path) -> None:
+ source = tmp_path / "report.txt"
+ source.write_text("new", encoding="utf-8")
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "report.txt").write_text("old", encoding="utf-8")
+ plan = plan_organization(tmp_path, collision_policy=CollisionPolicy.SKIP)
+
+ result = execute_plan(plan)
+
+ assert result.moved_count == 0
+ assert source.read_text(encoding="utf-8") == "new"
+ assert (documents / "report.txt").read_text(encoding="utf-8") == "old"
+
+
+def test_execute_plan_rejects_non_plan() -> None:
+ with pytest.raises(TypeError, match="OrganizationPlan"):
+ execute_plan(object()) # type: ignore[arg-type]
+
+
+def test_execute_plan_preflights_missing_source_before_mutation(tmp_path: Path) -> None:
+ first = tmp_path / "a.txt"
+ second = tmp_path / "b.csv"
+ first.write_text("a", encoding="utf-8")
+ second.write_text("b", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ second.unlink()
+
+ with pytest.raises(FileNotFoundError):
+ execute_plan(plan)
+
+ assert first.exists()
+ assert not (tmp_path / "documents").exists()
+ assert not (tmp_path / "data").exists()
+
+
+def test_execute_plan_binds_current_source_at_execution_start(tmp_path: Path) -> None:
+ source = tmp_path / "notes.txt"
+ source.write_text("observed during planning", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+
+ source.unlink()
+ source.write_text("current at execution start", encoding="utf-8")
+
+ result = execute_plan(plan)
+
+ destination = tmp_path / "documents" / "notes.txt"
+ assert result.moved_files == (destination,)
+ assert destination.read_text(encoding="utf-8") == "current at execution start"
+ assert not source.exists()
+
+
+def test_execute_plan_preflights_new_exact_collision_before_mutation(tmp_path: Path) -> None:
+ first = tmp_path / "a.txt"
+ second = tmp_path / "b.csv"
+ first.write_text("a", encoding="utf-8")
+ second.write_text("b", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "a.txt").write_text("existing", encoding="utf-8")
+
+ with pytest.raises(FileExistsError, match="appeared after planning"):
+ execute_plan(plan)
+
+ assert first.exists()
+ assert second.exists()
+ assert not (tmp_path / "data").exists()
+
+
+def test_execute_plan_preflights_new_casefold_collision_before_mutation(tmp_path: Path) -> None:
+ source = tmp_path / "Report.TXT"
+ source.write_text("new", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "report.txt").write_text("old", encoding="utf-8")
+
+ with pytest.raises(FileExistsError):
+ execute_plan(plan)
+ assert source.exists()
+
+
+def test_execute_plan_rejects_category_path_replaced_by_file(tmp_path: Path) -> None:
+ source = tmp_path / "a.txt"
+ source.write_text("x", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ (tmp_path / "documents").write_text("block", encoding="utf-8")
+
+ with pytest.raises(NotADirectoryError):
+ execute_plan(plan)
+ assert source.exists()
+
+
+def test_execute_plan_rejects_source_replaced_by_symlink(tmp_path: Path) -> None:
+ source = tmp_path / "a.txt"
+ source.write_text("x", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ source.unlink()
+ target = tmp_path / "target.txt"
+ target.write_text("target", encoding="utf-8")
+ try:
+ source.symlink_to(target)
+ except OSError:
+ pytest.skip("symlinks are not available in this environment")
+
+ with pytest.raises(FileNotFoundError):
+ execute_plan(plan)
+ assert target.read_text(encoding="utf-8") == "target"
+
+
+def test_execute_empty_plan_creates_nothing(tmp_path: Path) -> None:
+ plan = plan_organization(tmp_path)
+ result = execute_plan(plan)
+ assert result.moved_files == ()
+ assert list(tmp_path.iterdir()) == []
+
+
+def test_organization_result_requires_exact_destinations(tmp_path: Path) -> None:
+ source = tmp_path / "a.txt"
+ source.write_text("x", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ with pytest.raises(ValueError, match="match"):
+ OrganizationResult(plan, ())
+
+
+def test_organization_result_rejects_non_plan(tmp_path: Path) -> None:
+ with pytest.raises(TypeError, match="plan"):
+ OrganizationResult(object(), ()) # type: ignore[arg-type]
+
+
+def test_plan_properties_reflect_counts(tmp_path: Path) -> None:
+ (tmp_path / "a.txt").write_text("x", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+ assert plan.planned_count == len(plan.actions) == 1
+ assert plan.skipped_collision_count == 0
+ assert plan.ignored_symlink_count == 0
+
+
+def test_classification_does_not_require_file_to_exist() -> None:
+ assert classify_path("fictional/path/report.csv") is FileCategory.DATA
+
+
+def test_plan_does_not_recurse_into_existing_category_directories(tmp_path: Path) -> None:
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ (documents / "already.txt").write_text("x", encoding="utf-8")
+ (tmp_path / "new.txt").write_text("y", encoding="utf-8")
+
+ plan = plan_organization(tmp_path)
+
+ assert tuple(action.source.name for action in plan.actions) == ("new.txt",)
+
+
+def test_execute_plan_keeps_existing_unrelated_category_files(tmp_path: Path) -> None:
+ documents = tmp_path / "documents"
+ documents.mkdir()
+ existing = documents / "existing.txt"
+ existing.write_text("old", encoding="utf-8")
+ (tmp_path / "new.txt").write_text("new", encoding="utf-8")
+ plan = plan_organization(tmp_path)
+
+ execute_plan(plan)
+
+ assert existing.read_text(encoding="utf-8") == "old"
+ assert (documents / "new.txt").read_text(encoding="utf-8") == "new"
+
+
+def test_internal_recovery_artifacts_are_reserved_from_future_plans(tmp_path: Path) -> None:
+ (tmp_path / ".fo-stage-deadbeef").write_text("stage", encoding="utf-8")
+ (tmp_path / ".fo-recovery-deadbeef").write_text("recovery", encoding="utf-8")
+ (tmp_path / "notes.txt").write_text("user", encoding="utf-8")
+
+ plan = plan_organization(tmp_path)
+
+ assert tuple(action.source.name for action in plan.actions) == ("notes.txt",)
+
+
+@pytest.mark.skipif(os.name != "nt", reason="requires Windows NTFS junction semantics")
+def test_windows_real_source_and_category_junctions_are_rejected(tmp_path: Path) -> None:
+ outside = tmp_path / "outside"
+ outside.mkdir()
+
+ source_junction = tmp_path / "workspace-link"
+ subprocess.run(
+ ["cmd", "/c", "mklink", "/J", str(source_junction), str(outside)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ with pytest.raises(ValueError, match="symlink or junction"):
+ plan_organization(source_junction)
+
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ (workspace / "notes.txt").write_text("x", encoding="utf-8")
+ category_junction = workspace / "documents"
+ subprocess.run(
+ ["cmd", "/c", "mklink", "/J", str(category_junction), str(outside)],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ with pytest.raises(ValueError, match="symlink or junction"):
+ plan_organization(workspace)
diff --git a/practical-projects/README.es.md b/practical-projects/README.es.md
index cc463d6..550e51e 100644
--- a/practical-projects/README.es.md
+++ b/practical-projects/README.es.md
@@ -21,7 +21,7 @@ La Fase 10 combina conceptos de las fases anteriores en flujos completos y compr
3. ✅ [Registro de Usuarios](03-user-registration/README.es.md)
4. ✅ [Analizador CSV](04-csv-analyzer/README.es.md)
5. ✅ [Generador de Informes](05-report-generator/README.es.md)
-6. ⏳ Organizador de Archivos
+6. 🚧 [Organizador de Archivos](06-file-organizer/README.es.md)
7. ⏳ Flujo Ficticio de Conciliación
8. ⏳ Flujo Simulado de Automatización
@@ -38,4 +38,4 @@ Cada proyecto debe incluir:
- desafíos de extensión;
- discusión de portafolio.
-El Proyecto 01 establece el patrón de integración con registros monetarios validados y persistencia. El Proyecto 02 amplía ese patrón con políticas de calificación configurables, agregación ponderada exacta, estados parcial/final explícitos, informe estructurado y cobertura pytest centrada en límites. El Proyecto 03 añade datos de identidad canónicos, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV exactos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores duplicados, filtros deterministas y agregación sin ocultar la ingestión detrás de pandas. El Proyecto 05 transforma registros operativos validados en artefactos de informe deterministas con ventanas de fecha explícitas, métricas de resumen exactas, renderizadores TXT/Markdown y escritura UTF-8, manteniendo separadas la agregación, la presentación y la persistencia.
+El Proyecto 01 establece el patrón de integración con registros monetarios validados y persistencia. El Proyecto 02 amplía ese patrón con políticas de calificación configurables, agregación ponderada exacta, estados parcial/final explícitos, informe estructurado y cobertura pytest centrada en límites. El Proyecto 03 añade datos de identidad canónicos, prevención de duplicados, índices secundarios de lookup, actualizaciones seguras de campos indexados y transiciones explícitas del ciclo de vida sin introducir autenticación. El Proyecto 04 añade schemas CSV exactos, conversión tipada, separación entre fallos estructurales y fallos de fila, parsing con éxito parcial, identificadores duplicados, filtros deterministas y agregación sin ocultar la ingestión detrás de pandas. El Proyecto 05 transforma registros operativos validados en artefactos de informe deterministas con ventanas de fecha explícitas, métricas de resumen exactas, renderizadores TXT/Markdown y escritura UTF-8, manteniendo separadas la agregación, la presentación y la persistencia. El Proyecto 06 añade descubrimiento superficial del filesystem, clasificación por sufijo, planificación inmutable de movimientos, políticas explícitas de colisión, fronteras de symlink, revalidación en el momento de ejecución y protección exacta no-replace del destino antes de organizar los archivos en carpetas por categoría.
diff --git a/practical-projects/README.md b/practical-projects/README.md
index c5b273e..c989311 100644
--- a/practical-projects/README.md
+++ b/practical-projects/README.md
@@ -21,7 +21,7 @@ Phase 10 combines concepts from the previous phases into complete, testable work
3. ✅ [User Registration](03-user-registration/README.md)
4. ✅ [CSV Analyzer](04-csv-analyzer/README.md)
5. ✅ [Report Generator](05-report-generator/README.md)
-6. ⏳ File Organizer
+6. 🚧 [File Organizer](06-file-organizer/README.md)
7. ⏳ Fictional Reconciliation Workflow
8. ⏳ Simulated Automation Flow
@@ -38,4 +38,4 @@ Each project should include:
- extension challenges;
- portfolio discussion.
-Project 01 establishes the integration pattern with validated monetary records and persistence. Project 02 extends it with configurable grading policies, exact weighted aggregation, explicit partial/final states, structured reporting, and boundary-focused pytest coverage. Project 03 adds canonical identity-like data, duplicate prevention, secondary lookup indexes, safe indexed-field updates, and explicit user lifecycle transitions without introducing authentication. Project 04 adds exact CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate row identifiers, deterministic filtering, and aggregation without hiding ingestion behavior behind pandas. Project 05 turns validated operational records into deterministic reporting artifacts with explicit date windows, exact summary metrics, TXT/Markdown renderers, and UTF-8 file output while keeping aggregation, presentation, and persistence separate.
+Project 01 establishes the integration pattern with validated monetary records and persistence. Project 02 extends it with configurable grading policies, exact weighted aggregation, explicit partial/final states, structured reporting, and boundary-focused pytest coverage. Project 03 adds canonical identity-like data, duplicate prevention, secondary lookup indexes, safe indexed-field updates, and explicit user lifecycle transitions without introducing authentication. Project 04 adds exact CSV schemas, typed conversion, structural-versus-row failure handling, partial-success parsing, duplicate row identifiers, deterministic filtering, and aggregation without hiding ingestion behavior behind pandas. Project 05 turns validated operational records into deterministic reporting artifacts with explicit date windows, exact summary metrics, TXT/Markdown renderers, and UTF-8 file output while keeping aggregation, presentation, and persistence separate. Project 06 adds shallow filesystem discovery, suffix classification, immutable move planning, explicit collision policies, symlink boundaries, execution-time revalidation, and exact no-replace destination protection before files are organized into category folders.
diff --git a/practical-projects/README.pt-BR.md b/practical-projects/README.pt-BR.md
index 02e03e0..bcdfa79 100644
--- a/practical-projects/README.pt-BR.md
+++ b/practical-projects/README.pt-BR.md
@@ -21,7 +21,7 @@ A Fase 10 combina conceitos das fases anteriores em fluxos completos e testávei
3. ✅ [Cadastro de Usuários](03-user-registration/README.pt-BR.md)
4. ✅ [Analisador CSV](04-csv-analyzer/README.pt-BR.md)
5. ✅ [Gerador de Relatórios](05-report-generator/README.pt-BR.md)
-6. ⏳ Organizador de Arquivos
+6. 🚧 [Organizador de Arquivos](06-file-organizer/README.pt-BR.md)
7. ⏳ Fluxo Fictício de Conciliação
8. ⏳ Fluxo Simulado de Automação
@@ -38,4 +38,4 @@ Cada projeto deve incluir:
- desafios de extensão;
- discussão de portfólio.
-O Projeto 01 estabelece o padrão de integração com registros monetários validados e persistência. O Projeto 02 amplia esse padrão com políticas de notas configuráveis, agregação ponderada exata, estados parcial/final explícitos, relatório estruturado e cobertura pytest focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV exatos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores duplicados, filtros determinísticos e agregação sem esconder a ingestão atrás de pandas. O Projeto 05 transforma registros operacionais validados em artefatos de relatório determinísticos com janelas explícitas de datas, métricas exatas de resumo, renderizadores TXT/Markdown e escrita UTF-8, mantendo agregação, apresentação e persistência separadas.
+O Projeto 01 estabelece o padrão de integração com registros monetários validados e persistência. O Projeto 02 amplia esse padrão com políticas de notas configuráveis, agregação ponderada exata, estados parcial/final explícitos, relatório estruturado e cobertura pytest focada em fronteiras. O Projeto 03 adiciona dados de identidade canônicos, prevenção de duplicidade, índices secundários de lookup, atualizações seguras de campos indexados e transições explícitas de ciclo de vida sem introduzir autenticação. O Projeto 04 adiciona schemas CSV exatos, conversão tipada, separação entre falhas estruturais e falhas de linha, parsing com sucesso parcial, identificadores duplicados, filtros determinísticos e agregação sem esconder a ingestão atrás de pandas. O Projeto 05 transforma registros operacionais validados em artefatos de relatório determinísticos com janelas explícitas de datas, métricas exatas de resumo, renderizadores TXT/Markdown e escrita UTF-8, mantendo agregação, apresentação e persistência separadas. O Projeto 06 adiciona descoberta rasa no filesystem, classificação por sufixo, planejamento imutável de movimentos, políticas explícitas de colisão, fronteiras de symlink, revalidação no momento da execução e proteção exata no-replace do destino antes da organização em pastas por categoria.
diff --git a/scripts/example_manifest.txt b/scripts/example_manifest.txt
index be2dc3f..dd58562 100644
--- a/scripts/example_manifest.txt
+++ b/scripts/example_manifest.txt
@@ -183,3 +183,4 @@ practical-projects/02-grade-calculator/demo.py
practical-projects/03-user-registration/demo.py
practical-projects/04-csv-analyzer/demo.py
practical-projects/05-report-generator/demo.py
+practical-projects/06-file-organizer/demo.py