diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index b5bfea9..04f4968 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -29,6 +29,7 @@ This document describes the SQLite database schema for agent-session-analytics. | `bus_events` | Cross-session events from event-bus | ~2K | | `events_fts` | FTS5 virtual table for user message search | N/A | | `raw_entries` | Unparsed JSONL entries for future re-parsing | 100K+ | +| `project_aliases` | Alias mappings for renamed projects | ~10 | --- @@ -211,6 +212,26 @@ CREATE TABLE raw_entries ( **Design note**: The UNIQUE constraint on `entry_json` ensures exact deduplication. While this means large JSON values are compared, SQLite handles this efficiently and it avoids hash collision edge cases. +### project_aliases + +Maps alias names to target patterns for flexible project filtering. When filtering by an alias, queries automatically expand to match both the alias and all its targets. + +```sql +CREATE TABLE project_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT NOT NULL COLLATE NOCASE, -- The filter name (e.g., "genai-rs") + target TEXT NOT NULL COLLATE NOCASE, -- Pattern to also match (e.g., "rust-genai") + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(alias, target) +) +CREATE INDEX idx_project_aliases_alias ON project_aliases(alias COLLATE NOCASE) +``` + +**Key patterns**: +- One alias can have multiple targets (for projects renamed multiple times) +- `COLLATE NOCASE` ensures case-insensitive matching +- Query expansion: `WHERE project_path LIKE '%alias%' OR project_path LIKE '%target%'` + --- ## Indexes @@ -245,6 +266,7 @@ Performance-critical indexes on the `events` table: | `bus_events` | `idx_bus_events_repo` | `repo` | | `raw_entries` | `idx_raw_entries_session` | `session_id` | | `raw_entries` | `idx_raw_entries_timestamp` | `timestamp` | +| `project_aliases` | `idx_project_aliases_alias` | `alias COLLATE NOCASE` | --- @@ -284,6 +306,7 @@ Sync triggers maintain index consistency: | 11 | fix_compaction_detection_user_entries | Fix compaction detection to look at user entries (not just summary) | | 12 | fix_warmup_not_errors | Fix warmup events incorrectly marked as errors (Issue #75) | | 13 | add_raw_entries_table | Raw JSONL storage for future re-parsing (Issue #93) | +| 14 | add_project_aliases | Project alias table for renamed project matching (Issue #71) | --- diff --git a/src/agent_session_analytics/cli.py b/src/agent_session_analytics/cli.py index b608731..5059283 100644 --- a/src/agent_session_analytics/cli.py +++ b/src/agent_session_analytics/cli.py @@ -803,6 +803,27 @@ def _format_efficiency(data: dict) -> list[str]: return lines +@_register_formatter( + lambda d: ( + "aliases" in d + and all( + isinstance(a, dict) and "alias" in a and "target" in a for a in d.get("aliases", []) + ) + ) +) +def _format_aliases(data: dict) -> list[str]: + aliases = data.get("aliases", []) + if not aliases: + return ["No project aliases configured."] + lines = [ + "Project aliases:", + "", + ] + for a in aliases: + lines.append(f" {a['alias']} → {a['target']}") + return lines + + def format_output(data: dict, json_output: bool = False) -> str: """Format output as JSON or human-readable.""" if json_output: @@ -1471,6 +1492,8 @@ def cmd_benchmark(args): storage, days=7, min_size_kb=10, limit=10 ), "get_session_efficiency": lambda: queries_get_session_efficiency(storage, days=7), + # Issue #71: Project aliases + "list_project_aliases": lambda: storage.get_project_aliases(), } # Skipped tools (require specific data or modify DB): @@ -1478,6 +1501,7 @@ def cmd_benchmark(args): # - correlate_git_with_sessions, ingest_bus_events # - find_related_sessions (requires valid session_id) # - upload_entries, get_sync_status, finalize_sync (remote sync tools - modify DB or require client context) + # - add_project_alias, remove_project_alias (modify DB) benchmarks = [] for tool_name, tool_func in tool_functions.items(): @@ -1740,6 +1764,41 @@ def mcp_call(method_name: str, arguments: dict) -> dict | None: print(format_output(output, args.json)) +# --- Alias Commands --- + + +def cmd_alias_add(args): + """Add a project alias.""" + storage = SQLiteStorage() + try: + storage.add_project_alias(args.alias, args.target) + result = {"status": "ok", "alias": args.alias, "target": args.target} + except sqlite3.IntegrityError: + result = { + "status": "ok", + "message": "Alias already exists", + "alias": args.alias, + "target": args.target, + } + print(format_output(result, args.json)) + + +def cmd_alias_remove(args): + """Remove a project alias.""" + storage = SQLiteStorage() + removed = storage.remove_project_alias(args.alias, args.target) + result = {"status": "ok", "alias": args.alias, "removed_count": removed} + print(format_output(result, args.json)) + + +def cmd_alias_list(args): + """List project aliases.""" + storage = SQLiteStorage() + aliases = storage.get_project_aliases(args.alias) + result = {"aliases": aliases} + print(format_output(result, args.json)) + + def main(): """CLI entry point.""" epilog = """ @@ -2094,6 +2153,29 @@ def main(): ) sub.set_defaults(func=cmd_push) + # alias (Issue #71 - project aliases for renamed projects) + alias_parser = subparsers.add_parser( + "alias", help="Manage project aliases for flexible filtering" + ) + alias_subparsers = alias_parser.add_subparsers(dest="alias_command", required=True) + + # alias add + sub = alias_subparsers.add_parser("add", help="Add a project alias") + sub.add_argument("alias", help="The alias name (e.g., 'genai-rs')") + sub.add_argument("target", help="The target to match (e.g., 'rust-genai')") + sub.set_defaults(func=cmd_alias_add) + + # alias remove + sub = alias_subparsers.add_parser("remove", help="Remove project alias(es)") + sub.add_argument("alias", help="The alias to remove") + sub.add_argument("target", nargs="?", help="Target to remove (all if omitted)") + sub.set_defaults(func=cmd_alias_remove) + + # alias list + sub = alias_subparsers.add_parser("list", help="List project aliases") + sub.add_argument("--alias", help="Filter to specific alias") + sub.set_defaults(func=cmd_alias_list) + args = parser.parse_args() args.func(args) diff --git a/src/agent_session_analytics/guide.md b/src/agent_session_analytics/guide.md index 6c95995..71d7cae 100644 --- a/src/agent_session_analytics/guide.md +++ b/src/agent_session_analytics/guide.md @@ -44,6 +44,43 @@ The `push` command queries `get_sync_status()` first to determine what the serve **Raw entry storage:** All uploaded entries are stored in both parsed form (events table) and raw form (raw_entries table). This allows re-parsing historical data when the parser improves. +### Project Aliases + +When projects are renamed, historical data doesn't match new filters. Project aliases solve this: + +| Tool | Purpose | +|------|---------| +| `add_project_alias(alias, target)` | Link an alias to a target pattern | +| `remove_project_alias(alias, target?)` | Remove alias (all targets if target omitted) | +| `list_project_aliases(alias?)` | List configured aliases | + +**Example:** Your project was renamed from `rust-genai` to `genai-rs`: +``` +add_project_alias("genai-rs", "rust-genai") +``` + +Now `--project genai-rs` will match both `genai-rs` AND `rust-genai` in all queries. + +**CLI usage:** +```bash +# Add alias +agent-session-analytics-cli alias add genai-rs rust-genai + +# List all aliases +agent-session-analytics-cli alias list + +# Remove specific alias-target pair +agent-session-analytics-cli alias remove genai-rs rust-genai + +# Remove all targets for an alias +agent-session-analytics-cli alias remove genai-rs +``` + +**Notes:** +- Matching is case-insensitive (`GenAI-RS` matches `genai-rs`) +- Aliases expand to OR clauses: `WHERE project_path LIKE '%genai-rs%' OR project_path LIKE '%rust-genai%'` +- Multiple targets can be added per alias (e.g., for projects renamed multiple times) + ### Core Queries | Tool | Purpose | diff --git a/src/agent_session_analytics/queries.py b/src/agent_session_analytics/queries.py index 3f0374a..7ec4113 100644 --- a/src/agent_session_analytics/queries.py +++ b/src/agent_session_analytics/queries.py @@ -25,14 +25,16 @@ def build_where_clause( cutoff_column: str = "timestamp", project: str | None = None, extra_conditions: list[str] | None = None, + storage: SQLiteStorage | None = None, ) -> tuple[str, list]: """Build a WHERE clause with common query filters. Args: cutoff: Datetime for cutoff filter (>= comparison) cutoff_column: Column name for cutoff (default: "timestamp") - project: Optional project path filter (LIKE %project%) + project: Optional project path filter (LIKE match, supports aliases) extra_conditions: Additional WHERE conditions to include + storage: Storage instance for alias resolution (optional) Returns: Tuple of (where_clause_string, params_list) @@ -45,8 +47,21 @@ def build_where_clause( params.append(cutoff) if project: - conditions.append("project_path LIKE ?") - params.append(f"%{project}%") + # Resolve aliases if storage provided + if storage: + patterns = storage.resolve_project_aliases(project) + else: + patterns = [project] + + # Build condition with COLLATE NOCASE for case-insensitive matching + if len(patterns) == 1: + conditions.append("project_path LIKE ? COLLATE NOCASE") + params.append(f"%{patterns[0]}%") + else: + # Multiple patterns: (project_path LIKE ? OR project_path LIKE ? ...) + pattern_conditions = " OR ".join(["project_path LIKE ? COLLATE NOCASE"] * len(patterns)) + conditions.append(f"({pattern_conditions})") + params.extend(f"%{p}%" for p in patterns) if extra_conditions: conditions.extend(extra_conditions) @@ -147,6 +162,7 @@ def query_tool_frequency( cutoff=cutoff, project=project, extra_conditions=["tool_name IS NOT NULL"], + storage=storage, ) # Get tool frequency counts @@ -169,6 +185,7 @@ def query_tool_frequency( cutoff=cutoff, project=project, extra_conditions=["entry_type = 'command'"], + storage=storage, ) cmd_rows = storage.execute_query( f"SELECT COUNT(*) as count FROM events WHERE {cmd_where}", @@ -226,6 +243,7 @@ def _get_skill_breakdown( cutoff=cutoff, project=project, extra_conditions=["tool_name = 'Skill'", "skill_name IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -252,6 +270,7 @@ def _get_command_breakdown( cutoff=cutoff, project=project, extra_conditions=["entry_type = 'command'", "skill_name IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -278,6 +297,7 @@ def _get_task_breakdown( cutoff=cutoff, project=project, extra_conditions=["tool_name = 'Task'", "tool_input_json IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -308,6 +328,7 @@ def _get_bash_breakdown( cutoff=cutoff, project=project, extra_conditions=["tool_name = 'Bash'", "command IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -407,6 +428,7 @@ def query_commands( cutoff=cutoff, project=project, extra_conditions=["tool_name = 'Bash'", "command IS NOT NULL"], + storage=storage, ) # Add prefix filter if specified @@ -459,6 +481,7 @@ def query_sessions( cutoff=cutoff, cutoff_column="last_seen", project=project, + storage=storage, ) rows = storage.execute_query( @@ -531,6 +554,7 @@ def query_tokens( where_clause, params = build_where_clause( cutoff=cutoff, project=project, + storage=storage, ) if by == "day": @@ -1095,16 +1119,7 @@ def classify_sessions( - category_distribution: Count of sessions per category """ cutoff = get_cutoff(days=days) - - # Build where clause - where_parts = ["timestamp >= ?"] - params: list = [cutoff] - - if project: - where_parts.append("project_path LIKE ?") - params.append(f"%{project}%") - - where_clause = " AND ".join(where_parts) + where_clause, params = build_where_clause(cutoff=cutoff, project=project, storage=storage) # Get activity stats per session (including efficiency metrics for #79) # Safe: where_clause is built from hardcoded condition strings above @@ -1494,6 +1509,7 @@ def query_file_activity( cutoff=cutoff, project=project, extra_conditions=["tool_name IN ('Read', 'Edit', 'Write')", "file_path IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -1572,6 +1588,7 @@ def query_languages( cutoff=cutoff, project=project, extra_conditions=["tool_name IN ('Read', 'Edit', 'Write')", "file_path IS NOT NULL"], + storage=storage, ) rows = storage.execute_query( @@ -1712,6 +1729,7 @@ def query_mcp_usage( cutoff=cutoff, project=project, extra_conditions=["tool_name LIKE 'mcp__%'"], + storage=storage, ) rows = storage.execute_query( @@ -1796,6 +1814,7 @@ def query_agent_activity( where_clause, params = build_where_clause( cutoff=cutoff, project=project, + storage=storage, ) # Query aggregated stats per agent_id (NULL = main session) @@ -2601,7 +2620,7 @@ def get_session_efficiency( Dict with efficiency metrics per session """ cutoff = get_cutoff(days=days) - where_clause, params = build_where_clause(cutoff=cutoff, project=project) + where_clause, params = build_where_clause(cutoff=cutoff, project=project, storage=storage) # Get session-level efficiency metrics query_params = list(params) diff --git a/src/agent_session_analytics/server.py b/src/agent_session_analytics/server.py index c4b03cc..94be836 100644 --- a/src/agent_session_analytics/server.py +++ b/src/agent_session_analytics/server.py @@ -208,6 +208,50 @@ def finalize_sync() -> dict: } +# --- Project Alias Management --- + + +@mcp.tool() +def add_project_alias(alias: str, target: str) -> dict: + """Add a project alias for flexible project filtering. + + When filtering by 'alias', queries will match both 'alias' and 'target'. + + Args: + alias: The alias name (e.g., 'genai-rs') + target: The target to also match (e.g., 'rust-genai') + """ + try: + storage.add_project_alias(alias, target) + return {"status": "ok", "alias": alias, "target": target} + except sqlite3.IntegrityError: + return {"status": "ok", "message": "Alias already exists", "alias": alias, "target": target} + + +@mcp.tool() +def remove_project_alias(alias: str, target: str | None = None) -> dict: + """Remove project alias(es). + + Args: + alias: The alias to remove + target: If specified, remove only this alias-target pair. + If omitted, remove ALL targets for this alias. + """ + removed = storage.remove_project_alias(alias, target) + return {"status": "ok", "removed_count": removed} + + +@mcp.tool() +def list_project_aliases(alias: str | None = None) -> dict: + """List project aliases. + + Args: + alias: Filter to specific alias (shows all if omitted) + """ + aliases = storage.get_project_aliases(alias) + return {"status": "ok", "aliases": aliases} + + @mcp.tool() def get_tool_frequency(days: int = 7, project: str | None = None, expand: bool = True) -> dict: """Get tool usage frequency counts. diff --git a/src/agent_session_analytics/storage.py b/src/agent_session_analytics/storage.py index 55bd5cf..224c4b1 100644 --- a/src/agent_session_analytics/storage.py +++ b/src/agent_session_analytics/storage.py @@ -177,7 +177,7 @@ class BusEvent: OLD_DB_PATH = Path.home() / ".claude" / "contrib" / "analytics" / "data.db" # Schema version for migrations -SCHEMA_VERSION = 13 +SCHEMA_VERSION = 14 # Migration functions: dict of version -> (migration_name, migration_func) # Each migration upgrades FROM version-1 TO version @@ -617,6 +617,32 @@ def migrate_v13(conn): logger.info("Created raw_entries table for storing unparsed JSONL") +@migration(14, "add_project_aliases") +def migrate_v14(conn): + """Add project_aliases table for flexible project name matching. + + Enables filtering by alias (e.g., "genai-rs") to match historical data + stored under different names (e.g., "rust-genai"). Uses COLLATE NOCASE + for case-insensitive matching. + """ + conn.execute( + """ + CREATE TABLE IF NOT EXISTS project_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT NOT NULL COLLATE NOCASE, + target TEXT NOT NULL COLLATE NOCASE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(alias, target) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_aliases_alias " + "ON project_aliases(alias COLLATE NOCASE)" + ) + logger.info("Created project_aliases table for flexible project name matching") + + class SQLiteStorage: """SQLite-backed storage for session analytics.""" @@ -930,6 +956,23 @@ def _init_db(self): "CREATE INDEX IF NOT EXISTS idx_raw_entries_timestamp ON raw_entries(timestamp)" ) + # Project aliases table for flexible project name matching (v14) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS project_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + alias TEXT NOT NULL COLLATE NOCASE, + target TEXT NOT NULL COLLATE NOCASE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(alias, target) + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_project_aliases_alias " + "ON project_aliases(alias COLLATE NOCASE)" + ) + # Run migrations AFTER all tables are created # Only existing databases need migrations - fresh databases have full schema current_version = self._get_schema_version(conn) @@ -1706,3 +1749,87 @@ def to_iso(val): "db_size_bytes": db_size, "db_path": str(self.db_path), } + + # Project alias operations (Issue #71) + + def add_project_alias(self, alias: str, target: str) -> None: + """Add a project alias mapping. + + Args: + alias: The alias name (e.g., "genai-rs") + target: The target project path pattern (e.g., "rust-genai") + """ + with self._connect() as conn: + conn.execute( + """ + INSERT OR IGNORE INTO project_aliases (alias, target) + VALUES (?, ?) + """, + (alias, target), + ) + + def remove_project_alias(self, alias: str, target: str | None = None) -> int: + """Remove project alias(es). + + Args: + alias: The alias to remove + target: Optional specific target (removes all targets for alias if not specified) + + Returns: + Number of aliases removed + """ + with self._connect() as conn: + if target: + cursor = conn.execute( + "DELETE FROM project_aliases WHERE alias = ? COLLATE NOCASE AND target = ? COLLATE NOCASE", + (alias, target), + ) + else: + cursor = conn.execute( + "DELETE FROM project_aliases WHERE alias = ? COLLATE NOCASE", + (alias,), + ) + return cursor.rowcount + + def get_project_aliases(self, alias: str | None = None) -> list[dict]: + """Get project aliases. + + Args: + alias: Optional alias to filter by + + Returns: + List of dicts with 'alias' and 'target' keys + """ + with self._connect() as conn: + if alias: + rows = conn.execute( + "SELECT alias, target FROM project_aliases WHERE alias = ? COLLATE NOCASE ORDER BY target", + (alias,), + ).fetchall() + else: + rows = conn.execute( + "SELECT alias, target FROM project_aliases ORDER BY alias, target" + ).fetchall() + + return [{"alias": row["alias"], "target": row["target"]} for row in rows] + + def resolve_project_aliases(self, project: str) -> list[str]: + """Resolve a project name to all matching patterns (including aliases). + + Args: + project: Project name or alias + + Returns: + List of project patterns to match (original + all alias targets) + """ + with self._connect() as conn: + rows = conn.execute( + "SELECT target FROM project_aliases WHERE alias = ? COLLATE NOCASE", + (project,), + ).fetchall() + + # Always include the original project name + patterns = [project] + # Add all alias targets + patterns.extend(row["target"] for row in rows) + return patterns diff --git a/tests/test_queries.py b/tests/test_queries.py index 09c4d64..b2e7855 100644 --- a/tests/test_queries.py +++ b/tests/test_queries.py @@ -2820,3 +2820,75 @@ def test_returns_efficiency_metrics(self, storage): assert s1_data is not None assert s1_data["efficiency_signals"]["compaction_count"] == 1 assert s1_data["efficiency_signals"]["has_compaction"] is True + + +# Issue #71: Project alias expansion in queries + + +class TestProjectAliasExpansion: + """Tests for project alias expansion in build_where_clause (Issue #71).""" + + def test_query_with_alias_expansion(self, storage): + """Test that query expands aliases to match multiple project patterns.""" + # Add events for different project names + now = datetime.now() + storage.add_event( + Event( + id=None, + uuid="evt-new-name", + timestamp=now, + session_id="s1", + project_path="-home-user-genai-rs", + tool_name="Read", + ) + ) + storage.add_event( + Event( + id=None, + uuid="evt-old-name", + timestamp=now, + session_id="s2", + project_path="-home-user-rust-genai", + tool_name="Edit", + ) + ) + storage.add_event( + Event( + id=None, + uuid="evt-other", + timestamp=now, + session_id="s3", + project_path="-home-user-other-project", + tool_name="Bash", + ) + ) + + # Without alias, should only match one project + result_no_alias = query_tool_frequency(storage, days=7, project="genai-rs") + assert result_no_alias["total_tool_calls"] == 1 + + # Add alias + storage.add_project_alias("genai-rs", "rust-genai") + + # With alias, should match both project names + result_with_alias = query_tool_frequency(storage, days=7, project="genai-rs") + assert result_with_alias["total_tool_calls"] == 2 + + def test_alias_case_insensitive_query(self, storage): + """Test that alias expansion is case-insensitive in queries.""" + now = datetime.now() + storage.add_event( + Event( + id=None, + uuid="evt-1", + timestamp=now, + session_id="s1", + project_path="-home-user-MyProject", + tool_name="Read", + ) + ) + storage.add_project_alias("myproject", "old-myproject") + + # Should match even with different case + result = query_tool_frequency(storage, days=7, project="MYPROJECT") + assert result["total_tool_calls"] == 1 diff --git a/tests/test_storage.py b/tests/test_storage.py index 8716810..2c6c86c 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1159,3 +1159,106 @@ def test_raw_entries_table_exists(self, storage): assert "timestamp" in columns assert "entry_json" in columns assert "ingested_at" in columns + + +# Issue #71: Project alias tests + + +class TestProjectAliases: + """Tests for project alias functionality (Issue #71).""" + + def test_add_project_alias(self, storage): + """Test adding a project alias.""" + storage.add_project_alias("genai-rs", "rust-genai") + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 1 + assert aliases[0]["alias"] == "genai-rs" + assert aliases[0]["target"] == "rust-genai" + + def test_add_project_alias_duplicate_ignored(self, storage): + """Test that duplicate aliases are silently ignored.""" + storage.add_project_alias("genai-rs", "rust-genai") + storage.add_project_alias("genai-rs", "rust-genai") # Duplicate + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 1 + + def test_add_multiple_targets_for_alias(self, storage): + """Test that one alias can have multiple targets.""" + storage.add_project_alias("genai-rs", "rust-genai") + storage.add_project_alias("genai-rs", "genai-rust") + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 2 + targets = {a["target"] for a in aliases} + assert targets == {"rust-genai", "genai-rust"} + + def test_remove_project_alias_specific(self, storage): + """Test removing a specific alias-target pair.""" + storage.add_project_alias("genai-rs", "rust-genai") + storage.add_project_alias("genai-rs", "genai-rust") + removed = storage.remove_project_alias("genai-rs", "rust-genai") + assert removed == 1 + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 1 + assert aliases[0]["target"] == "genai-rust" + + def test_remove_project_alias_all_targets(self, storage): + """Test removing all targets for an alias.""" + storage.add_project_alias("genai-rs", "rust-genai") + storage.add_project_alias("genai-rs", "genai-rust") + removed = storage.remove_project_alias("genai-rs") + assert removed == 2 + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 0 + + def test_remove_nonexistent_alias(self, storage): + """Test removing non-existent alias returns 0.""" + removed = storage.remove_project_alias("nonexistent") + assert removed == 0 + + def test_get_project_aliases_all(self, storage): + """Test listing all aliases without filter.""" + storage.add_project_alias("project-a", "old-project-a") + storage.add_project_alias("project-b", "old-project-b") + aliases = storage.get_project_aliases() + assert len(aliases) == 2 + + def test_resolve_project_aliases(self, storage): + """Test resolving project name to all matching patterns.""" + storage.add_project_alias("genai-rs", "rust-genai") + storage.add_project_alias("genai-rs", "genai-rust") + patterns = storage.resolve_project_aliases("genai-rs") + # Should include original + all targets + assert set(patterns) == {"genai-rs", "rust-genai", "genai-rust"} + + def test_resolve_project_aliases_no_alias(self, storage): + """Test resolving project name with no aliases returns just the name.""" + patterns = storage.resolve_project_aliases("some-project") + assert patterns == ["some-project"] + + def test_alias_case_insensitive(self, storage): + """Test that alias lookup is case-insensitive.""" + storage.add_project_alias("GenAI-RS", "rust-genai") + # Query with different case + aliases = storage.get_project_aliases("genai-rs") + assert len(aliases) == 1 + assert aliases[0]["target"] == "rust-genai" + + def test_resolve_alias_case_insensitive(self, storage): + """Test that resolve_project_aliases is case-insensitive.""" + storage.add_project_alias("GenAI-RS", "rust-genai") + patterns = storage.resolve_project_aliases("genai-rs") + assert "rust-genai" in patterns + + def test_project_aliases_table_exists(self, storage): + """Verify that project_aliases table exists with correct schema.""" + rows = storage.execute_query("PRAGMA table_info(project_aliases)") + columns = {row[1] for row in rows} + assert "alias" in columns + assert "target" in columns + assert "created_at" in columns + + def test_project_aliases_index_exists(self, storage): + """Verify that idx_project_aliases_alias index exists.""" + rows = storage.execute_query("PRAGMA index_list(project_aliases)") + indexes = {row[1] for row in rows} + assert "idx_project_aliases_alias" in indexes