diff --git a/.env.example b/.env.example index 70f0d7f..3641a38 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,16 @@ # Required Environment Variables OPENAI_API_KEY="sk-*****" +# Optional: Model selection (defaults to gpt-4o-mini if not set) +# OPENAI_MODEL_NAME="gpt-4o-mini" +# OPENAI_MODEL_NAME="glm-4.5-air" # z.ai + +# Optional: Use OpenAI-compatible APIs (uncomment one) +# OPENAI_BASE_URL="https://api.z.ai/api/coding/paas/v4" # z.ai +# OPENAI_BASE_URL="https://openrouter.ai/api/v1" # OpenRouter +# OPENAI_BASE_URL="http://localhost:11434/v1" # Ollama (local) +# OPENAI_BASE_URL="http://localhost:1234/v1" # LM Studio (local) + # Agentic Web Search TAVILY_API_KEY="tvly-****" diff --git a/AGENTS.md b/AGENTS.md index 99ea0f6..ec93a00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,12 @@ This guide is for both external LLMs working within this codebase and developers building new agents and tools. +## Environment + +We use uv for package management and running commands. +cd is aliased to z (zoxide), use full path instead. +ls is aliased to eza, use full path instead. + ## Project Overview This is a **LangChain + MCP framework** for building agentic systems in Python 3.12+. @@ -135,11 +141,14 @@ Orchestrates 3 specialist agents (flight, city intel, reviewer) with MCP tools. Codebase exploration agent with specialized local tools and webfetch MCP: - `find_files` - Fast file search via fd - `discover_structure` - Directory tree exploration -- `get_file_outline` - Extract class/function signatures +- `get_file_outline` - Extract class/function signatures (multi-language) - `read_file_fragment` - Read specific line ranges - `code_search` - Fast pattern search via ripgrep - `webfetch` (MCP) - Web content fetching +**Supported Languages for `get_file_outline`**: +Python, JavaScript, TypeScript, Rust, Go, Java, C/C++, PHP + ## Using Existing Agents ### CLI Usage @@ -313,6 +322,8 @@ __all__ = [ - Use `find_files` when you need to locate files by name/pattern - Use `discover_structure` for project layout overview - Use `get_file_outline` to skim file contents before reading + - Supports: Python (`.py`), JavaScript (`.js`), TypeScript (`.ts`), Rust (`.rs`), Go (`.go`), Java (`.java`), C (`.c`, `.h`), C++ (`.cpp`, `.hpp`), PHP (`.php`) + - Returns: `[{"line": 15, "signature": "class MyAgent:"}, ...]` - Use `read_file_fragment` to read specific lines (format: `path:start:end`) - Use `code_search` for fast global pattern matching diff --git a/Makefile b/Makefile index 1e52e5b..598db4b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install run test clean lint check format docker-build docker-clean +.PHONY: help install run test clean fix check docker-build docker-clean .DEFAULT_GOAL := help # Use `uv` for python environment management @@ -31,19 +31,15 @@ run: ## Run the agentic-run simple command to exemplify the CLI test: ## Run tests with coverage @$(UV) --project $(PROJECT_DIR) run pytest $(PROJECT_DIR)/tests/ -v --cov=$(PROJECT_DIR)/src --cov-report=xml --cov-report=term -lint: ## Run ruff linting with auto-fix - @$(UV) --project $(PROJECT_DIR) run mypy $(PROJECT_DIR)/src/ - @$(UV) --project $(PROJECT_DIR) run ruff check --fix $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ - @$(UV) --project $(PROJECT_DIR) run ruff format $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ - -format: ## Run ruff code formatting - @$(UV) --project $(PROJECT_DIR) run ruff format $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ - -check: ## Run lint, format check, and mypy type checking (no fixes) +check: ## Run all checks (mypy, ruff lint, ruff format) - no modifications @$(UV) --project $(PROJECT_DIR) run mypy $(PROJECT_DIR)/src/ @$(UV) --project $(PROJECT_DIR) run ruff check $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ @$(UV) --project $(PROJECT_DIR) run ruff format --check $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ +fix: ## Auto-fix lint and format issues (runs ruff check --fix and ruff format) + @$(UV) --project $(PROJECT_DIR) run ruff check --fix $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ + @$(UV) --project $(PROJECT_DIR) run ruff format $(PROJECT_DIR)/src/ $(PROJECT_DIR)/tests/ + clean: ## Deep clean temporary files and virtual environment rm -rf $(PROJECT_DIR)/.venv rm -rf $(PROJECT_DIR)/../.pytest_cache/ diff --git a/README.md b/README.md index 16f936d..c011229 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,9 @@ tail -f agentic-framework/logs/agent.log The `developer` agent is designed to assist with codebase maintenance and understanding. It comes equipped with local tools for: - **File Discovery**: Finding files by name across the project. - **Structure Exploration**: Visualizing the project directory tree. -- **Code Outlining**: Extracting functions and classes from Python files. +- **Code Outlining**: Extracting functions, classes, and signatures from code files. + - Supports: Python, JavaScript, TypeScript, Rust, Go, Java, C/C++, PHP + - Returns line numbers for precise navigation. - **Pattern Search**: Global search using `ripgrep` for fast pattern matching. Implementation: `src/agentic_framework/core/developer_agent.py` diff --git a/agentic-framework/src/agentic_framework/cli.py b/agentic-framework/src/agentic_framework/cli.py index 6d8832d..46879e0 100644 --- a/agentic-framework/src/agentic_framework/cli.py +++ b/agentic-framework/src/agentic_framework/cli.py @@ -4,15 +4,12 @@ from typing import Any, Callable, Type import typer -from dotenv import load_dotenv from rich.console import Console from agentic_framework.constants import LOGS_DIR from agentic_framework.mcp import MCPConnectionError, MCPProvider from agentic_framework.registry import AgentRegistry -load_dotenv() - RUN_TIMEOUT_SECONDS = 600 app = typer.Typer( diff --git a/agentic-framework/src/agentic_framework/constants.py b/agentic-framework/src/agentic_framework/constants.py index 55eb104..5a5461e 100644 --- a/agentic-framework/src/agentic_framework/constants.py +++ b/agentic-framework/src/agentic_framework/constants.py @@ -1,4 +1,11 @@ +import os from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() # Load .env before reading environment variables + BASE_DIR = Path(__file__).resolve().parent.parent.parent LOGS_DIR = BASE_DIR / "logs" + +DEFAULT_MODEL = os.getenv("OPENAI_MODEL_NAME", "gpt-4o-mini") diff --git a/agentic-framework/src/agentic_framework/core/developer_agent.py b/agentic-framework/src/agentic_framework/core/developer_agent.py index afc53f1..cae3bb0 100644 --- a/agentic-framework/src/agentic_framework/core/developer_agent.py +++ b/agentic-framework/src/agentic_framework/core/developer_agent.py @@ -27,16 +27,17 @@ def system_prompt(self) -> str: Your goal is to help the user understand and maintain their codebase. You have access to several specialized tools for: 1. Discovering the project structure and finding files by name (`find_files`). - 2. Extracting class and function outlines from Python files (`get_file_outline`). + 2. Extracting class and function outlines from code files (`get_file_outline`). + Supports: Python, JavaScript, TypeScript, Rust, Go, Java, C/C++, PHP. 3. Reading specific fragments of a file (`read_file_fragment`). 4. Searching the codebase for patterns using ripgrep (`code_search`). - + When you need to find a specific file by name, use `find_files`. When asked about the project structure, start with `discover_structure`. When asked to explain a file, start with `get_file_outline` to get an overview. Use `read_file_fragment` to read specific lines if you need more detail. Use `code_search` for fast global pattern matching. - + Always provide clear, concise explanations and suggest improvements when relevant. You also have access to MCP tools like `webfetch` if you need to fetch information from the web. """ diff --git a/agentic-framework/src/agentic_framework/core/langgraph_agent.py b/agentic-framework/src/agentic_framework/core/langgraph_agent.py index d658138..d3e07d6 100644 --- a/agentic-framework/src/agentic_framework/core/langgraph_agent.py +++ b/agentic-framework/src/agentic_framework/core/langgraph_agent.py @@ -6,6 +6,7 @@ from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import InMemorySaver +from agentic_framework.constants import DEFAULT_MODEL from agentic_framework.interfaces.base import Agent from agentic_framework.mcp import MCPProvider @@ -15,7 +16,7 @@ class LangGraphMCPAgent(Agent): def __init__( self, - model_name: str = "gpt-5-nano", + model_name: str = DEFAULT_MODEL, temperature: float = 0.1, mcp_provider: MCPProvider | None = None, initial_mcp_tools: List[Any] | None = None, diff --git a/agentic-framework/src/agentic_framework/core/simple_agent.py b/agentic-framework/src/agentic_framework/core/simple_agent.py index dbdcc06..6717b43 100644 --- a/agentic-framework/src/agentic_framework/core/simple_agent.py +++ b/agentic-framework/src/agentic_framework/core/simple_agent.py @@ -4,6 +4,7 @@ from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI +from agentic_framework.constants import DEFAULT_MODEL from agentic_framework.interfaces.base import Agent from agentic_framework.registry import AgentRegistry @@ -15,7 +16,7 @@ class SimpleAgent(Agent): No MCP access (mcp_servers=None in registry). """ - def __init__(self, model_name: str = "gpt-5-nano", temperature: float = 0.0, **kwargs): + def __init__(self, model_name: str = DEFAULT_MODEL, temperature: float = 0.0, **kwargs: Any) -> None: self.model = ChatOpenAI(model=model_name, temperature=temperature) self.prompt = ChatPromptTemplate.from_messages( [("system", "You are a helpful assistant."), ("user", "{input}")] diff --git a/agentic-framework/src/agentic_framework/core/travel_coordinator_agent.py b/agentic-framework/src/agentic_framework/core/travel_coordinator_agent.py index 356a094..96dab96 100644 --- a/agentic-framework/src/agentic_framework/core/travel_coordinator_agent.py +++ b/agentic-framework/src/agentic_framework/core/travel_coordinator_agent.py @@ -3,6 +3,7 @@ from langchain_core.messages import BaseMessage +from agentic_framework.constants import DEFAULT_MODEL from agentic_framework.core.langgraph_agent import LangGraphMCPAgent from agentic_framework.interfaces.base import Agent from agentic_framework.mcp import MCPProvider @@ -54,7 +55,7 @@ class TravelCoordinatorAgent(Agent): def __init__( self, - model_name: str = "gpt-5-nano", + model_name: str = DEFAULT_MODEL, temperature: float = 0.2, mcp_provider: MCPProvider | None = None, initial_mcp_tools: List[Any] | None = None, diff --git a/agentic-framework/src/agentic_framework/mcp/provider.py b/agentic-framework/src/agentic_framework/mcp/provider.py index 52d881d..a432a69 100644 --- a/agentic-framework/src/agentic_framework/mcp/provider.py +++ b/agentic-framework/src/agentic_framework/mcp/provider.py @@ -65,7 +65,7 @@ async def get_tools(self) -> List[Any]: return self._tools_cache @asynccontextmanager - async def tool_session(self, fail_fast: bool = True): + async def tool_session(self, fail_fast: bool = True) -> Any: """ Async context manager: load MCP tools with sessions that close on exit. diff --git a/agentic-framework/src/agentic_framework/tools/codebase_explorer.py b/agentic-framework/src/agentic_framework/tools/codebase_explorer.py index 4687770..f13ab0a 100644 --- a/agentic-framework/src/agentic_framework/tools/codebase_explorer.py +++ b/agentic-framework/src/agentic_framework/tools/codebase_explorer.py @@ -5,6 +5,97 @@ from agentic_framework.interfaces.base import Tool +# Language detection by file extension +LANGUAGE_EXTENSIONS: Dict[str, str] = { + ".py": "python", + ".pyi": "python", + ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".rs": "rust", + ".go": "go", + ".java": "java", + ".c": "c", + ".cpp": "cpp", + ".h": "c", + ".hpp": "cpp", + ".php": "php", +} + +# Regex patterns for each language +# Each pattern captures the relevant code construct +LANGUAGE_PATTERNS: Dict[str, List[str]] = { + "python": [ + r"^\s*(class\s+\w+.*?)[:\(]", + r"^\s*(async\s+def\s+\w+.*?)\(", + r"^\s*(def\s+\w+.*?)\(", + ], + "javascript": [ + r"^\s*(export\s+)?(default\s+)?(class\s+\w+)", + r"^\s*(export\s+)?(default\s+)?(function\s*\*?\s+\w+)", + r"^\s*(export\s+)?(const\s+\w+\s*=\s*\([^)]*\)\s*=>)", + r"^\s*(export\s+)?(const\s+\w+\s*=\s*async\s*\([^)]*\)\s*=>)", + ], + "typescript": [ + r"^\s*(export\s+)?(default\s+)?(class\s+\w+)", + r"^\s*(export\s+)?(default\s+)?(function\s*\*?\s+\w+)", + r"^\s*(export\s+)?(const\s+\w+\s*=\s*\([^)]*\)\s*=>)", + r"^\s*(export\s+)?(const\s+\w+\s*=\s*async\s*\([^)]*\)\s*=>)", + r"^\s*(export\s+)?(interface\s+\w+)", + r"^\s*(export\s+)?(type\s+\w+)", + r"^\s*(export\s+)?(enum\s+\w+)", + r"^\s*(export\s+)?(abstract\s+class\s+\w+)", + r"^\s*(export\s+)?(namespace\s+\w+)", + ], + "rust": [ + r"^\s*(pub\s+(\([^)]+\)\s+)?)?(struct\s+\w+)", + r"^\s*(pub\s+)?(enum\s+\w+)", + r"^\s*(pub\s+)?(trait\s+\w+)", + r"^\s*(pub\s+(\([^)]+\)\s+)?)?(async\s+)?(fn\s+\w+)", + r"^\s*(impl\s+(<[^>]+>\s+)?\w+)", + r"^\s*(pub\s+)?(mod\s+\w+)", + ], + "go": [ + r"^\s*func\s+\(\w+\s+\*?\w+\)\s*\w+", # method with receiver + r"^\s*func\s+\w+", # function + r"^\s*type\s+\w+\s+struct\b", + r"^\s*type\s+\w+\s+interface\b", + r"^\s*type\s+\w+\s+func\b", + r"^\s*type\s+\w+\s*\(", # type alias with params + ], + "java": [ + r"^\s*(public|private|protected)?\s*(abstract\s+)?(class\s+\w+)", + r"^\s*(public\s+)?(interface\s+\w+)", + r"^\s*(public\s+)?(enum\s+\w+)", + r"^\s*@\w+(\([^)]*\))?\s*$", # annotations (standalone) + r"^\s*(public|private|protected)\s+(static\s+)?[\w<>?,\s]+\s+\w+\s*\(", # methods + r"^\s*(public|private|protected)\s+\w+\s*\([^\)]*\)\s*\{", # constructors + ], + "c": [ + r"^\s*(typedef\s+)?(struct\s+\w*)", + r"^\s*(typedef\s+)?(enum\s+\w*)", + r"^\s*(typedef\s+)?(union\s+\w*)", + r"^\s*(void|int|char|float|double|long|unsigned|signed|short)\s+[\w\s\*]+\s*\w+\s*\(", + ], + "cpp": [ + r"^\s*(typedef\s+)?(struct\s+\w*)", + r"^\s*(typedef\s+)?(enum\s+\w*)", + r"^\s*(typedef\s+)?(union\s+\w*)", + r"^\s*(class\s+\w+)", + r"^\s*(namespace\s+\w+)", + r"^\s*(template\s*<[^>]*>)?\s*class\s+\w+", # template class + r"^\s*(template\s*<[^>]*>)?\s*[\w:]+\s+[\w:]+\s*\(", # template function + ], + "php": [ + r"^\s*(abstract\s+)?(final\s+)?(class\s+\w+)", + r"^\s*(interface\s+\w+)", + r"^\s*(trait\s+\w+)", + r"^\s*(public|private|protected)?\s*(static\s+)?function\s+\w+", + ], +} + class CodebaseExplorer: """ @@ -77,7 +168,7 @@ def _build_tree(self, current_dir: Path, depth: int, max_depth: int) -> Dict[str class FileOutlinerTool(CodebaseExplorer, Tool): - """Tool to extract high-level signatures from a file.""" + """Tool to extract high-level signatures from various programming language files.""" @property def name(self) -> str: @@ -85,25 +176,47 @@ def name(self) -> str: @property def description(self) -> str: - return "Extracts classes and functions signatures from a Python file to skim its content." + return """Extracts classes, functions, and other definitions from code files. + Supports: Python, JavaScript, TypeScript, Rust, Go, Java, C/C++, PHP. + Returns a list of signatures with their line numbers. + Output format: [{"line": 15, "signature": "class MyAgent:"}, ...]""" def invoke(self, file_path: str) -> Any: full_path = self.root_dir / file_path if not full_path.exists() or not full_path.is_file(): return f"Error: File {file_path} not found." - outline = [] - pattern = re.compile(r"^\s*(class\s+\w+|async\s+def\s+\w+|def\s+\w+)") + language = self._detect_language(file_path) + if language is None: + return f"Error: Unsupported file type for {file_path}. Supported: Python, JS/TS, Rust, Go, Java, C/C++, PHP" + + patterns = LANGUAGE_PATTERNS.get(language, []) + return self._extract_outline(full_path, patterns) + + def _detect_language(self, file_path: str) -> Optional[str]: + """Detect programming language from file extension.""" + ext = Path(file_path).suffix.lower() + return LANGUAGE_EXTENSIONS.get(ext) + + def _extract_outline(self, file_path: Path, patterns: List[str]) -> List[Dict[str, Any]]: + """Extract code outline using regex patterns.""" + outline: List[Dict[str, Any]] = [] + compiled_patterns = [re.compile(p) for p in patterns] try: - with open(full_path, "r", encoding="utf-8") as f: - for line in f: - match = pattern.match(line) - if match: - outline.append(line.strip()) + with open(file_path, "r", encoding="utf-8", errors="replace") as f: + for line_num, line in enumerate(f, 1): + for pattern in compiled_patterns: + if pattern.search(line): + # Clean up the signature (limit length for readability) + signature = line.strip() + if len(signature) > 100: + signature = signature[:97] + "..." + outline.append({"line": line_num, "signature": signature}) + break # Only match first pattern per line return outline except Exception as e: - return f"Error reading file: {e}" + return [{"error": f"Error reading file: {e}"}] class FileFragmentReaderTool(CodebaseExplorer, Tool): diff --git a/agentic-framework/src/agentic_framework/tools/web_search.py b/agentic-framework/src/agentic_framework/tools/web_search.py index 1401dca..5d5aabc 100644 --- a/agentic-framework/src/agentic_framework/tools/web_search.py +++ b/agentic-framework/src/agentic_framework/tools/web_search.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, cast from tavily import TavilyClient # type: ignore[import-untyped] @@ -8,7 +8,7 @@ class WebSearchTool(Tool): """WebSearch Tool that uses Tavily API.""" - def __init__(self): + def __init__(self) -> None: self.tavily_client = TavilyClient() @property @@ -21,4 +21,5 @@ def description(self) -> str: def invoke(self, query: str) -> Dict[str, Any]: """Search the web for information""" - return self.tavily_client.search(query) + result = self.tavily_client.search(query) + return cast(Dict[str, Any], result) diff --git a/agentic-framework/tests/test_codebase_explorer.py b/agentic-framework/tests/test_codebase_explorer.py new file mode 100644 index 0000000..4a9a6b8 --- /dev/null +++ b/agentic-framework/tests/test_codebase_explorer.py @@ -0,0 +1,580 @@ +"""Tests for codebase explorer tools.""" + +import tempfile +from pathlib import Path + +import pytest + +from agentic_framework.tools.codebase_explorer import ( + LANGUAGE_EXTENSIONS, + LANGUAGE_PATTERNS, + FileFragmentReaderTool, + FileOutlinerTool, + StructureExplorerTool, +) + + +class TestLanguageDetection: + """Tests for language detection by file extension.""" + + def test_python_extensions(self): + assert LANGUAGE_EXTENSIONS[".py"] == "python" + assert LANGUAGE_EXTENSIONS[".pyi"] == "python" + + def test_javascript_extensions(self): + assert LANGUAGE_EXTENSIONS[".js"] == "javascript" + assert LANGUAGE_EXTENSIONS[".mjs"] == "javascript" + assert LANGUAGE_EXTENSIONS[".cjs"] == "javascript" + + def test_typescript_extensions(self): + assert LANGUAGE_EXTENSIONS[".ts"] == "typescript" + assert LANGUAGE_EXTENSIONS[".tsx"] == "typescript" + + def test_rust_extension(self): + assert LANGUAGE_EXTENSIONS[".rs"] == "rust" + + def test_go_extension(self): + assert LANGUAGE_EXTENSIONS[".go"] == "go" + + def test_java_extension(self): + assert LANGUAGE_EXTENSIONS[".java"] == "java" + + def test_c_cpp_extensions(self): + assert LANGUAGE_EXTENSIONS[".c"] == "c" + assert LANGUAGE_EXTENSIONS[".h"] == "c" + assert LANGUAGE_EXTENSIONS[".cpp"] == "cpp" + assert LANGUAGE_EXTENSIONS[".hpp"] == "cpp" + + def test_php_extension(self): + assert LANGUAGE_EXTENSIONS[".php"] == "php" + + +class TestFileOutlinerTool: + """Tests for FileOutlinerTool with multi-language support.""" + + @pytest.fixture + def temp_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def outliner(self, temp_dir): + return FileOutlinerTool(str(temp_dir)) + + def test_python_outline(self, outliner, temp_dir): + """Test Python file outline extraction.""" + code = ''' +import os + +class MyAgent: + """A simple agent.""" + + def __init__(self): + pass + + async def run(self, input_data): + return "done" + +def helper_function(): + pass +''' + (temp_dir / "test.py").write_text(code) + result = outliner.invoke("test.py") + + assert isinstance(result, list) + assert len(result) == 4 + + signatures = [item["signature"] for item in result] + lines = [item["line"] for item in result] + + assert any("class MyAgent" in s for s in signatures) + assert any("def __init__" in s for s in signatures) + assert any("async def run" in s for s in signatures) + assert any("def helper_function" in s for s in signatures) + + # Verify line numbers + assert 4 in lines # class + assert 7 in lines # def __init__ + assert 10 in lines # async def run + + def test_javascript_outline(self, outliner, temp_dir): + """Test JavaScript file outline extraction.""" + code = """ +export class UserService { + constructor() {} + + async fetchUser(id) { + return fetch(`/users/${id}`); + } +} + +export function helper() { + return "helper"; +} + +const processItem = (item) => { + return item; +}; + +const asyncProcess = async (data) => { + return data; +}; +""" + (temp_dir / "test.js").write_text(code) + result = outliner.invoke("test.js") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("class UserService" in s for s in signatures) + assert any("function helper" in s for s in signatures) + assert any("processItem" in s for s in signatures) + assert any("asyncProcess" in s for s in signatures) + + def test_typescript_outline(self, outliner, temp_dir): + """Test TypeScript file outline extraction.""" + code = """ +interface User { + id: number; + name: string; +} + +type UserRole = "admin" | "user"; + +export class UserService { + async getUser(id: number): Promise { + return {} as User; + } +} + +export enum Status { + Active, + Inactive +} + +abstract class BaseService { + abstract connect(): void; +} +""" + (temp_dir / "test.ts").write_text(code) + result = outliner.invoke("test.ts") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("interface User" in s for s in signatures) + assert any("type UserRole" in s for s in signatures) + assert any("class UserService" in s for s in signatures) + assert any("enum Status" in s for s in signatures) + assert any("abstract class BaseService" in s for s in signatures) + + def test_rust_outline(self, outliner, temp_dir): + """Test Rust file outline extraction.""" + code = """ +pub struct User { + pub name: String, +} + +struct PrivateData { + id: u64, +} + +pub enum Status { + Active, + Inactive, +} + +pub trait Repository { + fn find(&self, id: u64) -> Option; +} + +impl User { + pub fn new(name: String) -> Self { + Self { name } + } +} + +pub fn create_user(name: &str) -> User { + User { name: name.to_string() } +} + +pub async fn fetch_user(id: u64) -> Result { + Ok(User { name: "test".into() }) +} + +pub mod models { + pub struct Model {} +} +""" + (temp_dir / "test.rs").write_text(code) + result = outliner.invoke("test.rs") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("struct User" in s for s in signatures) + assert any("struct PrivateData" in s for s in signatures) + assert any("enum Status" in s for s in signatures) + assert any("trait Repository" in s for s in signatures) + assert any("impl User" in s for s in signatures) + assert any("fn create_user" in s for s in signatures) + assert any("fn fetch_user" in s for s in signatures) + assert any("mod models" in s for s in signatures) + + def test_go_outline(self, outliner, temp_dir): + """Test Go file outline extraction.""" + code = """ +package main + +type User struct { + Name string + Age int +} + +type UserRepository interface { + FindByID(id int) (*User, error) + Save(user *User) error +} + +type Handler func(w http.ResponseWriter, r *http.Request) + +func NewUser(name string, age int) *User { + return &User{Name: name, Age: age} +} + +func (u *User) Greet() string { + return fmt.Sprintf("Hello, %s", u.Name) +} + +func (u *User) SetAge(age int) { + u.Age = age +} +""" + (temp_dir / "test.go").write_text(code) + result = outliner.invoke("test.go") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("type User struct" in s for s in signatures) + assert any("type UserRepository interface" in s for s in signatures) + assert any("type Handler func" in s for s in signatures) + assert any("func NewUser" in s for s in signatures) + assert any("(u *User) Greet" in s for s in signatures) + assert any("(u *User) SetAge" in s for s in signatures) + + def test_java_outline(self, outliner, temp_dir): + """Test Java file outline extraction.""" + code = """ +package com.example; + +import java.util.List; + +public class UserService { + private String name; + + public UserService(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + private void processInternal() { + // internal logic + } + + public static UserService createDefault() { + return new UserService("default"); + } +} + +interface Repository { + T findById(long id); + void save(T entity); +} + +enum Status { + ACTIVE, + INACTIVE +} + +abstract class BaseService { + public abstract void initialize(); +} +""" + (temp_dir / "Test.java").write_text(code) + result = outliner.invoke("Test.java") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("class UserService" in s for s in signatures) + assert any("interface Repository" in s for s in signatures) + assert any("enum Status" in s for s in signatures) + assert any("abstract class BaseService" in s for s in signatures) + # Methods + assert any("UserService(" in s for s in signatures) + assert any("getName(" in s for s in signatures) + assert any("processInternal(" in s for s in signatures) + assert any("createDefault(" in s for s in signatures) + + def test_c_outline(self, outliner, temp_dir): + """Test C file outline extraction.""" + code = """ +#include + +typedef struct { + int x; + int y; +} Point; + +struct User { + char name[50]; + int age; +}; + +enum Status { + STATUS_OK, + STATUS_ERROR +}; + +int add(int a, int b) { + return a + b; +} + +void print_hello(void) { + printf("Hello\\n"); +} + +double calculate(double x) { + return x * 2.0; +} +""" + (temp_dir / "test.c").write_text(code) + result = outliner.invoke("test.c") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + # Note: typedef struct { ... } Point; matches "typedef struct" line, not "Point" + assert any("typedef struct" in s for s in signatures) + assert any("struct User" in s for s in signatures) + assert any("enum Status" in s for s in signatures) + assert any("int add" in s for s in signatures) + assert any("void print_hello" in s for s in signatures) + assert any("double calculate" in s for s in signatures) + + def test_cpp_outline(self, outliner, temp_dir): + """Test C++ file outline extraction.""" + code = """ +#include +#include + +namespace myapp { + +class User { +private: + std::string name; +public: + User(const std::string& name); + std::string getName() const; +}; + +struct Point { + int x, y; +}; + +template class Container { +public: + void add(const T& item); + T get(int index) const; +}; + +} // namespace myapp + +void helper_function() { + // helper +} +""" + (temp_dir / "test.cpp").write_text(code) + result = outliner.invoke("test.cpp") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("class User" in s for s in signatures) + assert any("struct Point" in s for s in signatures) + assert any("namespace myapp" in s for s in signatures) + assert any("template" in s and "Container" in s for s in signatures) + assert any("helper_function" in s for s in signatures) + + def test_php_outline(self, outliner, temp_dir): + """Test PHP file outline extraction.""" + code = """repository = $repository; + } + + public function getUser(int $id): ?User { + return $this->repository->find($id); + } + + private function validateData(array $data): bool { + return true; + } + + public static function createDefault(): self { + return new self(null); + } +} + +interface UserRepositoryInterface { + public function find(int $id): ?User; + public function save(User $user): bool; +} + +trait LoggingTrait { + protected function log(string $message): void { + echo $message; + } +} +""" + (temp_dir / "test.php").write_text(code) + result = outliner.invoke("test.php") + + assert isinstance(result, list) + signatures = [item["signature"] for item in result] + + assert any("class AbstractService" in s for s in signatures) + assert any("final class UserService" in s for s in signatures) + assert any("interface UserRepositoryInterface" in s for s in signatures) + assert any("trait LoggingTrait" in s for s in signatures) + # Methods + assert any("function __construct" in s for s in signatures) + assert any("function getUser" in s for s in signatures) + assert any("function validateData" in s for s in signatures) + assert any("static function createDefault" in s for s in signatures) + + def test_unsupported_file_type(self, outliner, temp_dir): + """Test that unsupported file types return an error.""" + (temp_dir / "test.xyz").write_text("some content") + result = outliner.invoke("test.xyz") + + assert isinstance(result, str) + assert "Error" in result + assert "Unsupported file type" in result + + def test_file_not_found(self, outliner): + """Test that non-existent files return an error.""" + result = outliner.invoke("nonexistent.py") + + assert isinstance(result, str) + assert "Error" in result + assert "not found" in result + + def test_empty_file(self, outliner, temp_dir): + """Test that empty files return empty outline.""" + (temp_dir / "empty.py").write_text("") + result = outliner.invoke("empty.py") + + assert isinstance(result, list) + assert len(result) == 0 + + def test_output_has_line_numbers(self, outliner, temp_dir): + """Test that output includes line numbers.""" + code = """def foo(): + pass + +class Bar: + pass +""" + (temp_dir / "test.py").write_text(code) + result = outliner.invoke("test.py") + + assert isinstance(result, list) + for item in result: + assert "line" in item + assert isinstance(item["line"], int) + assert "signature" in item + assert isinstance(item["signature"], str) + + +class TestFileFragmentReaderTool: + """Tests for FileFragmentReaderTool.""" + + @pytest.fixture + def temp_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def reader(self, temp_dir): + return FileFragmentReaderTool(str(temp_dir)) + + def test_read_fragment(self, reader, temp_dir): + """Test reading a fragment of a file.""" + content = "line1\nline2\nline3\nline4\nline5" + (temp_dir / "test.txt").write_text(content) + + result = reader.invoke("test.txt:2:4") + assert result == "line2\nline3\nline4\n" + + def test_invalid_format(self, reader): + """Test that invalid format returns error.""" + result = reader.invoke("invalid") + assert "Error" in result + assert "Invalid input format" in result + + +class TestStructureExplorerTool: + """Tests for StructureExplorerTool.""" + + @pytest.fixture + def temp_dir(self): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) + + @pytest.fixture + def explorer(self, temp_dir): + return StructureExplorerTool(str(temp_dir)) + + def test_discover_structure(self, explorer, temp_dir): + """Test directory structure discovery.""" + (temp_dir / "file1.py").write_text("# file1") + (temp_dir / "file2.py").write_text("# file2") + (temp_dir / "subdir").mkdir() + (temp_dir / "subdir" / "file3.py").write_text("# file3") + + result = explorer.invoke("2") + + assert result["type"] == "directory" + children_names = [c["name"] for c in result["children"]] + assert "file1.py" in children_names + assert "file2.py" in children_names + assert "subdir" in children_names + + +class TestLanguagePatterns: + """Tests to verify all language patterns are valid regex.""" + + def test_all_patterns_compile(self): + """Verify all patterns in LANGUAGE_PATTERNS compile successfully.""" + import re + + for language, patterns in LANGUAGE_PATTERNS.items(): + for pattern in patterns: + # Should not raise exception + compiled = re.compile(pattern) + assert compiled is not None