diff --git a/shellhist/core/__init__.py b/shellhist/core/__init__.py new file mode 100644 index 0000000..a5290e8 --- /dev/null +++ b/shellhist/core/__init__.py @@ -0,0 +1,176 @@ +"""Core module for shell history loading and parsing.""" + +import os +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Optional + + +@dataclass +class HistoryEntry: + """Represents a single entry in shell history.""" + command: str + timestamp: Optional[datetime] = None + line_number: int = 0 + shell_type: str = "unknown" + + def __hash__(self): + return hash(self.command) + + def __eq__(self, other): + if isinstance(other, HistoryEntry): + return self.command == other.command + return False + + +@dataclass +class HistoryStore: + """Stores and manages shell history entries.""" + entries: list[HistoryEntry] = field(default_factory=list) + command_frequency: dict[str, int] = field(default_factory=dict) + + def add_entry(self, entry: HistoryEntry) -> None: + """Add a history entry to the store.""" + self.entries.append(entry) + self.command_frequency[entry.command] = self.command_frequency.get(entry.command, 0) + 1 + + def get_frequency(self, command: str) -> int: + """Get the frequency of a command.""" + return self.command_frequency.get(command, 0) + + def get_most_frequent(self, limit: int = 10) -> list[tuple[str, int]]: + """Get the most frequently used commands.""" + sorted_freq = sorted(self.command_frequency.items(), key=lambda x: x[1], reverse=True) + return sorted_freq[:limit] + + def get_unique_commands(self) -> list[str]: + """Get list of unique commands.""" + return list(self.command_frequency.keys()) + + +class HistoryLoader: + """Loads and parses shell history from various shell formats.""" + + BASH_TIMESTAMP_PATTERN = re.compile(r'^#(\d{10})$') + ZSH_TIMESTAMP_PATTERN = re.compile(r'^: (\d{10}):(\d+);(.*)$') + + def __init__(self, history_path: Optional[str] = None): + """Initialize the history loader. + + Args: + history_path: Optional path to history file. If not provided, + will attempt to detect from environment. + """ + self.history_path = history_path + self.shell_type = self._detect_shell() + + def _detect_shell(self) -> str: + """Detect the shell type from environment.""" + shell = os.environ.get("SHELL", "") + if "zsh" in shell: + return "zsh" + elif "bash" in shell: + return "bash" + return "bash" + + def _get_default_history_path(self) -> Optional[str]: + """Get the default history file path based on shell type.""" + home = os.path.expanduser("~") + if self.shell_type == "zsh": + return os.environ.get("HISTFILE", f"{home}/.zsh_history") + return os.environ.get("HISTFILE", f"{home}/.bash_history") + + def load(self) -> HistoryStore: + """Load history from the configured path. + + Returns: + HistoryStore containing all parsed entries. + """ + path = self.history_path or self._get_default_history_path() + + if not path or not os.path.exists(path): + raise FileNotFoundError( + f"History file not found: {path}. " + "Set HISTFILE environment variable or use --history flag." + ) + + store = HistoryStore() + + with open(path, "r", encoding="utf-8", errors="replace") as f: + if self.shell_type == "zsh": + self._parse_zsh_history(f, store) + else: + self._parse_bash_history(f, store) + + return store + + def _parse_bash_history(self, file, store: HistoryStore) -> None: + """Parse bash history format.""" + line_number = 0 + pending_timestamp: Optional[datetime] = None + + for line in file: + line = line.rstrip("\n") + line_number += 1 + + timestamp_match = self.BASH_TIMESTAMP_PATTERN.match(line) + if timestamp_match: + ts = int(timestamp_match.group(1)) + pending_timestamp = datetime.fromtimestamp(ts) + continue + + if line and not line.startswith("#"): + command = line + entry = HistoryEntry( + command=command, + timestamp=pending_timestamp, + line_number=line_number, + shell_type="bash" + ) + store.add_entry(entry) + pending_timestamp = None + + def _parse_zsh_history(self, file, store: HistoryStore) -> None: + """Parse zsh history format.""" + line_number = 0 + + for line in file: + line = line.rstrip("\n") + line_number += 1 + + timestamp_match = self.ZSH_TIMESTAMP_PATTERN.match(line) + if timestamp_match: + ts = int(timestamp_match.group(1)) + command = timestamp_match.group(3) + timestamp = datetime.fromtimestamp(ts) + + entry = HistoryEntry( + command=command, + timestamp=timestamp, + line_number=line_number, + shell_type="zsh" + ) + store.add_entry(entry) + elif line: + entry = HistoryEntry( + command=line, + timestamp=None, + line_number=line_number, + shell_type="zsh" + ) + store.add_entry(entry) + + @staticmethod + def from_file(path: str) -> HistoryStore: + """Convenience method to load history from a specific file. + + Args: + path: Path to history file. + + Returns: + HistoryStore containing parsed entries. + """ + loader = HistoryLoader(history_path=path) + return loader.load()