diff --git a/src/git_pulsar/cli.py b/src/git_pulsar/cli.py index fa1adbd..e8a466a 100644 --- a/src/git_pulsar/cli.py +++ b/src/git_pulsar/cli.py @@ -17,7 +17,7 @@ from rich.text import Text from . import __version__, daemon, ops, service, system -from .config import Config +from .config import Config, generate_default_config_template from .constants import ( APP_NAME, BACKUP_NAMESPACE, @@ -153,38 +153,8 @@ def _ensure_global_config_exists() -> None: """Creates the global config file with a comprehensive template if it does not exist.""" if not CONFIG_FILE.exists(): CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) - template = ( - "# Git Pulsar Global Configuration\n" - "# Uncomment settings below to override their defaults.\n\n" - "[core]\n" - "# The namespace used for backup references.\n" - '# backup_branch = "wip/pulsar"\n' - "# The git remote to push backups to.\n" - '# remote_name = "origin"\n\n' - "[daemon]\n" - "# Configuration preset. Options: paranoid, aggressive, balanced, lazy\n" - '# preset = "balanced"\n' - "# Time between local backup commits\n" - '# commit_interval = "10m"\n' - "# Time between pushing to remote\n" - '# push_interval = "1h"\n' - "# Battery percentage floor for commits (pauses backups if battery is lower)\n" - "# min_battery_percent = 10\n" - "# Battery percentage floor for pushing to remotes (pauses pushes if battery is lower)\n" - "# eco_mode_percent = 20\n\n" - "[limits]\n" - "# Max bytes for log files before rotation\n" - '# max_log_size = "5MB"\n' - "# Max bytes for a file before triggering a warning (and ignoring it)\n" - '# large_file_threshold = "100MB"\n\n' - "[files]\n" - "# List of glob patterns to ignore for backups (appended to defaults)\n" - "# ignore = []\n" - "# Whether the daemon is allowed to modify .gitignore automatically\n" - "# manage_gitignore = true\n" - ) with open(CONFIG_FILE, "w") as f: - f.write(template) + f.write(generate_default_config_template()) def open_config() -> None: diff --git a/src/git_pulsar/config.py b/src/git_pulsar/config.py index 0afd898..e8331f6 100644 --- a/src/git_pulsar/config.py +++ b/src/git_pulsar/config.py @@ -14,6 +14,13 @@ logger = logging.getLogger(APP_NAME) +PRESETS: dict[str, dict[str, int]] = { + "paranoid": {"commit_interval": 300, "push_interval": 300}, + "aggressive": {"commit_interval": 600, "push_interval": 600}, + "balanced": {"commit_interval": 900, "push_interval": 3600}, + "lazy": {"commit_interval": 3600, "push_interval": 14400}, +} + def parse_size(value: int | str) -> int: """Converts human-readable size strings (e.g., '100MB') to bytes.""" @@ -48,6 +55,118 @@ def parse_time(value: int | str) -> int: return int(num * multiplier[unit]) +@dataclass(frozen=True) +class ConfigFieldMeta: + """Metadata describing a configuration setting. + + Attributes: + key (str): The configuration field key. + description (str): Human-readable comment explaining the setting. + example_value (str): The example value displayed in configuration templates. + parser (Any): Optional parser function for human-readable values. + """ + + key: str + description: str + example_value: str + parser: Any = None + + +# Single source of truth for configuration metadata and template generation +CONFIG_SECTIONS_METADATA: dict[str, list[ConfigFieldMeta]] = { + "core": [ + ConfigFieldMeta( + key="backup_branch", + description="The namespace used for backup references.", + example_value=f'"{BACKUP_NAMESPACE}"', + ), + ConfigFieldMeta( + key="remote_name", + description="The git remote to push backups to.", + example_value='"origin"', + ), + ], + "daemon": [ + ConfigFieldMeta( + key="preset", + description=f"Configuration preset. Options: {', '.join(PRESETS.keys())}", + example_value='"balanced"', + ), + ConfigFieldMeta( + key="commit_interval", + description="Time between local backup commits", + example_value='"10m"', + parser=parse_time, + ), + ConfigFieldMeta( + key="push_interval", + description="Time between pushing to remote", + example_value='"1h"', + parser=parse_time, + ), + ConfigFieldMeta( + key="min_battery_percent", + description="Battery percentage floor for commits (pauses backups if battery is lower)", + example_value="10", + ), + ConfigFieldMeta( + key="eco_mode_percent", + description="Battery percentage floor for pushing to remotes (pauses pushes if battery is lower)", + example_value="20", + ), + ], + "limits": [ + ConfigFieldMeta( + key="max_log_size", + description="Max bytes for log files before rotation", + example_value='"5MB"', + parser=parse_size, + ), + ConfigFieldMeta( + key="large_file_threshold", + description="Max bytes for a file before triggering a warning (and ignoring it)", + example_value='"100MB"', + parser=parse_size, + ), + ], + "files": [ + ConfigFieldMeta( + key="ignore", + description="List of glob patterns to ignore for backups (appended to defaults)", + example_value="[]", + ), + ConfigFieldMeta( + key="manage_gitignore", + description="Whether the daemon is allowed to modify .gitignore automatically", + example_value="true", + ), + ], +} + +FIELD_PARSERS: dict[str, Any] = { + meta.key: meta.parser + for section_fields in CONFIG_SECTIONS_METADATA.values() + for meta in section_fields + if meta.parser is not None +} + + +def generate_default_config_template() -> str: + """Generates the canonical TOML configuration template with descriptions and default examples.""" + lines = [ + "# Git Pulsar Global Configuration", + "# Uncomment settings below to override their defaults.", + "", + ] + for section, fields in CONFIG_SECTIONS_METADATA.items(): + lines.append(f"[{section}]") + for f in fields: + lines.append(f"# {f.description}") + lines.append(f"# {f.key} = {f.example_value}") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + @dataclass class CoreConfig: """Core application settings. @@ -108,18 +227,10 @@ class DaemonConfig: def apply_preset(self) -> None: """Overwrites intervals based on the selected preset.""" - if self.preset == "paranoid": - self.commit_interval = 300 # 5 mins - self.push_interval = 300 # 5 mins - elif self.preset == "aggressive": - self.commit_interval = 600 # 10 mins - self.push_interval = 600 # 10 mins - elif self.preset == "balanced": - self.commit_interval = 900 # 15 mins - self.push_interval = 3600 # 1 hour - elif self.preset == "lazy": - self.commit_interval = 3600 # 1 hour - self.push_interval = 14400 # 4 hours + if self.preset and self.preset in PRESETS: + preset_values = PRESETS[self.preset] + self.commit_interval = preset_values["commit_interval"] + self.push_interval = preset_values["push_interval"] @dataclass @@ -237,11 +348,9 @@ def _update_dataclass( continue try: - # Route specific keys through our parsers - if k in ["max_log_size", "large_file_threshold"]: - filtered_updates[k] = parse_size(v) - elif k in ["commit_interval", "push_interval"]: - filtered_updates[k] = parse_time(v) + parser = FIELD_PARSERS.get(k) + if parser is not None: + filtered_updates[k] = parser(v) else: filtered_updates[k] = v except ValueError as e: diff --git a/tests/test_config.py b/tests/test_config.py index 2f877d6..c867e05 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -179,3 +179,50 @@ def test_config_load_does_not_mutate_global_cache( # 3. Verify global cache was not mutated fresh_global = Config.load() assert fresh_global.files.ignore == ["*.global"] + + +def test_generate_default_config_template() -> None: + """Verifies that generate_default_config_template produces a comprehensive and valid TOML template.""" + import tomllib + + from git_pulsar.config import ( + CONFIG_SECTIONS_METADATA, + generate_default_config_template, + ) + + template = generate_default_config_template() + + # Check all sections and keys are present + for section, fields in CONFIG_SECTIONS_METADATA.items(): + assert f"[{section}]" in template + for f in fields: + assert f.key in template + assert f.description in template + + # When uncommented, the template should be valid TOML + uncommented_lines = [] + for line in template.splitlines(): + if (line.startswith("# [") and line.endswith("]")) or ( + line.startswith("# ") and "=" in line + ): + uncommented_lines.append(line[2:]) + elif not line.startswith("#"): + uncommented_lines.append(line) + + uncommented_toml = "\n".join(uncommented_lines) + parsed = tomllib.loads(uncommented_toml) + for section in CONFIG_SECTIONS_METADATA: + assert section in parsed + + +def test_config_presets_unknown() -> None: + """Verifies that an unknown preset does not alter intervals and is handled safely.""" + conf = Config() + original_commit = conf.daemon.commit_interval + original_push = conf.daemon.push_interval + + conf.daemon.preset = "nonexistent_preset" + conf.daemon.apply_preset() + + assert conf.daemon.commit_interval == original_commit + assert conf.daemon.push_interval == original_push