code-explore 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {code_explore-0.1.0 → code_explore-0.2.0}/PKG-INFO +3 -1
- code_explore-0.2.0/code_explore/cli/config_cmd.py +96 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/cli/main.py +7 -1
- code_explore-0.2.0/code_explore/config.py +329 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/database.py +3 -3
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/indexer/embeddings.py +31 -18
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/hybrid.py +6 -4
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/semantic.py +5 -3
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/summarizer/ollama.py +9 -5
- {code_explore-0.1.0 → code_explore-0.2.0}/pyproject.toml +3 -1
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/conftest.py +9 -0
- code_explore-0.2.0/tests/test_config.py +314 -0
- code_explore-0.2.0/tests/test_config_cli.py +121 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_search_hybrid.py +3 -2
- {code_explore-0.1.0 → code_explore-0.2.0}/.editorconfig +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/.github/workflows/publish.yml +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/.gitignore +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/README.md +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/dependencies.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/language.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/metrics.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/patterns.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/api/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/api/main.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/cli/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/indexer/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/models.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/git_info.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/local.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/readme.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/fulltext.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/summarizer/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/__init__.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_cli.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_database.py +0 -0
- {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-explore
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Developer knowledge base CLI — scan, index, and search your programming projects
|
|
5
5
|
Project-URL: Homepage, https://github.com/aipioneers/code-explore
|
|
6
6
|
Project-URL: Repository, https://github.com/aipioneers/code-explore
|
|
@@ -24,7 +24,9 @@ Requires-Dist: httpx>=0.24.0
|
|
|
24
24
|
Requires-Dist: lancedb>=0.4.0
|
|
25
25
|
Requires-Dist: pyarrow>=14.0.0
|
|
26
26
|
Requires-Dist: pydantic>=2.0.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
27
28
|
Requires-Dist: rich>=13.0.0
|
|
29
|
+
Requires-Dist: tomli-w>=1.0.0
|
|
28
30
|
Requires-Dist: typer>=0.9.0
|
|
29
31
|
Provides-Extra: api
|
|
30
32
|
Requires-Dist: fastapi>=0.100.0; extra == 'api'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""CLI commands for configuration management."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from code_explore.config import (
|
|
8
|
+
_get_config_dir,
|
|
9
|
+
get_config_path,
|
|
10
|
+
get_resolved_settings,
|
|
11
|
+
reset_config,
|
|
12
|
+
write_default_config,
|
|
13
|
+
_discover_config_file,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
config_app = typer.Typer(
|
|
17
|
+
name="config",
|
|
18
|
+
help="Manage code-explore configuration.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@config_app.command()
|
|
25
|
+
def show() -> None:
|
|
26
|
+
"""Display all current settings with values and sources."""
|
|
27
|
+
settings = get_resolved_settings()
|
|
28
|
+
config_path = get_config_path()
|
|
29
|
+
|
|
30
|
+
table = Table(title="Code Explore Configuration")
|
|
31
|
+
table.add_column("Setting", style="cyan", no_wrap=True)
|
|
32
|
+
table.add_column("Value", style="white")
|
|
33
|
+
table.add_column("Source", style="green")
|
|
34
|
+
|
|
35
|
+
for s in settings:
|
|
36
|
+
table.add_row(s.name, s.value, s.source.value)
|
|
37
|
+
|
|
38
|
+
table.add_section()
|
|
39
|
+
path_display = str(config_path) if config_path else "(no config file)"
|
|
40
|
+
table.add_row("Config file", path_display, "")
|
|
41
|
+
|
|
42
|
+
console.print(table)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@config_app.command()
|
|
46
|
+
def init(
|
|
47
|
+
fmt: str = typer.Option("toml", "--format", "-f", help="Config format: toml or yaml"),
|
|
48
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing config file"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Create a configuration file with all default values."""
|
|
51
|
+
config_dir = _get_config_dir()
|
|
52
|
+
ext = "yaml" if fmt in ("yaml", "yml") else "toml"
|
|
53
|
+
path = config_dir / f"config.{ext}"
|
|
54
|
+
|
|
55
|
+
if path.exists() and not force:
|
|
56
|
+
console.print(
|
|
57
|
+
f"[yellow]Config file already exists:[/yellow] {path}\n"
|
|
58
|
+
f"Use [bold]--force[/bold] to overwrite."
|
|
59
|
+
)
|
|
60
|
+
raise typer.Exit(1)
|
|
61
|
+
|
|
62
|
+
write_default_config(path, fmt=ext)
|
|
63
|
+
console.print(f"[green]Created configuration file:[/green] {path}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@config_app.command()
|
|
67
|
+
def reset(
|
|
68
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Delete the configuration file and revert to defaults."""
|
|
71
|
+
config_path = _discover_config_file()
|
|
72
|
+
|
|
73
|
+
if not config_path:
|
|
74
|
+
console.print("[yellow]No configuration file found. Already using defaults.[/yellow]")
|
|
75
|
+
raise typer.Exit(0)
|
|
76
|
+
|
|
77
|
+
if not yes:
|
|
78
|
+
confirm = typer.confirm(f"Delete {config_path} and revert to defaults?")
|
|
79
|
+
if not confirm:
|
|
80
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
81
|
+
raise typer.Exit(0)
|
|
82
|
+
|
|
83
|
+
config_path.unlink()
|
|
84
|
+
reset_config()
|
|
85
|
+
console.print(f"[green]Reset configuration to defaults.[/green] Removed: {config_path}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@config_app.command()
|
|
89
|
+
def path() -> None:
|
|
90
|
+
"""Print the path to the active configuration file."""
|
|
91
|
+
config_path = _discover_config_file()
|
|
92
|
+
if config_path:
|
|
93
|
+
console.print(str(config_path))
|
|
94
|
+
else:
|
|
95
|
+
default_path = _get_config_dir() / "config.toml"
|
|
96
|
+
console.print(f"{default_path} [dim](not created yet)[/dim]")
|
|
@@ -14,12 +14,14 @@ from rich.tree import Tree
|
|
|
14
14
|
|
|
15
15
|
from code_explore.database import init_db, save_project, get_project, get_all_projects, get_project_count
|
|
16
16
|
from code_explore.models import Project, ProjectSource, ProjectStatus
|
|
17
|
+
from code_explore.cli.config_cmd import config_app
|
|
17
18
|
|
|
18
19
|
app = typer.Typer(
|
|
19
20
|
name="code-explore",
|
|
20
21
|
help="Personal developer knowledge base - index, analyze and search all your projects.",
|
|
21
22
|
no_args_is_help=True,
|
|
22
23
|
)
|
|
24
|
+
app.add_typer(config_app, name="config")
|
|
23
25
|
console = Console()
|
|
24
26
|
|
|
25
27
|
|
|
@@ -142,9 +144,13 @@ def scan(
|
|
|
142
144
|
def search(
|
|
143
145
|
query: str = typer.Argument(..., help="Search query"),
|
|
144
146
|
mode: str = typer.Option("hybrid", "--mode", "-m", help="Search mode: fulltext, semantic, or hybrid"),
|
|
145
|
-
limit: int = typer.Option(
|
|
147
|
+
limit: int = typer.Option(None, "--limit", "-l", help="Maximum results"),
|
|
146
148
|
) -> None:
|
|
147
149
|
"""Search across all indexed projects."""
|
|
150
|
+
from code_explore.config import get_config
|
|
151
|
+
|
|
152
|
+
if limit is None:
|
|
153
|
+
limit = get_config().result_limit
|
|
148
154
|
init_db()
|
|
149
155
|
|
|
150
156
|
if mode == "fulltext":
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Configuration management for Code Explore.
|
|
2
|
+
|
|
3
|
+
Loads settings from: built-in defaults → config file → environment variables.
|
|
4
|
+
Config file supports TOML and YAML formats, auto-detected by file extension.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import tomllib
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigSource(str, Enum):
|
|
20
|
+
"""Where a configuration value came from."""
|
|
21
|
+
|
|
22
|
+
DEFAULT = "default"
|
|
23
|
+
FILE = "file"
|
|
24
|
+
ENV = "env"
|
|
25
|
+
CLI = "cli"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResolvedSetting(BaseModel):
|
|
29
|
+
"""A single setting with its resolved value and source."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
key: str
|
|
33
|
+
value: str
|
|
34
|
+
source: ConfigSource
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AppConfig(BaseModel):
|
|
38
|
+
"""Application configuration with all settings and their defaults."""
|
|
39
|
+
|
|
40
|
+
ollama_url: str = Field(default="http://localhost:11434")
|
|
41
|
+
summary_model: str = Field(default="llama3.2:3b")
|
|
42
|
+
embedding_model: str = Field(default="qwen3-embedding:8b")
|
|
43
|
+
embedding_dim: int = Field(default=4096)
|
|
44
|
+
db_path: Path = Field(default_factory=lambda: Path.home() / ".code-explore" / "code-explore.db")
|
|
45
|
+
vector_path: Path = Field(default_factory=lambda: Path.home() / ".code-explore" / "vectors")
|
|
46
|
+
rrf_k: int = Field(default=60)
|
|
47
|
+
result_limit: int = Field(default=20)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Mapping: env var name → (config file section.key, AppConfig field name)
|
|
51
|
+
_ENV_MAP: dict[str, tuple[str, str]] = {
|
|
52
|
+
"CEX_OLLAMA_URL": ("ollama.url", "ollama_url"),
|
|
53
|
+
"CEX_OLLAMA_SUMMARY_MODEL": ("ollama.summary_model", "summary_model"),
|
|
54
|
+
"CEX_OLLAMA_EMBEDDING_MODEL": ("ollama.embedding_model", "embedding_model"),
|
|
55
|
+
"CEX_OLLAMA_EMBEDDING_DIM": ("ollama.embedding_dim", "embedding_dim"),
|
|
56
|
+
"CEX_STORAGE_DB_PATH": ("storage.db_path", "db_path"),
|
|
57
|
+
"CEX_STORAGE_VECTOR_PATH": ("storage.vector_path", "vector_path"),
|
|
58
|
+
"CEX_SEARCH_RRF_K": ("search.rrf_k", "rrf_k"),
|
|
59
|
+
"CEX_SEARCH_RESULT_LIMIT": ("search.result_limit", "result_limit"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Mapping: AppConfig field name → (config file section, key within section)
|
|
63
|
+
_FIELD_TO_FILE_KEY: dict[str, tuple[str, str]] = {
|
|
64
|
+
"ollama_url": ("ollama", "url"),
|
|
65
|
+
"summary_model": ("ollama", "summary_model"),
|
|
66
|
+
"embedding_model": ("ollama", "embedding_model"),
|
|
67
|
+
"embedding_dim": ("ollama", "embedding_dim"),
|
|
68
|
+
"db_path": ("storage", "db_path"),
|
|
69
|
+
"vector_path": ("storage", "vector_path"),
|
|
70
|
+
"rrf_k": ("search", "rrf_k"),
|
|
71
|
+
"result_limit": ("search", "result_limit"),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Human-readable display names
|
|
75
|
+
_FIELD_DISPLAY_NAMES: dict[str, str] = {
|
|
76
|
+
"ollama_url": "Ollama URL",
|
|
77
|
+
"summary_model": "Summary model",
|
|
78
|
+
"embedding_model": "Embedding model",
|
|
79
|
+
"embedding_dim": "Embedding dimension",
|
|
80
|
+
"db_path": "Database path",
|
|
81
|
+
"vector_path": "Vector store path",
|
|
82
|
+
"rrf_k": "Search fusion (k)",
|
|
83
|
+
"result_limit": "Result limit",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_config_dir() -> Path:
|
|
88
|
+
"""Return the configuration directory, respecting XDG_CONFIG_HOME."""
|
|
89
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
90
|
+
if xdg:
|
|
91
|
+
return Path(xdg) / "code-explore"
|
|
92
|
+
return Path.home() / ".config" / "code-explore"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _discover_config_file(config_dir: Path | None = None) -> Path | None:
|
|
96
|
+
"""Search for config file in priority order: .toml → .yaml → .yml."""
|
|
97
|
+
d = config_dir or _get_config_dir()
|
|
98
|
+
candidates = [d / "config.toml", d / "config.yaml", d / "config.yml"]
|
|
99
|
+
found: list[Path] = [p for p in candidates if p.is_file()]
|
|
100
|
+
|
|
101
|
+
if not found:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if len(found) > 1:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Multiple config files found. Using %s, ignoring: %s",
|
|
107
|
+
found[0],
|
|
108
|
+
", ".join(str(p) for p in found[1:]),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return found[0]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _load_file(path: Path) -> dict[str, Any]:
|
|
115
|
+
"""Load a config file (TOML or YAML) and return a flat field dict."""
|
|
116
|
+
try:
|
|
117
|
+
raw = path.read_bytes()
|
|
118
|
+
except OSError as e:
|
|
119
|
+
logger.warning("Cannot read config file %s: %s", path, e)
|
|
120
|
+
return {}
|
|
121
|
+
|
|
122
|
+
suffix = path.suffix.lower()
|
|
123
|
+
nested: dict[str, Any] = {}
|
|
124
|
+
|
|
125
|
+
if suffix == ".toml":
|
|
126
|
+
try:
|
|
127
|
+
nested = tomllib.loads(raw.decode("utf-8"))
|
|
128
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
|
|
129
|
+
logger.warning("Failed to parse TOML config %s: %s. Using defaults.", path, e)
|
|
130
|
+
return {}
|
|
131
|
+
elif suffix in (".yaml", ".yml"):
|
|
132
|
+
try:
|
|
133
|
+
import yaml
|
|
134
|
+
|
|
135
|
+
nested = yaml.safe_load(raw.decode("utf-8")) or {}
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.warning("Failed to parse YAML config %s: %s. Using defaults.", path, e)
|
|
138
|
+
return {}
|
|
139
|
+
else:
|
|
140
|
+
logger.warning("Unsupported config file format: %s. Expected .toml, .yaml, or .yml.", path)
|
|
141
|
+
return {}
|
|
142
|
+
|
|
143
|
+
# Flatten nested dict to field names
|
|
144
|
+
flat: dict[str, Any] = {}
|
|
145
|
+
for field_name, (section, key) in _FIELD_TO_FILE_KEY.items():
|
|
146
|
+
if section in nested and isinstance(nested[section], dict):
|
|
147
|
+
if key in nested[section]:
|
|
148
|
+
flat[field_name] = nested[section][key]
|
|
149
|
+
|
|
150
|
+
return flat
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _load_env_vars() -> dict[str, Any]:
|
|
154
|
+
"""Load configuration overrides from CEX_* environment variables."""
|
|
155
|
+
result: dict[str, Any] = {}
|
|
156
|
+
int_fields = {"embedding_dim", "rrf_k", "result_limit"}
|
|
157
|
+
|
|
158
|
+
for env_var, (_file_key, field_name) in _ENV_MAP.items():
|
|
159
|
+
value = os.environ.get(env_var)
|
|
160
|
+
if value is not None:
|
|
161
|
+
if field_name in int_fields:
|
|
162
|
+
try:
|
|
163
|
+
result[field_name] = int(value)
|
|
164
|
+
except ValueError:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"Invalid integer value for %s=%s. Ignoring.", env_var, value
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
result[field_name] = value
|
|
170
|
+
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_config(
|
|
175
|
+
file_dict: dict[str, Any],
|
|
176
|
+
env_dict: dict[str, Any],
|
|
177
|
+
) -> tuple[AppConfig, dict[str, ConfigSource]]:
|
|
178
|
+
"""Layer defaults → file → env and track provenance."""
|
|
179
|
+
sources: dict[str, ConfigSource] = {
|
|
180
|
+
field: ConfigSource.DEFAULT for field in _FIELD_DISPLAY_NAMES
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
merged: dict[str, Any] = {}
|
|
184
|
+
|
|
185
|
+
# Layer file values
|
|
186
|
+
for key, value in file_dict.items():
|
|
187
|
+
merged[key] = value
|
|
188
|
+
sources[key] = ConfigSource.FILE
|
|
189
|
+
|
|
190
|
+
# Layer env values (override file)
|
|
191
|
+
for key, value in env_dict.items():
|
|
192
|
+
merged[key] = value
|
|
193
|
+
sources[key] = ConfigSource.ENV
|
|
194
|
+
|
|
195
|
+
config = AppConfig(**merged) if merged else AppConfig()
|
|
196
|
+
return config, sources
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_config(config: AppConfig) -> None:
|
|
200
|
+
"""Validate configuration values. Raises ValueError on invalid settings."""
|
|
201
|
+
errors: list[str] = []
|
|
202
|
+
|
|
203
|
+
if config.embedding_dim <= 0:
|
|
204
|
+
errors.append("Embedding dimension must be a positive integer.")
|
|
205
|
+
if config.rrf_k <= 0:
|
|
206
|
+
errors.append("Search fusion constant must be a positive integer.")
|
|
207
|
+
if not 1 <= config.result_limit <= 1000:
|
|
208
|
+
errors.append("Result limit must be between 1 and 1000.")
|
|
209
|
+
if not config.ollama_url.startswith(("http://", "https://")):
|
|
210
|
+
errors.append(f"Ollama URL must start with http:// or https://, got: {config.ollama_url}")
|
|
211
|
+
|
|
212
|
+
if errors:
|
|
213
|
+
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def write_default_config(path: Path, fmt: str = "toml") -> None:
|
|
217
|
+
"""Write a config file with all default values."""
|
|
218
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
defaults = AppConfig()
|
|
220
|
+
|
|
221
|
+
if fmt == "toml":
|
|
222
|
+
import tomli_w
|
|
223
|
+
|
|
224
|
+
data = {
|
|
225
|
+
"ollama": {
|
|
226
|
+
"url": defaults.ollama_url,
|
|
227
|
+
"summary_model": defaults.summary_model,
|
|
228
|
+
"embedding_model": defaults.embedding_model,
|
|
229
|
+
"embedding_dim": defaults.embedding_dim,
|
|
230
|
+
},
|
|
231
|
+
"storage": {
|
|
232
|
+
"db_path": str(defaults.db_path),
|
|
233
|
+
"vector_path": str(defaults.vector_path),
|
|
234
|
+
},
|
|
235
|
+
"search": {
|
|
236
|
+
"rrf_k": defaults.rrf_k,
|
|
237
|
+
"result_limit": defaults.result_limit,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
path.write_bytes(tomli_w.dumps(data).encode("utf-8"))
|
|
241
|
+
elif fmt in ("yaml", "yml"):
|
|
242
|
+
import yaml
|
|
243
|
+
|
|
244
|
+
data = {
|
|
245
|
+
"ollama": {
|
|
246
|
+
"url": defaults.ollama_url,
|
|
247
|
+
"summary_model": defaults.summary_model,
|
|
248
|
+
"embedding_model": defaults.embedding_model,
|
|
249
|
+
"embedding_dim": defaults.embedding_dim,
|
|
250
|
+
},
|
|
251
|
+
"storage": {
|
|
252
|
+
"db_path": str(defaults.db_path),
|
|
253
|
+
"vector_path": str(defaults.vector_path),
|
|
254
|
+
},
|
|
255
|
+
"search": {
|
|
256
|
+
"rrf_k": defaults.rrf_k,
|
|
257
|
+
"result_limit": defaults.result_limit,
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# --- Singleton ---
|
|
264
|
+
|
|
265
|
+
_cached_config: AppConfig | None = None
|
|
266
|
+
_cached_sources: dict[str, ConfigSource] | None = None
|
|
267
|
+
_cached_config_path: Path | None = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def reset_config() -> None:
|
|
271
|
+
"""Clear the cached configuration. Used for testing and after config reset."""
|
|
272
|
+
global _cached_config, _cached_sources, _cached_config_path
|
|
273
|
+
_cached_config = None
|
|
274
|
+
_cached_sources = None
|
|
275
|
+
_cached_config_path = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def get_config() -> AppConfig:
|
|
279
|
+
"""Load and return the application configuration (cached after first call)."""
|
|
280
|
+
global _cached_config, _cached_sources, _cached_config_path
|
|
281
|
+
|
|
282
|
+
if _cached_config is not None:
|
|
283
|
+
return _cached_config
|
|
284
|
+
|
|
285
|
+
file_dict: dict[str, Any] = {}
|
|
286
|
+
config_path = _discover_config_file()
|
|
287
|
+
_cached_config_path = config_path
|
|
288
|
+
|
|
289
|
+
if config_path:
|
|
290
|
+
file_dict = _load_file(config_path)
|
|
291
|
+
|
|
292
|
+
env_dict = _load_env_vars()
|
|
293
|
+
config, sources = _resolve_config(file_dict, env_dict)
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
_validate_config(config)
|
|
297
|
+
except ValueError as e:
|
|
298
|
+
logger.warning("%s", e)
|
|
299
|
+
|
|
300
|
+
_cached_config = config
|
|
301
|
+
_cached_sources = sources
|
|
302
|
+
return config
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def get_resolved_settings() -> list[ResolvedSetting]:
|
|
306
|
+
"""Return all settings with their values and sources for display."""
|
|
307
|
+
config = get_config()
|
|
308
|
+
sources = _cached_sources or {f: ConfigSource.DEFAULT for f in _FIELD_DISPLAY_NAMES}
|
|
309
|
+
|
|
310
|
+
settings = []
|
|
311
|
+
for field_name, display_name in _FIELD_DISPLAY_NAMES.items():
|
|
312
|
+
value = getattr(config, field_name)
|
|
313
|
+
section, key = _FIELD_TO_FILE_KEY[field_name]
|
|
314
|
+
settings.append(
|
|
315
|
+
ResolvedSetting(
|
|
316
|
+
name=display_name,
|
|
317
|
+
key=f"{section}.{key}",
|
|
318
|
+
value=str(value),
|
|
319
|
+
source=sources.get(field_name, ConfigSource.DEFAULT),
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return settings
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def get_config_path() -> Path | None:
|
|
327
|
+
"""Return the path to the active config file, or None."""
|
|
328
|
+
get_config() # ensure discovery has run
|
|
329
|
+
return _cached_config_path
|
|
@@ -6,11 +6,11 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
from code_explore.models import Project
|
|
8
8
|
|
|
9
|
-
DEFAULT_DB_PATH = Path.home() / ".code-explore" / "code-explore.db"
|
|
10
|
-
|
|
11
9
|
|
|
12
10
|
def get_db_path() -> Path:
|
|
13
|
-
|
|
11
|
+
from code_explore.config import get_config
|
|
12
|
+
|
|
13
|
+
path = get_config().db_path
|
|
14
14
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
15
|
return path
|
|
16
16
|
|
|
@@ -11,38 +11,45 @@ from code_explore.models import Project
|
|
|
11
11
|
|
|
12
12
|
logger = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
|
-
OLLAMA_BASE_URL = "http://localhost:11434"
|
|
15
|
-
EMBEDDING_MODEL = "qwen3-embedding:8b"
|
|
16
|
-
EMBEDDING_DIM = 4096
|
|
17
|
-
VECTOR_DB_PATH = Path.home() / ".code-explore" / "vectors"
|
|
18
14
|
TABLE_NAME = "project_embeddings"
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
16
|
+
|
|
17
|
+
def _get_schema() -> pa.Schema:
|
|
18
|
+
from code_explore.config import get_config
|
|
19
|
+
|
|
20
|
+
dim = get_config().embedding_dim
|
|
21
|
+
return pa.schema([
|
|
22
|
+
pa.field("id", pa.string()),
|
|
23
|
+
pa.field("text", pa.string()),
|
|
24
|
+
pa.field("vector", pa.list_(pa.float32(), dim)),
|
|
25
|
+
])
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def _ollama_available() -> bool:
|
|
29
|
+
from code_explore.config import get_config
|
|
30
|
+
|
|
31
|
+
url = get_config().ollama_url
|
|
28
32
|
try:
|
|
29
|
-
resp = httpx.get(f"{
|
|
33
|
+
resp = httpx.get(f"{url}/api/tags", timeout=5.0)
|
|
30
34
|
return resp.status_code == 200
|
|
31
35
|
except (httpx.ConnectError, httpx.TimeoutException):
|
|
32
36
|
return False
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def generate_embedding(text: str) -> list[float] | None:
|
|
40
|
+
from code_explore.config import get_config
|
|
41
|
+
|
|
42
|
+
cfg = get_config()
|
|
36
43
|
try:
|
|
37
44
|
resp = httpx.post(
|
|
38
|
-
f"{
|
|
39
|
-
json={"model":
|
|
45
|
+
f"{cfg.ollama_url}/api/embeddings",
|
|
46
|
+
json={"model": cfg.embedding_model, "prompt": text},
|
|
40
47
|
timeout=30.0,
|
|
41
48
|
)
|
|
42
49
|
resp.raise_for_status()
|
|
43
50
|
return resp.json()["embedding"]
|
|
44
51
|
except (httpx.ConnectError, httpx.TimeoutException):
|
|
45
|
-
logger.warning("Ollama is not running at %s. Skipping embedding generation.",
|
|
52
|
+
logger.warning("Ollama is not running at %s. Skipping embedding generation.", cfg.ollama_url)
|
|
46
53
|
return None
|
|
47
54
|
except (httpx.HTTPStatusError, KeyError) as e:
|
|
48
55
|
logger.error("Failed to generate embedding: %s", e)
|
|
@@ -115,7 +122,7 @@ def _project_to_text(project: Project) -> str:
|
|
|
115
122
|
def _get_table(db: lancedb.DBConnection) -> lancedb.table.Table:
|
|
116
123
|
if TABLE_NAME in db.table_names():
|
|
117
124
|
return db.open_table(TABLE_NAME)
|
|
118
|
-
return db.create_table(TABLE_NAME, schema=
|
|
125
|
+
return db.create_table(TABLE_NAME, schema=_get_schema())
|
|
119
126
|
|
|
120
127
|
|
|
121
128
|
def index_project(project: Project) -> None:
|
|
@@ -128,8 +135,11 @@ def index_project(project: Project) -> None:
|
|
|
128
135
|
if vector is None:
|
|
129
136
|
return
|
|
130
137
|
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
from code_explore.config import get_config
|
|
139
|
+
|
|
140
|
+
vector_path = get_config().vector_path
|
|
141
|
+
vector_path.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
db = lancedb.connect(str(vector_path))
|
|
133
143
|
table = _get_table(db)
|
|
134
144
|
|
|
135
145
|
data = [{"id": project.id, "text": text, "vector": vector}]
|
|
@@ -164,8 +174,11 @@ def index_all_projects(projects: list[Project]) -> None:
|
|
|
164
174
|
logger.warning("No embeddings generated. Skipping vector store update.")
|
|
165
175
|
return
|
|
166
176
|
|
|
167
|
-
|
|
168
|
-
|
|
177
|
+
from code_explore.config import get_config
|
|
178
|
+
|
|
179
|
+
vector_path = get_config().vector_path
|
|
180
|
+
vector_path.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
db = lancedb.connect(str(vector_path))
|
|
169
182
|
table = _get_table(db)
|
|
170
183
|
|
|
171
184
|
existing_ids = {item["id"] for item in data}
|
|
@@ -10,20 +10,22 @@ from code_explore.search.semantic import search as semantic_search
|
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
|
-
RRF_K = 60
|
|
14
|
-
|
|
15
13
|
|
|
16
14
|
def _reciprocal_rank_fusion(
|
|
17
15
|
fulltext_results: list[SearchResult],
|
|
18
16
|
semantic_results: list[SearchResult],
|
|
19
17
|
) -> list[SearchResult]:
|
|
18
|
+
from code_explore.config import get_config
|
|
19
|
+
|
|
20
|
+
rrf_k = get_config().rrf_k
|
|
21
|
+
|
|
20
22
|
scores: dict[str, float] = {}
|
|
21
23
|
results_map: dict[str, SearchResult] = {}
|
|
22
24
|
highlights_map: dict[str, list[str]] = {}
|
|
23
25
|
|
|
24
26
|
for rank, result in enumerate(fulltext_results):
|
|
25
27
|
pid = result.project.id
|
|
26
|
-
scores[pid] = scores.get(pid, 0.0) + 1.0 / (
|
|
28
|
+
scores[pid] = scores.get(pid, 0.0) + 1.0 / (rrf_k + rank + 1)
|
|
27
29
|
if pid not in results_map:
|
|
28
30
|
results_map[pid] = result
|
|
29
31
|
highlights_map[pid] = list(result.highlights)
|
|
@@ -34,7 +36,7 @@ def _reciprocal_rank_fusion(
|
|
|
34
36
|
|
|
35
37
|
for rank, result in enumerate(semantic_results):
|
|
36
38
|
pid = result.project.id
|
|
37
|
-
scores[pid] = scores.get(pid, 0.0) + 1.0 / (
|
|
39
|
+
scores[pid] = scores.get(pid, 0.0) + 1.0 / (rrf_k + rank + 1)
|
|
38
40
|
if pid not in results_map:
|
|
39
41
|
results_map[pid] = result
|
|
40
42
|
highlights_map[pid] = list(result.highlights)
|
|
@@ -7,7 +7,6 @@ import lancedb
|
|
|
7
7
|
|
|
8
8
|
from code_explore.database import get_project
|
|
9
9
|
from code_explore.indexer.embeddings import (
|
|
10
|
-
VECTOR_DB_PATH,
|
|
11
10
|
TABLE_NAME,
|
|
12
11
|
generate_embedding,
|
|
13
12
|
_ollama_available,
|
|
@@ -30,12 +29,15 @@ def search(
|
|
|
30
29
|
logger.warning("Failed to generate query embedding. Falling back to fulltext search.")
|
|
31
30
|
return fulltext_search(query, limit=limit, db_path=db_path)
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
from code_explore.config import get_config
|
|
33
|
+
|
|
34
|
+
vector_path = get_config().vector_path
|
|
35
|
+
if not vector_path.exists():
|
|
34
36
|
logger.warning("Vector store not found. Falling back to fulltext search.")
|
|
35
37
|
return fulltext_search(query, limit=limit, db_path=db_path)
|
|
36
38
|
|
|
37
39
|
try:
|
|
38
|
-
db = lancedb.connect(str(
|
|
40
|
+
db = lancedb.connect(str(vector_path))
|
|
39
41
|
if TABLE_NAME not in db.table_names():
|
|
40
42
|
logger.warning("Embeddings table not found. Falling back to fulltext search.")
|
|
41
43
|
return fulltext_search(query, limit=limit, db_path=db_path)
|
|
@@ -8,9 +8,6 @@ from code_explore.models import Project
|
|
|
8
8
|
|
|
9
9
|
logger = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
|
-
OLLAMA_BASE_URL = "http://localhost:11434"
|
|
12
|
-
DEFAULT_MODEL = "llama3.2:3b"
|
|
13
|
-
|
|
14
11
|
|
|
15
12
|
def _build_prompt(project: Project) -> str:
|
|
16
13
|
parts = [f"Project: {project.name}"]
|
|
@@ -90,9 +87,16 @@ def _parse_response(text: str) -> tuple[str | None, list[str], list[str]]:
|
|
|
90
87
|
|
|
91
88
|
def summarize_project(
|
|
92
89
|
project: Project,
|
|
93
|
-
model: str =
|
|
94
|
-
base_url: str =
|
|
90
|
+
model: str | None = None,
|
|
91
|
+
base_url: str | None = None,
|
|
95
92
|
) -> tuple[str | None, list[str], list[str]]:
|
|
93
|
+
from code_explore.config import get_config
|
|
94
|
+
|
|
95
|
+
cfg = get_config()
|
|
96
|
+
if model is None:
|
|
97
|
+
model = cfg.summary_model
|
|
98
|
+
if base_url is None:
|
|
99
|
+
base_url = cfg.ollama_url
|
|
96
100
|
prompt = _build_prompt(project)
|
|
97
101
|
|
|
98
102
|
try:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "code-explore"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Developer knowledge base CLI — scan, index, and search your programming projects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -39,6 +39,8 @@ dependencies = [
|
|
|
39
39
|
"lancedb>=0.4.0",
|
|
40
40
|
"pyarrow>=14.0.0",
|
|
41
41
|
"gitpython>=3.1.0",
|
|
42
|
+
"tomli_w>=1.0.0",
|
|
43
|
+
"pyyaml>=6.0.0",
|
|
42
44
|
]
|
|
43
45
|
|
|
44
46
|
[project.optional-dependencies]
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
|
|
8
|
+
from code_explore.config import reset_config
|
|
8
9
|
from code_explore.database import init_db, save_project, get_connection
|
|
9
10
|
from code_explore.models import (
|
|
10
11
|
DependencyInfo,
|
|
@@ -18,6 +19,14 @@ from code_explore.models import (
|
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
@pytest.fixture(autouse=True)
|
|
23
|
+
def _reset_config_singleton():
|
|
24
|
+
"""Reset the config singleton before each test to prevent leakage."""
|
|
25
|
+
reset_config()
|
|
26
|
+
yield
|
|
27
|
+
reset_config()
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
@pytest.fixture
|
|
22
31
|
def tmp_db(tmp_path):
|
|
23
32
|
"""Create a temporary SQLite database."""
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Tests for configuration module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import tomllib
|
|
7
|
+
|
|
8
|
+
from code_explore.config import (
|
|
9
|
+
AppConfig,
|
|
10
|
+
ConfigSource,
|
|
11
|
+
_discover_config_file,
|
|
12
|
+
_load_env_vars,
|
|
13
|
+
_load_file,
|
|
14
|
+
_resolve_config,
|
|
15
|
+
_validate_config,
|
|
16
|
+
get_config,
|
|
17
|
+
get_resolved_settings,
|
|
18
|
+
reset_config,
|
|
19
|
+
write_default_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestAppConfigDefaults:
|
|
24
|
+
"""Verify all defaults match current hardcoded values."""
|
|
25
|
+
|
|
26
|
+
def test_ollama_url(self):
|
|
27
|
+
cfg = AppConfig()
|
|
28
|
+
assert cfg.ollama_url == "http://localhost:11434"
|
|
29
|
+
|
|
30
|
+
def test_summary_model(self):
|
|
31
|
+
cfg = AppConfig()
|
|
32
|
+
assert cfg.summary_model == "llama3.2:3b"
|
|
33
|
+
|
|
34
|
+
def test_embedding_model(self):
|
|
35
|
+
cfg = AppConfig()
|
|
36
|
+
assert cfg.embedding_model == "qwen3-embedding:8b"
|
|
37
|
+
|
|
38
|
+
def test_embedding_dim(self):
|
|
39
|
+
cfg = AppConfig()
|
|
40
|
+
assert cfg.embedding_dim == 4096
|
|
41
|
+
|
|
42
|
+
def test_db_path(self):
|
|
43
|
+
cfg = AppConfig()
|
|
44
|
+
assert cfg.db_path == Path.home() / ".code-explore" / "code-explore.db"
|
|
45
|
+
|
|
46
|
+
def test_vector_path(self):
|
|
47
|
+
cfg = AppConfig()
|
|
48
|
+
assert cfg.vector_path == Path.home() / ".code-explore" / "vectors"
|
|
49
|
+
|
|
50
|
+
def test_rrf_k(self):
|
|
51
|
+
cfg = AppConfig()
|
|
52
|
+
assert cfg.rrf_k == 60
|
|
53
|
+
|
|
54
|
+
def test_result_limit(self):
|
|
55
|
+
cfg = AppConfig()
|
|
56
|
+
assert cfg.result_limit == 20
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestLoadFile:
|
|
60
|
+
def test_toml_loading(self, tmp_path):
|
|
61
|
+
config_file = tmp_path / "config.toml"
|
|
62
|
+
config_file.write_text(
|
|
63
|
+
'[ollama]\nurl = "http://remote:11434"\nsummary_model = "mistral:7b"\n'
|
|
64
|
+
)
|
|
65
|
+
result = _load_file(config_file)
|
|
66
|
+
assert result["ollama_url"] == "http://remote:11434"
|
|
67
|
+
assert result["summary_model"] == "mistral:7b"
|
|
68
|
+
|
|
69
|
+
def test_yaml_loading(self, tmp_path):
|
|
70
|
+
config_file = tmp_path / "config.yaml"
|
|
71
|
+
config_file.write_text(
|
|
72
|
+
"ollama:\n url: http://remote:11434\n summary_model: mistral:7b\n"
|
|
73
|
+
)
|
|
74
|
+
result = _load_file(config_file)
|
|
75
|
+
assert result["ollama_url"] == "http://remote:11434"
|
|
76
|
+
assert result["summary_model"] == "mistral:7b"
|
|
77
|
+
|
|
78
|
+
def test_malformed_toml_returns_empty(self, tmp_path):
|
|
79
|
+
config_file = tmp_path / "config.toml"
|
|
80
|
+
config_file.write_text("invalid toml {{{{")
|
|
81
|
+
result = _load_file(config_file)
|
|
82
|
+
assert result == {}
|
|
83
|
+
|
|
84
|
+
def test_malformed_yaml_returns_empty(self, tmp_path):
|
|
85
|
+
config_file = tmp_path / "config.yaml"
|
|
86
|
+
config_file.write_text(":\n :\n - [invalid")
|
|
87
|
+
result = _load_file(config_file)
|
|
88
|
+
assert result == {}
|
|
89
|
+
|
|
90
|
+
def test_unsupported_extension_returns_empty(self, tmp_path):
|
|
91
|
+
config_file = tmp_path / "config.ini"
|
|
92
|
+
config_file.write_text("[section]\nkey=value")
|
|
93
|
+
result = _load_file(config_file)
|
|
94
|
+
assert result == {}
|
|
95
|
+
|
|
96
|
+
def test_partial_config(self, tmp_path):
|
|
97
|
+
config_file = tmp_path / "config.toml"
|
|
98
|
+
config_file.write_text('[search]\nrrf_k = 100\n')
|
|
99
|
+
result = _load_file(config_file)
|
|
100
|
+
assert result == {"rrf_k": 100}
|
|
101
|
+
assert "ollama_url" not in result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TestLoadEnvVars:
|
|
105
|
+
def test_all_env_vars(self, monkeypatch):
|
|
106
|
+
monkeypatch.setenv("CEX_OLLAMA_URL", "http://gpu:11434")
|
|
107
|
+
monkeypatch.setenv("CEX_OLLAMA_SUMMARY_MODEL", "mistral:7b")
|
|
108
|
+
monkeypatch.setenv("CEX_OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
|
109
|
+
monkeypatch.setenv("CEX_OLLAMA_EMBEDDING_DIM", "768")
|
|
110
|
+
monkeypatch.setenv("CEX_STORAGE_DB_PATH", "/tmp/test.db")
|
|
111
|
+
monkeypatch.setenv("CEX_STORAGE_VECTOR_PATH", "/tmp/vectors")
|
|
112
|
+
monkeypatch.setenv("CEX_SEARCH_RRF_K", "30")
|
|
113
|
+
monkeypatch.setenv("CEX_SEARCH_RESULT_LIMIT", "50")
|
|
114
|
+
|
|
115
|
+
result = _load_env_vars()
|
|
116
|
+
assert result["ollama_url"] == "http://gpu:11434"
|
|
117
|
+
assert result["summary_model"] == "mistral:7b"
|
|
118
|
+
assert result["embedding_model"] == "nomic-embed-text"
|
|
119
|
+
assert result["embedding_dim"] == 768
|
|
120
|
+
assert result["db_path"] == "/tmp/test.db"
|
|
121
|
+
assert result["vector_path"] == "/tmp/vectors"
|
|
122
|
+
assert result["rrf_k"] == 30
|
|
123
|
+
assert result["result_limit"] == 50
|
|
124
|
+
|
|
125
|
+
def test_no_env_vars(self, monkeypatch):
|
|
126
|
+
for var in ["CEX_OLLAMA_URL", "CEX_OLLAMA_SUMMARY_MODEL", "CEX_OLLAMA_EMBEDDING_MODEL",
|
|
127
|
+
"CEX_OLLAMA_EMBEDDING_DIM", "CEX_STORAGE_DB_PATH", "CEX_STORAGE_VECTOR_PATH",
|
|
128
|
+
"CEX_SEARCH_RRF_K", "CEX_SEARCH_RESULT_LIMIT"]:
|
|
129
|
+
monkeypatch.delenv(var, raising=False)
|
|
130
|
+
result = _load_env_vars()
|
|
131
|
+
assert result == {}
|
|
132
|
+
|
|
133
|
+
def test_invalid_int_ignored(self, monkeypatch):
|
|
134
|
+
monkeypatch.setenv("CEX_OLLAMA_EMBEDDING_DIM", "not_a_number")
|
|
135
|
+
result = _load_env_vars()
|
|
136
|
+
assert "embedding_dim" not in result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestResolveConfig:
|
|
140
|
+
def test_defaults_only(self):
|
|
141
|
+
config, sources = _resolve_config({}, {})
|
|
142
|
+
assert config.ollama_url == "http://localhost:11434"
|
|
143
|
+
assert all(s == ConfigSource.DEFAULT for s in sources.values())
|
|
144
|
+
|
|
145
|
+
def test_file_overrides_default(self):
|
|
146
|
+
config, sources = _resolve_config({"summary_model": "mistral:7b"}, {})
|
|
147
|
+
assert config.summary_model == "mistral:7b"
|
|
148
|
+
assert sources["summary_model"] == ConfigSource.FILE
|
|
149
|
+
assert sources["ollama_url"] == ConfigSource.DEFAULT
|
|
150
|
+
|
|
151
|
+
def test_env_overrides_file(self):
|
|
152
|
+
config, sources = _resolve_config(
|
|
153
|
+
{"summary_model": "mistral:7b"},
|
|
154
|
+
{"summary_model": "llama3:8b"},
|
|
155
|
+
)
|
|
156
|
+
assert config.summary_model == "llama3:8b"
|
|
157
|
+
assert sources["summary_model"] == ConfigSource.ENV
|
|
158
|
+
|
|
159
|
+
def test_source_tracking(self):
|
|
160
|
+
config, sources = _resolve_config(
|
|
161
|
+
{"rrf_k": 100},
|
|
162
|
+
{"result_limit": 50},
|
|
163
|
+
)
|
|
164
|
+
assert sources["rrf_k"] == ConfigSource.FILE
|
|
165
|
+
assert sources["result_limit"] == ConfigSource.ENV
|
|
166
|
+
assert sources["ollama_url"] == ConfigSource.DEFAULT
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class TestValidateConfig:
|
|
170
|
+
def test_valid_config(self):
|
|
171
|
+
_validate_config(AppConfig()) # should not raise
|
|
172
|
+
|
|
173
|
+
def test_invalid_embedding_dim(self):
|
|
174
|
+
with pytest.raises(ValueError, match="Embedding dimension"):
|
|
175
|
+
_validate_config(AppConfig(embedding_dim=0))
|
|
176
|
+
|
|
177
|
+
def test_negative_rrf_k(self):
|
|
178
|
+
with pytest.raises(ValueError, match="fusion constant"):
|
|
179
|
+
_validate_config(AppConfig(rrf_k=-1))
|
|
180
|
+
|
|
181
|
+
def test_result_limit_zero(self):
|
|
182
|
+
with pytest.raises(ValueError, match="Result limit"):
|
|
183
|
+
_validate_config(AppConfig(result_limit=0))
|
|
184
|
+
|
|
185
|
+
def test_result_limit_too_high(self):
|
|
186
|
+
with pytest.raises(ValueError, match="Result limit"):
|
|
187
|
+
_validate_config(AppConfig(result_limit=5000))
|
|
188
|
+
|
|
189
|
+
def test_bad_url(self):
|
|
190
|
+
with pytest.raises(ValueError, match="http://"):
|
|
191
|
+
_validate_config(AppConfig(ollama_url="ftp://bad"))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestDiscoverConfigFile:
|
|
195
|
+
def test_finds_toml(self, tmp_path):
|
|
196
|
+
(tmp_path / "config.toml").write_text("[ollama]\n")
|
|
197
|
+
result = _discover_config_file(tmp_path)
|
|
198
|
+
assert result == tmp_path / "config.toml"
|
|
199
|
+
|
|
200
|
+
def test_finds_yaml(self, tmp_path):
|
|
201
|
+
(tmp_path / "config.yaml").write_text("ollama:\n url: x\n")
|
|
202
|
+
result = _discover_config_file(tmp_path)
|
|
203
|
+
assert result == tmp_path / "config.yaml"
|
|
204
|
+
|
|
205
|
+
def test_toml_priority_over_yaml(self, tmp_path):
|
|
206
|
+
(tmp_path / "config.toml").write_text("[ollama]\n")
|
|
207
|
+
(tmp_path / "config.yaml").write_text("ollama:\n url: x\n")
|
|
208
|
+
result = _discover_config_file(tmp_path)
|
|
209
|
+
assert result == tmp_path / "config.toml"
|
|
210
|
+
|
|
211
|
+
def test_no_config(self, tmp_path):
|
|
212
|
+
result = _discover_config_file(tmp_path)
|
|
213
|
+
assert result is None
|
|
214
|
+
|
|
215
|
+
def test_missing_dir(self, tmp_path):
|
|
216
|
+
result = _discover_config_file(tmp_path / "nonexistent")
|
|
217
|
+
assert result is None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class TestWriteDefaultConfig:
|
|
221
|
+
def test_toml_output(self, tmp_path):
|
|
222
|
+
path = tmp_path / "config.toml"
|
|
223
|
+
write_default_config(path, fmt="toml")
|
|
224
|
+
assert path.exists()
|
|
225
|
+
data = tomllib.loads(path.read_text())
|
|
226
|
+
assert data["ollama"]["url"] == "http://localhost:11434"
|
|
227
|
+
assert data["ollama"]["embedding_dim"] == 4096
|
|
228
|
+
assert data["search"]["rrf_k"] == 60
|
|
229
|
+
|
|
230
|
+
def test_yaml_output(self, tmp_path):
|
|
231
|
+
import yaml
|
|
232
|
+
|
|
233
|
+
path = tmp_path / "config.yaml"
|
|
234
|
+
write_default_config(path, fmt="yaml")
|
|
235
|
+
assert path.exists()
|
|
236
|
+
data = yaml.safe_load(path.read_text())
|
|
237
|
+
assert data["ollama"]["url"] == "http://localhost:11434"
|
|
238
|
+
assert data["search"]["result_limit"] == 20
|
|
239
|
+
|
|
240
|
+
def test_creates_parent_dirs(self, tmp_path):
|
|
241
|
+
path = tmp_path / "deep" / "nested" / "config.toml"
|
|
242
|
+
write_default_config(path, fmt="toml")
|
|
243
|
+
assert path.exists()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class TestGetConfigSingleton:
|
|
247
|
+
def test_returns_defaults_without_file(self, monkeypatch, tmp_path):
|
|
248
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
249
|
+
for var in ["CEX_OLLAMA_URL", "CEX_OLLAMA_SUMMARY_MODEL", "CEX_OLLAMA_EMBEDDING_MODEL",
|
|
250
|
+
"CEX_OLLAMA_EMBEDDING_DIM", "CEX_STORAGE_DB_PATH", "CEX_STORAGE_VECTOR_PATH",
|
|
251
|
+
"CEX_SEARCH_RRF_K", "CEX_SEARCH_RESULT_LIMIT"]:
|
|
252
|
+
monkeypatch.delenv(var, raising=False)
|
|
253
|
+
reset_config()
|
|
254
|
+
cfg = get_config()
|
|
255
|
+
assert cfg.ollama_url == "http://localhost:11434"
|
|
256
|
+
assert cfg.summary_model == "llama3.2:3b"
|
|
257
|
+
|
|
258
|
+
def test_reads_from_file(self, monkeypatch, tmp_path):
|
|
259
|
+
config_dir = tmp_path / "code-explore"
|
|
260
|
+
config_dir.mkdir()
|
|
261
|
+
(config_dir / "config.toml").write_text(
|
|
262
|
+
'[ollama]\nsummary_model = "phi3:mini"\n'
|
|
263
|
+
)
|
|
264
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
265
|
+
for var in ["CEX_OLLAMA_URL", "CEX_OLLAMA_SUMMARY_MODEL"]:
|
|
266
|
+
monkeypatch.delenv(var, raising=False)
|
|
267
|
+
reset_config()
|
|
268
|
+
cfg = get_config()
|
|
269
|
+
assert cfg.summary_model == "phi3:mini"
|
|
270
|
+
|
|
271
|
+
def test_env_overrides_file(self, monkeypatch, tmp_path):
|
|
272
|
+
config_dir = tmp_path / "code-explore"
|
|
273
|
+
config_dir.mkdir()
|
|
274
|
+
(config_dir / "config.toml").write_text(
|
|
275
|
+
'[ollama]\nsummary_model = "phi3:mini"\n'
|
|
276
|
+
)
|
|
277
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
278
|
+
monkeypatch.setenv("CEX_OLLAMA_SUMMARY_MODEL", "gemma:2b")
|
|
279
|
+
reset_config()
|
|
280
|
+
cfg = get_config()
|
|
281
|
+
assert cfg.summary_model == "gemma:2b"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class TestGetResolvedSettings:
|
|
285
|
+
def test_returns_all_settings(self, monkeypatch, tmp_path):
|
|
286
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
287
|
+
for var in ["CEX_OLLAMA_URL", "CEX_OLLAMA_SUMMARY_MODEL", "CEX_OLLAMA_EMBEDDING_MODEL",
|
|
288
|
+
"CEX_OLLAMA_EMBEDDING_DIM", "CEX_STORAGE_DB_PATH", "CEX_STORAGE_VECTOR_PATH",
|
|
289
|
+
"CEX_SEARCH_RRF_K", "CEX_SEARCH_RESULT_LIMIT"]:
|
|
290
|
+
monkeypatch.delenv(var, raising=False)
|
|
291
|
+
reset_config()
|
|
292
|
+
settings = get_resolved_settings()
|
|
293
|
+
assert len(settings) == 8
|
|
294
|
+
names = {s.name for s in settings}
|
|
295
|
+
assert "Ollama URL" in names
|
|
296
|
+
assert "Embedding model" in names
|
|
297
|
+
assert "Result limit" in names
|
|
298
|
+
|
|
299
|
+
def test_source_tracking(self, monkeypatch, tmp_path):
|
|
300
|
+
config_dir = tmp_path / "code-explore"
|
|
301
|
+
config_dir.mkdir()
|
|
302
|
+
(config_dir / "config.toml").write_text('[search]\nrrf_k = 100\n')
|
|
303
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
304
|
+
monkeypatch.setenv("CEX_SEARCH_RESULT_LIMIT", "50")
|
|
305
|
+
for var in ["CEX_OLLAMA_URL", "CEX_OLLAMA_SUMMARY_MODEL", "CEX_OLLAMA_EMBEDDING_MODEL",
|
|
306
|
+
"CEX_OLLAMA_EMBEDDING_DIM", "CEX_STORAGE_DB_PATH", "CEX_STORAGE_VECTOR_PATH",
|
|
307
|
+
"CEX_SEARCH_RRF_K"]:
|
|
308
|
+
monkeypatch.delenv(var, raising=False)
|
|
309
|
+
reset_config()
|
|
310
|
+
settings = get_resolved_settings()
|
|
311
|
+
by_name = {s.name: s for s in settings}
|
|
312
|
+
assert by_name["Search fusion (k)"].source == ConfigSource.FILE
|
|
313
|
+
assert by_name["Result limit"].source == ConfigSource.ENV
|
|
314
|
+
assert by_name["Ollama URL"].source == ConfigSource.DEFAULT
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Tests for CLI config commands."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from code_explore.cli.main import app
|
|
9
|
+
from code_explore.config import reset_config
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestConfigShow:
|
|
15
|
+
def test_exit_code(self, monkeypatch, tmp_path):
|
|
16
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
17
|
+
reset_config()
|
|
18
|
+
result = runner.invoke(app, ["config", "show"])
|
|
19
|
+
assert result.exit_code == 0
|
|
20
|
+
|
|
21
|
+
def test_contains_all_settings(self, monkeypatch, tmp_path):
|
|
22
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
23
|
+
reset_config()
|
|
24
|
+
result = runner.invoke(app, ["config", "show"])
|
|
25
|
+
assert "Ollama URL" in result.stdout
|
|
26
|
+
assert "Summary model" in result.stdout
|
|
27
|
+
assert "Embedding model" in result.stdout
|
|
28
|
+
assert "Embedding dimension" in result.stdout
|
|
29
|
+
assert "Database path" in result.stdout
|
|
30
|
+
assert "Vector store path" in result.stdout
|
|
31
|
+
assert "Search fusion" in result.stdout
|
|
32
|
+
assert "Result limit" in result.stdout
|
|
33
|
+
|
|
34
|
+
def test_shows_default_source(self, monkeypatch, tmp_path):
|
|
35
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
36
|
+
reset_config()
|
|
37
|
+
result = runner.invoke(app, ["config", "show"])
|
|
38
|
+
assert "default" in result.stdout
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestConfigInit:
|
|
42
|
+
def test_creates_toml(self, monkeypatch, tmp_path):
|
|
43
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
44
|
+
reset_config()
|
|
45
|
+
result = runner.invoke(app, ["config", "init"])
|
|
46
|
+
assert result.exit_code == 0
|
|
47
|
+
assert (tmp_path / "code-explore" / "config.toml").exists()
|
|
48
|
+
|
|
49
|
+
def test_creates_yaml(self, monkeypatch, tmp_path):
|
|
50
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
51
|
+
reset_config()
|
|
52
|
+
result = runner.invoke(app, ["config", "init", "--format", "yaml"])
|
|
53
|
+
assert result.exit_code == 0
|
|
54
|
+
assert (tmp_path / "code-explore" / "config.yaml").exists()
|
|
55
|
+
|
|
56
|
+
def test_fails_if_exists(self, monkeypatch, tmp_path):
|
|
57
|
+
config_dir = tmp_path / "code-explore"
|
|
58
|
+
config_dir.mkdir()
|
|
59
|
+
(config_dir / "config.toml").write_text("[ollama]\n")
|
|
60
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
61
|
+
reset_config()
|
|
62
|
+
result = runner.invoke(app, ["config", "init"])
|
|
63
|
+
assert result.exit_code == 1
|
|
64
|
+
assert "already exists" in result.stdout
|
|
65
|
+
|
|
66
|
+
def test_force_overwrites(self, monkeypatch, tmp_path):
|
|
67
|
+
config_dir = tmp_path / "code-explore"
|
|
68
|
+
config_dir.mkdir()
|
|
69
|
+
(config_dir / "config.toml").write_text("[ollama]\n")
|
|
70
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
71
|
+
reset_config()
|
|
72
|
+
result = runner.invoke(app, ["config", "init", "--force"])
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
content = (config_dir / "config.toml").read_text()
|
|
75
|
+
assert "localhost" in content
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestConfigReset:
|
|
79
|
+
def test_deletes_config_file(self, monkeypatch, tmp_path):
|
|
80
|
+
config_dir = tmp_path / "code-explore"
|
|
81
|
+
config_dir.mkdir()
|
|
82
|
+
config_file = config_dir / "config.toml"
|
|
83
|
+
config_file.write_text("[ollama]\n")
|
|
84
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
85
|
+
reset_config()
|
|
86
|
+
result = runner.invoke(app, ["config", "reset", "--yes"])
|
|
87
|
+
assert result.exit_code == 0
|
|
88
|
+
assert not config_file.exists()
|
|
89
|
+
|
|
90
|
+
def test_no_file_to_reset(self, monkeypatch, tmp_path):
|
|
91
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
92
|
+
reset_config()
|
|
93
|
+
result = runner.invoke(app, ["config", "reset", "--yes"])
|
|
94
|
+
assert result.exit_code == 0
|
|
95
|
+
assert "defaults" in result.stdout.lower()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestConfigPath:
|
|
99
|
+
def test_shows_path(self, monkeypatch, tmp_path):
|
|
100
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
101
|
+
reset_config()
|
|
102
|
+
result = runner.invoke(app, ["config", "path"])
|
|
103
|
+
assert result.exit_code == 0
|
|
104
|
+
assert "code-explore" in result.stdout
|
|
105
|
+
|
|
106
|
+
def test_shows_existing_path(self, monkeypatch, tmp_path):
|
|
107
|
+
config_dir = tmp_path / "code-explore"
|
|
108
|
+
config_dir.mkdir()
|
|
109
|
+
(config_dir / "config.toml").write_text("[ollama]\n")
|
|
110
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
111
|
+
reset_config()
|
|
112
|
+
result = runner.invoke(app, ["config", "path"])
|
|
113
|
+
assert result.exit_code == 0
|
|
114
|
+
assert "config.toml" in result.stdout
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestConfigHelp:
|
|
118
|
+
def test_config_help(self):
|
|
119
|
+
result = runner.invoke(app, ["config", "--help"])
|
|
120
|
+
assert result.exit_code == 0
|
|
121
|
+
assert "config" in result.stdout.lower()
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Tests for hybrid search / reciprocal rank fusion."""
|
|
2
2
|
|
|
3
|
+
from code_explore.config import reset_config
|
|
3
4
|
from code_explore.models import Project, QualityMetrics, SearchResult
|
|
4
|
-
from code_explore.search.hybrid import _reciprocal_rank_fusion
|
|
5
|
+
from code_explore.search.hybrid import _reciprocal_rank_fusion
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def _make_result(pid: str, name: str, score: float, match_type: str = "fulltext") -> SearchResult:
|
|
@@ -44,7 +45,7 @@ class TestReciprocalRankFusion:
|
|
|
44
45
|
# "a" appears in both → should be ranked first (higher combined score)
|
|
45
46
|
assert result[0].project.id == "a"
|
|
46
47
|
# "a" gets score from both lists
|
|
47
|
-
expected_score_a = 1.0 / (
|
|
48
|
+
expected_score_a = 1.0 / (60 + 1) + 1.0 / (60 + 1)
|
|
48
49
|
assert abs(result[0].score - expected_score_a) < 0.001
|
|
49
50
|
|
|
50
51
|
def test_disjoint_results(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|