diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index 0aa1faf..8256189 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -139,6 +139,9 @@ class UserSettings: embedding: EmbeddingSettings envs: dict[str, str] = field(default_factory=dict) daemon: DaemonSettings = field(default_factory=DaemonSettings) + include_patterns: list[str] | None = None + exclude_patterns: list[str] | None = None + max_file_size: int | None = None @dataclass @@ -180,8 +183,33 @@ def default_user_settings() -> UserSettings: ) -def default_project_settings() -> ProjectSettings: - return ProjectSettings() +def default_project_settings(user_settings: UserSettings | None = None) -> ProjectSettings: + if user_settings is None: + try: + user_settings = load_user_settings() + except Exception: + user_settings = None + + include_patterns = ( + list(user_settings.include_patterns) + if user_settings is not None and user_settings.include_patterns is not None + else list(DEFAULT_INCLUDED_PATTERNS) + ) + exclude_patterns = ( + list(user_settings.exclude_patterns) + if user_settings is not None and user_settings.exclude_patterns is not None + else list(DEFAULT_EXCLUDED_PATTERNS) + ) + max_file_size = ( + user_settings.max_file_size + if user_settings is not None and user_settings.max_file_size is not None + else None + ) + return ProjectSettings( + include_patterns=include_patterns, + exclude_patterns=exclude_patterns, + max_file_size=max_file_size, + ) # --------------------------------------------------------------------------- @@ -526,6 +554,12 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]: daemon_dict["keep_alive_with_mcp"] = settings.daemon.keep_alive_with_mcp if daemon_dict: d["daemon"] = daemon_dict + if settings.include_patterns is not None: + d["include_patterns"] = list(settings.include_patterns) + if settings.exclude_patterns is not None: + d["exclude_patterns"] = list(settings.exclude_patterns) + if settings.max_file_size is not None: + d["max_file_size"] = settings.max_file_size return d @@ -565,7 +599,34 @@ def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings: raise ValueError("daemon.keep_alive_with_mcp must be a boolean") daemon_kwargs["keep_alive_with_mcp"] = keep_alive_with_mcp daemon = DaemonSettings(**daemon_kwargs) - return UserSettings(embedding=embedding, envs=envs, daemon=daemon) + + include_patterns = d.get("include_patterns") + if include_patterns is not None: + if not isinstance(include_patterns, list) or not all( + isinstance(x, str) for x in include_patterns + ): + raise ValueError("include_patterns must be a list of strings") + include_patterns = list(include_patterns) + + exclude_patterns = d.get("exclude_patterns") + if exclude_patterns is not None: + if not isinstance(exclude_patterns, list) or not all( + isinstance(x, str) for x in exclude_patterns + ): + raise ValueError("exclude_patterns must be a list of strings") + exclude_patterns = list(exclude_patterns) + + raw_max_size = d.get("max_file_size") + max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size) + + return UserSettings( + embedding=embedding, + envs=envs, + daemon=daemon, + include_patterns=include_patterns, + exclude_patterns=exclude_patterns, + max_file_size=max_file_size, + ) _SIZE_UNITS: dict[str, int] = { @@ -628,16 +689,43 @@ def _project_settings_to_dict(settings: ProjectSettings) -> dict[str, Any]: return d -def _project_settings_from_dict(d: dict[str, Any]) -> ProjectSettings: +def _project_settings_from_dict( + d: dict[str, Any], + user_settings: UserSettings | None = None, +) -> ProjectSettings: overrides = [ LanguageOverride(ext=lo["ext"], lang=lo["lang"]) for lo in d.get("language_overrides", []) ] chunkers = [ChunkerMapping(ext=cm["ext"], module=cm["module"]) for cm in d.get("chunkers", [])] - raw_max_size = d.get("max_file_size") - max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size) + + # Include patterns: project > user > default + if "include_patterns" in d and d["include_patterns"] is not None: + include_patterns = list(d["include_patterns"]) + elif user_settings is not None and user_settings.include_patterns is not None: + include_patterns = list(user_settings.include_patterns) + else: + include_patterns = list(DEFAULT_INCLUDED_PATTERNS) + + # Exclude patterns: project > user > default + if "exclude_patterns" in d and d["exclude_patterns"] is not None: + exclude_patterns = list(d["exclude_patterns"]) + elif user_settings is not None and user_settings.exclude_patterns is not None: + exclude_patterns = list(user_settings.exclude_patterns) + else: + exclude_patterns = list(DEFAULT_EXCLUDED_PATTERNS) + + # Max file size: project > user > None + if "max_file_size" in d: + raw_max_size = d.get("max_file_size") + max_file_size = None if raw_max_size is None else parse_file_size(raw_max_size) + elif user_settings is not None and user_settings.max_file_size is not None: + max_file_size = user_settings.max_file_size + else: + max_file_size = None + return ProjectSettings( - include_patterns=d.get("include_patterns", list(DEFAULT_INCLUDED_PATTERNS)), - exclude_patterns=d.get("exclude_patterns", list(DEFAULT_EXCLUDED_PATTERNS)), + include_patterns=include_patterns, + exclude_patterns=exclude_patterns, language_overrides=overrides, chunkers=chunkers, max_file_size=max_file_size, @@ -757,7 +845,10 @@ def save_initial_user_settings( return path -def load_project_settings(project_root: Path) -> ProjectSettings: +def load_project_settings( + project_root: Path, + user_settings: UserSettings | None = None, +) -> ProjectSettings: """Read ``$PROJECT_ROOT/.cocoindex_code/settings.yml``. Raises ``FileNotFoundError`` if the file does not exist. @@ -765,12 +856,17 @@ def load_project_settings(project_root: Path) -> ProjectSettings: path = project_settings_path(project_root) if not path.is_file(): raise FileNotFoundError(f"Project settings not found: {path}") + if user_settings is None: + try: + user_settings = load_user_settings() + except Exception: + user_settings = None try: with open(path) as f: data = _yaml.safe_load(f) if not data: - return default_project_settings() - return _project_settings_from_dict(data) + return default_project_settings(user_settings=user_settings) + return _project_settings_from_dict(data, user_settings=user_settings) except Exception as e: raise type(e)(f"Error loading {path}: {e}") from e diff --git a/tests/test_settings.py b/tests/test_settings.py index abce8e2..65bf983 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -25,6 +25,7 @@ _reset_db_path_mapping_cache, _reset_host_path_mapping_cache, _user_settings_from_dict, + _user_settings_to_dict, default_project_settings, default_user_settings, find_parent_with_marker, @@ -909,3 +910,87 @@ def test_project_settings_parses_human_readable_max_file_size(tmp_path: Path) -> path = save_project_settings(tmp_path, ProjectSettings()) path.write_text(path.read_text() + "\nmax_file_size: 500KB\n") assert load_project_settings(tmp_path).max_file_size == 500 * 1024 + + +def test_user_settings_round_trip_include_exclude_and_max_file_size() -> None: + settings = UserSettings( + embedding=EmbeddingSettings(provider="litellm", model="custom-model"), + include_patterns=["**/*.py", "**/*.rs"], + exclude_patterns=["**/raw/**", "**/imgs/**"], + max_file_size=1024 * 1024, + ) + d = _user_settings_to_dict(settings) + assert d["include_patterns"] == ["**/*.py", "**/*.rs"] + assert d["exclude_patterns"] == ["**/raw/**", "**/imgs/**"] + assert d["max_file_size"] == 1024 * 1024 + + loaded = _user_settings_from_dict(d) + assert loaded.include_patterns == ["**/*.py", "**/*.rs"] + assert loaded.exclude_patterns == ["**/raw/**", "**/imgs/**"] + assert loaded.max_file_size == 1024 * 1024 + + +def test_user_settings_rejects_invalid_patterns() -> None: + base = {"embedding": {"provider": "litellm", "model": "m"}} + with pytest.raises(ValueError, match="include_patterns must be a list of strings"): + _user_settings_from_dict({**base, "include_patterns": "not-a-list"}) + + with pytest.raises(ValueError, match="include_patterns must be a list of strings"): + _user_settings_from_dict({**base, "include_patterns": [123]}) + + with pytest.raises(ValueError, match="exclude_patterns must be a list of strings"): + _user_settings_from_dict({**base, "exclude_patterns": "not-a-list"}) + + with pytest.raises(ValueError, match="exclude_patterns must be a list of strings"): + _user_settings_from_dict({**base, "exclude_patterns": [None]}) + + +def test_project_settings_inherits_user_settings_defaults(tmp_path: Path) -> None: + user_settings = UserSettings( + embedding=EmbeddingSettings(provider="litellm", model="m"), + include_patterns=["**/*.py", "**/*.ts"], + exclude_patterns=["**/build/**", "**/raw/**"], + max_file_size=200 * 1024, + ) + # Project settings file without patterns or max_file_size + path = tmp_path / ".cocoindex_code" / "settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("language_overrides: []\n") + + loaded = load_project_settings(tmp_path, user_settings=user_settings) + assert loaded.include_patterns == ["**/*.py", "**/*.ts"] + assert loaded.exclude_patterns == ["**/build/**", "**/raw/**"] + assert loaded.max_file_size == 200 * 1024 + + +def test_project_settings_overrides_user_settings_defaults(tmp_path: Path) -> None: + user_settings = UserSettings( + embedding=EmbeddingSettings(provider="litellm", model="m"), + include_patterns=["**/*.py"], + exclude_patterns=["**/global_exclude/**"], + max_file_size=200 * 1024, + ) + # Project settings overrides exclude_patterns and max_file_size + path = tmp_path / ".cocoindex_code" / "settings.yml" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("exclude_patterns:\n - '**/local_exclude/**'\nmax_file_size: 50KB\n") + + loaded = load_project_settings(tmp_path, user_settings=user_settings) + # include_patterns was not set in project, so it inherits user settings + assert loaded.include_patterns == ["**/*.py"] + # exclude_patterns was set in project, so it completely replaces user settings + assert loaded.exclude_patterns == ["**/local_exclude/**"] + assert loaded.max_file_size == 50 * 1024 + + +def test_default_project_settings_with_user_settings() -> None: + user_settings = UserSettings( + embedding=EmbeddingSettings(provider="litellm", model="m"), + include_patterns=["**/*.go"], + exclude_patterns=["**/vendor/**"], + max_file_size=100 * 1024, + ) + ps = default_project_settings(user_settings=user_settings) + assert ps.include_patterns == ["**/*.go"] + assert ps.exclude_patterns == ["**/vendor/**"] + assert ps.max_file_size == 100 * 1024