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.
Files changed (40) hide show
  1. {code_explore-0.1.0 → code_explore-0.2.0}/PKG-INFO +3 -1
  2. code_explore-0.2.0/code_explore/cli/config_cmd.py +96 -0
  3. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/cli/main.py +7 -1
  4. code_explore-0.2.0/code_explore/config.py +329 -0
  5. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/database.py +3 -3
  6. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/indexer/embeddings.py +31 -18
  7. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/hybrid.py +6 -4
  8. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/semantic.py +5 -3
  9. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/summarizer/ollama.py +9 -5
  10. {code_explore-0.1.0 → code_explore-0.2.0}/pyproject.toml +3 -1
  11. {code_explore-0.1.0 → code_explore-0.2.0}/tests/conftest.py +9 -0
  12. code_explore-0.2.0/tests/test_config.py +314 -0
  13. code_explore-0.2.0/tests/test_config_cli.py +121 -0
  14. {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_search_hybrid.py +3 -2
  15. {code_explore-0.1.0 → code_explore-0.2.0}/.editorconfig +0 -0
  16. {code_explore-0.1.0 → code_explore-0.2.0}/.github/workflows/publish.yml +0 -0
  17. {code_explore-0.1.0 → code_explore-0.2.0}/.gitignore +0 -0
  18. {code_explore-0.1.0 → code_explore-0.2.0}/README.md +0 -0
  19. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/__init__.py +0 -0
  20. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/__init__.py +0 -0
  21. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/dependencies.py +0 -0
  22. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/language.py +0 -0
  23. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/metrics.py +0 -0
  24. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/analyzer/patterns.py +0 -0
  25. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/api/__init__.py +0 -0
  26. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/api/main.py +0 -0
  27. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/cli/__init__.py +0 -0
  28. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/indexer/__init__.py +0 -0
  29. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/models.py +0 -0
  30. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/__init__.py +0 -0
  31. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/git_info.py +0 -0
  32. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/local.py +0 -0
  33. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/scanner/readme.py +0 -0
  34. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/__init__.py +0 -0
  35. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/search/fulltext.py +0 -0
  36. {code_explore-0.1.0 → code_explore-0.2.0}/code_explore/summarizer/__init__.py +0 -0
  37. {code_explore-0.1.0 → code_explore-0.2.0}/tests/__init__.py +0 -0
  38. {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_cli.py +0 -0
  39. {code_explore-0.1.0 → code_explore-0.2.0}/tests/test_database.py +0 -0
  40. {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.1.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(20, "--limit", "-l", help="Maximum results"),
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
- path = DEFAULT_DB_PATH
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
- SCHEMA = pa.schema([
21
- pa.field("id", pa.string()),
22
- pa.field("text", pa.string()),
23
- pa.field("vector", pa.list_(pa.float32(), EMBEDDING_DIM)),
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"{OLLAMA_BASE_URL}/api/tags", timeout=5.0)
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"{OLLAMA_BASE_URL}/api/embeddings",
39
- json={"model": EMBEDDING_MODEL, "prompt": text},
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.", OLLAMA_BASE_URL)
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=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
- VECTOR_DB_PATH.mkdir(parents=True, exist_ok=True)
132
- db = lancedb.connect(str(VECTOR_DB_PATH))
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
- VECTOR_DB_PATH.mkdir(parents=True, exist_ok=True)
168
- db = lancedb.connect(str(VECTOR_DB_PATH))
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 / (RRF_K + rank + 1)
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 / (RRF_K + rank + 1)
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
- if not VECTOR_DB_PATH.exists():
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(VECTOR_DB_PATH))
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 = DEFAULT_MODEL,
94
- base_url: str = OLLAMA_BASE_URL,
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.1.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, RRF_K
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 / (RRF_K + 1) + 1.0 / (RRF_K + 1)
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