@@ -8516,13 +8516,271 @@ def esdb_replay_cmd(project_dir: str) -> None:
85168516 console .print (f"[bold]Replay check:[/bold] { st .backend } " )
85178517 console .print (f" Records: { st .record_count } " )
85188518 if st .chain_valid :
8519- console .print ("[green]\u2714 [/green] WAL chain valid — state consistent." )
8519+ console .print ("[green]\u2714 [/green] WAL chain valid \u2014 state consistent." )
85208520 else :
85218521 console .print ("[red]\u2717 [/red] WAL chain integrity failure detected." )
85228522
85238523
8524+ @esdb_group .command (name = "export" )
8525+ @click .option ("--project-dir" , type = click .Path (exists = True ), default = "." )
8526+ @click .option (
8527+ "--output" ,
8528+ default = "" ,
8529+ help = "Output file path (default: <project>/.specsmith/esdb_export.json)" ,
8530+ )
8531+ @click .option ("--json" , "as_json" , is_flag = True , default = False )
8532+ def esdb_export_cmd (project_dir : str , output : str , as_json : bool ) -> None :
8533+ """Export the full ESDB to a JSON file."""
8534+ import json as _json
8535+
8536+ from specsmith .esdb .bridge import EsdbBridge
8537+
8538+ bridge = EsdbBridge (project_dir )
8539+ st = bridge .status ()
8540+ reqs = bridge .requirements ()
8541+ tests = bridge .testcases ()
8542+ payload = {
8543+ "esdb_version" : 1 ,
8544+ "backend" : st .backend ,
8545+ "record_count" : st .record_count ,
8546+ "requirements" : [r .to_dict () for r in reqs ],
8547+ "testcases" : [t .to_dict () for t in tests ],
8548+ }
8549+ dest = output or str (Path (project_dir ).resolve () / ".specsmith" / "esdb_export.json" )
8550+ Path (dest ).parent .mkdir (parents = True , exist_ok = True )
8551+ Path (dest ).write_text (_json .dumps (payload , indent = 2 , ensure_ascii = False ), encoding = "utf-8" )
8552+ if as_json :
8553+ click .echo (_json .dumps ({"ok" : True , "path" : dest , "records" : st .record_count }, indent = 2 ))
8554+ else :
8555+ console .print (f"[green]\u2714 [/green] Exported { st .record_count } records to { dest } " )
8556+
8557+
8558+ @esdb_group .command (name = "import" )
8559+ @click .argument ("source" )
8560+ @click .option ("--project-dir" , type = click .Path (exists = True ), default = "." )
8561+ @click .option ("--json" , "as_json" , is_flag = True , default = False )
8562+ def esdb_import_cmd (source : str , project_dir : str , as_json : bool ) -> None :
8563+ """Import an ESDB JSON export into the project store."""
8564+ import json as _json
8565+
8566+ src = Path (source )
8567+ if not src .is_file ():
8568+ console .print (f"[red]File not found:[/red] { source } " )
8569+ raise SystemExit (1 )
8570+ try :
8571+ data = _json .loads (src .read_text (encoding = "utf-8" ))
8572+ except ValueError as exc :
8573+ console .print (f"[red]Invalid JSON:[/red] { exc } " )
8574+ raise SystemExit (1 ) from exc
8575+
8576+ reqs = data .get ("requirements" , [])
8577+ tests = data .get ("testcases" , [])
8578+ specsmith_dir = Path (project_dir ).resolve () / ".specsmith"
8579+ specsmith_dir .mkdir (parents = True , exist_ok = True )
8580+
8581+ # Write requirements and testcases directly to the live JSON stores.
8582+ # Existing data is replaced with the imported snapshot.
8583+ reqs_path = specsmith_dir / "requirements.json"
8584+ tests_path = specsmith_dir / "testcases.json"
8585+ reqs_path .write_text (_json .dumps (reqs , indent = 2 , ensure_ascii = False ), encoding = "utf-8" )
8586+ tests_path .write_text (_json .dumps (tests , indent = 2 , ensure_ascii = False ), encoding = "utf-8" )
8587+
8588+ result = {"ok" : True , "requirements" : len (reqs ), "testcases" : len (tests )}
8589+ if as_json :
8590+ click .echo (_json .dumps (result , indent = 2 ))
8591+ else :
8592+ console .print (
8593+ f"[green]\u2714 [/green] Imported { len (reqs )} requirements, { len (tests )} test cases."
8594+ )
8595+ console .print (f" Wrote .specsmith/requirements.json and .specsmith/testcases.json" )
8596+
8597+
8598+ @esdb_group .command (name = "backup" )
8599+ @click .option ("--project-dir" , type = click .Path (exists = True ), default = "." )
8600+ @click .option (
8601+ "--dir" ,
8602+ "backup_dir" ,
8603+ default = "" ,
8604+ help = "Directory for backup files (default: .specsmith/backups/)" ,
8605+ )
8606+ @click .option ("--json" , "as_json" , is_flag = True , default = False )
8607+ def esdb_backup_cmd (project_dir : str , backup_dir : str , as_json : bool ) -> None :
8608+ """Create a timestamped snapshot backup of the ESDB."""
8609+ import datetime
8610+ import json as _json
8611+
8612+ from specsmith .esdb .bridge import EsdbBridge
8613+
8614+ bridge = EsdbBridge (project_dir )
8615+ st = bridge .status ()
8616+ ts = datetime .datetime .now (tz = datetime .timezone .utc ).strftime ("%Y%m%dT%H%M%SZ" )
8617+ dest_dir = (
8618+ Path (backup_dir ) if backup_dir else Path (project_dir ).resolve () / ".specsmith" / "backups"
8619+ )
8620+ dest_dir .mkdir (parents = True , exist_ok = True )
8621+ dest = dest_dir / f"esdb_backup_{ ts } .json"
8622+ reqs = bridge .requirements ()
8623+ tests = bridge .testcases ()
8624+ payload = {
8625+ "esdb_version" : 1 ,
8626+ "timestamp" : ts ,
8627+ "backend" : st .backend ,
8628+ "record_count" : st .record_count ,
8629+ "requirements" : [r .to_dict () for r in reqs ],
8630+ "testcases" : [t .to_dict () for t in tests ],
8631+ }
8632+ dest .write_text (_json .dumps (payload , indent = 2 , ensure_ascii = False ), encoding = "utf-8" )
8633+ result = {"ok" : True , "path" : str (dest ), "timestamp" : ts , "records" : st .record_count }
8634+ if as_json :
8635+ click .echo (_json .dumps (result , indent = 2 ))
8636+ else :
8637+ console .print (f"[green]\u2714 [/green] Backup created: { dest } ({ st .record_count } records)" )
8638+
8639+
8640+ @esdb_group .command (name = "rollback" )
8641+ @click .option ("--project-dir" , type = click .Path (exists = True ), default = "." )
8642+ @click .option ("--steps" , default = 1 , show_default = True , help = "Number of backups to roll back (default: 1 = latest backup)." )
8643+ @click .option ("--json" , "as_json" , is_flag = True , default = False )
8644+ def esdb_rollback_cmd (project_dir : str , steps : int , as_json : bool ) -> None :
8645+ """Restore the ESDB from the most recent backup snapshot.
8646+
8647+ Finds the N-th most recent backup in .specsmith/backups/ (N = --steps)
8648+ and restores requirements.json + testcases.json from it.
8649+ """
8650+ import json as _json
8651+
8652+ from specsmith .esdb .bridge import EsdbBridge
8653+
8654+ root = Path (project_dir ).resolve ()
8655+ backups_dir = root / ".specsmith" / "backups"
8656+ if not backups_dir .is_dir ():
8657+ result = {"ok" : False , "error" : "No backups directory found. Run `specsmith esdb backup` first." }
8658+ if as_json :
8659+ click .echo (_json .dumps (result , indent = 2 ))
8660+ else :
8661+ console .print (f"[red]\u2717 [/red] { result ['error' ]} " )
8662+ raise SystemExit (1 )
8663+
8664+ backup_files = sorted (backups_dir .glob ("esdb_backup_*.json" ), reverse = True )
8665+ if not backup_files :
8666+ result = {"ok" : False , "error" : "No backup files found in .specsmith/backups/." }
8667+ if as_json :
8668+ click .echo (_json .dumps (result , indent = 2 ))
8669+ else :
8670+ console .print (f"[red]\u2717 [/red] { result ['error' ]} " )
8671+ raise SystemExit (1 )
8672+
8673+ target_idx = min (steps - 1 , len (backup_files ) - 1 )
8674+ backup_path = backup_files [target_idx ]
8675+
8676+ try :
8677+ data = _json .loads (backup_path .read_text (encoding = "utf-8" ))
8678+ except (OSError , ValueError ) as exc :
8679+ result = {"ok" : False , "error" : f"Cannot read backup { backup_path .name } : { exc } " }
8680+ if as_json :
8681+ click .echo (_json .dumps (result , indent = 2 ))
8682+ else :
8683+ console .print (f"[red]\u2717 [/red] { result ['error' ]} " )
8684+ raise SystemExit (1 ) from exc
8685+
8686+ reqs = data .get ("requirements" , [])
8687+ tests = data .get ("testcases" , [])
8688+ specsmith_dir = root / ".specsmith"
8689+ specsmith_dir .mkdir (parents = True , exist_ok = True )
8690+ (specsmith_dir / "requirements.json" ).write_text (
8691+ _json .dumps (reqs , indent = 2 , ensure_ascii = False ), encoding = "utf-8"
8692+ )
8693+ (specsmith_dir / "testcases.json" ).write_text (
8694+ _json .dumps (tests , indent = 2 , ensure_ascii = False ), encoding = "utf-8"
8695+ )
8696+
8697+ # Invalidate the bridge cache so any subsequent bridge calls reflect the restore.
8698+ bridge = EsdbBridge (project_dir )
8699+ st = bridge .status ()
8700+
8701+ result = {
8702+ "ok" : True ,
8703+ "restored_from" : backup_path .name ,
8704+ "timestamp" : data .get ("timestamp" , "" ),
8705+ "requirements" : len (reqs ),
8706+ "testcases" : len (tests ),
8707+ "records_after" : st .record_count ,
8708+ }
8709+ if as_json :
8710+ click .echo (_json .dumps (result , indent = 2 ))
8711+ else :
8712+ console .print (f"[green]\u2714 [/green] Restored from backup: [bold]{ backup_path .name } [/bold]" )
8713+ console .print (f" Requirements: { len (reqs )} \u00b7 Test cases: { len (tests )} " )
8714+
8715+
8716+ @esdb_group .command (name = "compact" )
8717+ @click .option ("--project-dir" , type = click .Path (exists = True ), default = "." )
8718+ @click .option ("--json" , "as_json" , is_flag = True , default = False )
8719+ def esdb_compact_cmd (project_dir : str , as_json : bool ) -> None :
8720+ """Compact the ESDB: deduplicate records and remove empty entries.
8721+
8722+ Reads .specsmith/requirements.json and .specsmith/testcases.json,
8723+ deduplicates by ID (last-write-wins), drops records with no ID,
8724+ and writes the compacted lists back to disk.
8725+ """
8726+ import json as _json
8727+
8728+ root = Path (project_dir ).resolve ()
8729+ specsmith_dir = root / ".specsmith"
8730+
8731+ removed_reqs = 0
8732+ removed_tests = 0
8733+
8734+ for filename , kind in (("requirements.json" , "requirements" ), ("testcases.json" , "testcases" )):
8735+ path = specsmith_dir / filename
8736+ if not path .is_file ():
8737+ continue
8738+ try :
8739+ records = _json .loads (path .read_text (encoding = "utf-8" ))
8740+ except (OSError , ValueError ):
8741+ continue
8742+ if not isinstance (records , list ):
8743+ continue
8744+ before = len (records )
8745+ # Deduplicate by ID (last occurrence wins); drop entries with no ID.
8746+ seen : dict [str , object ] = {}
8747+ for rec in records :
8748+ if not isinstance (rec , dict ):
8749+ continue
8750+ rid = rec .get ("id" ) or rec .get ("req_id" ) or ""
8751+ if not rid :
8752+ continue
8753+ seen [rid ] = rec
8754+ compacted = list (seen .values ())
8755+ after = len (compacted )
8756+ if kind == "requirements" :
8757+ removed_reqs = before - after
8758+ else :
8759+ removed_tests = before - after
8760+ path .write_text (_json .dumps (compacted , indent = 2 , ensure_ascii = False ), encoding = "utf-8" )
8761+
8762+ from specsmith .esdb .bridge import EsdbBridge
8763+
8764+ bridge = EsdbBridge (project_dir )
8765+ st = bridge .status ()
8766+
8767+ result = {
8768+ "ok" : True ,
8769+ "backend" : st .backend ,
8770+ "records_after" : st .record_count ,
8771+ "removed_duplicate_requirements" : removed_reqs ,
8772+ "removed_duplicate_testcases" : removed_tests ,
8773+ }
8774+ if as_json :
8775+ click .echo (_json .dumps (result , indent = 2 ))
8776+ else :
8777+ total_removed = removed_reqs + removed_tests
8778+ console .print (
8779+ f"[green]\u2714 [/green] Compact complete on { st .backend } "
8780+ f"({ st .record_count } records, { total_removed } duplicates removed)"
8781+ )
8782+
85248783
8525- > >> >> >> 968261582 aa77adba116f3c092ae13c2449a4df8
85268784main .add_command (esdb_group )
85278785
85288786
0 commit comments