# SPDX-License-Identifier: Apache-2.0 # Copyright (c) 2025 Allaun """Plan tool for NoDupeLabs. This tool bridges the gap between 'scan' and 'apply' by creating an execution plan based on duplicate detection results. """ from nodupe.core.tool_system.base import Tool from typing import Any, Dict import argparse import json # Tool manager is injected by the core system PM = None class PlanTool(Tool): """Plan tool implementation. Responsibilities: - Register with tool manager - Provide plan functionality - Handle strategy selection """ name = "plan" version = "1.0.0" dependencies = ["scan", "database"] def __init__(self): """Initialize plan tool.""" self.description = "Create execution plan from scan results" # Hook registration moved to initialize def initialize(self, container: Any) -> None: """Initialize the tool.""" # Retrieve PM from container if available # But global PM usage in this file is legacy. # Ideally we use container.get_service('tool_manager') def shutdown(self) -> None: """Shutdown the tool.""" def get_capabilities(self) -> Dict[str, Any]: """Get tool capabilities.""" return {'commands': ['plan'], 'strategies': ['newest', 'oldest', 'interactive']} @property def api_methods(self) -> Dict[str, Any]: """Dictionary of methods exposed via programmatic API.""" return { 'create_plan': self.execute_plan, 'get_strategies': lambda: ['newest', 'oldest', 'interactive'], } def run_standalone(self, args: list[str]) -> int: """Execute the tool in stand-alone mode.""" parser = argparse.ArgumentParser(description='Create execution plan from scan results') parser.add_argument('--strategy', choices=['newest', 'oldest', 'interactive'], default='newest', help='Strategy to select keeper file') parser.add_argument('--output', '-o', default='plan.json', help='Output plan file path') parsed = parser.parse_args(args) return self.execute_plan(parsed) def describe_usage(self) -> str: """Return human-readable usage description.""" return "Plan tool: Creates an execution plan for handling duplicate files. Use --strategy to choose how to select the keeper file (newest, oldest, or interactive)." def _on_plan_start(self, **kwargs: Any) -> None: """Handle plan start event.""" print(f"[TOOL] Planning started with strategy: {kwargs.get('strategy', 'unknown')}") def _on_plan_complete(self, **kwargs: Any) -> None: """Handle plan complete event.""" print(f"[TOOL] Planning completed. Actions generated: {kwargs.get('action_count', 0)}") def register_commands(self, subparsers: Any) -> None: """Register plan command with argument parser. Args: subparsers: Argument parser subparsers """ parser = subparsers.add_parser('plan', help='Create execution plan from scan results') parser.add_argument('--strategy', choices=['newest', 'oldest', 'interactive'], default='newest', help='Strategy to select keeper file') parser.add_argument('--output', '-o', default='plan.json', help='Output plan file path') parser.set_defaults(func=self.execute_plan) def execute_plan(self, args: argparse.Namespace) -> int: """Execute plan command. Args: args: Parsed arguments Returns: Exit code """ try: print(f"[TOOL] Executing plan with strategy: {args.strategy}") # 1. Get Services container = getattr(args, 'container', None) if not container: from nodupe.core.container import container as global_container container = global_container db = container.get_service('database') if not db: print("[ERROR] Database service not available") from nodupe.tools.databases.connection import DatabaseConnection db = DatabaseConnection.get_instance() from nodupe.tools.databases.files import FileRepository repo = FileRepository(db) files = repo.get_all_files() if not files: print("[TOOL] No files in database to plan.") return 0 # 2. Group by Hash print(f"[TOOL] Grouping {len(files)} files by hash...") groups = {} for f in files: if not f.get('hash'): continue if f['hash'] not in groups: groups[f['hash']] = [] groups[f['hash']].append(f) action_plan = [] stats = {"total_groups": 0, "duplicates_found": 0, "reassigned": 0} # 3. Apply Strategy print(f"[TOOL] Applying strategy '{args.strategy}'...") for _, group in groups.items(): if len(group) < 2: continue stats["total_groups"] += 1 # Sort group based on strategy # The first item in sorted list will be the KEEPER (Original) if args.strategy == 'newest': # Keep newest modified: Sort descending by mtime group.sort(key=lambda x: x.get('modified_time', 0), reverse=True) elif args.strategy == 'oldest': # Keep oldest modified: Sort ascending by mtime group.sort(key=lambda x: x.get('modified_time', 0)) else: # Default/Interactive: Keep shortest path length (preferred usually) group.sort(key=lambda x: len(x['path'])) keeper = group[0] duplicates = group[1:] stats["duplicates_found"] += len(duplicates) # 4. Generate Actions & Update DB # Ensure keeper is NOT marked as duplicate if keeper.get('is_duplicate'): repo.mark_as_original(keeper['id']) stats["reassigned"] += 1 action_plan.append({ "type": "KEEP", "path": keeper['path'], "reason": f"Selected by {args.strategy} strategy (id={keeper['id']})" }) for dup in duplicates: # Update DB to point to new keeper repo.mark_as_duplicate(dup['id'], keeper['id']) action_plan.append({ "type": "DELETE", # Or implies 'process' "path": dup['path'], "duplicate_of": keeper['path'], "reason": f"Duplicate of {keeper['path']}" }) # 5. Output JSON Plan plan_data = { "metadata": { "strategy": args.strategy, "version": "1.0", "generated_at": "2025-12-14", "stats": stats }, "actions": action_plan } with open(args.output, 'w') as f: json.dump(plan_data, f, indent=2) print(f"[TOOL] Plan saved to {args.output}") print( f"[TOOL] Summary: {stats['duplicates_found']} duplicates identified in {stats['total_groups']} groups.") if stats['reassigned'] > 0: print( f"[TOOL] Reassigned {stats['reassigned']} files as originals based on strategy.") self._on_plan_complete(action_count=len(action_plan)) return 0 except Exception as e: print(f"[TOOL ERROR] Plan failed: {e}") return 1 # Create tool instance when module is loaded plan_tool = PlanTool() def register_tool(): """Register tool with core system.""" return plan_tool # Export tool interface __all__ = ['plan_tool', 'register_tool']