fonzarely/regine-photos-archiverpublic⑂ Fork 0
⑂ 247d8fa
Commits
⬇ Clone ▾
git clone https://git.rickub.com/fonzarely/regine-photos-archiver.git
git clone ssh://git@rickub.com/fonzarely/regine-photos-archiver.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

implemente config

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fabien Champigny committed 2026-09-20T00:17:50+02:00 Browse files
247d8fa parent: 6a31918
modified packages/regine-cli/pyproject.toml +3 -0
@@ -14,3 +14,6 @@ build-backend = "hatchling.build"
1414
1515 [tool.hatch.build.targets.wheel]
1616 packages = ["src/regine_cli"]
17+
18+[tool.pytest.ini_options]
19+testpaths = ["tests"]
@@ -14,3 +14,6 @@ build-backend = "hatchling.build"
14 14
15 [tool.hatch.build.targets.wheel]15 [tool.hatch.build.targets.wheel]
16 packages = ["src/regine_cli"]16 packages = ["src/regine_cli"]
17+
18+[tool.pytest.ini_options]
19+testpaths = ["tests"]
added packages/regine-cli/src/regine_cli/config_cmd.py +263 -0
new file mode 100644
@@ -0,0 +1,263 @@
1+"""Commandes `regine config ...` (cf. contracts/cli-config.md).
2+
3+Façade fine : orchestre `regine_core.config`, formatte le résultat, ne contient
4+aucune logique métier propre (Principe VI de la constitution).
5+"""
6+
7+from __future__ import annotations
8+
9+import argparse
10+import json
11+import sys
12+from pathlib import Path
13+
14+from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier
15+from regine_core.config.context import (
16+ ArchiveInaccessibleError,
17+ CheminArchiveInvalideError,
18+ CheminsAmbigusError,
19+ ContexteTravail,
20+ RepertoireAbsentError,
21+ TravailNonReconcilieError,
22+ charger_contexte,
23+ enregistrer_chemins,
24+)
25+from regine_core.config.db import open_context_db
26+
27+
28+def _ouvrir_conn(args: argparse.Namespace):
29+ return open_context_db(Path(args.contexte_db) if args.contexte_db else None)
30+
31+
32+def _afficher_contexte(contexte: ContexteTravail) -> None:
33+ print(f" temp_dir : {contexte.temp_dir or '(non configuré)'}")
34+ print(f" local_work_dir : {contexte.local_work_dir or '(non configuré)'}")
35+ print(f" archive_smb_path : {contexte.archive_smb_path or '(non configuré)'}")
36+ print(f" état SMB : {contexte.smb_last_known_state}")
37+
38+
39+def _cmd_set_paths(args: argparse.Namespace) -> int:
40+ conn = _ouvrir_conn(args)
41+ try:
42+ contexte = enregistrer_chemins(
43+ conn,
44+ temp_dir=Path(args.temp_dir) if args.temp_dir else None,
45+ local_work_dir=Path(args.local_work_dir) if args.local_work_dir else None,
46+ archive_smb_path=args.archive_smb,
47+ creer_repertoires_manquants=args.yes,
48+ forcer_collision=args.force,
49+ ignorer_verification_archive=args.ignorer_verification_archive,
50+ )
51+ except RepertoireAbsentError as exc:
52+ print(f"{exc.chemin} n'existe pas. Relancer avec --yes pour le créer.", file=sys.stderr)
53+ return 1
54+ except CheminsAmbigusError as exc:
55+ print(f"{exc} — relancer avec --force pour confirmer explicitement.", file=sys.stderr)
56+ return 1
57+ except CheminArchiveInvalideError as exc:
58+ print(str(exc), file=sys.stderr)
59+ return 1
60+ except TravailNonReconcilieError as exc:
61+ dossiers = "\n".join(f" - {d}" for d in exc.dossiers)
62+ print(
63+ f"Changement refusé : réconcilier ou archiver d'abord :\n{dossiers}",
64+ file=sys.stderr,
65+ )
66+ return 1
67+ except ArchiveInaccessibleError as exc:
68+ print(
69+ f"{exc} — impossible de vérifier l'absence de travail non réconcilié. "
70+ "Rétablir l'accès au NAS, ou relancer avec --ignorer-verification-archive.",
71+ file=sys.stderr,
72+ )
73+ return 1
74+ finally:
75+ conn.close()
76+
77+ print("Contexte de travail enregistré :")
78+ _afficher_contexte(contexte)
79+ return 0
80+
81+
82+def _cmd_show(args: argparse.Namespace) -> int:
83+ conn = _ouvrir_conn(args)
84+ try:
85+ contexte = charger_contexte(conn)
86+ if contexte.archive_smb_path:
87+ from regine_core.config.smb import assurer_disponibilite # noqa: PLC0415
88+
89+ assurer_disponibilite(conn, contexte.archive_smb_path)
90+ contexte = charger_contexte(conn)
91+ finally:
92+ conn.close()
93+
94+ manquants = [
95+ nom
96+ for nom, valeur in (
97+ ("temp_dir", contexte.temp_dir),
98+ ("local_work_dir", contexte.local_work_dir),
99+ ("archive_smb_path", contexte.archive_smb_path),
100+ )
101+ if valeur is None
102+ ]
103+
104+ if args.json:
105+ print(
106+ json.dumps(
107+ {
108+ "temp_dir": str(contexte.temp_dir) if contexte.temp_dir else None,
109+ "local_work_dir": str(contexte.local_work_dir)
110+ if contexte.local_work_dir
111+ else None,
112+ "archive_smb_path": contexte.archive_smb_path,
113+ "smb_last_known_state": contexte.smb_last_known_state,
114+ }
115+ )
116+ )
117+ elif manquants:
118+ print(f"Contexte de travail incomplet, chemins manquants : {', '.join(manquants)}")
119+ else:
120+ _afficher_contexte(contexte)
121+
122+ return 1 if manquants else 0
123+
124+
125+def _cmd_cameras_list(args: argparse.Namespace) -> int:
126+ conn = _ouvrir_conn(args)
127+ try:
128+ boitiers = lister_boitiers(conn)
129+ finally:
130+ conn.close()
131+
132+ if args.json:
133+ print(
134+ json.dumps(
135+ [
136+ {
137+ "id": b.id,
138+ "modele": b.modele,
139+ "numero_serie": b.numero_serie,
140+ "nom_lisible": b.nom_lisible,
141+ "source": b.source,
142+ }
143+ for b in boitiers
144+ ]
145+ )
146+ )
147+ return 0
148+
149+ if not boitiers:
150+ print("Aucun boîtier connu (aucun import effectué pour l'instant).")
151+ return 0
152+
153+ for b in boitiers:
154+ nom = b.nom_lisible or "(non nommé)"
155+ serie = f", {b.numero_serie}" if b.numero_serie else ""
156+ print(f" [{b.id}] {nom} — {b.modele}{serie} (source={b.source})")
157+ return 0
158+
159+
160+def _cmd_cameras_rename(args: argparse.Namespace) -> int:
161+ conn = _ouvrir_conn(args)
162+ try:
163+ nommer_boitier(conn, args.id, args.nom)
164+ except BoitierInconnuError as exc:
165+ print(str(exc), file=sys.stderr)
166+ return 1
167+ finally:
168+ conn.close()
169+
170+ print(f"Boîtier {args.id} renommé en « {args.nom} ».")
171+ return 0
172+
173+
174+def _cmd_cameras_scan_nas(args: argparse.Namespace) -> int:
175+ if not args.confirm:
176+ print(
177+ "Le parcours complet de l'archive peut être long sur une grosse archive. "
178+ "Relancer avec --confirm pour l'exécuter.",
179+ )
180+ return 1
181+
182+ from regine_core.config.cameras_screen import parcourir_nas # noqa: PLC0415
183+ from regine_core.config.smb import ( # noqa: PLC0415
184+ assurer_disponibilite,
185+ chemin_local_du_partage,
186+ )
187+
188+ conn = _ouvrir_conn(args)
189+ try:
190+ contexte = charger_contexte(conn)
191+ if contexte.archive_smb_path is None:
192+ print("Aucune archive configurée (regine config set-paths).", file=sys.stderr)
193+ return 1
194+ if not assurer_disponibilite(conn, contexte.archive_smb_path):
195+ print(
196+ f"{contexte.archive_smb_path} n'est pas accessible — montage natif déclenché, "
197+ "réessayer une fois l'accès rétabli.",
198+ file=sys.stderr,
199+ )
200+ return 1
201+ archive_root_local = chemin_local_du_partage(contexte.archive_smb_path)
202+ nouveaux = parcourir_nas(conn, archive_root_local)
203+ finally:
204+ conn.close()
205+
206+ print(f"Parcours terminé : {nouveaux} nouveau(x) boîtier(s) détecté(s).")
207+ return 0
208+
209+
210+def construire_analyseur() -> argparse.ArgumentParser:
211+ analyseur = argparse.ArgumentParser(prog="regine")
212+ sous_commandes = analyseur.add_subparsers(dest="commande", required=True)
213+
214+ config_parser = sous_commandes.add_parser("config", help="Configure le contexte de travail")
215+ config_sous = config_parser.add_subparsers(dest="sous_commande", required=True)
216+
217+ set_paths_parser = config_sous.add_parser("set-paths", help="Configure les emplacements")
218+ set_paths_parser.add_argument("--temp-dir", default=None)
219+ set_paths_parser.add_argument("--local-work-dir", default=None)
220+ set_paths_parser.add_argument("--archive-smb", default=None)
221+ set_paths_parser.add_argument("--yes", action="store_true")
222+ set_paths_parser.add_argument("--force", action="store_true")
223+ set_paths_parser.add_argument("--ignorer-verification-archive", action="store_true")
224+ set_paths_parser.add_argument("--contexte-db", default=None)
225+ set_paths_parser.set_defaults(func=_cmd_set_paths)
226+
227+ show_parser = config_sous.add_parser("show", help="Affiche le contexte de travail actif")
228+ show_parser.add_argument("--json", action="store_true")
229+ show_parser.add_argument("--contexte-db", default=None)
230+ show_parser.set_defaults(func=_cmd_show)
231+
232+ cameras_parser = config_sous.add_parser("cameras", help="Gère les boîtiers connus")
233+ cameras_sous = cameras_parser.add_subparsers(dest="cameras_sous_commande", required=True)
234+
235+ cameras_list_parser = cameras_sous.add_parser("list", help="Liste les boîtiers connus")
236+ cameras_list_parser.add_argument("--json", action="store_true")
237+ cameras_list_parser.add_argument("--contexte-db", default=None)
238+ cameras_list_parser.set_defaults(func=_cmd_cameras_list)
239+
240+ cameras_rename_parser = cameras_sous.add_parser("rename", help="Nomme un boîtier")
241+ cameras_rename_parser.add_argument("--id", type=int, required=True)
242+ cameras_rename_parser.add_argument("--nom", required=True)
243+ cameras_rename_parser.add_argument("--contexte-db", default=None)
244+ cameras_rename_parser.set_defaults(func=_cmd_cameras_rename)
245+
246+ cameras_scan_parser = cameras_sous.add_parser(
247+ "scan-nas", help="Parcours volontaire de l'archive (FR-012)"
248+ )
249+ cameras_scan_parser.add_argument("--confirm", action="store_true")
250+ cameras_scan_parser.add_argument("--contexte-db", default=None)
251+ cameras_scan_parser.set_defaults(func=_cmd_cameras_scan_nas)
252+
253+ return analyseur
254+
255+
256+def main(argv: list[str] | None = None) -> int:
257+ analyseur = construire_analyseur()
258+ args = analyseur.parse_args(argv)
259+ return args.func(args)
260+
261+
262+if __name__ == "__main__":
263+ raise SystemExit(main())
new file mode 100644
@@ -0,0 +1,263 @@
1+"""Commandes `regine config ...` (cf. contracts/cli-config.md).
2+
3+Façade fine : orchestre `regine_core.config`, formatte le résultat, ne contient
4+aucune logique métier propre (Principe VI de la constitution).
5+"""
6+
7+from __future__ import annotations
8+
9+import argparse
10+import json
11+import sys
12+from pathlib import Path
13+
14+from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier
15+from regine_core.config.context import (
16+ ArchiveInaccessibleError,
17+ CheminArchiveInvalideError,
18+ CheminsAmbigusError,
19+ ContexteTravail,
20+ RepertoireAbsentError,
21+ TravailNonReconcilieError,
22+ charger_contexte,
23+ enregistrer_chemins,
24+)
25+from regine_core.config.db import open_context_db
26+
27+
28+def _ouvrir_conn(args: argparse.Namespace):
29+ return open_context_db(Path(args.contexte_db) if args.contexte_db else None)
30+
31+
32+def _afficher_contexte(contexte: ContexteTravail) -> None:
33+ print(f" temp_dir : {contexte.temp_dir or '(non configuré)'}")
34+ print(f" local_work_dir : {contexte.local_work_dir or '(non configuré)'}")
35+ print(f" archive_smb_path : {contexte.archive_smb_path or '(non configuré)'}")
36+ print(f" état SMB : {contexte.smb_last_known_state}")
37+
38+
39+def _cmd_set_paths(args: argparse.Namespace) -> int:
40+ conn = _ouvrir_conn(args)
41+ try:
42+ contexte = enregistrer_chemins(
43+ conn,
44+ temp_dir=Path(args.temp_dir) if args.temp_dir else None,
45+ local_work_dir=Path(args.local_work_dir) if args.local_work_dir else None,
46+ archive_smb_path=args.archive_smb,
47+ creer_repertoires_manquants=args.yes,
48+ forcer_collision=args.force,
49+ ignorer_verification_archive=args.ignorer_verification_archive,
50+ )
51+ except RepertoireAbsentError as exc:
52+ print(f"{exc.chemin} n'existe pas. Relancer avec --yes pour le créer.", file=sys.stderr)
53+ return 1
54+ except CheminsAmbigusError as exc:
55+ print(f"{exc} — relancer avec --force pour confirmer explicitement.", file=sys.stderr)
56+ return 1
57+ except CheminArchiveInvalideError as exc:
58+ print(str(exc), file=sys.stderr)
59+ return 1
60+ except TravailNonReconcilieError as exc:
61+ dossiers = "\n".join(f" - {d}" for d in exc.dossiers)
62+ print(
63+ f"Changement refusé : réconcilier ou archiver d'abord :\n{dossiers}",
64+ file=sys.stderr,
65+ )
66+ return 1
67+ except ArchiveInaccessibleError as exc:
68+ print(
69+ f"{exc} — impossible de vérifier l'absence de travail non réconcilié. "
70+ "Rétablir l'accès au NAS, ou relancer avec --ignorer-verification-archive.",
71+ file=sys.stderr,
72+ )
73+ return 1
74+ finally:
75+ conn.close()
76+
77+ print("Contexte de travail enregistré :")
78+ _afficher_contexte(contexte)
79+ return 0
80+
81+
82+def _cmd_show(args: argparse.Namespace) -> int:
83+ conn = _ouvrir_conn(args)
84+ try:
85+ contexte = charger_contexte(conn)
86+ if contexte.archive_smb_path:
87+ from regine_core.config.smb import assurer_disponibilite # noqa: PLC0415
88+
89+ assurer_disponibilite(conn, contexte.archive_smb_path)
90+ contexte = charger_contexte(conn)
91+ finally:
92+ conn.close()
93+
94+ manquants = [
95+ nom
96+ for nom, valeur in (
97+ ("temp_dir", contexte.temp_dir),
98+ ("local_work_dir", contexte.local_work_dir),
99+ ("archive_smb_path", contexte.archive_smb_path),
100+ )
101+ if valeur is None
102+ ]
103+
104+ if args.json:
105+ print(
106+ json.dumps(
107+ {
108+ "temp_dir": str(contexte.temp_dir) if contexte.temp_dir else None,
109+ "local_work_dir": str(contexte.local_work_dir)
110+ if contexte.local_work_dir
111+ else None,
112+ "archive_smb_path": contexte.archive_smb_path,
113+ "smb_last_known_state": contexte.smb_last_known_state,
114+ }
115+ )
116+ )
117+ elif manquants:
118+ print(f"Contexte de travail incomplet, chemins manquants : {', '.join(manquants)}")
119+ else:
120+ _afficher_contexte(contexte)
121+
122+ return 1 if manquants else 0
123+
124+
125+def _cmd_cameras_list(args: argparse.Namespace) -> int:
126+ conn = _ouvrir_conn(args)
127+ try:
128+ boitiers = lister_boitiers(conn)
129+ finally:
130+ conn.close()
131+
132+ if args.json:
133+ print(
134+ json.dumps(
135+ [
136+ {
137+ "id": b.id,
138+ "modele": b.modele,
139+ "numero_serie": b.numero_serie,
140+ "nom_lisible": b.nom_lisible,
141+ "source": b.source,
142+ }
143+ for b in boitiers
144+ ]
145+ )
146+ )
147+ return 0
148+
149+ if not boitiers:
150+ print("Aucun boîtier connu (aucun import effectué pour l'instant).")
151+ return 0
152+
153+ for b in boitiers:
154+ nom = b.nom_lisible or "(non nommé)"
155+ serie = f", {b.numero_serie}" if b.numero_serie else ""
156+ print(f" [{b.id}] {nom} — {b.modele}{serie} (source={b.source})")
157+ return 0
158+
159+
160+def _cmd_cameras_rename(args: argparse.Namespace) -> int:
161+ conn = _ouvrir_conn(args)
162+ try:
163+ nommer_boitier(conn, args.id, args.nom)
164+ except BoitierInconnuError as exc:
165+ print(str(exc), file=sys.stderr)
166+ return 1
167+ finally:
168+ conn.close()
169+
170+ print(f"Boîtier {args.id} renommé en « {args.nom} ».")
171+ return 0
172+
173+
174+def _cmd_cameras_scan_nas(args: argparse.Namespace) -> int:
175+ if not args.confirm:
176+ print(
177+ "Le parcours complet de l'archive peut être long sur une grosse archive. "
178+ "Relancer avec --confirm pour l'exécuter.",
179+ )
180+ return 1
181+
182+ from regine_core.config.cameras_screen import parcourir_nas # noqa: PLC0415
183+ from regine_core.config.smb import ( # noqa: PLC0415
184+ assurer_disponibilite,
185+ chemin_local_du_partage,
186+ )
187+
188+ conn = _ouvrir_conn(args)
189+ try:
190+ contexte = charger_contexte(conn)
191+ if contexte.archive_smb_path is None:
192+ print("Aucune archive configurée (regine config set-paths).", file=sys.stderr)
193+ return 1
194+ if not assurer_disponibilite(conn, contexte.archive_smb_path):
195+ print(
196+ f"{contexte.archive_smb_path} n'est pas accessible — montage natif déclenché, "
197+ "réessayer une fois l'accès rétabli.",
198+ file=sys.stderr,
199+ )
200+ return 1
201+ archive_root_local = chemin_local_du_partage(contexte.archive_smb_path)
202+ nouveaux = parcourir_nas(conn, archive_root_local)
203+ finally:
204+ conn.close()
205+
206+ print(f"Parcours terminé : {nouveaux} nouveau(x) boîtier(s) détecté(s).")
207+ return 0
208+
209+
210+def construire_analyseur() -> argparse.ArgumentParser:
211+ analyseur = argparse.ArgumentParser(prog="regine")
212+ sous_commandes = analyseur.add_subparsers(dest="commande", required=True)
213+
214+ config_parser = sous_commandes.add_parser("config", help="Configure le contexte de travail")
215+ config_sous = config_parser.add_subparsers(dest="sous_commande", required=True)
216+
217+ set_paths_parser = config_sous.add_parser("set-paths", help="Configure les emplacements")
218+ set_paths_parser.add_argument("--temp-dir", default=None)
219+ set_paths_parser.add_argument("--local-work-dir", default=None)
220+ set_paths_parser.add_argument("--archive-smb", default=None)
221+ set_paths_parser.add_argument("--yes", action="store_true")
222+ set_paths_parser.add_argument("--force", action="store_true")
223+ set_paths_parser.add_argument("--ignorer-verification-archive", action="store_true")
224+ set_paths_parser.add_argument("--contexte-db", default=None)
225+ set_paths_parser.set_defaults(func=_cmd_set_paths)
226+
227+ show_parser = config_sous.add_parser("show", help="Affiche le contexte de travail actif")
228+ show_parser.add_argument("--json", action="store_true")
229+ show_parser.add_argument("--contexte-db", default=None)
230+ show_parser.set_defaults(func=_cmd_show)
231+
232+ cameras_parser = config_sous.add_parser("cameras", help="Gère les boîtiers connus")
233+ cameras_sous = cameras_parser.add_subparsers(dest="cameras_sous_commande", required=True)
234+
235+ cameras_list_parser = cameras_sous.add_parser("list", help="Liste les boîtiers connus")
236+ cameras_list_parser.add_argument("--json", action="store_true")
237+ cameras_list_parser.add_argument("--contexte-db", default=None)
238+ cameras_list_parser.set_defaults(func=_cmd_cameras_list)
239+
240+ cameras_rename_parser = cameras_sous.add_parser("rename", help="Nomme un boîtier")
241+ cameras_rename_parser.add_argument("--id", type=int, required=True)
242+ cameras_rename_parser.add_argument("--nom", required=True)
243+ cameras_rename_parser.add_argument("--contexte-db", default=None)
244+ cameras_rename_parser.set_defaults(func=_cmd_cameras_rename)
245+
246+ cameras_scan_parser = cameras_sous.add_parser(
247+ "scan-nas", help="Parcours volontaire de l'archive (FR-012)"
248+ )
249+ cameras_scan_parser.add_argument("--confirm", action="store_true")
250+ cameras_scan_parser.add_argument("--contexte-db", default=None)
251+ cameras_scan_parser.set_defaults(func=_cmd_cameras_scan_nas)
252+
253+ return analyseur
254+
255+
256+def main(argv: list[str] | None = None) -> int:
257+ analyseur = construire_analyseur()
258+ args = analyseur.parse_args(argv)
259+ return args.func(args)
260+
261+
262+if __name__ == "__main__":
263+ raise SystemExit(main())
added packages/regine-cli/tests/contract/test_cli_config.py +156 -0
new file mode 100644
@@ -0,0 +1,156 @@
1+"""Contrat de la surface CLI `regine config` (cf. contracts/cli-config.md, T015/T025)."""
2+
3+from __future__ import annotations
4+
5+import json
6+from pathlib import Path
7+
8+from regine_cli.config_cmd import main
9+from regine_core.camera_profile.db import get_or_create_boitier
10+from regine_core.config.db import open_context_db
11+
12+
13+def _contexte_db(tmp_path: Path) -> list[str]:
14+ return ["--contexte-db", str(tmp_path / "contexte.sqlite3")]
15+
16+
17+def test_set_paths_puis_show_reflete_le_contexte(tmp_path: Path, capsys) -> None:
18+ temp_dir = tmp_path / "tmp"
19+ local_work_dir = tmp_path / "travail"
20+
21+ code = main(
22+ [
23+ "config",
24+ "set-paths",
25+ "--temp-dir",
26+ str(temp_dir),
27+ "--local-work-dir",
28+ str(local_work_dir),
29+ "--archive-smb",
30+ "smb://nas.local/regine-archive",
31+ "--yes",
32+ *_contexte_db(tmp_path),
33+ ]
34+ )
35+ assert code == 0
36+ capsys.readouterr()
37+
38+ code = main(["config", "show", "--json", *_contexte_db(tmp_path)])
39+ assert code == 0
40+ sortie = json.loads(capsys.readouterr().out)
41+ assert sortie["temp_dir"] == str(temp_dir)
42+ assert sortie["local_work_dir"] == str(local_work_dir)
43+ assert sortie["archive_smb_path"] == "smb://nas.local/regine-archive"
44+
45+
46+def test_show_sans_contexte_configure_signale_les_chemins_manquants(tmp_path: Path, capsys) -> None:
47+ code = main(["config", "show", *_contexte_db(tmp_path)])
48+
49+ assert code != 0
50+ sortie = capsys.readouterr().out
51+ assert "temp_dir" in sortie
52+ assert "local_work_dir" in sortie
53+ assert "archive_smb_path" in sortie
54+
55+
56+def test_set_paths_repertoire_manquant_sans_yes_echoue(tmp_path: Path, capsys) -> None:
57+ code = main(
58+ [
59+ "config",
60+ "set-paths",
61+ "--temp-dir",
62+ str(tmp_path / "absent"),
63+ *_contexte_db(tmp_path),
64+ ]
65+ )
66+
67+ assert code != 0
68+ assert "n'existe pas" in capsys.readouterr().err
69+
70+
71+def test_set_paths_collision_sans_force_echoue(tmp_path: Path, capsys) -> None:
72+ meme_chemin = tmp_path / "meme"
73+
74+ code = main(
75+ [
76+ "config",
77+ "set-paths",
78+ "--temp-dir",
79+ str(meme_chemin),
80+ "--local-work-dir",
81+ str(meme_chemin),
82+ "--yes",
83+ *_contexte_db(tmp_path),
84+ ]
85+ )
86+
87+ assert code != 0
88+ assert "force" in capsys.readouterr().err.lower()
89+
90+
91+def test_set_paths_archive_smb_invalide_echoue(tmp_path: Path, capsys) -> None:
92+ code = main(
93+ [
94+ "config",
95+ "set-paths",
96+ "--archive-smb",
97+ "/Volumes/regine-archive",
98+ *_contexte_db(tmp_path),
99+ ]
100+ )
101+
102+ assert code != 0
103+ assert capsys.readouterr().err
104+
105+
106+def test_cameras_list_vide_sans_erreur(tmp_path: Path, capsys) -> None:
107+ code = main(["config", "cameras", "list", *_contexte_db(tmp_path)])
108+
109+ assert code == 0
110+ assert "Aucun boîtier" in capsys.readouterr().out
111+
112+
113+def test_cameras_list_puis_rename(tmp_path: Path, capsys) -> None:
114+ conn = open_context_db(tmp_path / "contexte.sqlite3")
115+ boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
116+ conn.close()
117+
118+ code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)])
119+ assert code == 0
120+ boitiers = json.loads(capsys.readouterr().out)
121+ assert boitiers[0]["nom_lisible"] is None
122+
123+ code = main(
124+ [
125+ "config",
126+ "cameras",
127+ "rename",
128+ "--id",
129+ str(boitier_id),
130+ "--nom",
131+ "Fuji principal",
132+ *_contexte_db(tmp_path),
133+ ]
134+ )
135+ assert code == 0
136+ capsys.readouterr()
137+
138+ code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)])
139+ boitiers = json.loads(capsys.readouterr().out)
140+ assert boitiers[0]["nom_lisible"] == "Fuji principal"
141+
142+
143+def test_cameras_rename_id_inconnu_echoue(tmp_path: Path, capsys) -> None:
144+ code = main(
145+ ["config", "cameras", "rename", "--id", "999", "--nom", "X", *_contexte_db(tmp_path)]
146+ )
147+
148+ assert code != 0
149+ assert "inconnu" in capsys.readouterr().err
150+
151+
152+def test_cameras_scan_nas_sans_confirm_n_execute_rien(tmp_path: Path, capsys) -> None:
153+ code = main(["config", "cameras", "scan-nas", *_contexte_db(tmp_path)])
154+
155+ assert code != 0
156+ assert "--confirm" in capsys.readouterr().out
new file mode 100644
@@ -0,0 +1,156 @@
1+"""Contrat de la surface CLI `regine config` (cf. contracts/cli-config.md, T015/T025)."""
2+
3+from __future__ import annotations
4+
5+import json
6+from pathlib import Path
7+
8+from regine_cli.config_cmd import main
9+from regine_core.camera_profile.db import get_or_create_boitier
10+from regine_core.config.db import open_context_db
11+
12+
13+def _contexte_db(tmp_path: Path) -> list[str]:
14+ return ["--contexte-db", str(tmp_path / "contexte.sqlite3")]
15+
16+
17+def test_set_paths_puis_show_reflete_le_contexte(tmp_path: Path, capsys) -> None:
18+ temp_dir = tmp_path / "tmp"
19+ local_work_dir = tmp_path / "travail"
20+
21+ code = main(
22+ [
23+ "config",
24+ "set-paths",
25+ "--temp-dir",
26+ str(temp_dir),
27+ "--local-work-dir",
28+ str(local_work_dir),
29+ "--archive-smb",
30+ "smb://nas.local/regine-archive",
31+ "--yes",
32+ *_contexte_db(tmp_path),
33+ ]
34+ )
35+ assert code == 0
36+ capsys.readouterr()
37+
38+ code = main(["config", "show", "--json", *_contexte_db(tmp_path)])
39+ assert code == 0
40+ sortie = json.loads(capsys.readouterr().out)
41+ assert sortie["temp_dir"] == str(temp_dir)
42+ assert sortie["local_work_dir"] == str(local_work_dir)
43+ assert sortie["archive_smb_path"] == "smb://nas.local/regine-archive"
44+
45+
46+def test_show_sans_contexte_configure_signale_les_chemins_manquants(tmp_path: Path, capsys) -> None:
47+ code = main(["config", "show", *_contexte_db(tmp_path)])
48+
49+ assert code != 0
50+ sortie = capsys.readouterr().out
51+ assert "temp_dir" in sortie
52+ assert "local_work_dir" in sortie
53+ assert "archive_smb_path" in sortie
54+
55+
56+def test_set_paths_repertoire_manquant_sans_yes_echoue(tmp_path: Path, capsys) -> None:
57+ code = main(
58+ [
59+ "config",
60+ "set-paths",
61+ "--temp-dir",
62+ str(tmp_path / "absent"),
63+ *_contexte_db(tmp_path),
64+ ]
65+ )
66+
67+ assert code != 0
68+ assert "n'existe pas" in capsys.readouterr().err
69+
70+
71+def test_set_paths_collision_sans_force_echoue(tmp_path: Path, capsys) -> None:
72+ meme_chemin = tmp_path / "meme"
73+
74+ code = main(
75+ [
76+ "config",
77+ "set-paths",
78+ "--temp-dir",
79+ str(meme_chemin),
80+ "--local-work-dir",
81+ str(meme_chemin),
82+ "--yes",
83+ *_contexte_db(tmp_path),
84+ ]
85+ )
86+
87+ assert code != 0
88+ assert "force" in capsys.readouterr().err.lower()
89+
90+
91+def test_set_paths_archive_smb_invalide_echoue(tmp_path: Path, capsys) -> None:
92+ code = main(
93+ [
94+ "config",
95+ "set-paths",
96+ "--archive-smb",
97+ "/Volumes/regine-archive",
98+ *_contexte_db(tmp_path),
99+ ]
100+ )
101+
102+ assert code != 0
103+ assert capsys.readouterr().err
104+
105+
106+def test_cameras_list_vide_sans_erreur(tmp_path: Path, capsys) -> None:
107+ code = main(["config", "cameras", "list", *_contexte_db(tmp_path)])
108+
109+ assert code == 0
110+ assert "Aucun boîtier" in capsys.readouterr().out
111+
112+
113+def test_cameras_list_puis_rename(tmp_path: Path, capsys) -> None:
114+ conn = open_context_db(tmp_path / "contexte.sqlite3")
115+ boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
116+ conn.close()
117+
118+ code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)])
119+ assert code == 0
120+ boitiers = json.loads(capsys.readouterr().out)
121+ assert boitiers[0]["nom_lisible"] is None
122+
123+ code = main(
124+ [
125+ "config",
126+ "cameras",
127+ "rename",
128+ "--id",
129+ str(boitier_id),
130+ "--nom",
131+ "Fuji principal",
132+ *_contexte_db(tmp_path),
133+ ]
134+ )
135+ assert code == 0
136+ capsys.readouterr()
137+
138+ code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)])
139+ boitiers = json.loads(capsys.readouterr().out)
140+ assert boitiers[0]["nom_lisible"] == "Fuji principal"
141+
142+
143+def test_cameras_rename_id_inconnu_echoue(tmp_path: Path, capsys) -> None:
144+ code = main(
145+ ["config", "cameras", "rename", "--id", "999", "--nom", "X", *_contexte_db(tmp_path)]
146+ )
147+
148+ assert code != 0
149+ assert "inconnu" in capsys.readouterr().err
150+
151+
152+def test_cameras_scan_nas_sans_confirm_n_execute_rien(tmp_path: Path, capsys) -> None:
153+ code = main(["config", "cameras", "scan-nas", *_contexte_db(tmp_path)])
154+
155+ assert code != 0
156+ assert "--confirm" in capsys.readouterr().out
added packages/regine-core/src/regine_core/config/cameras_screen.py +73 -0
new file mode 100644
@@ -0,0 +1,73 @@
1+"""Écran de nommage des boîtiers (specs/003) : délègue à regine_core.camera_profile.
2+
3+Ne possède aucune logique de désambiguïsation propre (cf. plan.md « Correction du
4+2026-09-18 ») : la table `boitiers` et l'algorithme de résolution de collision
5+appartiennent à `regine_core.camera_profile` (specs/002-profil-boitiers-optionnel).
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from pathlib import Path
12+
13+from regine_core.camera_profile.db import Boitier, ensure_schema, get_or_create_boitier
14+from regine_core.camera_profile.db import list_boitiers as _list_boitiers
15+from regine_core.camera_profile.db import rename_boitier as _rename_boitier
16+
17+
18+class BoitierInconnuError(Exception):
19+ """Aucun boîtier ne correspond à l'identifiant fourni (contracts/cli-config.md)."""
20+
21+ def __init__(self, boitier_id: int) -> None:
22+ self.boitier_id = boitier_id
23+ super().__init__(f"boîtier {boitier_id} inconnu")
24+
25+
26+def lister_boitiers(conn: sqlite3.Connection) -> list[Boitier]:
27+ """Liste les boîtiers connus dans la base de contexte centralisée (FR-008),
28+ alimentée à chaque import — aucun accès au NAS ici (SC-007)."""
29+ return _list_boitiers(conn)
30+
31+
32+def nommer_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None:
33+ """Attribue un nom lisible à un boîtier déjà détecté (FR-008/FR-009).
34+
35+ Lève `BoitierInconnuError` si `boitier_id` ne correspond à aucun boîtier —
36+ `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans
37+ ligne correspondante n'est pas une erreur SQL), donc cette vérification est
38+ portée ici, au niveau attendu par le contrat CLI de cette spec.
39+ """
40+ ensure_schema(conn)
41+ existe = conn.execute("SELECT 1 FROM boitiers WHERE id = ?", (boitier_id,)).fetchone()
42+ if existe is None:
43+ raise BoitierInconnuError(boitier_id)
44+ _rename_boitier(conn, boitier_id, nom)
45+
46+
47+def parcourir_nas(conn: sqlite3.Connection, archive_root: Path) -> int:
48+ """Parcours volontaire de l'archive pour détecter des boîtiers non encore
49+ connus (FR-012) — jamais déclenché par défaut (SC-007), seulement à la demande
50+ explicite de l'utilisateur. Retourne le nombre de boîtiers réellement nouveaux.
51+ """
52+ from regine_core.import_carte.copie import EXTENSIONS_MAITRES # noqa: PLC0415
53+ from regine_core.metadata.exif import read_camera_tags # noqa: PLC0415
54+
55+ ensure_schema(conn)
56+ nouveaux = 0
57+ for chemin in sorted(p for p in archive_root.rglob("*") if p.is_file()):
58+ if chemin.suffix.lstrip(".").lower() not in EXTENSIONS_MAITRES:
59+ continue
60+ tags = read_camera_tags(chemin)
61+ if tags.modele is None:
62+ continue
63+ deja_connu = (
64+ conn.execute(
65+ "SELECT 1 FROM boitiers WHERE modele IS ? AND numero_serie IS ?",
66+ (tags.modele, tags.numero_serie),
67+ ).fetchone()
68+ is not None
69+ )
70+ get_or_create_boitier(conn, tags.modele, tags.numero_serie, source="parcours_nas")
71+ if not deja_connu:
72+ nouveaux += 1
73+ return nouveaux
new file mode 100644
@@ -0,0 +1,73 @@
1+"""Écran de nommage des boîtiers (specs/003) : délègue à regine_core.camera_profile.
2+
3+Ne possède aucune logique de désambiguïsation propre (cf. plan.md « Correction du
4+2026-09-18 ») : la table `boitiers` et l'algorithme de résolution de collision
5+appartiennent à `regine_core.camera_profile` (specs/002-profil-boitiers-optionnel).
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from pathlib import Path
12+
13+from regine_core.camera_profile.db import Boitier, ensure_schema, get_or_create_boitier
14+from regine_core.camera_profile.db import list_boitiers as _list_boitiers
15+from regine_core.camera_profile.db import rename_boitier as _rename_boitier
16+
17+
18+class BoitierInconnuError(Exception):
19+ """Aucun boîtier ne correspond à l'identifiant fourni (contracts/cli-config.md)."""
20+
21+ def __init__(self, boitier_id: int) -> None:
22+ self.boitier_id = boitier_id
23+ super().__init__(f"boîtier {boitier_id} inconnu")
24+
25+
26+def lister_boitiers(conn: sqlite3.Connection) -> list[Boitier]:
27+ """Liste les boîtiers connus dans la base de contexte centralisée (FR-008),
28+ alimentée à chaque import — aucun accès au NAS ici (SC-007)."""
29+ return _list_boitiers(conn)
30+
31+
32+def nommer_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None:
33+ """Attribue un nom lisible à un boîtier déjà détecté (FR-008/FR-009).
34+
35+ Lève `BoitierInconnuError` si `boitier_id` ne correspond à aucun boîtier —
36+ `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans
37+ ligne correspondante n'est pas une erreur SQL), donc cette vérification est
38+ portée ici, au niveau attendu par le contrat CLI de cette spec.
39+ """
40+ ensure_schema(conn)
41+ existe = conn.execute("SELECT 1 FROM boitiers WHERE id = ?", (boitier_id,)).fetchone()
42+ if existe is None:
43+ raise BoitierInconnuError(boitier_id)
44+ _rename_boitier(conn, boitier_id, nom)
45+
46+
47+def parcourir_nas(conn: sqlite3.Connection, archive_root: Path) -> int:
48+ """Parcours volontaire de l'archive pour détecter des boîtiers non encore
49+ connus (FR-012) — jamais déclenché par défaut (SC-007), seulement à la demande
50+ explicite de l'utilisateur. Retourne le nombre de boîtiers réellement nouveaux.
51+ """
52+ from regine_core.import_carte.copie import EXTENSIONS_MAITRES # noqa: PLC0415
53+ from regine_core.metadata.exif import read_camera_tags # noqa: PLC0415
54+
55+ ensure_schema(conn)
56+ nouveaux = 0
57+ for chemin in sorted(p for p in archive_root.rglob("*") if p.is_file()):
58+ if chemin.suffix.lstrip(".").lower() not in EXTENSIONS_MAITRES:
59+ continue
60+ tags = read_camera_tags(chemin)
61+ if tags.modele is None:
62+ continue
63+ deja_connu = (
64+ conn.execute(
65+ "SELECT 1 FROM boitiers WHERE modele IS ? AND numero_serie IS ?",
66+ (tags.modele, tags.numero_serie),
67+ ).fetchone()
68+ is not None
69+ )
70+ get_or_create_boitier(conn, tags.modele, tags.numero_serie, source="parcours_nas")
71+ if not deja_connu:
72+ nouveaux += 1
73+ return nouveaux
added packages/regine-core/src/regine_core/config/context.py +230 -0
new file mode 100644
@@ -0,0 +1,230 @@
1+"""Contexte de travail : persistance des 3 emplacements et de l'état SMB (specs/003).
2+
3+Réutilise le manifeste persistant et le verrouillage de `regine_core.archive`
4+(`specs/005-checkout-reconciliation`) pour détecter un travail non réconcilié
5+(FR-010) plutôt que d'inventer un second mécanisme de suivi (Principe VI).
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from dataclasses import dataclass
12+from pathlib import Path
13+from typing import Literal
14+
15+from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE, ouvrir_ou_creer
16+from regine_core.archive.verrou import verifier
17+
18+EtatSmb = Literal["mounted", "unavailable", "unknown"]
19+
20+_CREATE_CONTEXTE = """
21+CREATE TABLE IF NOT EXISTS contexte (
22+ id INTEGER PRIMARY KEY CHECK (id = 1),
23+ temp_dir TEXT,
24+ local_work_dir TEXT,
25+ archive_smb_path TEXT,
26+ smb_last_known_state TEXT NOT NULL DEFAULT 'unknown'
27+)
28+"""
29+
30+
31+class RepertoireAbsentError(Exception):
32+ """Un répertoire configuré n'existe pas encore et sa création n'a pas été
33+ explicitement demandée (FR-003)."""
34+
35+ def __init__(self, chemin: Path) -> None:
36+ self.chemin = chemin
37+ super().__init__(f"{chemin} n'existe pas")
38+
39+
40+class CheminsAmbigusError(Exception):
41+ """`temp_dir` et `local_work_dir` pointent vers le même emplacement (Edge Case)."""
42+
43+
44+class CheminArchiveInvalideError(Exception):
45+ """`archive_smb_path` ne correspond pas à un partage réseau SMB (Edge Case)."""
46+
47+
48+class TravailNonReconcilieError(Exception):
49+ """Un changement de `local_work_dir` est refusé tant qu'un travail n'est pas
50+ réconcilié ou archivé sous l'ancien chemin (FR-010, Clarification n°1)."""
51+
52+ def __init__(self, dossiers: list[Path]) -> None:
53+ self.dossiers = dossiers
54+ noms = ", ".join(str(d) for d in dossiers)
55+ super().__init__(f"travail non réconcilié sous : {noms}")
56+
57+
58+class ArchiveInaccessibleError(Exception):
59+ """L'archive configurée n'est pas accessible : impossible de vérifier l'absence
60+ de travail non réconcilié avant un changement de `local_work_dir` (FR-010)."""
61+
62+
63+@dataclass(frozen=True)
64+class ContexteTravail:
65+ """Contexte de travail actif — unique par installation (cf. spec.md Assumptions)."""
66+
67+ temp_dir: Path | None
68+ local_work_dir: Path | None
69+ archive_smb_path: str | None
70+ smb_last_known_state: EtatSmb
71+
72+
73+def _ensure_schema(conn: sqlite3.Connection) -> None:
74+ conn.execute(_CREATE_CONTEXTE)
75+ conn.commit()
76+
77+
78+def charger_contexte(conn: sqlite3.Connection) -> ContexteTravail:
79+ """Charge le contexte de travail actif ; tous les champs sont `None`/`unknown`
80+ tant qu'aucune configuration n'a encore été enregistrée."""
81+ _ensure_schema(conn)
82+ ligne = conn.execute(
83+ "SELECT temp_dir, local_work_dir, archive_smb_path, smb_last_known_state "
84+ "FROM contexte WHERE id = 1"
85+ ).fetchone()
86+ if ligne is None:
87+ return ContexteTravail(
88+ temp_dir=None,
89+ local_work_dir=None,
90+ archive_smb_path=None,
91+ smb_last_known_state="unknown",
92+ )
93+ temp_dir, local_work_dir, archive_smb_path, smb_last_known_state = ligne
94+ return ContexteTravail(
95+ temp_dir=Path(temp_dir) if temp_dir else None,
96+ local_work_dir=Path(local_work_dir) if local_work_dir else None,
97+ archive_smb_path=archive_smb_path,
98+ smb_last_known_state=smb_last_known_state,
99+ )
100+
101+
102+def lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]:
103+ """Dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé
104+ côté archive (checkout ou import non réconcilié, FR-010).
105+
106+ L'état de verrouillage vit uniquement côté archive, jamais dupliqué dans la copie
107+ de travail locale (cf. specs/005-checkout-reconciliation : le manifeste n'est
108+ jamais recopié lors d'un checkout) — ce parcours part donc des manifestes de
109+ `archive_root_local` (le point de montage local du partage SMB configuré), pas de
110+ `local_work_dir` lui-même, et ne retient que ceux ayant une copie de travail
111+ effectivement présente en local.
112+ """
113+ if not archive_root_local.exists():
114+ raise ArchiveInaccessibleError(f"{archive_root_local} n'est pas accessible")
115+ resultat: list[Path] = []
116+ for chemin_manifeste in archive_root_local.rglob(NOM_FICHIER_MANIFESTE):
117+ dossier_archive = chemin_manifeste.parent
118+ dossier_local = local_work_dir / dossier_archive.relative_to(archive_root_local)
119+ if not dossier_local.exists():
120+ continue
121+ handle = ouvrir_ou_creer(dossier_archive)
122+ try:
123+ if verifier(handle):
124+ resultat.append(dossier_local)
125+ finally:
126+ handle.conn.close()
127+ return resultat
128+
129+
130+def _valider_chemin_archive(archive_smb_path: str) -> None:
131+ if not archive_smb_path.startswith("smb://"):
132+ raise CheminArchiveInvalideError(
133+ f"{archive_smb_path} ne correspond pas à un partage réseau SMB "
134+ "(attendu : un chemin commençant par 'smb://')"
135+ )
136+
137+
138+def enregistrer_chemins(
139+ conn: sqlite3.Connection,
140+ *,
141+ temp_dir: Path | None = None,
142+ local_work_dir: Path | None = None,
143+ archive_smb_path: str | None = None,
144+ creer_repertoires_manquants: bool = False,
145+ forcer_collision: bool = False,
146+ ignorer_verification_archive: bool = False,
147+) -> ContexteTravail:
148+ """Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010).
149+
150+ Ne modifie que les emplacements explicitement fournis ; les autres conservent
151+ leur valeur actuelle. Lève `CheminArchiveInvalideError`, `CheminsAmbigusError`,
152+ `TravailNonReconcilieError` ou `RepertoireAbsentError` plutôt que d'appliquer un
153+ changement dangereux ou silencieux.
154+
155+ Un changement de `local_work_dir` alors qu'aucune archive n'est encore configurée
156+ n'a rien à vérifier (aucun travail archivé possible sans archive). Alors qu'une
157+ archive est configurée mais inaccessible au moment du changement, le changement
158+ est refusé par défaut (`ArchiveInaccessibleError` remontée) plutôt que supposé
159+ sûr à tort — `ignorer_verification_archive=True` permet de passer outre en
160+ connaissance de cause, même geste explicite que `forcer_collision`.
161+ """
162+ actuel = charger_contexte(conn)
163+
164+ if archive_smb_path is not None:
165+ _valider_chemin_archive(archive_smb_path)
166+
167+ nouveau_temp = temp_dir if temp_dir is not None else actuel.temp_dir
168+ nouveau_local = local_work_dir if local_work_dir is not None else actuel.local_work_dir
169+ nouveau_smb = archive_smb_path if archive_smb_path is not None else actuel.archive_smb_path
170+
171+ if nouveau_temp is not None and nouveau_temp == nouveau_local and not forcer_collision:
172+ raise CheminsAmbigusError(
173+ f"temp_dir et local_work_dir pointent vers le même emplacement ({nouveau_temp}) "
174+ "— passer forcer_collision=True pour confirmer explicitement"
175+ )
176+
177+ if (
178+ local_work_dir is not None
179+ and actuel.local_work_dir is not None
180+ and local_work_dir != actuel.local_work_dir
181+ and actuel.archive_smb_path is not None
182+ and not ignorer_verification_archive
183+ ):
184+ from regine_core.config.smb import chemin_local_du_partage # noqa: PLC0415
185+
186+ archive_root_local = chemin_local_du_partage(actuel.archive_smb_path)
187+ non_reconcilies = lister_dossiers_non_reconcilies(actuel.local_work_dir, archive_root_local)
188+ if non_reconcilies:
189+ raise TravailNonReconcilieError(non_reconcilies)
190+
191+ for chemin in (temp_dir, local_work_dir):
192+ if chemin is not None and not chemin.exists():
193+ if not creer_repertoires_manquants:
194+ raise RepertoireAbsentError(chemin)
195+ chemin.mkdir(parents=True, exist_ok=True)
196+
197+ if local_work_dir is not None:
198+ # FR-006/SC-003 : base de travail prête à la racine, avant tout autre besoin.
199+ handle = ouvrir_ou_creer(local_work_dir)
200+ handle.conn.close()
201+
202+ _ensure_schema(conn)
203+ conn.execute(
204+ "INSERT INTO contexte "
205+ "(id, temp_dir, local_work_dir, archive_smb_path, smb_last_known_state) "
206+ "VALUES (1, ?, ?, ?, ?) "
207+ "ON CONFLICT(id) DO UPDATE SET "
208+ "temp_dir = excluded.temp_dir, "
209+ "local_work_dir = excluded.local_work_dir, "
210+ "archive_smb_path = excluded.archive_smb_path",
211+ (
212+ str(nouveau_temp) if nouveau_temp else None,
213+ str(nouveau_local) if nouveau_local else None,
214+ nouveau_smb,
215+ actuel.smb_last_known_state,
216+ ),
217+ )
218+ conn.commit()
219+ return charger_contexte(conn)
220+
221+
222+def mettre_a_jour_etat_smb(conn: sqlite3.Connection, etat: EtatSmb) -> None:
223+ """Met à jour l'état de disponibilité connu du partage SMB (FR-004/005)."""
224+ _ensure_schema(conn)
225+ conn.execute(
226+ "INSERT INTO contexte (id, smb_last_known_state) VALUES (1, ?) "
227+ "ON CONFLICT(id) DO UPDATE SET smb_last_known_state = excluded.smb_last_known_state",
228+ (etat,),
229+ )
230+ conn.commit()
new file mode 100644
@@ -0,0 +1,230 @@
1+"""Contexte de travail : persistance des 3 emplacements et de l'état SMB (specs/003).
2+
3+Réutilise le manifeste persistant et le verrouillage de `regine_core.archive`
4+(`specs/005-checkout-reconciliation`) pour détecter un travail non réconcilié
5+(FR-010) plutôt que d'inventer un second mécanisme de suivi (Principe VI).
6+"""
7+
8+from __future__ import annotations
9+
10+import sqlite3
11+from dataclasses import dataclass
12+from pathlib import Path
13+from typing import Literal
14+
15+from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE, ouvrir_ou_creer
16+from regine_core.archive.verrou import verifier
17+
18+EtatSmb = Literal["mounted", "unavailable", "unknown"]
19+
20+_CREATE_CONTEXTE = """
21+CREATE TABLE IF NOT EXISTS contexte (
22+ id INTEGER PRIMARY KEY CHECK (id = 1),
23+ temp_dir TEXT,
24+ local_work_dir TEXT,
25+ archive_smb_path TEXT,
26+ smb_last_known_state TEXT NOT NULL DEFAULT 'unknown'
27+)
28+"""
29+
30+
31+class RepertoireAbsentError(Exception):
32+ """Un répertoire configuré n'existe pas encore et sa création n'a pas été
33+ explicitement demandée (FR-003)."""
34+
35+ def __init__(self, chemin: Path) -> None:
36+ self.chemin = chemin
37+ super().__init__(f"{chemin} n'existe pas")
38+
39+
40+class CheminsAmbigusError(Exception):
41+ """`temp_dir` et `local_work_dir` pointent vers le même emplacement (Edge Case)."""
42+
43+
44+class CheminArchiveInvalideError(Exception):
45+ """`archive_smb_path` ne correspond pas à un partage réseau SMB (Edge Case)."""
46+
47+
48+class TravailNonReconcilieError(Exception):
49+ """Un changement de `local_work_dir` est refusé tant qu'un travail n'est pas
50+ réconcilié ou archivé sous l'ancien chemin (FR-010, Clarification n°1)."""
51+
52+ def __init__(self, dossiers: list[Path]) -> None:
53+ self.dossiers = dossiers
54+ noms = ", ".join(str(d) for d in dossiers)
55+ super().__init__(f"travail non réconcilié sous : {noms}")
56+
57+
58+class ArchiveInaccessibleError(Exception):
59+ """L'archive configurée n'est pas accessible : impossible de vérifier l'absence
60+ de travail non réconcilié avant un changement de `local_work_dir` (FR-010)."""
61+
62+
63+@dataclass(frozen=True)
64+class ContexteTravail:
65+ """Contexte de travail actif — unique par installation (cf. spec.md Assumptions)."""
66+
67+ temp_dir: Path | None
68+ local_work_dir: Path | None
69+ archive_smb_path: str | None
70+ smb_last_known_state: EtatSmb
71+
72+
73+def _ensure_schema(conn: sqlite3.Connection) -> None:
74+ conn.execute(_CREATE_CONTEXTE)
75+ conn.commit()
76+
77+
78+def charger_contexte(conn: sqlite3.Connection) -> ContexteTravail:
79+ """Charge le contexte de travail actif ; tous les champs sont `None`/`unknown`
80+ tant qu'aucune configuration n'a encore été enregistrée."""
81+ _ensure_schema(conn)
82+ ligne = conn.execute(
83+ "SELECT temp_dir, local_work_dir, archive_smb_path, smb_last_known_state "
84+ "FROM contexte WHERE id = 1"
85+ ).fetchone()
86+ if ligne is None:
87+ return ContexteTravail(
88+ temp_dir=None,
89+ local_work_dir=None,
90+ archive_smb_path=None,
91+ smb_last_known_state="unknown",
92+ )
93+ temp_dir, local_work_dir, archive_smb_path, smb_last_known_state = ligne
94+ return ContexteTravail(
95+ temp_dir=Path(temp_dir) if temp_dir else None,
96+ local_work_dir=Path(local_work_dir) if local_work_dir else None,
97+ archive_smb_path=archive_smb_path,
98+ smb_last_known_state=smb_last_known_state,
99+ )
100+
101+
102+def lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]:
103+ """Dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé
104+ côté archive (checkout ou import non réconcilié, FR-010).
105+
106+ L'état de verrouillage vit uniquement côté archive, jamais dupliqué dans la copie
107+ de travail locale (cf. specs/005-checkout-reconciliation : le manifeste n'est
108+ jamais recopié lors d'un checkout) — ce parcours part donc des manifestes de
109+ `archive_root_local` (le point de montage local du partage SMB configuré), pas de
110+ `local_work_dir` lui-même, et ne retient que ceux ayant une copie de travail
111+ effectivement présente en local.
112+ """
113+ if not archive_root_local.exists():
114+ raise ArchiveInaccessibleError(f"{archive_root_local} n'est pas accessible")
115+ resultat: list[Path] = []
116+ for chemin_manifeste in archive_root_local.rglob(NOM_FICHIER_MANIFESTE):
117+ dossier_archive = chemin_manifeste.parent
118+ dossier_local = local_work_dir / dossier_archive.relative_to(archive_root_local)
119+ if not dossier_local.exists():
120+ continue
121+ handle = ouvrir_ou_creer(dossier_archive)
122+ try:
123+ if verifier(handle):
124+ resultat.append(dossier_local)
125+ finally:
126+ handle.conn.close()
127+ return resultat
128+
129+
130+def _valider_chemin_archive(archive_smb_path: str) -> None:
131+ if not archive_smb_path.startswith("smb://"):
132+ raise CheminArchiveInvalideError(
133+ f"{archive_smb_path} ne correspond pas à un partage réseau SMB "
134+ "(attendu : un chemin commençant par 'smb://')"
135+ )
136+
137+
138+def enregistrer_chemins(
139+ conn: sqlite3.Connection,
140+ *,
141+ temp_dir: Path | None = None,
142+ local_work_dir: Path | None = None,
143+ archive_smb_path: str | None = None,
144+ creer_repertoires_manquants: bool = False,
145+ forcer_collision: bool = False,
146+ ignorer_verification_archive: bool = False,
147+) -> ContexteTravail:
148+ """Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010).
149+
150+ Ne modifie que les emplacements explicitement fournis ; les autres conservent
151+ leur valeur actuelle. Lève `CheminArchiveInvalideError`, `CheminsAmbigusError`,
152+ `TravailNonReconcilieError` ou `RepertoireAbsentError` plutôt que d'appliquer un
153+ changement dangereux ou silencieux.
154+
155+ Un changement de `local_work_dir` alors qu'aucune archive n'est encore configurée
156+ n'a rien à vérifier (aucun travail archivé possible sans archive). Alors qu'une
157+ archive est configurée mais inaccessible au moment du changement, le changement
158+ est refusé par défaut (`ArchiveInaccessibleError` remontée) plutôt que supposé
159+ sûr à tort — `ignorer_verification_archive=True` permet de passer outre en
160+ connaissance de cause, même geste explicite que `forcer_collision`.
161+ """
162+ actuel = charger_contexte(conn)
163+
164+ if archive_smb_path is not None:
165+ _valider_chemin_archive(archive_smb_path)
166+
167+ nouveau_temp = temp_dir if temp_dir is not None else actuel.temp_dir
168+ nouveau_local = local_work_dir if local_work_dir is not None else actuel.local_work_dir
169+ nouveau_smb = archive_smb_path if archive_smb_path is not None else actuel.archive_smb_path
170+
171+ if nouveau_temp is not None and nouveau_temp == nouveau_local and not forcer_collision:
172+ raise CheminsAmbigusError(
173+ f"temp_dir et local_work_dir pointent vers le même emplacement ({nouveau_temp}) "
174+ "— passer forcer_collision=True pour confirmer explicitement"
175+ )
176+
177+ if (
178+ local_work_dir is not None
179+ and actuel.local_work_dir is not None
180+ and local_work_dir != actuel.local_work_dir
181+ and actuel.archive_smb_path is not None
182+ and not ignorer_verification_archive
183+ ):
184+ from regine_core.config.smb import chemin_local_du_partage # noqa: PLC0415
185+
186+ archive_root_local = chemin_local_du_partage(actuel.archive_smb_path)
187+ non_reconcilies = lister_dossiers_non_reconcilies(actuel.local_work_dir, archive_root_local)
188+ if non_reconcilies:
189+ raise TravailNonReconcilieError(non_reconcilies)
190+
191+ for chemin in (temp_dir, local_work_dir):
192+ if chemin is not None and not chemin.exists():
193+ if not creer_repertoires_manquants:
194+ raise RepertoireAbsentError(chemin)
195+ chemin.mkdir(parents=True, exist_ok=True)
196+
197+ if local_work_dir is not None:
198+ # FR-006/SC-003 : base de travail prête à la racine, avant tout autre besoin.
199+ handle = ouvrir_ou_creer(local_work_dir)
200+ handle.conn.close()
201+
202+ _ensure_schema(conn)
203+ conn.execute(
204+ "INSERT INTO contexte "
205+ "(id, temp_dir, local_work_dir, archive_smb_path, smb_last_known_state) "
206+ "VALUES (1, ?, ?, ?, ?) "
207+ "ON CONFLICT(id) DO UPDATE SET "
208+ "temp_dir = excluded.temp_dir, "
209+ "local_work_dir = excluded.local_work_dir, "
210+ "archive_smb_path = excluded.archive_smb_path",
211+ (
212+ str(nouveau_temp) if nouveau_temp else None,
213+ str(nouveau_local) if nouveau_local else None,
214+ nouveau_smb,
215+ actuel.smb_last_known_state,
216+ ),
217+ )
218+ conn.commit()
219+ return charger_contexte(conn)
220+
221+
222+def mettre_a_jour_etat_smb(conn: sqlite3.Connection, etat: EtatSmb) -> None:
223+ """Met à jour l'état de disponibilité connu du partage SMB (FR-004/005)."""
224+ _ensure_schema(conn)
225+ conn.execute(
226+ "INSERT INTO contexte (id, smb_last_known_state) VALUES (1, ?) "
227+ "ON CONFLICT(id) DO UPDATE SET smb_last_known_state = excluded.smb_last_known_state",
228+ (etat,),
229+ )
230+ conn.commit()
modified packages/regine-core/src/regine_core/config/db.py +9 -2
@@ -19,14 +19,21 @@ APPLICATION_ID = 0x52454749
1919 #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé.
2020 SCHEMA_VERSION = 1
2121
22+#: Emplacement par défaut de la base de contexte centralisée : fixe, par utilisateur,
23+#: indépendant de ``local_work_dir`` (specs/003-config-contexte-travail) — ce dernier
24+#: est modifiable (FR-010) et la base de contexte (boîtiers, catégories, chemins) ne
25+#: doit jamais se perdre ni nécessiter de migration à un tel changement.
26+CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"
2227
23-def open_context_db(path: Path) -> sqlite3.Connection:
28+
29+def open_context_db(path: Path | None = None) -> sqlite3.Connection:
2430 """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas.
2531
2632 Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de
2733 contexte Régine (``application_id`` différent), plutôt que de l'interpréter à
28- tort.
34+ tort. Sans ``path`` explicite, utilise ``CHEMIN_CONTEXTE_PAR_DEFAUT``.
2935 """
36+ path = path or CHEMIN_CONTEXTE_PAR_DEFAUT
3037 path.parent.mkdir(parents=True, exist_ok=True)
3138 conn = sqlite3.connect(path)
3239 conn.execute("PRAGMA foreign_keys = ON")
@@ -19,14 +19,21 @@ APPLICATION_ID = 0x52454749
19 #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé.19 #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé.
20 SCHEMA_VERSION = 120 SCHEMA_VERSION = 1
21 21
22+#: Emplacement par défaut de la base de contexte centralisée : fixe, par utilisateur,
23+#: indépendant de ``local_work_dir`` (specs/003-config-contexte-travail) — ce dernier
24+#: est modifiable (FR-010) et la base de contexte (boîtiers, catégories, chemins) ne
25+#: doit jamais se perdre ni nécessiter de migration à un tel changement.
26+CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"
22 27
23-def open_context_db(path: Path) -> sqlite3.Connection:28+
29+def open_context_db(path: Path | None = None) -> sqlite3.Connection:
24 """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas.30 """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas.
25 31
26 Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de32 Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de
27 contexte Régine (``application_id`` différent), plutôt que de l'interpréter à33 contexte Régine (``application_id`` différent), plutôt que de l'interpréter à
28- tort.34+ tort. Sans ``path`` explicite, utilise ``CHEMIN_CONTEXTE_PAR_DEFAUT``.
29 """35 """
36+ path = path or CHEMIN_CONTEXTE_PAR_DEFAUT
30 path.parent.mkdir(parents=True, exist_ok=True)37 path.parent.mkdir(parents=True, exist_ok=True)
31 conn = sqlite3.connect(path)38 conn = sqlite3.connect(path)
32 conn.execute("PRAGMA foreign_keys = ON")39 conn.execute("PRAGMA foreign_keys = ON")
added packages/regine-core/src/regine_core/config/smb.py +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+"""Détection de disponibilité et déclenchement du montage natif SMB (macOS, specs/003).
2+
3+Aucune gestion ni stockage d'identifiants réseau (FR-004) : l'authentification et la
4+sécurité des paramètres de connexion restent entièrement déléguées à l'OS via son
5+mécanisme natif de montage (`open smb://...`, cf. research.md § 1). Une seule
6+vérification par appel — pas de nouvelle tentative automatique en boucle (US2
7+Acceptance Scenario 3, cohérent avec l'exécution sans démon de la constitution).
8+"""
9+
10+from __future__ import annotations
11+
12+import sqlite3
13+import subprocess
14+from pathlib import Path
15+
16+
17+def chemin_local_du_partage(archive_smb_path: str) -> Path:
18+ """Dérive le point de montage local macOS d'un partage `smb://hôte/partage`.
19+
20+ Convention macOS : un partage SMB monté apparaît sous `/Volumes/<partage>`
21+ (cf. research.md § 1). Best-effort : ne garantit pas que le partage soit
22+ effectivement monté, seulement où le chercher.
23+ """
24+ partage = archive_smb_path.rstrip("/").rsplit("/", 1)[-1]
25+ return Path("/Volumes") / partage
26+
27+
28+def disponible(archive_smb_path: str) -> bool:
29+ """Le partage est-il actuellement monté et accessible (FR-004) ?"""
30+ return chemin_local_du_partage(archive_smb_path).exists()
31+
32+
33+def declencher_montage(archive_smb_path: str) -> None:
34+ """Déclenche le mécanisme natif de montage macOS (FR-004) — authentification
35+ entièrement gérée par l'OS, Régine ne voit ni ne stocke aucun identifiant."""
36+ subprocess.run(["open", archive_smb_path], check=False) # noqa: S603, S607
37+
38+
39+def assurer_disponibilite(
40+ conn: sqlite3.Connection, archive_smb_path: str, *, declencher: bool = True
41+) -> bool:
42+ """Vérifie la disponibilité du partage, met à jour l'état connu (FR-005) et
43+ déclenche le montage natif si indisponible. Retourne `True` si déjà disponible."""
44+ from regine_core.config.context import mettre_a_jour_etat_smb # noqa: PLC0415
45+
46+ if disponible(archive_smb_path):
47+ mettre_a_jour_etat_smb(conn, "mounted")
48+ return True
49+
50+ mettre_a_jour_etat_smb(conn, "unavailable")
51+ if declencher:
52+ declencher_montage(archive_smb_path)
53+ return False
new file mode 100644
@@ -0,0 +1,53 @@
1+"""Détection de disponibilité et déclenchement du montage natif SMB (macOS, specs/003).
2+
3+Aucune gestion ni stockage d'identifiants réseau (FR-004) : l'authentification et la
4+sécurité des paramètres de connexion restent entièrement déléguées à l'OS via son
5+mécanisme natif de montage (`open smb://...`, cf. research.md § 1). Une seule
6+vérification par appel — pas de nouvelle tentative automatique en boucle (US2
7+Acceptance Scenario 3, cohérent avec l'exécution sans démon de la constitution).
8+"""
9+
10+from __future__ import annotations
11+
12+import sqlite3
13+import subprocess
14+from pathlib import Path
15+
16+
17+def chemin_local_du_partage(archive_smb_path: str) -> Path:
18+ """Dérive le point de montage local macOS d'un partage `smb://hôte/partage`.
19+
20+ Convention macOS : un partage SMB monté apparaît sous `/Volumes/<partage>`
21+ (cf. research.md § 1). Best-effort : ne garantit pas que le partage soit
22+ effectivement monté, seulement où le chercher.
23+ """
24+ partage = archive_smb_path.rstrip("/").rsplit("/", 1)[-1]
25+ return Path("/Volumes") / partage
26+
27+
28+def disponible(archive_smb_path: str) -> bool:
29+ """Le partage est-il actuellement monté et accessible (FR-004) ?"""
30+ return chemin_local_du_partage(archive_smb_path).exists()
31+
32+
33+def declencher_montage(archive_smb_path: str) -> None:
34+ """Déclenche le mécanisme natif de montage macOS (FR-004) — authentification
35+ entièrement gérée par l'OS, Régine ne voit ni ne stocke aucun identifiant."""
36+ subprocess.run(["open", archive_smb_path], check=False) # noqa: S603, S607
37+
38+
39+def assurer_disponibilite(
40+ conn: sqlite3.Connection, archive_smb_path: str, *, declencher: bool = True
41+) -> bool:
42+ """Vérifie la disponibilité du partage, met à jour l'état connu (FR-005) et
43+ déclenche le montage natif si indisponible. Retourne `True` si déjà disponible."""
44+ from regine_core.config.context import mettre_a_jour_etat_smb # noqa: PLC0415
45+
46+ if disponible(archive_smb_path):
47+ mettre_a_jour_etat_smb(conn, "mounted")
48+ return True
49+
50+ mettre_a_jour_etat_smb(conn, "unavailable")
51+ if declencher:
52+ declencher_montage(archive_smb_path)
53+ return False
added packages/regine-core/tests/integration/test_cameras_scan_nas.py +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+"""Test d'intégration du parcours volontaire du NAS (T023, FR-012)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.camera_profile.db import get_or_create_boitier, list_boitiers
12+from regine_core.config.cameras_screen import parcourir_nas
13+from regine_core.config.db import open_context_db
14+from regine_core.metadata.exif import close_session
15+
16+_JPEG_1X1_BASE64 = (
17+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
18+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
19+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
20+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
21+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
22+)
23+
24+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
25+
26+
27+@pytest.fixture(autouse=True)
28+def _close_shared_session():
29+ yield
30+ close_session()
31+
32+
33+def _fichier_avec_modele(chemin: Path, modele: str) -> None:
34+ chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
35+ subprocess.run( # noqa: S603, S607
36+ ["exiftool", f"-Model={modele}", "-overwrite_original", str(chemin)],
37+ check=True,
38+ capture_output=True,
39+ )
40+
41+
42+def test_parcourir_nas_ajoute_les_boitiers_non_encore_connus(tmp_path: Path) -> None:
43+ archive_root = tmp_path / "archive"
44+ dossier_ancien = archive_root / "2020" / "2020-01-01_Ancien"
45+ dossier_ancien.mkdir(parents=True)
46+ _fichier_avec_modele(dossier_ancien / "IMG_0001.JPG", "Canon EOS 90D")
47+
48+ conn = open_context_db(tmp_path / "contexte.sqlite3")
49+ # Boîtier déjà connu via un import (specs/001), à ne pas dupliquer ni modifier.
50+ get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
51+
52+ nouveaux = parcourir_nas(conn, archive_root)
53+
54+ assert nouveaux == 1
55+ boitiers = {b.modele: b.source for b in list_boitiers(conn)}
56+ assert boitiers["Canon EOS 90D"] == "parcours_nas"
57+ assert boitiers["Fujifilm X100V"] == "modele" # inchangé
58+ conn.close()
59+
60+
61+def test_parcourir_nas_est_idempotent(tmp_path: Path) -> None:
62+ archive_root = tmp_path / "archive"
63+ dossier = archive_root / "2020" / "2020-01-01_Ancien"
64+ dossier.mkdir(parents=True)
65+ _fichier_avec_modele(dossier / "IMG_0001.JPG", "Canon EOS 90D")
66+ conn = open_context_db(tmp_path / "contexte.sqlite3")
67+
68+ premier_parcours = parcourir_nas(conn, archive_root)
69+ second_parcours = parcourir_nas(conn, archive_root)
70+
71+ assert premier_parcours == 1
72+ assert second_parcours == 0
73+ assert len(list_boitiers(conn)) == 1
74+ conn.close()
new file mode 100644
@@ -0,0 +1,74 @@
1+"""Test d'intégration du parcours volontaire du NAS (T023, FR-012)."""
2+
3+from __future__ import annotations
4+
5+import base64
6+import shutil
7+import subprocess
8+from pathlib import Path
9+
10+import pytest
11+from regine_core.camera_profile.db import get_or_create_boitier, list_boitiers
12+from regine_core.config.cameras_screen import parcourir_nas
13+from regine_core.config.db import open_context_db
14+from regine_core.metadata.exif import close_session
15+
16+_JPEG_1X1_BASE64 = (
17+ "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M"
18+ "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ"
19+ "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA"
20+ "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU"
21+ "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
22+)
23+
24+pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé")
25+
26+
27+@pytest.fixture(autouse=True)
28+def _close_shared_session():
29+ yield
30+ close_session()
31+
32+
33+def _fichier_avec_modele(chemin: Path, modele: str) -> None:
34+ chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64))
35+ subprocess.run( # noqa: S603, S607
36+ ["exiftool", f"-Model={modele}", "-overwrite_original", str(chemin)],
37+ check=True,
38+ capture_output=True,
39+ )
40+
41+
42+def test_parcourir_nas_ajoute_les_boitiers_non_encore_connus(tmp_path: Path) -> None:
43+ archive_root = tmp_path / "archive"
44+ dossier_ancien = archive_root / "2020" / "2020-01-01_Ancien"
45+ dossier_ancien.mkdir(parents=True)
46+ _fichier_avec_modele(dossier_ancien / "IMG_0001.JPG", "Canon EOS 90D")
47+
48+ conn = open_context_db(tmp_path / "contexte.sqlite3")
49+ # Boîtier déjà connu via un import (specs/001), à ne pas dupliquer ni modifier.
50+ get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
51+
52+ nouveaux = parcourir_nas(conn, archive_root)
53+
54+ assert nouveaux == 1
55+ boitiers = {b.modele: b.source for b in list_boitiers(conn)}
56+ assert boitiers["Canon EOS 90D"] == "parcours_nas"
57+ assert boitiers["Fujifilm X100V"] == "modele" # inchangé
58+ conn.close()
59+
60+
61+def test_parcourir_nas_est_idempotent(tmp_path: Path) -> None:
62+ archive_root = tmp_path / "archive"
63+ dossier = archive_root / "2020" / "2020-01-01_Ancien"
64+ dossier.mkdir(parents=True)
65+ _fichier_avec_modele(dossier / "IMG_0001.JPG", "Canon EOS 90D")
66+ conn = open_context_db(tmp_path / "contexte.sqlite3")
67+
68+ premier_parcours = parcourir_nas(conn, archive_root)
69+ second_parcours = parcourir_nas(conn, archive_root)
70+
71+ assert premier_parcours == 1
72+ assert second_parcours == 0
73+ assert len(list_boitiers(conn)) == 1
74+ conn.close()
added packages/regine-core/tests/integration/test_config_context.py +82 -0
new file mode 100644
@@ -0,0 +1,82 @@
1+"""Test d'intégration du cycle configuration -> initialisation -> changement bloqué (T014).
2+
3+Couvre les Acceptance Scenarios 1 à 5 de User Story 1 (spec.md) comme un seul
4+parcours cohérent, plutôt que de re-tester chaque règle isolément (déjà couvert au
5+niveau unitaire par test_context_paths.py).
6+"""
7+
8+from __future__ import annotations
9+
10+from pathlib import Path
11+
12+import pytest
13+from regine_core.archive.checkout import checkout
14+from regine_core.config.context import (
15+ TravailNonReconcilieError,
16+ charger_contexte,
17+ enregistrer_chemins,
18+)
19+from regine_core.config.db import open_context_db
20+
21+
22+def test_cycle_configuration_initialisation_et_changement_bloque(tmp_path: Path) -> None:
23+ conn = open_context_db(tmp_path / "contexte.sqlite3")
24+
25+ # Scenario 1 : installation neuve, configuration des 3 emplacements.
26+ temp_dir = tmp_path / "tmp"
27+ local_work_dir = tmp_path / "travail"
28+ archive_smb_path = "smb://nas.local/regine-archive"
29+
30+ # Scenario 2 : les répertoires n'existent pas encore -> proposés à la création.
31+ assert not temp_dir.exists()
32+ assert not local_work_dir.exists()
33+
34+ contexte = enregistrer_chemins(
35+ conn,
36+ temp_dir=temp_dir,
37+ local_work_dir=local_work_dir,
38+ archive_smb_path=archive_smb_path,
39+ creer_repertoires_manquants=True,
40+ )
41+ assert temp_dir.exists()
42+ assert local_work_dir.exists()
43+
44+ # Scenario 3 : une base de travail est prête à la racine du répertoire local,
45+ # avant qu'aucun import/checkout n'en ait eu besoin (FR-006/SC-003).
46+ assert (local_work_dir / ".regine-manifest.sqlite3").exists()
47+
48+ # Une autre fonctionnalité (import, cf. specs/001) peut relire ce contexte sans
49+ # reconfiguration supplémentaire : simulé ici par un simple rechargement.
50+ relu = charger_contexte(conn)
51+ assert relu.temp_dir == temp_dir
52+ assert relu.local_work_dir == local_work_dir
53+ assert relu.archive_smb_path == archive_smb_path
54+
55+ # Scenario 4 : modifier un chemin ne perd pas les autres paramètres déjà définis.
56+ nouveau_temp = tmp_path / "tmp2"
57+ contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True)
58+ assert contexte.temp_dir == nouveau_temp
59+ assert contexte.local_work_dir == local_work_dir
60+ assert contexte.archive_smb_path == archive_smb_path
61+
62+ # Scenario 5 : un checkout non réconcilié sous local_work_dir bloque son
63+ # changement, en indiquant précisément le dossier concerné.
64+ archive_root_local = tmp_path / "archive"
65+ (archive_root_local / "2026-08-15_Sortie").mkdir(parents=True)
66+ (archive_root_local / "2026-08-15_Sortie" / "photo.jpg").write_bytes(b"contenu")
67+ dest_locale = local_work_dir / "2026-08-15_Sortie"
68+ checkout(archive_root_local / "2026-08-15_Sortie", dest_locale)
69+
70+ import regine_core.config.smb as smb_module
71+
72+ original_chemin_local_du_partage = smb_module.chemin_local_du_partage
73+ smb_module.chemin_local_du_partage = lambda _chemin: archive_root_local
74+ try:
75+ with pytest.raises(TravailNonReconcilieError) as exc_info:
76+ enregistrer_chemins(
77+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
78+ )
79+ assert dest_locale in exc_info.value.dossiers
80+ finally:
81+ smb_module.chemin_local_du_partage = original_chemin_local_du_partage
82+ conn.close()
new file mode 100644
@@ -0,0 +1,82 @@
1+"""Test d'intégration du cycle configuration -> initialisation -> changement bloqué (T014).
2+
3+Couvre les Acceptance Scenarios 1 à 5 de User Story 1 (spec.md) comme un seul
4+parcours cohérent, plutôt que de re-tester chaque règle isolément (déjà couvert au
5+niveau unitaire par test_context_paths.py).
6+"""
7+
8+from __future__ import annotations
9+
10+from pathlib import Path
11+
12+import pytest
13+from regine_core.archive.checkout import checkout
14+from regine_core.config.context import (
15+ TravailNonReconcilieError,
16+ charger_contexte,
17+ enregistrer_chemins,
18+)
19+from regine_core.config.db import open_context_db
20+
21+
22+def test_cycle_configuration_initialisation_et_changement_bloque(tmp_path: Path) -> None:
23+ conn = open_context_db(tmp_path / "contexte.sqlite3")
24+
25+ # Scenario 1 : installation neuve, configuration des 3 emplacements.
26+ temp_dir = tmp_path / "tmp"
27+ local_work_dir = tmp_path / "travail"
28+ archive_smb_path = "smb://nas.local/regine-archive"
29+
30+ # Scenario 2 : les répertoires n'existent pas encore -> proposés à la création.
31+ assert not temp_dir.exists()
32+ assert not local_work_dir.exists()
33+
34+ contexte = enregistrer_chemins(
35+ conn,
36+ temp_dir=temp_dir,
37+ local_work_dir=local_work_dir,
38+ archive_smb_path=archive_smb_path,
39+ creer_repertoires_manquants=True,
40+ )
41+ assert temp_dir.exists()
42+ assert local_work_dir.exists()
43+
44+ # Scenario 3 : une base de travail est prête à la racine du répertoire local,
45+ # avant qu'aucun import/checkout n'en ait eu besoin (FR-006/SC-003).
46+ assert (local_work_dir / ".regine-manifest.sqlite3").exists()
47+
48+ # Une autre fonctionnalité (import, cf. specs/001) peut relire ce contexte sans
49+ # reconfiguration supplémentaire : simulé ici par un simple rechargement.
50+ relu = charger_contexte(conn)
51+ assert relu.temp_dir == temp_dir
52+ assert relu.local_work_dir == local_work_dir
53+ assert relu.archive_smb_path == archive_smb_path
54+
55+ # Scenario 4 : modifier un chemin ne perd pas les autres paramètres déjà définis.
56+ nouveau_temp = tmp_path / "tmp2"
57+ contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True)
58+ assert contexte.temp_dir == nouveau_temp
59+ assert contexte.local_work_dir == local_work_dir
60+ assert contexte.archive_smb_path == archive_smb_path
61+
62+ # Scenario 5 : un checkout non réconcilié sous local_work_dir bloque son
63+ # changement, en indiquant précisément le dossier concerné.
64+ archive_root_local = tmp_path / "archive"
65+ (archive_root_local / "2026-08-15_Sortie").mkdir(parents=True)
66+ (archive_root_local / "2026-08-15_Sortie" / "photo.jpg").write_bytes(b"contenu")
67+ dest_locale = local_work_dir / "2026-08-15_Sortie"
68+ checkout(archive_root_local / "2026-08-15_Sortie", dest_locale)
69+
70+ import regine_core.config.smb as smb_module
71+
72+ original_chemin_local_du_partage = smb_module.chemin_local_du_partage
73+ smb_module.chemin_local_du_partage = lambda _chemin: archive_root_local
74+ try:
75+ with pytest.raises(TravailNonReconcilieError) as exc_info:
76+ enregistrer_chemins(
77+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
78+ )
79+ assert dest_locale in exc_info.value.dossiers
80+ finally:
81+ smb_module.chemin_local_du_partage = original_chemin_local_du_partage
82+ conn.close()
added packages/regine-core/tests/unit/test_cameras_screen.py +40 -0
new file mode 100644
@@ -0,0 +1,40 @@
1+"""Tests de `regine_core.config.cameras_screen` (T022)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.camera_profile.db import get_or_create_boitier
9+from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier
10+from regine_core.config.db import open_context_db
11+
12+
13+def test_lister_boitiers_reflete_ceux_deja_enregistres(tmp_path: Path) -> None:
14+ conn = open_context_db(tmp_path / "contexte.sqlite3")
15+ get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
16+
17+ boitiers = lister_boitiers(conn)
18+
19+ assert len(boitiers) == 1
20+ assert boitiers[0].modele == "Fujifilm X100V"
21+ assert boitiers[0].nom_lisible is None
22+ conn.close()
23+
24+
25+def test_nommer_boitier_connu(tmp_path: Path) -> None:
26+ conn = open_context_db(tmp_path / "contexte.sqlite3")
27+ boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", None, source="modele")
28+
29+ nommer_boitier(conn, boitier_id, "Fuji principal")
30+
31+ assert lister_boitiers(conn)[0].nom_lisible == "Fuji principal"
32+ conn.close()
33+
34+
35+def test_nommer_boitier_inconnu_leve(tmp_path: Path) -> None:
36+ conn = open_context_db(tmp_path / "contexte.sqlite3")
37+
38+ with pytest.raises(BoitierInconnuError):
39+ nommer_boitier(conn, 999, "Nom")
40+ conn.close()
new file mode 100644
@@ -0,0 +1,40 @@
1+"""Tests de `regine_core.config.cameras_screen` (T022)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.camera_profile.db import get_or_create_boitier
9+from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier
10+from regine_core.config.db import open_context_db
11+
12+
13+def test_lister_boitiers_reflete_ceux_deja_enregistres(tmp_path: Path) -> None:
14+ conn = open_context_db(tmp_path / "contexte.sqlite3")
15+ get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele")
16+
17+ boitiers = lister_boitiers(conn)
18+
19+ assert len(boitiers) == 1
20+ assert boitiers[0].modele == "Fujifilm X100V"
21+ assert boitiers[0].nom_lisible is None
22+ conn.close()
23+
24+
25+def test_nommer_boitier_connu(tmp_path: Path) -> None:
26+ conn = open_context_db(tmp_path / "contexte.sqlite3")
27+ boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", None, source="modele")
28+
29+ nommer_boitier(conn, boitier_id, "Fuji principal")
30+
31+ assert lister_boitiers(conn)[0].nom_lisible == "Fuji principal"
32+ conn.close()
33+
34+
35+def test_nommer_boitier_inconnu_leve(tmp_path: Path) -> None:
36+ conn = open_context_db(tmp_path / "contexte.sqlite3")
37+
38+ with pytest.raises(BoitierInconnuError):
39+ nommer_boitier(conn, 999, "Nom")
40+ conn.close()
added packages/regine-core/tests/unit/test_config_db_defaut.py +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+"""Test du chemin par défaut de la base de contexte centralisée (T006)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import regine_core.config.db as db_module
8+from regine_core.config.db import APPLICATION_ID, open_context_db
9+
10+
11+def test_open_context_db_sans_argument_utilise_le_chemin_par_defaut(
12+ tmp_path: Path, monkeypatch
13+) -> None:
14+ chemin_attendu = tmp_path / "home" / ".regine" / "contexte.sqlite3"
15+ monkeypatch.setattr(db_module, "CHEMIN_CONTEXTE_PAR_DEFAUT", chemin_attendu)
16+
17+ conn = open_context_db()
18+
19+ assert chemin_attendu.exists()
20+ assert conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID
21+ conn.close()
22+
23+
24+def test_open_context_db_avec_argument_explicite_ignore_le_defaut(tmp_path: Path) -> None:
25+ chemin_explicite = tmp_path / "autre" / "contexte.sqlite3"
26+
27+ conn = open_context_db(chemin_explicite)
28+
29+ assert chemin_explicite.exists()
30+ conn.close()
new file mode 100644
@@ -0,0 +1,30 @@
1+"""Test du chemin par défaut de la base de contexte centralisée (T006)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import regine_core.config.db as db_module
8+from regine_core.config.db import APPLICATION_ID, open_context_db
9+
10+
11+def test_open_context_db_sans_argument_utilise_le_chemin_par_defaut(
12+ tmp_path: Path, monkeypatch
13+) -> None:
14+ chemin_attendu = tmp_path / "home" / ".regine" / "contexte.sqlite3"
15+ monkeypatch.setattr(db_module, "CHEMIN_CONTEXTE_PAR_DEFAUT", chemin_attendu)
16+
17+ conn = open_context_db()
18+
19+ assert chemin_attendu.exists()
20+ assert conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID
21+ conn.close()
22+
23+
24+def test_open_context_db_avec_argument_explicite_ignore_le_defaut(tmp_path: Path) -> None:
25+ chemin_explicite = tmp_path / "autre" / "contexte.sqlite3"
26+
27+ conn = open_context_db(chemin_explicite)
28+
29+ assert chemin_explicite.exists()
30+ conn.close()
added packages/regine-core/tests/unit/test_context_paths.py +257 -0
new file mode 100644
@@ -0,0 +1,257 @@
1+"""Tests de `regine_core.config.context` (T012, T013)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive.checkout import checkout
9+from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer
10+from regine_core.config.context import (
11+ ArchiveInaccessibleError,
12+ CheminArchiveInvalideError,
13+ CheminsAmbigusError,
14+ RepertoireAbsentError,
15+ TravailNonReconcilieError,
16+ charger_contexte,
17+ enregistrer_chemins,
18+ lister_dossiers_non_reconcilies,
19+ mettre_a_jour_etat_smb,
20+)
21+from regine_core.config.db import open_context_db
22+
23+
24+def _conn(tmp_path: Path):
25+ return open_context_db(tmp_path / "contexte.sqlite3")
26+
27+
28+def test_charger_contexte_vide_par_defaut(tmp_path: Path) -> None:
29+ conn = _conn(tmp_path)
30+
31+ contexte = charger_contexte(conn)
32+
33+ assert contexte.temp_dir is None
34+ assert contexte.local_work_dir is None
35+ assert contexte.archive_smb_path is None
36+ assert contexte.smb_last_known_state == "unknown"
37+ conn.close()
38+
39+
40+def test_enregistrer_chemins_configuration_initiale(tmp_path: Path) -> None:
41+ conn = _conn(tmp_path)
42+ temp_dir = tmp_path / "tmp"
43+ local_work_dir = tmp_path / "travail"
44+
45+ contexte = enregistrer_chemins(
46+ conn,
47+ temp_dir=temp_dir,
48+ local_work_dir=local_work_dir,
49+ archive_smb_path="smb://nas.local/regine-archive",
50+ creer_repertoires_manquants=True,
51+ )
52+
53+ assert contexte.temp_dir == temp_dir
54+ assert contexte.local_work_dir == local_work_dir
55+ assert contexte.archive_smb_path == "smb://nas.local/regine-archive"
56+ assert temp_dir.exists()
57+ assert local_work_dir.exists()
58+ assert (local_work_dir / ".regine-manifest.sqlite3").exists() # FR-006/SC-003
59+ conn.close()
60+
61+
62+def test_enregistrer_chemins_repertoire_absent_sans_creation_leve(tmp_path: Path) -> None:
63+ conn = _conn(tmp_path)
64+
65+ with pytest.raises(RepertoireAbsentError):
66+ enregistrer_chemins(conn, temp_dir=tmp_path / "absent")
67+ conn.close()
68+
69+
70+def test_enregistrer_chemins_repertoire_absent_avec_creation_reussit(tmp_path: Path) -> None:
71+ conn = _conn(tmp_path)
72+ chemin = tmp_path / "nouveau"
73+
74+ enregistrer_chemins(conn, temp_dir=chemin, creer_repertoires_manquants=True)
75+
76+ assert chemin.exists()
77+ conn.close()
78+
79+
80+def test_enregistrer_chemins_collision_sans_force_leve(tmp_path: Path) -> None:
81+ conn = _conn(tmp_path)
82+ meme_chemin = tmp_path / "meme"
83+
84+ with pytest.raises(CheminsAmbigusError):
85+ enregistrer_chemins(
86+ conn,
87+ temp_dir=meme_chemin,
88+ local_work_dir=meme_chemin,
89+ creer_repertoires_manquants=True,
90+ )
91+ conn.close()
92+
93+
94+def test_enregistrer_chemins_collision_avec_force_reussit(tmp_path: Path) -> None:
95+ conn = _conn(tmp_path)
96+ meme_chemin = tmp_path / "meme"
97+
98+ contexte = enregistrer_chemins(
99+ conn,
100+ temp_dir=meme_chemin,
101+ local_work_dir=meme_chemin,
102+ creer_repertoires_manquants=True,
103+ forcer_collision=True,
104+ )
105+
106+ assert contexte.temp_dir == meme_chemin
107+ assert contexte.local_work_dir == meme_chemin
108+ conn.close()
109+
110+
111+def test_enregistrer_chemins_archive_smb_invalide_leve(tmp_path: Path) -> None:
112+ conn = _conn(tmp_path)
113+
114+ with pytest.raises(CheminArchiveInvalideError):
115+ enregistrer_chemins(conn, archive_smb_path="/Volumes/regine-archive")
116+ conn.close()
117+
118+
119+def test_enregistrer_chemins_conserve_les_champs_non_fournis(tmp_path: Path) -> None:
120+ conn = _conn(tmp_path)
121+ temp_dir = tmp_path / "tmp"
122+ local_work_dir = tmp_path / "travail"
123+ enregistrer_chemins(
124+ conn,
125+ temp_dir=temp_dir,
126+ local_work_dir=local_work_dir,
127+ archive_smb_path="smb://nas.local/regine-archive",
128+ creer_repertoires_manquants=True,
129+ )
130+
131+ nouveau_temp = tmp_path / "tmp2"
132+ contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True)
133+
134+ assert contexte.temp_dir == nouveau_temp
135+ assert contexte.local_work_dir == local_work_dir # SC-005 : inchangé
136+ assert contexte.archive_smb_path == "smb://nas.local/regine-archive"
137+ conn.close()
138+
139+
140+def test_mettre_a_jour_etat_smb(tmp_path: Path) -> None:
141+ conn = _conn(tmp_path)
142+
143+ mettre_a_jour_etat_smb(conn, "unavailable")
144+
145+ assert charger_contexte(conn).smb_last_known_state == "unavailable"
146+ conn.close()
147+
148+
149+def test_lister_dossiers_non_reconcilies_detecte_un_dossier_verrouille(tmp_path: Path) -> None:
150+ archive_root_local = tmp_path / "archive"
151+ archive_root = archive_root_local / "2026-08-15_Sortie"
152+ archive_root.mkdir(parents=True)
153+ (archive_root / "photo.jpg").write_bytes(b"contenu")
154+ local_work_dir = tmp_path / "travail"
155+ dest_locale = local_work_dir / "2026-08-15_Sortie"
156+
157+ checkout(archive_root, dest_locale) # pose le verrou, jamais réconcilié ensuite
158+
159+ assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [dest_locale]
160+
161+
162+def test_lister_dossiers_non_reconcilies_ignore_un_dossier_reconcilie(tmp_path: Path) -> None:
163+ archive_root_local = tmp_path / "archive"
164+ archive_root = archive_root_local / "2026-08-15_Sortie"
165+ archive_root.mkdir(parents=True)
166+ (archive_root / "photo.jpg").write_bytes(b"contenu")
167+ local_work_dir = tmp_path / "travail"
168+ dest_locale = local_work_dir / "2026-08-15_Sortie"
169+
170+ snapshot = checkout(archive_root, dest_locale)
171+ rapport = comparer(snapshot, dest_locale)
172+ archiver(snapshot, rapport, DecisionsUtilisateur()) # lève le verrou
173+
174+ assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == []
175+
176+
177+def test_lister_dossiers_non_reconcilies_leve_si_archive_inaccessible(tmp_path: Path) -> None:
178+ with pytest.raises(ArchiveInaccessibleError):
179+ lister_dossiers_non_reconcilies(tmp_path / "travail", tmp_path / "archive-absente")
180+
181+
182+def test_enregistrer_chemins_refuse_local_work_dir_si_travail_non_reconcilie(
183+ tmp_path: Path, monkeypatch
184+) -> None:
185+ conn = _conn(tmp_path)
186+ archive_root_local = tmp_path / "archive"
187+ archive_root = archive_root_local / "2026-08-15_Sortie"
188+ archive_root.mkdir(parents=True)
189+ (archive_root / "photo.jpg").write_bytes(b"contenu")
190+ ancien_local = tmp_path / "travail"
191+ dest_locale = ancien_local / "2026-08-15_Sortie"
192+ checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié
193+
194+ import regine_core.config.smb as smb_module
195+
196+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local)
197+
198+ enregistrer_chemins(
199+ conn,
200+ local_work_dir=ancien_local,
201+ archive_smb_path="smb://nas.local/archive",
202+ creer_repertoires_manquants=True,
203+ )
204+
205+ with pytest.raises(TravailNonReconcilieError) as exc_info:
206+ enregistrer_chemins(
207+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
208+ )
209+ assert exc_info.value.dossiers == [dest_locale]
210+ conn.close()
211+
212+
213+def test_enregistrer_chemins_sans_archive_configuree_ne_verifie_rien(tmp_path: Path) -> None:
214+ """Aucune archive configurée : rien à vérifier, le changement est autorisé."""
215+ conn = _conn(tmp_path)
216+ ancien_local = tmp_path / "travail"
217+ enregistrer_chemins(conn, local_work_dir=ancien_local, creer_repertoires_manquants=True)
218+
219+ contexte = enregistrer_chemins(
220+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
221+ )
222+
223+ assert contexte.local_work_dir == tmp_path / "autre-travail"
224+ conn.close()
225+
226+
227+def test_enregistrer_chemins_ignorer_verification_archive_passe_outre(
228+ tmp_path: Path, monkeypatch
229+) -> None:
230+ conn = _conn(tmp_path)
231+ archive_root_local = tmp_path / "archive"
232+ archive_root = archive_root_local / "2026-08-15_Sortie"
233+ archive_root.mkdir(parents=True)
234+ (archive_root / "photo.jpg").write_bytes(b"contenu")
235+ ancien_local = tmp_path / "travail"
236+ dest_locale = ancien_local / "2026-08-15_Sortie"
237+ checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié
238+
239+ import regine_core.config.smb as smb_module
240+
241+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local)
242+ enregistrer_chemins(
243+ conn,
244+ local_work_dir=ancien_local,
245+ archive_smb_path="smb://nas.local/archive",
246+ creer_repertoires_manquants=True,
247+ )
248+
249+ contexte = enregistrer_chemins(
250+ conn,
251+ local_work_dir=tmp_path / "autre-travail",
252+ creer_repertoires_manquants=True,
253+ ignorer_verification_archive=True,
254+ )
255+
256+ assert contexte.local_work_dir == tmp_path / "autre-travail"
257+ conn.close()
new file mode 100644
@@ -0,0 +1,257 @@
1+"""Tests de `regine_core.config.context` (T012, T013)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import pytest
8+from regine_core.archive.checkout import checkout
9+from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer
10+from regine_core.config.context import (
11+ ArchiveInaccessibleError,
12+ CheminArchiveInvalideError,
13+ CheminsAmbigusError,
14+ RepertoireAbsentError,
15+ TravailNonReconcilieError,
16+ charger_contexte,
17+ enregistrer_chemins,
18+ lister_dossiers_non_reconcilies,
19+ mettre_a_jour_etat_smb,
20+)
21+from regine_core.config.db import open_context_db
22+
23+
24+def _conn(tmp_path: Path):
25+ return open_context_db(tmp_path / "contexte.sqlite3")
26+
27+
28+def test_charger_contexte_vide_par_defaut(tmp_path: Path) -> None:
29+ conn = _conn(tmp_path)
30+
31+ contexte = charger_contexte(conn)
32+
33+ assert contexte.temp_dir is None
34+ assert contexte.local_work_dir is None
35+ assert contexte.archive_smb_path is None
36+ assert contexte.smb_last_known_state == "unknown"
37+ conn.close()
38+
39+
40+def test_enregistrer_chemins_configuration_initiale(tmp_path: Path) -> None:
41+ conn = _conn(tmp_path)
42+ temp_dir = tmp_path / "tmp"
43+ local_work_dir = tmp_path / "travail"
44+
45+ contexte = enregistrer_chemins(
46+ conn,
47+ temp_dir=temp_dir,
48+ local_work_dir=local_work_dir,
49+ archive_smb_path="smb://nas.local/regine-archive",
50+ creer_repertoires_manquants=True,
51+ )
52+
53+ assert contexte.temp_dir == temp_dir
54+ assert contexte.local_work_dir == local_work_dir
55+ assert contexte.archive_smb_path == "smb://nas.local/regine-archive"
56+ assert temp_dir.exists()
57+ assert local_work_dir.exists()
58+ assert (local_work_dir / ".regine-manifest.sqlite3").exists() # FR-006/SC-003
59+ conn.close()
60+
61+
62+def test_enregistrer_chemins_repertoire_absent_sans_creation_leve(tmp_path: Path) -> None:
63+ conn = _conn(tmp_path)
64+
65+ with pytest.raises(RepertoireAbsentError):
66+ enregistrer_chemins(conn, temp_dir=tmp_path / "absent")
67+ conn.close()
68+
69+
70+def test_enregistrer_chemins_repertoire_absent_avec_creation_reussit(tmp_path: Path) -> None:
71+ conn = _conn(tmp_path)
72+ chemin = tmp_path / "nouveau"
73+
74+ enregistrer_chemins(conn, temp_dir=chemin, creer_repertoires_manquants=True)
75+
76+ assert chemin.exists()
77+ conn.close()
78+
79+
80+def test_enregistrer_chemins_collision_sans_force_leve(tmp_path: Path) -> None:
81+ conn = _conn(tmp_path)
82+ meme_chemin = tmp_path / "meme"
83+
84+ with pytest.raises(CheminsAmbigusError):
85+ enregistrer_chemins(
86+ conn,
87+ temp_dir=meme_chemin,
88+ local_work_dir=meme_chemin,
89+ creer_repertoires_manquants=True,
90+ )
91+ conn.close()
92+
93+
94+def test_enregistrer_chemins_collision_avec_force_reussit(tmp_path: Path) -> None:
95+ conn = _conn(tmp_path)
96+ meme_chemin = tmp_path / "meme"
97+
98+ contexte = enregistrer_chemins(
99+ conn,
100+ temp_dir=meme_chemin,
101+ local_work_dir=meme_chemin,
102+ creer_repertoires_manquants=True,
103+ forcer_collision=True,
104+ )
105+
106+ assert contexte.temp_dir == meme_chemin
107+ assert contexte.local_work_dir == meme_chemin
108+ conn.close()
109+
110+
111+def test_enregistrer_chemins_archive_smb_invalide_leve(tmp_path: Path) -> None:
112+ conn = _conn(tmp_path)
113+
114+ with pytest.raises(CheminArchiveInvalideError):
115+ enregistrer_chemins(conn, archive_smb_path="/Volumes/regine-archive")
116+ conn.close()
117+
118+
119+def test_enregistrer_chemins_conserve_les_champs_non_fournis(tmp_path: Path) -> None:
120+ conn = _conn(tmp_path)
121+ temp_dir = tmp_path / "tmp"
122+ local_work_dir = tmp_path / "travail"
123+ enregistrer_chemins(
124+ conn,
125+ temp_dir=temp_dir,
126+ local_work_dir=local_work_dir,
127+ archive_smb_path="smb://nas.local/regine-archive",
128+ creer_repertoires_manquants=True,
129+ )
130+
131+ nouveau_temp = tmp_path / "tmp2"
132+ contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True)
133+
134+ assert contexte.temp_dir == nouveau_temp
135+ assert contexte.local_work_dir == local_work_dir # SC-005 : inchangé
136+ assert contexte.archive_smb_path == "smb://nas.local/regine-archive"
137+ conn.close()
138+
139+
140+def test_mettre_a_jour_etat_smb(tmp_path: Path) -> None:
141+ conn = _conn(tmp_path)
142+
143+ mettre_a_jour_etat_smb(conn, "unavailable")
144+
145+ assert charger_contexte(conn).smb_last_known_state == "unavailable"
146+ conn.close()
147+
148+
149+def test_lister_dossiers_non_reconcilies_detecte_un_dossier_verrouille(tmp_path: Path) -> None:
150+ archive_root_local = tmp_path / "archive"
151+ archive_root = archive_root_local / "2026-08-15_Sortie"
152+ archive_root.mkdir(parents=True)
153+ (archive_root / "photo.jpg").write_bytes(b"contenu")
154+ local_work_dir = tmp_path / "travail"
155+ dest_locale = local_work_dir / "2026-08-15_Sortie"
156+
157+ checkout(archive_root, dest_locale) # pose le verrou, jamais réconcilié ensuite
158+
159+ assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [dest_locale]
160+
161+
162+def test_lister_dossiers_non_reconcilies_ignore_un_dossier_reconcilie(tmp_path: Path) -> None:
163+ archive_root_local = tmp_path / "archive"
164+ archive_root = archive_root_local / "2026-08-15_Sortie"
165+ archive_root.mkdir(parents=True)
166+ (archive_root / "photo.jpg").write_bytes(b"contenu")
167+ local_work_dir = tmp_path / "travail"
168+ dest_locale = local_work_dir / "2026-08-15_Sortie"
169+
170+ snapshot = checkout(archive_root, dest_locale)
171+ rapport = comparer(snapshot, dest_locale)
172+ archiver(snapshot, rapport, DecisionsUtilisateur()) # lève le verrou
173+
174+ assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == []
175+
176+
177+def test_lister_dossiers_non_reconcilies_leve_si_archive_inaccessible(tmp_path: Path) -> None:
178+ with pytest.raises(ArchiveInaccessibleError):
179+ lister_dossiers_non_reconcilies(tmp_path / "travail", tmp_path / "archive-absente")
180+
181+
182+def test_enregistrer_chemins_refuse_local_work_dir_si_travail_non_reconcilie(
183+ tmp_path: Path, monkeypatch
184+) -> None:
185+ conn = _conn(tmp_path)
186+ archive_root_local = tmp_path / "archive"
187+ archive_root = archive_root_local / "2026-08-15_Sortie"
188+ archive_root.mkdir(parents=True)
189+ (archive_root / "photo.jpg").write_bytes(b"contenu")
190+ ancien_local = tmp_path / "travail"
191+ dest_locale = ancien_local / "2026-08-15_Sortie"
192+ checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié
193+
194+ import regine_core.config.smb as smb_module
195+
196+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local)
197+
198+ enregistrer_chemins(
199+ conn,
200+ local_work_dir=ancien_local,
201+ archive_smb_path="smb://nas.local/archive",
202+ creer_repertoires_manquants=True,
203+ )
204+
205+ with pytest.raises(TravailNonReconcilieError) as exc_info:
206+ enregistrer_chemins(
207+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
208+ )
209+ assert exc_info.value.dossiers == [dest_locale]
210+ conn.close()
211+
212+
213+def test_enregistrer_chemins_sans_archive_configuree_ne_verifie_rien(tmp_path: Path) -> None:
214+ """Aucune archive configurée : rien à vérifier, le changement est autorisé."""
215+ conn = _conn(tmp_path)
216+ ancien_local = tmp_path / "travail"
217+ enregistrer_chemins(conn, local_work_dir=ancien_local, creer_repertoires_manquants=True)
218+
219+ contexte = enregistrer_chemins(
220+ conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True
221+ )
222+
223+ assert contexte.local_work_dir == tmp_path / "autre-travail"
224+ conn.close()
225+
226+
227+def test_enregistrer_chemins_ignorer_verification_archive_passe_outre(
228+ tmp_path: Path, monkeypatch
229+) -> None:
230+ conn = _conn(tmp_path)
231+ archive_root_local = tmp_path / "archive"
232+ archive_root = archive_root_local / "2026-08-15_Sortie"
233+ archive_root.mkdir(parents=True)
234+ (archive_root / "photo.jpg").write_bytes(b"contenu")
235+ ancien_local = tmp_path / "travail"
236+ dest_locale = ancien_local / "2026-08-15_Sortie"
237+ checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié
238+
239+ import regine_core.config.smb as smb_module
240+
241+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local)
242+ enregistrer_chemins(
243+ conn,
244+ local_work_dir=ancien_local,
245+ archive_smb_path="smb://nas.local/archive",
246+ creer_repertoires_manquants=True,
247+ )
248+
249+ contexte = enregistrer_chemins(
250+ conn,
251+ local_work_dir=tmp_path / "autre-travail",
252+ creer_repertoires_manquants=True,
253+ ignorer_verification_archive=True,
254+ )
255+
256+ assert contexte.local_work_dir == tmp_path / "autre-travail"
257+ conn.close()
added packages/regine-core/tests/unit/test_smb_disponibilite.py +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+"""Tests de `regine_core.config.smb` (T019)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import regine_core.config.smb as smb_module
8+from regine_core.config.context import charger_contexte
9+from regine_core.config.db import open_context_db
10+from regine_core.config.smb import assurer_disponibilite, disponible
11+
12+
13+def test_chemin_local_du_partage_derive_le_point_de_montage_macos() -> None:
14+ assert smb_module.chemin_local_du_partage("smb://nas.local/regine-archive") == Path(
15+ "/Volumes/regine-archive"
16+ )
17+
18+
19+def test_disponible_vrai_si_le_point_de_montage_existe(tmp_path: Path, monkeypatch) -> None:
20+ point_de_montage = tmp_path / "regine-archive"
21+ point_de_montage.mkdir()
22+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage)
23+
24+ assert disponible("smb://nas.local/regine-archive") is True
25+
26+
27+def test_disponible_faux_si_le_point_de_montage_absent(tmp_path: Path, monkeypatch) -> None:
28+ monkeypatch.setattr(
29+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
30+ )
31+
32+ assert disponible("smb://nas.local/regine-archive") is False
33+
34+
35+def test_assurer_disponibilite_met_a_jour_l_etat_mounted(tmp_path: Path, monkeypatch) -> None:
36+ point_de_montage = tmp_path / "regine-archive"
37+ point_de_montage.mkdir()
38+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage)
39+ conn = open_context_db(tmp_path / "contexte.sqlite3")
40+
41+ resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive")
42+
43+ assert resultat is True
44+ assert charger_contexte(conn).smb_last_known_state == "mounted"
45+ conn.close()
46+
47+
48+def test_assurer_disponibilite_declenche_le_montage_si_indisponible(
49+ tmp_path: Path, monkeypatch
50+) -> None:
51+ monkeypatch.setattr(
52+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
53+ )
54+ appels: list[str] = []
55+ monkeypatch.setattr(smb_module, "declencher_montage", appels.append)
56+ conn = open_context_db(tmp_path / "contexte.sqlite3")
57+
58+ resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive")
59+
60+ assert resultat is False
61+ assert appels == ["smb://nas.local/regine-archive"]
62+ assert charger_contexte(conn).smb_last_known_state == "unavailable"
63+ conn.close()
64+
65+
66+def test_assurer_disponibilite_sans_declenchement(tmp_path: Path, monkeypatch) -> None:
67+ monkeypatch.setattr(
68+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
69+ )
70+ appels: list[str] = []
71+ monkeypatch.setattr(smb_module, "declencher_montage", appels.append)
72+ conn = open_context_db(tmp_path / "contexte.sqlite3")
73+
74+ assurer_disponibilite(conn, "smb://nas.local/regine-archive", declencher=False)
75+
76+ assert appels == []
77+ conn.close()
new file mode 100644
@@ -0,0 +1,77 @@
1+"""Tests de `regine_core.config.smb` (T019)."""
2+
3+from __future__ import annotations
4+
5+from pathlib import Path
6+
7+import regine_core.config.smb as smb_module
8+from regine_core.config.context import charger_contexte
9+from regine_core.config.db import open_context_db
10+from regine_core.config.smb import assurer_disponibilite, disponible
11+
12+
13+def test_chemin_local_du_partage_derive_le_point_de_montage_macos() -> None:
14+ assert smb_module.chemin_local_du_partage("smb://nas.local/regine-archive") == Path(
15+ "/Volumes/regine-archive"
16+ )
17+
18+
19+def test_disponible_vrai_si_le_point_de_montage_existe(tmp_path: Path, monkeypatch) -> None:
20+ point_de_montage = tmp_path / "regine-archive"
21+ point_de_montage.mkdir()
22+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage)
23+
24+ assert disponible("smb://nas.local/regine-archive") is True
25+
26+
27+def test_disponible_faux_si_le_point_de_montage_absent(tmp_path: Path, monkeypatch) -> None:
28+ monkeypatch.setattr(
29+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
30+ )
31+
32+ assert disponible("smb://nas.local/regine-archive") is False
33+
34+
35+def test_assurer_disponibilite_met_a_jour_l_etat_mounted(tmp_path: Path, monkeypatch) -> None:
36+ point_de_montage = tmp_path / "regine-archive"
37+ point_de_montage.mkdir()
38+ monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage)
39+ conn = open_context_db(tmp_path / "contexte.sqlite3")
40+
41+ resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive")
42+
43+ assert resultat is True
44+ assert charger_contexte(conn).smb_last_known_state == "mounted"
45+ conn.close()
46+
47+
48+def test_assurer_disponibilite_declenche_le_montage_si_indisponible(
49+ tmp_path: Path, monkeypatch
50+) -> None:
51+ monkeypatch.setattr(
52+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
53+ )
54+ appels: list[str] = []
55+ monkeypatch.setattr(smb_module, "declencher_montage", appels.append)
56+ conn = open_context_db(tmp_path / "contexte.sqlite3")
57+
58+ resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive")
59+
60+ assert resultat is False
61+ assert appels == ["smb://nas.local/regine-archive"]
62+ assert charger_contexte(conn).smb_last_known_state == "unavailable"
63+ conn.close()
64+
65+
66+def test_assurer_disponibilite_sans_declenchement(tmp_path: Path, monkeypatch) -> None:
67+ monkeypatch.setattr(
68+ smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout"
69+ )
70+ appels: list[str] = []
71+ monkeypatch.setattr(smb_module, "declencher_montage", appels.append)
72+ conn = open_context_db(tmp_path / "contexte.sqlite3")
73+
74+ assurer_disponibilite(conn, "smb://nas.local/regine-archive", declencher=False)
75+
76+ assert appels == []
77+ conn.close()
modified specs/003-config-contexte-travail/contracts/cli-config.md +8 -0
@@ -67,3 +67,11 @@ regine config cameras scan-nas [--confirm]
6767 **Sorties** :
6868 - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012.
6969 - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage).
70+
71+## Notes d'implémentation
72+
73+- `regine config set-paths` accepte aussi `--ignorer-verification-archive` (non prévu dans la version initiale de ce contrat) : permet de passer outre le refus `TravailNonReconcilieError`/`ArchiveInaccessibleError` en connaissance de cause, même geste explicite que `--force` pour la collision de chemins.
74+- `regine config show` sans `--json`, contexte incomplet : le message sur stdout liste explicitement les 3 noms de champs manquants (`temp_dir`, `local_work_dir`, `archive_smb_path`), code de sortie `1`.
75+- `regine config cameras list --json` retourne un tableau d'objets `{id, modele, numero_serie, nom_lisible, source}` (`source` peut valoir `modele`, `numero_serie`, `manuel` — désambiguïsation à l'import, specs/002 — ou `parcours_nas`, cf. `contracts/regine-core-api.md`).
76+- Toutes les sous-commandes acceptent `--contexte-db CHEMIN` (optionnel, défaut `~/.regine/contexte.sqlite3`) pour cibler une base de contexte différente — utile notamment pour les tests.
77+- **Scénario 4 du quickstart (montage/démontage SMB réel) non vérifiable dans l'environnement de développement de ce projet** (pas de partage SMB physique disponible) : le mécanisme (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté, et `regine config show` détecte correctement un partage réellement inexistant comme `unavailable` en conditions réelles (validé manuellement, cf. `tasks.md` T026).
@@ -67,3 +67,11 @@ regine config cameras scan-nas [--confirm]
67 **Sorties** :67 **Sorties** :
68 - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012.68 - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012.
69 - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage).69 - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage).
70+
71+## Notes d'implémentation
72+
73+- `regine config set-paths` accepte aussi `--ignorer-verification-archive` (non prévu dans la version initiale de ce contrat) : permet de passer outre le refus `TravailNonReconcilieError`/`ArchiveInaccessibleError` en connaissance de cause, même geste explicite que `--force` pour la collision de chemins.
74+- `regine config show` sans `--json`, contexte incomplet : le message sur stdout liste explicitement les 3 noms de champs manquants (`temp_dir`, `local_work_dir`, `archive_smb_path`), code de sortie `1`.
75+- `regine config cameras list --json` retourne un tableau d'objets `{id, modele, numero_serie, nom_lisible, source}` (`source` peut valoir `modele`, `numero_serie`, `manuel` — désambiguïsation à l'import, specs/002 — ou `parcours_nas`, cf. `contracts/regine-core-api.md`).
76+- Toutes les sous-commandes acceptent `--contexte-db CHEMIN` (optionnel, défaut `~/.regine/contexte.sqlite3`) pour cibler une base de contexte différente — utile notamment pour les tests.
77+- **Scénario 4 du quickstart (montage/démontage SMB réel) non vérifiable dans l'environnement de développement de ce projet** (pas de partage SMB physique disponible) : le mécanisme (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté, et `regine config show` détecte correctement un partage réellement inexistant comme `unavailable` en conditions réelles (validé manuellement, cf. `tasks.md` T026).
added specs/003-config-contexte-travail/contracts/regine-core-api.md +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+# Contrat d'API interne : `regine_core.config`
2+
3+Fonctions pures/orchestratrices consommées par `regine-cli` (`regine config ...`, cf. `contracts/cli-config.md`) — objets structurés, jamais de texte à parser (Principe VI).
4+
5+## `db.open_context_db(path: Path | None = None) -> sqlite3.Connection`
6+
7+Ouvre la base de contexte centralisée (chemins, boîtiers, catégories), en l'initialisant si nécessaire. Sans `path`, utilise `db.CHEMIN_CONTEXTE_PAR_DEFAUT` (`~/.regine/contexte.sqlite3`) — emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable). Déjà utilisée par `specs/001/002/004` avec un `path` explicite : l'ajout du défaut est rétrocompatible.
8+
9+## `context.charger_contexte(conn: sqlite3.Connection) -> ContexteTravail`
10+
11+Charge le contexte de travail actif. Retourne toujours un objet (jamais `None`) ; les champs valent `None`/`"unknown"` tant qu'aucune configuration n'a été enregistrée.
12+
13+## `context.enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail`
14+
15+Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). Ne modifie que les champs explicitement fournis (`None` = inchangé). Lève :
16+- `CheminArchiveInvalideError` si `archive_smb_path` ne commence pas par `smb://` ;
17+- `CheminsAmbigusError` si `temp_dir == local_work_dir` sans `forcer_collision=True` ;
18+- `TravailNonReconcilieError` (porte `.dossiers: list[Path]`) si un changement de `local_work_dir` laisserait du travail non réconcilié derrière lui ;
19+- `ArchiveInaccessibleError` si cette vérification est impossible faute d'accès à l'archive (sauf `ignorer_verification_archive=True`) ;
20+- `RepertoireAbsentError` (porte `.chemin`) si un répertoire n'existe pas et `creer_repertoires_manquants=False`.
21+
22+Initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003).
23+
24+## `context.lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]`
25+
26+Retourne les dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé côté archive (checkout/import non réconcilié, FR-010). Lève `ArchiveInaccessibleError` si `archive_root_local` n'est pas accessible.
27+
28+## `context.mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None`
29+
30+Met à jour l'état de disponibilité connu du partage SMB (FR-004/005).
31+
32+## `smb.chemin_local_du_partage(archive_smb_path: str) -> Path`
33+
34+Dérive le point de montage local macOS (`/Volumes/<partage>`) d'une URL `smb://hôte/partage` — convention best-effort, ne garantit pas que le partage soit effectivement monté.
35+
36+## `smb.disponible(archive_smb_path: str) -> bool` / `smb.declencher_montage(archive_smb_path: str) -> None` / `smb.assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool`
37+
38+Détection de disponibilité et déclenchement du montage natif (FR-004/005). `assurer_disponibilite` met à jour l'état connu et retourne `True` si déjà disponible, sinon déclenche le montage (si `declencher=True`) et retourne `False`. Une seule vérification par appel — pas de nouvelle tentative automatique en boucle.
39+
40+## `cameras_screen.lister_boitiers(conn) -> list[Boitier]` / `cameras_screen.nommer_boitier(conn, boitier_id: int, nom: str) -> None`
41+
42+Délèguent entièrement à `regine_core.camera_profile.db` (specs/002, qui possède la table `boitiers`). `nommer_boitier` lève `BoitierInconnuError` si `boitier_id` n'existe pas — vérification portée ici, car `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans ligne correspondante n'est pas une erreur SQL).
43+
44+## `cameras_screen.parcourir_nas(conn, archive_root: Path) -> int`
45+
46+Parcours volontaire de l'archive (FR-012) : lit le tag `Model` de chaque fichier maître (`regine_core.import_carte.copie.EXTENSIONS_MAITRES`), enregistre les boîtiers non encore connus avec `source="parcours_nas"`. Retourne le nombre de boîtiers réellement nouveaux. Idempotent (un second parcours identique n'ajoute rien).
47+
48+## Notes d'implémentation
49+
50+- **Écart architectural trouvé à l'implémentation (T009)** : la conception initiale de `lister_dossiers_non_reconcilies` prévoyait de parcourir `local_work_dir` à la recherche de manifestes. Le verrou/manifeste vit en réalité uniquement côté archive (`specs/005-checkout-reconciliation` ne le recopie jamais dans la copie de travail locale lors d'un checkout) : une recherche sous `local_work_dir` seul ne peut donc jamais rien trouver. Corrigé pour partir des manifestes de l'archive (via son point de montage local, `smb.chemin_local_du_partage`) et ne retenir que ceux ayant une copie locale présente.
51+- **Réutilisation de la colonne `source` de `camera_profile.db.boitiers`** : `data-model.md` (rédigé avant `specs/002`) envisageait `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 utilise déjà cette colonne pour une distinction différente (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Cette colonne texte libre n'a pas été migrée ni renommée (déjà testée par specs/002) : `parcourir_nas` y ajoute simplement la valeur `parcours_nas` en plus des valeurs existantes. À l'affichage (`cameras list`), toute valeur différente de `parcours_nas` est implicitement « détecté via import ».
52+- **Emplacement de la base de contexte centralisée** : non fixé par `research.md`. Décision prise à l'implémentation (T005) : `~/.regine/contexte.sqlite3`, fixe par utilisateur, indépendant de `local_work_dir` (qui reste modifiable, FR-010/SC-005, sans jamais risquer de perdre boîtiers/catégories déjà enregistrés).
53+- **Intégration avec `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) non construite ici** : ces façades prennent aujourd'hui `--archive-root`/`--local-root` en flags CLI explicites, indépendamment de ce contexte configuré. `FR-010` ne protège donc que le travail effectivement réalisé sous le `local_work_dir` configuré ici — cohérent tant que cette intégration (lire `regine config show` par défaut plutôt que d'exiger les flags) n'a pas été demandée séparément.
new file mode 100644
@@ -0,0 +1,53 @@
1+# Contrat d'API interne : `regine_core.config`
2+
3+Fonctions pures/orchestratrices consommées par `regine-cli` (`regine config ...`, cf. `contracts/cli-config.md`) — objets structurés, jamais de texte à parser (Principe VI).
4+
5+## `db.open_context_db(path: Path | None = None) -> sqlite3.Connection`
6+
7+Ouvre la base de contexte centralisée (chemins, boîtiers, catégories), en l'initialisant si nécessaire. Sans `path`, utilise `db.CHEMIN_CONTEXTE_PAR_DEFAUT` (`~/.regine/contexte.sqlite3`) — emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable). Déjà utilisée par `specs/001/002/004` avec un `path` explicite : l'ajout du défaut est rétrocompatible.
8+
9+## `context.charger_contexte(conn: sqlite3.Connection) -> ContexteTravail`
10+
11+Charge le contexte de travail actif. Retourne toujours un objet (jamais `None`) ; les champs valent `None`/`"unknown"` tant qu'aucune configuration n'a été enregistrée.
12+
13+## `context.enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail`
14+
15+Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). Ne modifie que les champs explicitement fournis (`None` = inchangé). Lève :
16+- `CheminArchiveInvalideError` si `archive_smb_path` ne commence pas par `smb://` ;
17+- `CheminsAmbigusError` si `temp_dir == local_work_dir` sans `forcer_collision=True` ;
18+- `TravailNonReconcilieError` (porte `.dossiers: list[Path]`) si un changement de `local_work_dir` laisserait du travail non réconcilié derrière lui ;
19+- `ArchiveInaccessibleError` si cette vérification est impossible faute d'accès à l'archive (sauf `ignorer_verification_archive=True`) ;
20+- `RepertoireAbsentError` (porte `.chemin`) si un répertoire n'existe pas et `creer_repertoires_manquants=False`.
21+
22+Initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003).
23+
24+## `context.lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]`
25+
26+Retourne les dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé côté archive (checkout/import non réconcilié, FR-010). Lève `ArchiveInaccessibleError` si `archive_root_local` n'est pas accessible.
27+
28+## `context.mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None`
29+
30+Met à jour l'état de disponibilité connu du partage SMB (FR-004/005).
31+
32+## `smb.chemin_local_du_partage(archive_smb_path: str) -> Path`
33+
34+Dérive le point de montage local macOS (`/Volumes/<partage>`) d'une URL `smb://hôte/partage` — convention best-effort, ne garantit pas que le partage soit effectivement monté.
35+
36+## `smb.disponible(archive_smb_path: str) -> bool` / `smb.declencher_montage(archive_smb_path: str) -> None` / `smb.assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool`
37+
38+Détection de disponibilité et déclenchement du montage natif (FR-004/005). `assurer_disponibilite` met à jour l'état connu et retourne `True` si déjà disponible, sinon déclenche le montage (si `declencher=True`) et retourne `False`. Une seule vérification par appel — pas de nouvelle tentative automatique en boucle.
39+
40+## `cameras_screen.lister_boitiers(conn) -> list[Boitier]` / `cameras_screen.nommer_boitier(conn, boitier_id: int, nom: str) -> None`
41+
42+Délèguent entièrement à `regine_core.camera_profile.db` (specs/002, qui possède la table `boitiers`). `nommer_boitier` lève `BoitierInconnuError` si `boitier_id` n'existe pas — vérification portée ici, car `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans ligne correspondante n'est pas une erreur SQL).
43+
44+## `cameras_screen.parcourir_nas(conn, archive_root: Path) -> int`
45+
46+Parcours volontaire de l'archive (FR-012) : lit le tag `Model` de chaque fichier maître (`regine_core.import_carte.copie.EXTENSIONS_MAITRES`), enregistre les boîtiers non encore connus avec `source="parcours_nas"`. Retourne le nombre de boîtiers réellement nouveaux. Idempotent (un second parcours identique n'ajoute rien).
47+
48+## Notes d'implémentation
49+
50+- **Écart architectural trouvé à l'implémentation (T009)** : la conception initiale de `lister_dossiers_non_reconcilies` prévoyait de parcourir `local_work_dir` à la recherche de manifestes. Le verrou/manifeste vit en réalité uniquement côté archive (`specs/005-checkout-reconciliation` ne le recopie jamais dans la copie de travail locale lors d'un checkout) : une recherche sous `local_work_dir` seul ne peut donc jamais rien trouver. Corrigé pour partir des manifestes de l'archive (via son point de montage local, `smb.chemin_local_du_partage`) et ne retenir que ceux ayant une copie locale présente.
51+- **Réutilisation de la colonne `source` de `camera_profile.db.boitiers`** : `data-model.md` (rédigé avant `specs/002`) envisageait `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 utilise déjà cette colonne pour une distinction différente (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Cette colonne texte libre n'a pas été migrée ni renommée (déjà testée par specs/002) : `parcourir_nas` y ajoute simplement la valeur `parcours_nas` en plus des valeurs existantes. À l'affichage (`cameras list`), toute valeur différente de `parcours_nas` est implicitement « détecté via import ».
52+- **Emplacement de la base de contexte centralisée** : non fixé par `research.md`. Décision prise à l'implémentation (T005) : `~/.regine/contexte.sqlite3`, fixe par utilisateur, indépendant de `local_work_dir` (qui reste modifiable, FR-010/SC-005, sans jamais risquer de perdre boîtiers/catégories déjà enregistrés).
53+- **Intégration avec `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) non construite ici** : ces façades prennent aujourd'hui `--archive-root`/`--local-root` en flags CLI explicites, indépendamment de ce contexte configuré. `FR-010` ne protège donc que le travail effectivement réalisé sous le `local_work_dir` configuré ici — cohérent tant que cette intégration (lire `regine config show` par défaut plutôt que d'exiger les flags) n'a pas été demandée séparément.
added specs/003-config-contexte-travail/tasks.md +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
1+---
2+
3+description: "Task list for feature implementation"
4+---
5+
6+# Tasks: Configuration du contexte de travail de Régine
7+
8+**Input**: Design documents from `/specs/003-config-contexte-travail/`
9+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
10+
11+**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération.
12+
13+**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune.
14+
15+## Dépendances inter-specs (à lire avant de commencer)
16+
17+| Dépendance | Fournit | État |
18+|---|---|---|
19+| `specs/004-categorisation-dossiers` | `regine_core.config.db.open_context_db` (base de contexte centralisée, déjà créée) | Implémentée |
20+| `specs/002-profil-boitiers-optionnel` | `regine_core.camera_profile.db` (table `boitiers`, `list_boitiers`, `rename_boitier`, `get_or_create_boitier`) — **possède** la table `boitiers`, cette spec ne fait que l'exposer (cf. plan.md « Correction du 2026-09-18 ») | Implémentée |
21+| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest` (manifeste persistant par dossier, `PRAGMA user_version`/`application_id`) et `regine_core.archive.verrou` (verrouillage) — réutilisés tels quels pour FR-006/FR-010 (détection de travail non réconcilié) | Implémentée |
22+| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par le parcours NAS, US3/FR-012, pour reconnaître un fichier maître sans dupliquer la liste) | Implémentée |
23+
24+## Écarts entre les documents de conception et l'état réel du code (à respecter pendant l'implémentation)
25+
26+- **`data-model.md` § Base de contexte centralisée** a été rédigé avant l'implémentation de `specs/002-profil-boitiers-optionnel` et décrit une colonne `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 (`camera_profile/db.py`) utilise déjà `source` pour une distinction différente et orthogonale (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Ne PAS renommer ni migrer cette colonne déjà testée : réutiliser la même colonne texte libre en y ajoutant la seule valeur manquante `parcours_nas` (FR-012) ; à l'affichage (`cameras list`), traiter toute valeur différente de `parcours_nas` comme « détecté via import » (FR-008).
27+- **research.md ne fixe pas l'emplacement de fichier de la base de contexte centralisée** (seulement son schéma). Décision prise dans cette liste de tâches (T005) : un emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable, cf. FR-010/SC-005 — la base de contexte ne doit jamais se perdre à un changement de chemin).
28+- **FR-006/Acceptance Scenario 3 (US1)** : lu littéralement (« une base de données de travail est créée... à la racine de ce répertoire »). Réutilise directement `regine_core.archive.manifest.ouvrir_ou_creer` sur `local_work_dir` lui-même (déjà générique, accepte n'importe quel dossier) plutôt que d'inventer un second mécanisme — cohérent avec l'Assumption de spec.md (« cette spécification couvre son initialisation... pas son schéma détaillé »). Le manifeste par dossier archivé (FR-007) reste géré indépendamment par `specs/001`/`specs/005`, déjà correct.
29+
30+## Phase 1: Setup
31+
32+- [X] T001 [P] Créer `packages/regine-core/src/regine_core/config/context.py` (squelette, docstring de module uniquement)
33+- [X] T002 [P] Créer `packages/regine-core/src/regine_core/config/smb.py` (squelette, docstring de module uniquement)
34+- [X] T003 [P] Créer `packages/regine-core/src/regine_core/config/cameras_screen.py` (squelette, docstring de module uniquement)
35+- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/config_cmd.py` (squelette, docstring de module uniquement) — et `packages/regine-cli/tests/contract/`, `[tool.pytest.ini_options] testpaths = ["tests"]` ajouté à `packages/regine-cli/pyproject.toml` (aucun test CLI automatisé n'existait encore dans le dépôt)
36+
37+---
38+
39+## Phase 2: Foundational (Blocking Prerequisites)
40+
41+**Objectif** : la persistance du contexte de travail (chemins + état SMB) et la détection de travail non réconcilié, dont dépendent les 3 user stories.
42+
43+**⚠️ CRITIQUE** : aucune user story ne peut être implémentée avant la fin de cette phase.
44+
45+- [X] T005 Étendre `packages/regine-core/src/regine_core/config/db.py` : ajouter `CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"` et rendre `open_context_db(path: Path | None = None)` optionnel (défaut : `CHEMIN_CONTEXTE_PAR_DEFAUT`), sans changer le comportement pour les appelants existants qui passent déjà un chemin explicite (specs/001/002/004)
46+- [X] T006 [P] Test unitaire de `open_context_db(None)` (utilise le chemin par défaut, le crée si absent) dans `packages/regine-core/tests/unit/test_config_db_defaut.py` — le test patch la constante de module `CHEMIN_CONTEXTE_PAR_DEFAUT` plutôt que `Path.home` (celle-ci est déjà évaluée à l'import, un monkeypatch après coup n'aurait aucun effet)
47+- [X] T007 Implémenter `ContexteTravail` (dataclass), le schéma de la table `contexte` (ligne unique, `id INTEGER PRIMARY KEY CHECK (id = 1)`) et `charger_contexte(conn) -> ContexteTravail` dans `packages/regine-core/src/regine_core/config/context.py` (data-model.md § Contexte de travail)
48+- [X] T008 Implémenter les exceptions dédiées dans `context.py` : `RepertoireAbsentError`, `CheminsAmbigusError`, `CheminArchiveInvalideError`, `TravailNonReconcilieError` (porte la liste des dossiers concernés) — `ArchiveInaccessibleError` ajoutée en plus (cf. note T009)
49+- [X] T009 Implémenter `lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` dans `context.py` — **écart avec la conception initiale découvert à l'implémentation** : le verrou/manifeste vit uniquement côté archive (`specs/005`, jamais recopié dans la copie de travail locale lors d'un checkout), donc parcourir `local_work_dir` seul ne peut jamais rien trouver. Corrigé : parcourt les manifestes de `archive_root_local` (point de montage local du partage SMB, `smb.chemin_local_du_partage`), ne retient que ceux ayant une copie locale présente sous `local_work_dir`. Lève `ArchiveInaccessibleError` si l'archive n'est pas accessible plutôt que de conclure à tort qu'il n'y a rien à signaler
50+- [X] T010 Implémenter `enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` dans `context.py` : valide `archive_smb_path` (DOIT commencer par `smb://`, sinon `CheminArchiveInvalideError`), détecte la collision `temp_dir == local_work_dir` (`CheminsAmbigusError` sauf `forcer_collision`), bloque un changement de `local_work_dir` via `lister_dossiers_non_reconcilies` (`TravailNonReconcilieError`) — sauf si aucune archive n'est encore configurée (rien à vérifier) ou `ignorer_verification_archive=True` (geste explicite, même famille que `forcer_collision`) ; une archive configurée mais inaccessible refuse le changement par défaut (`ArchiveInaccessibleError` remontée telle quelle). Crée les répertoires manquants seulement si `creer_repertoires_manquants` (sinon `RepertoireAbsentError`), initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003), puis persiste
51+- [X] T011 Implémenter `mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` dans `context.py`
52+- [X] T012 [P] Tests unitaires de `context.py` dans `packages/regine-core/tests/unit/test_context_paths.py` : configuration initiale, répertoire manquant sans/avec `creer_repertoires_manquants`, collision `temp_dir`/`local_work_dir` sans/avec `forcer_collision`, `archive_smb_path` invalide (ne commence pas par `smb://`), FR-006 (base de travail présente à la racine après configuration), champs non fournis conservés (SC-005)
53+- [X] T013 [P] Tests unitaires de `lister_dossiers_non_reconcilies`/`enregistrer_chemins` dans `test_context_paths.py` : dossier verrouillé détecté, dossier réconcilié ignoré, archive inaccessible lève, aucune archive configurée n'exige aucune vérification, `ignorer_verification_archive` passe outre explicitement — 15/15 tests passent (`packages/regine-core/src/regine_core/config/smb.py` reçoit dès cette phase `chemin_local_du_partage`, requis par T009/T010 ; le reste de `smb.py` — `disponible`/`declencher_montage`/`assurer_disponibilite` — reste prévu en Phase 4/T020)
54+
55+**Checkpoint** : la persistance du contexte est prête ; les 3 user stories peuvent commencer.
56+
57+---
58+
59+## Phase 3: User Story 1 - Configuration initiale des emplacements de travail (Priority: P1) 🎯 MVP
60+
61+**Objectif** : configurer et consulter les 3 emplacements de travail, avec initialisation de la base de travail et refus explicite d'un changement dangereux.
62+
63+**Test indépendant** : configurer les 3 chemins sur un contexte vide (`regine config set-paths`), vérifier leur persistance (`regine config show`), vérifier qu'un changement de `local_work_dir` avec un dossier verrouillé dessous est refusé.
64+
65+### Tests for User Story 1
66+
67+- [X] T014 [P] [US1] Test d'intégration du cycle configuration → initialisation de la base de travail → changement de chemin bloqué dans `packages/regine-core/tests/integration/test_config_context.py` (Acceptance Scenarios 1 à 5 de US1)
68+- [X] T015 [P] [US1] Test de contrat de la surface CLI (`set-paths`, `show`, codes de sortie, `--json`) dans `packages/regine-cli/tests/contract/test_cli_config.py`
69+
70+### Implementation for User Story 1
71+
72+- [X] T016 [US1] Implémenter la sous-commande `regine config set-paths` dans `packages/regine-cli/src/regine_cli/config_cmd.py` : `--temp-dir`, `--local-work-dir`, `--archive-smb`, `--yes` (confirme la création de répertoire, FR-003), `--force` (confirme la collision de chemins), `--ignorer-verification-archive` ; traduit chaque exception de `context.py` en message stderr + code de sortie non-zéro (cf. `contracts/cli-config.md`)
73+- [X] T017 [US1] Implémenter la sous-commande `regine config show [--json]` dans `config_cmd.py` : affiche les 3 chemins et l'état SMB connu, ou la liste des chemins manquants si le contexte n'est pas encore configuré (Edge Case) — **implémentée en même temps que T021 (US2)** : `show` déclenche `smb.assurer_disponibilite` dès qu'une archive est configurée, testé par `test_cli_config.py`
74+- [X] T018 [US1] Construire l'analyseur d'arguments (`construire_analyseur`/`main`) dans `config_cmd.py`, cohérent avec `import_cmd.py`/`archive_cmd.py`
75+
76+**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP) — 125/125 tests passent.
77+
78+---
79+
80+## Phase 4: User Story 2 - Assistance quand l'archive NAS n'est pas accessible (Priority: P2)
81+
82+**Objectif** : détecter l'indisponibilité du partage SMB avant qu'une opération échoue, déclencher le montage natif, permettre une reprise sans reconfiguration.
83+
84+**Test indépendant** : configurer un `archive_smb_path` pointant vers un partage démonté, lancer `regine config show`, vérifier qu'un montage est déclenché et que l'état SMB est mis à jour ; remonter manuellement puis relancer, vérifier la reprise sans reconfiguration.
85+
86+**Note d'ordonnancement** : implémentée en avance sur l'ordre de priorité initial, dès la Phase 3 — `regine config show` (T017) en a besoin directement (cf. quickstart.md scénario 4) et `context.enregistrer_chemins`/`lister_dossiers_non_reconcilies` (T009/T010, Phase 2) dépendaient déjà de `smb.chemin_local_du_partage`.
87+
88+### Tests for User Story 2
89+
90+- [X] T019 [P] [US2] Test unitaire de `smb.py` dans `packages/regine-core/tests/unit/test_smb_disponibilite.py` : `disponible()` sur un point de montage présent/absent (via un répertoire temporaire simulant `/Volumes/<partage>`, injecté par dépendance plutôt que codé en dur), `assurer_disponibilite()` met à jour `smb_last_known_state`, déclenche/ne déclenche pas le montage selon `declencher`
91+
92+### Implementation for User Story 2
93+
94+- [X] T020 [US2] Implémenter `packages/regine-core/src/regine_core/config/smb.py` : `disponible(archive_smb_path: str) -> bool` (vérifie l'accessibilité du point de montage macOS `/Volumes/<partage>`, dérivé de l'URL `smb://...`), `declencher_montage(archive_smb_path: str) -> None` (`open smb://...`, cf. research.md § 1), `assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` (met à jour l'état via `context.mettre_a_jour_etat_smb`, déclenche le montage si indisponible)
95+- [X] T021 [US2] Intégrer `smb.assurer_disponibilite` dans `regine config show` (`config_cmd.py`) : si un `archive_smb_path` est configuré, vérifier sa disponibilité avant d'afficher l'état, déclencher le montage natif si nécessaire (Acceptance Scenario 1/2 de US2) — pas de nouvelle tentative automatique en boucle (Acceptance Scenario 3, une seule vérification par invocation, cohérent avec l'exécution sans démon)
96+
97+**Checkpoint** : User Stories 1 et 2 fonctionnelles indépendamment.
98+
99+---
100+
101+## Phase 5: User Story 3 - Nommer les boîtiers à partir des photos déjà archivées (Priority: P3)
102+
103+**Objectif** : lister et nommer les boîtiers déjà enregistrés dans la base de contexte centralisée, sans réimport ni parcours du NAS ; parcours volontaire du NAS en complément optionnel (FR-012).
104+
105+**Test indépendant** : sur une base de contexte contenant déjà des boîtiers enregistrés par un import (cf. specs/001), lister les boîtiers puis en nommer un, sans déclencher d'accès au NAS.
106+
107+### Tests for User Story 3
108+
109+- [X] T022 [P] [US3] Test unitaire de `cameras_screen.py` dans `packages/regine-core/tests/unit/test_cameras_screen.py` : liste des boîtiers déjà enregistrés (réutilise `camera_profile.db`), nommage d'un boîtier connu, `BoitierInconnuError` sur un identifiant absent
110+- [X] T023 [P] [US3] Test d'intégration du parcours NAS dans `packages/regine-core/tests/integration/test_cameras_scan_nas.py` : une arborescence simulant une archive avec des fichiers maîtres de boîtiers non encore connus (tags EXIF réels via `exiftool`) ; vérifie que `parcourir_nas` les ajoute avec `source="parcours_nas"` et n'affecte pas les boîtiers déjà connus (source `modele`), plus idempotence (second parcours ne redouble rien)
111+
112+### Implementation for User Story 3
113+
114+- [X] T024 [US3] Implémenter `packages/regine-core/src/regine_core/config/cameras_screen.py` : `lister_boitiers(conn) -> list[Boitier]` (délègue à `camera_profile.db.list_boitiers`, FR-008), `nommer_boitier(conn, boitier_id: int, nom: str) -> None` (délègue à `camera_profile.db.rename_boitier` après avoir vérifié l'existence de `boitier_id`, sinon `BoitierInconnuError` — cf. `contracts/cli-config.md`), `parcourir_nas(conn, archive_root: Path) -> int` (parcourt `archive_root` à la recherche de fichiers d'extension `regine_core.import_carte.copie.EXTENSIONS_MAITRES`, lit `Model` via `regine_core.metadata.exif.read_camera_tags`, appelle `camera_profile.db.get_or_create_boitier(..., source="parcours_nas")`, retourne le nombre de boîtiers réellement nouveaux — FR-012, PEUT donc optionnel mais implémenté ici)
115+- [X] T025 [US3] Implémenter les sous-commandes `regine config cameras list [--json]`, `regine config cameras rename --id ID --nom NOM`, `regine config cameras scan-nas [--confirm]` dans `config_cmd.py` — `scan-nas` sans `--confirm` avertit et sort en code non-zéro sans rien exécuter (FR-012, cf. `contracts/cli-config.md`) ; vérifie la disponibilité de l'archive (`smb.assurer_disponibilite`) puis dérive son point de montage local via `smb.chemin_local_du_partage` (même convention que US2, pas de second mécanisme de résolution). 9/9 tests de contrat CLI passent (`test_cli_config.py`, étendu pour `cameras list`/`rename`/`scan-nas`)
116+
117+**Checkpoint** : les 3 user stories sont fonctionnelles indépendamment — 134/134 tests passent (`packages/regine-core` + `packages/regine-cli`).
118+
119+---
120+
121+## Phase 6: Polish & Cross-Cutting Concerns
122+
123+- [X] T026 [P] Exécuter manuellement les 6 scénarios de `specs/003-config-contexte-travail/quickstart.md` (adaptés à la surface CLI réellement livrée) et consigner le résultat — scénarios 1, 2, 3, 5, 6 exécutés via la vraie CLI (`uv run python -m regine_cli.config_cmd config ...`), tous conformes. Scénario 4 (montage/démontage SMB réel) non exécutable dans cet environnement de développement (pas de partage SMB physique disponible) — le mécanisme sous-jacent (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté (T019), et `config show` a correctement détecté `smb://nas.local/regine-archive` (inexistant dans cet environnement) comme `unavailable` lors du scénario 1, confirmant la détection réelle
124+- [X] T027 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core` et `packages/regine-cli`
125+- [X] T028 Mettre à jour `contracts/cli-config.md` et créer `contracts/regine-core-api.md` — signatures réelles documentées, écarts de conception consignés (cf. § Écarts en tête de fichier) ; note explicite que `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) ne consomment pas encore ce contexte configuré (ils prennent `--archive-root`/`--local-root` en flags explicites) — intégration future hors périmètre de cette spec, non construite ici sans nouvelle demande explicite
126+
127+---
128+
129+## Dependencies & Execution Order
130+
131+- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle.
132+- **Foundational (Phase 2)** : dépend de Setup ; bloque toutes les user stories.
133+- **US1 (Phase 3)** : dépend de Foundational uniquement. MVP.
134+- **US2 (Phase 4)** : dépend de Foundational ; s'intègre à `regine config show` (US1) mais reste testable indépendamment (`smb.py` seul).
135+- **US3 (Phase 5)** : dépend de Foundational et de `specs/002` (déjà livrée) ; indépendante de US1/US2.
136+- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1).
137+
138+## Implementation Strategy
139+
140+MVP = Phase 1 + 2 + 3 (US1). US2 et US3 sont des incréments indépendants livrables ensuite dans l'ordre de priorité.
new file mode 100644
@@ -0,0 +1,140 @@
1+---
2+
3+description: "Task list for feature implementation"
4+---
5+
6+# Tasks: Configuration du contexte de travail de Régine
7+
8+**Input**: Design documents from `/specs/003-config-contexte-travail/`
9+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md
10+
11+**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération.
12+
13+**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune.
14+
15+## Dépendances inter-specs (à lire avant de commencer)
16+
17+| Dépendance | Fournit | État |
18+|---|---|---|
19+| `specs/004-categorisation-dossiers` | `regine_core.config.db.open_context_db` (base de contexte centralisée, déjà créée) | Implémentée |
20+| `specs/002-profil-boitiers-optionnel` | `regine_core.camera_profile.db` (table `boitiers`, `list_boitiers`, `rename_boitier`, `get_or_create_boitier`) — **possède** la table `boitiers`, cette spec ne fait que l'exposer (cf. plan.md « Correction du 2026-09-18 ») | Implémentée |
21+| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest` (manifeste persistant par dossier, `PRAGMA user_version`/`application_id`) et `regine_core.archive.verrou` (verrouillage) — réutilisés tels quels pour FR-006/FR-010 (détection de travail non réconcilié) | Implémentée |
22+| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par le parcours NAS, US3/FR-012, pour reconnaître un fichier maître sans dupliquer la liste) | Implémentée |
23+
24+## Écarts entre les documents de conception et l'état réel du code (à respecter pendant l'implémentation)
25+
26+- **`data-model.md` § Base de contexte centralisée** a été rédigé avant l'implémentation de `specs/002-profil-boitiers-optionnel` et décrit une colonne `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 (`camera_profile/db.py`) utilise déjà `source` pour une distinction différente et orthogonale (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Ne PAS renommer ni migrer cette colonne déjà testée : réutiliser la même colonne texte libre en y ajoutant la seule valeur manquante `parcours_nas` (FR-012) ; à l'affichage (`cameras list`), traiter toute valeur différente de `parcours_nas` comme « détecté via import » (FR-008).
27+- **research.md ne fixe pas l'emplacement de fichier de la base de contexte centralisée** (seulement son schéma). Décision prise dans cette liste de tâches (T005) : un emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable, cf. FR-010/SC-005 — la base de contexte ne doit jamais se perdre à un changement de chemin).
28+- **FR-006/Acceptance Scenario 3 (US1)** : lu littéralement (« une base de données de travail est créée... à la racine de ce répertoire »). Réutilise directement `regine_core.archive.manifest.ouvrir_ou_creer` sur `local_work_dir` lui-même (déjà générique, accepte n'importe quel dossier) plutôt que d'inventer un second mécanisme — cohérent avec l'Assumption de spec.md (« cette spécification couvre son initialisation... pas son schéma détaillé »). Le manifeste par dossier archivé (FR-007) reste géré indépendamment par `specs/001`/`specs/005`, déjà correct.
29+
30+## Phase 1: Setup
31+
32+- [X] T001 [P] Créer `packages/regine-core/src/regine_core/config/context.py` (squelette, docstring de module uniquement)
33+- [X] T002 [P] Créer `packages/regine-core/src/regine_core/config/smb.py` (squelette, docstring de module uniquement)
34+- [X] T003 [P] Créer `packages/regine-core/src/regine_core/config/cameras_screen.py` (squelette, docstring de module uniquement)
35+- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/config_cmd.py` (squelette, docstring de module uniquement) — et `packages/regine-cli/tests/contract/`, `[tool.pytest.ini_options] testpaths = ["tests"]` ajouté à `packages/regine-cli/pyproject.toml` (aucun test CLI automatisé n'existait encore dans le dépôt)
36+
37+---
38+
39+## Phase 2: Foundational (Blocking Prerequisites)
40+
41+**Objectif** : la persistance du contexte de travail (chemins + état SMB) et la détection de travail non réconcilié, dont dépendent les 3 user stories.
42+
43+**⚠️ CRITIQUE** : aucune user story ne peut être implémentée avant la fin de cette phase.
44+
45+- [X] T005 Étendre `packages/regine-core/src/regine_core/config/db.py` : ajouter `CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"` et rendre `open_context_db(path: Path | None = None)` optionnel (défaut : `CHEMIN_CONTEXTE_PAR_DEFAUT`), sans changer le comportement pour les appelants existants qui passent déjà un chemin explicite (specs/001/002/004)
46+- [X] T006 [P] Test unitaire de `open_context_db(None)` (utilise le chemin par défaut, le crée si absent) dans `packages/regine-core/tests/unit/test_config_db_defaut.py` — le test patch la constante de module `CHEMIN_CONTEXTE_PAR_DEFAUT` plutôt que `Path.home` (celle-ci est déjà évaluée à l'import, un monkeypatch après coup n'aurait aucun effet)
47+- [X] T007 Implémenter `ContexteTravail` (dataclass), le schéma de la table `contexte` (ligne unique, `id INTEGER PRIMARY KEY CHECK (id = 1)`) et `charger_contexte(conn) -> ContexteTravail` dans `packages/regine-core/src/regine_core/config/context.py` (data-model.md § Contexte de travail)
48+- [X] T008 Implémenter les exceptions dédiées dans `context.py` : `RepertoireAbsentError`, `CheminsAmbigusError`, `CheminArchiveInvalideError`, `TravailNonReconcilieError` (porte la liste des dossiers concernés) — `ArchiveInaccessibleError` ajoutée en plus (cf. note T009)
49+- [X] T009 Implémenter `lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` dans `context.py` — **écart avec la conception initiale découvert à l'implémentation** : le verrou/manifeste vit uniquement côté archive (`specs/005`, jamais recopié dans la copie de travail locale lors d'un checkout), donc parcourir `local_work_dir` seul ne peut jamais rien trouver. Corrigé : parcourt les manifestes de `archive_root_local` (point de montage local du partage SMB, `smb.chemin_local_du_partage`), ne retient que ceux ayant une copie locale présente sous `local_work_dir`. Lève `ArchiveInaccessibleError` si l'archive n'est pas accessible plutôt que de conclure à tort qu'il n'y a rien à signaler
50+- [X] T010 Implémenter `enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` dans `context.py` : valide `archive_smb_path` (DOIT commencer par `smb://`, sinon `CheminArchiveInvalideError`), détecte la collision `temp_dir == local_work_dir` (`CheminsAmbigusError` sauf `forcer_collision`), bloque un changement de `local_work_dir` via `lister_dossiers_non_reconcilies` (`TravailNonReconcilieError`) — sauf si aucune archive n'est encore configurée (rien à vérifier) ou `ignorer_verification_archive=True` (geste explicite, même famille que `forcer_collision`) ; une archive configurée mais inaccessible refuse le changement par défaut (`ArchiveInaccessibleError` remontée telle quelle). Crée les répertoires manquants seulement si `creer_repertoires_manquants` (sinon `RepertoireAbsentError`), initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003), puis persiste
51+- [X] T011 Implémenter `mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` dans `context.py`
52+- [X] T012 [P] Tests unitaires de `context.py` dans `packages/regine-core/tests/unit/test_context_paths.py` : configuration initiale, répertoire manquant sans/avec `creer_repertoires_manquants`, collision `temp_dir`/`local_work_dir` sans/avec `forcer_collision`, `archive_smb_path` invalide (ne commence pas par `smb://`), FR-006 (base de travail présente à la racine après configuration), champs non fournis conservés (SC-005)
53+- [X] T013 [P] Tests unitaires de `lister_dossiers_non_reconcilies`/`enregistrer_chemins` dans `test_context_paths.py` : dossier verrouillé détecté, dossier réconcilié ignoré, archive inaccessible lève, aucune archive configurée n'exige aucune vérification, `ignorer_verification_archive` passe outre explicitement — 15/15 tests passent (`packages/regine-core/src/regine_core/config/smb.py` reçoit dès cette phase `chemin_local_du_partage`, requis par T009/T010 ; le reste de `smb.py` — `disponible`/`declencher_montage`/`assurer_disponibilite` — reste prévu en Phase 4/T020)
54+
55+**Checkpoint** : la persistance du contexte est prête ; les 3 user stories peuvent commencer.
56+
57+---
58+
59+## Phase 3: User Story 1 - Configuration initiale des emplacements de travail (Priority: P1) 🎯 MVP
60+
61+**Objectif** : configurer et consulter les 3 emplacements de travail, avec initialisation de la base de travail et refus explicite d'un changement dangereux.
62+
63+**Test indépendant** : configurer les 3 chemins sur un contexte vide (`regine config set-paths`), vérifier leur persistance (`regine config show`), vérifier qu'un changement de `local_work_dir` avec un dossier verrouillé dessous est refusé.
64+
65+### Tests for User Story 1
66+
67+- [X] T014 [P] [US1] Test d'intégration du cycle configuration → initialisation de la base de travail → changement de chemin bloqué dans `packages/regine-core/tests/integration/test_config_context.py` (Acceptance Scenarios 1 à 5 de US1)
68+- [X] T015 [P] [US1] Test de contrat de la surface CLI (`set-paths`, `show`, codes de sortie, `--json`) dans `packages/regine-cli/tests/contract/test_cli_config.py`
69+
70+### Implementation for User Story 1
71+
72+- [X] T016 [US1] Implémenter la sous-commande `regine config set-paths` dans `packages/regine-cli/src/regine_cli/config_cmd.py` : `--temp-dir`, `--local-work-dir`, `--archive-smb`, `--yes` (confirme la création de répertoire, FR-003), `--force` (confirme la collision de chemins), `--ignorer-verification-archive` ; traduit chaque exception de `context.py` en message stderr + code de sortie non-zéro (cf. `contracts/cli-config.md`)
73+- [X] T017 [US1] Implémenter la sous-commande `regine config show [--json]` dans `config_cmd.py` : affiche les 3 chemins et l'état SMB connu, ou la liste des chemins manquants si le contexte n'est pas encore configuré (Edge Case) — **implémentée en même temps que T021 (US2)** : `show` déclenche `smb.assurer_disponibilite` dès qu'une archive est configurée, testé par `test_cli_config.py`
74+- [X] T018 [US1] Construire l'analyseur d'arguments (`construire_analyseur`/`main`) dans `config_cmd.py`, cohérent avec `import_cmd.py`/`archive_cmd.py`
75+
76+**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP) — 125/125 tests passent.
77+
78+---
79+
80+## Phase 4: User Story 2 - Assistance quand l'archive NAS n'est pas accessible (Priority: P2)
81+
82+**Objectif** : détecter l'indisponibilité du partage SMB avant qu'une opération échoue, déclencher le montage natif, permettre une reprise sans reconfiguration.
83+
84+**Test indépendant** : configurer un `archive_smb_path` pointant vers un partage démonté, lancer `regine config show`, vérifier qu'un montage est déclenché et que l'état SMB est mis à jour ; remonter manuellement puis relancer, vérifier la reprise sans reconfiguration.
85+
86+**Note d'ordonnancement** : implémentée en avance sur l'ordre de priorité initial, dès la Phase 3 — `regine config show` (T017) en a besoin directement (cf. quickstart.md scénario 4) et `context.enregistrer_chemins`/`lister_dossiers_non_reconcilies` (T009/T010, Phase 2) dépendaient déjà de `smb.chemin_local_du_partage`.
87+
88+### Tests for User Story 2
89+
90+- [X] T019 [P] [US2] Test unitaire de `smb.py` dans `packages/regine-core/tests/unit/test_smb_disponibilite.py` : `disponible()` sur un point de montage présent/absent (via un répertoire temporaire simulant `/Volumes/<partage>`, injecté par dépendance plutôt que codé en dur), `assurer_disponibilite()` met à jour `smb_last_known_state`, déclenche/ne déclenche pas le montage selon `declencher`
91+
92+### Implementation for User Story 2
93+
94+- [X] T020 [US2] Implémenter `packages/regine-core/src/regine_core/config/smb.py` : `disponible(archive_smb_path: str) -> bool` (vérifie l'accessibilité du point de montage macOS `/Volumes/<partage>`, dérivé de l'URL `smb://...`), `declencher_montage(archive_smb_path: str) -> None` (`open smb://...`, cf. research.md § 1), `assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` (met à jour l'état via `context.mettre_a_jour_etat_smb`, déclenche le montage si indisponible)
95+- [X] T021 [US2] Intégrer `smb.assurer_disponibilite` dans `regine config show` (`config_cmd.py`) : si un `archive_smb_path` est configuré, vérifier sa disponibilité avant d'afficher l'état, déclencher le montage natif si nécessaire (Acceptance Scenario 1/2 de US2) — pas de nouvelle tentative automatique en boucle (Acceptance Scenario 3, une seule vérification par invocation, cohérent avec l'exécution sans démon)
96+
97+**Checkpoint** : User Stories 1 et 2 fonctionnelles indépendamment.
98+
99+---
100+
101+## Phase 5: User Story 3 - Nommer les boîtiers à partir des photos déjà archivées (Priority: P3)
102+
103+**Objectif** : lister et nommer les boîtiers déjà enregistrés dans la base de contexte centralisée, sans réimport ni parcours du NAS ; parcours volontaire du NAS en complément optionnel (FR-012).
104+
105+**Test indépendant** : sur une base de contexte contenant déjà des boîtiers enregistrés par un import (cf. specs/001), lister les boîtiers puis en nommer un, sans déclencher d'accès au NAS.
106+
107+### Tests for User Story 3
108+
109+- [X] T022 [P] [US3] Test unitaire de `cameras_screen.py` dans `packages/regine-core/tests/unit/test_cameras_screen.py` : liste des boîtiers déjà enregistrés (réutilise `camera_profile.db`), nommage d'un boîtier connu, `BoitierInconnuError` sur un identifiant absent
110+- [X] T023 [P] [US3] Test d'intégration du parcours NAS dans `packages/regine-core/tests/integration/test_cameras_scan_nas.py` : une arborescence simulant une archive avec des fichiers maîtres de boîtiers non encore connus (tags EXIF réels via `exiftool`) ; vérifie que `parcourir_nas` les ajoute avec `source="parcours_nas"` et n'affecte pas les boîtiers déjà connus (source `modele`), plus idempotence (second parcours ne redouble rien)
111+
112+### Implementation for User Story 3
113+
114+- [X] T024 [US3] Implémenter `packages/regine-core/src/regine_core/config/cameras_screen.py` : `lister_boitiers(conn) -> list[Boitier]` (délègue à `camera_profile.db.list_boitiers`, FR-008), `nommer_boitier(conn, boitier_id: int, nom: str) -> None` (délègue à `camera_profile.db.rename_boitier` après avoir vérifié l'existence de `boitier_id`, sinon `BoitierInconnuError` — cf. `contracts/cli-config.md`), `parcourir_nas(conn, archive_root: Path) -> int` (parcourt `archive_root` à la recherche de fichiers d'extension `regine_core.import_carte.copie.EXTENSIONS_MAITRES`, lit `Model` via `regine_core.metadata.exif.read_camera_tags`, appelle `camera_profile.db.get_or_create_boitier(..., source="parcours_nas")`, retourne le nombre de boîtiers réellement nouveaux — FR-012, PEUT donc optionnel mais implémenté ici)
115+- [X] T025 [US3] Implémenter les sous-commandes `regine config cameras list [--json]`, `regine config cameras rename --id ID --nom NOM`, `regine config cameras scan-nas [--confirm]` dans `config_cmd.py` — `scan-nas` sans `--confirm` avertit et sort en code non-zéro sans rien exécuter (FR-012, cf. `contracts/cli-config.md`) ; vérifie la disponibilité de l'archive (`smb.assurer_disponibilite`) puis dérive son point de montage local via `smb.chemin_local_du_partage` (même convention que US2, pas de second mécanisme de résolution). 9/9 tests de contrat CLI passent (`test_cli_config.py`, étendu pour `cameras list`/`rename`/`scan-nas`)
116+
117+**Checkpoint** : les 3 user stories sont fonctionnelles indépendamment — 134/134 tests passent (`packages/regine-core` + `packages/regine-cli`).
118+
119+---
120+
121+## Phase 6: Polish & Cross-Cutting Concerns
122+
123+- [X] T026 [P] Exécuter manuellement les 6 scénarios de `specs/003-config-contexte-travail/quickstart.md` (adaptés à la surface CLI réellement livrée) et consigner le résultat — scénarios 1, 2, 3, 5, 6 exécutés via la vraie CLI (`uv run python -m regine_cli.config_cmd config ...`), tous conformes. Scénario 4 (montage/démontage SMB réel) non exécutable dans cet environnement de développement (pas de partage SMB physique disponible) — le mécanisme sous-jacent (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté (T019), et `config show` a correctement détecté `smb://nas.local/regine-archive` (inexistant dans cet environnement) comme `unavailable` lors du scénario 1, confirmant la détection réelle
124+- [X] T027 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core` et `packages/regine-cli`
125+- [X] T028 Mettre à jour `contracts/cli-config.md` et créer `contracts/regine-core-api.md` — signatures réelles documentées, écarts de conception consignés (cf. § Écarts en tête de fichier) ; note explicite que `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) ne consomment pas encore ce contexte configuré (ils prennent `--archive-root`/`--local-root` en flags explicites) — intégration future hors périmètre de cette spec, non construite ici sans nouvelle demande explicite
126+
127+---
128+
129+## Dependencies & Execution Order
130+
131+- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle.
132+- **Foundational (Phase 2)** : dépend de Setup ; bloque toutes les user stories.
133+- **US1 (Phase 3)** : dépend de Foundational uniquement. MVP.
134+- **US2 (Phase 4)** : dépend de Foundational ; s'intègre à `regine config show` (US1) mais reste testable indépendamment (`smb.py` seul).
135+- **US3 (Phase 5)** : dépend de Foundational et de `specs/002` (déjà livrée) ; indépendante de US1/US2.
136+- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1).
137+
138+## Implementation Strategy
139+
140+MVP = Phase 1 + 2 + 3 (US1). US2 et US3 sont des incréments indépendants livrables ensuite dans l'ordre de priorité.