Add core modules: manifest, merger, validator
This commit is contained in:
207
confsync/core/manifest.py
Normal file
207
confsync/core/manifest.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""Manifest builder and management for ConfSync."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from confsync.models.config_models import (
|
||||||
|
Manifest,
|
||||||
|
ManifestEntry,
|
||||||
|
ConfigFile,
|
||||||
|
ConfigCategory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestBuilder:
|
||||||
|
"""Builds and manages configuration manifests."""
|
||||||
|
|
||||||
|
def __init__(self, ignore_patterns: Optional[List[str]] = None):
|
||||||
|
self.ignore_patterns = ignore_patterns or []
|
||||||
|
self._load_default_ignore_patterns()
|
||||||
|
|
||||||
|
def _load_default_ignore_patterns(self) -> None:
|
||||||
|
"""Load default patterns to ignore."""
|
||||||
|
default_patterns = [
|
||||||
|
"*.swp",
|
||||||
|
"*.swo",
|
||||||
|
"*~",
|
||||||
|
".DS_Store",
|
||||||
|
"*.bak",
|
||||||
|
"*.orig",
|
||||||
|
"*.rej",
|
||||||
|
"confsync",
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
"venv",
|
||||||
|
"__pycache__",
|
||||||
|
]
|
||||||
|
for pattern in default_patterns:
|
||||||
|
if pattern not in self.ignore_patterns:
|
||||||
|
self.ignore_patterns.append(pattern)
|
||||||
|
|
||||||
|
def should_ignore(self, path: str) -> bool:
|
||||||
|
"""Check if a path should be ignored."""
|
||||||
|
path_obj = Path(path)
|
||||||
|
path_str = str(path_obj)
|
||||||
|
for pattern in self.ignore_patterns:
|
||||||
|
if path_obj.match(pattern):
|
||||||
|
return True
|
||||||
|
if pattern.startswith('.'):
|
||||||
|
if path_obj.name == pattern:
|
||||||
|
return True
|
||||||
|
if pattern in path_obj.parts:
|
||||||
|
return True
|
||||||
|
elif pattern in path_str.split('/'):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def build_from_detected(
|
||||||
|
self,
|
||||||
|
detected_configs: List[ConfigFile],
|
||||||
|
metadata: Optional[Dict] = None
|
||||||
|
) -> Manifest:
|
||||||
|
"""Build a manifest from detected configuration files."""
|
||||||
|
manifest = Manifest(
|
||||||
|
version="1.0.0",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
for config_file in detected_configs:
|
||||||
|
if self.should_ignore(config_file.path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = ManifestEntry(
|
||||||
|
config_file=config_file,
|
||||||
|
priority=self._get_default_priority(config_file.category),
|
||||||
|
merge_strategy=self._get_default_merge_strategy(config_file),
|
||||||
|
)
|
||||||
|
manifest.add_entry(entry)
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def _get_default_priority(self, category: ConfigCategory) -> int:
|
||||||
|
"""Get default priority for a category."""
|
||||||
|
priorities = {
|
||||||
|
ConfigCategory.GIT: 10,
|
||||||
|
ConfigCategory.SHELL: 9,
|
||||||
|
ConfigCategory.TERMINAL: 8,
|
||||||
|
ConfigCategory.EDITOR: 7,
|
||||||
|
ConfigCategory.SSH: 6,
|
||||||
|
ConfigCategory.DOCKER: 5,
|
||||||
|
ConfigCategory.TMUX: 4,
|
||||||
|
ConfigCategory.MISC: 1,
|
||||||
|
}
|
||||||
|
return priorities.get(category, 1)
|
||||||
|
|
||||||
|
def _get_default_merge_strategy(self, config_file: ConfigFile) -> str:
|
||||||
|
"""Get default merge strategy for a config file."""
|
||||||
|
ext_strategy_map = {
|
||||||
|
".json": "three_way",
|
||||||
|
".yaml": "three_way",
|
||||||
|
".yml": "three_way",
|
||||||
|
".toml": "three_way",
|
||||||
|
".ini": "keep_local",
|
||||||
|
".cfg": "keep_local",
|
||||||
|
".conf": "keep_local",
|
||||||
|
".sh": "keep_common",
|
||||||
|
".bash": "keep_common",
|
||||||
|
".zsh": "keep_common",
|
||||||
|
".fish": "keep_common",
|
||||||
|
}
|
||||||
|
suffix = Path(config_file.path).suffix.lower()
|
||||||
|
return ext_strategy_map.get(suffix, "keep_local")
|
||||||
|
|
||||||
|
def save_manifest(self, manifest: Manifest, path: str) -> None:
|
||||||
|
"""Save manifest to a YAML file."""
|
||||||
|
path_obj = Path(path)
|
||||||
|
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path_obj, 'w', encoding='utf-8') as f:
|
||||||
|
yaml.safe_dump(manifest.to_dict(), f, default_flow_style=False, allow_unicode=True)
|
||||||
|
|
||||||
|
def load_manifest(self, path: str) -> Manifest:
|
||||||
|
"""Load manifest from a YAML file."""
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
if data is None:
|
||||||
|
return Manifest()
|
||||||
|
return Manifest.from_dict(data)
|
||||||
|
|
||||||
|
def merge_manifests(
|
||||||
|
self,
|
||||||
|
base: Manifest,
|
||||||
|
remote: Manifest,
|
||||||
|
local: Manifest,
|
||||||
|
strategy: str = "local_wins"
|
||||||
|
) -> Manifest:
|
||||||
|
"""Merge multiple manifests with the specified strategy."""
|
||||||
|
result = Manifest(
|
||||||
|
version=base.version,
|
||||||
|
created_at=base.created_at,
|
||||||
|
metadata=base.metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
all_ids = set(base.entries.keys()) | set(remote.entries.keys()) | set(local.entries.keys())
|
||||||
|
|
||||||
|
for entry_id in all_ids:
|
||||||
|
base_entry = base.entries.get(entry_id)
|
||||||
|
remote_entry = remote.entries.get(entry_id)
|
||||||
|
local_entry = local.entries.get(entry_id)
|
||||||
|
|
||||||
|
merged_entry = self._merge_entry(
|
||||||
|
base_entry, remote_entry, local_entry, strategy
|
||||||
|
)
|
||||||
|
if merged_entry:
|
||||||
|
result.entries[entry_id] = merged_entry
|
||||||
|
|
||||||
|
result.updated_at = datetime.now()
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _merge_entry(
|
||||||
|
self,
|
||||||
|
base: Optional[ManifestEntry],
|
||||||
|
remote: Optional[ManifestEntry],
|
||||||
|
local: Optional[ManifestEntry],
|
||||||
|
strategy: str
|
||||||
|
) -> Optional[ManifestEntry]:
|
||||||
|
"""Merge a single manifest entry."""
|
||||||
|
if local and not remote:
|
||||||
|
return local
|
||||||
|
|
||||||
|
if remote and not local:
|
||||||
|
return remote
|
||||||
|
|
||||||
|
if local and remote:
|
||||||
|
if local.config_file.file_hash == remote.config_file.file_hash:
|
||||||
|
return local
|
||||||
|
|
||||||
|
if base and base.config_file.file_hash == local.config_file.file_hash:
|
||||||
|
return remote
|
||||||
|
if base and base.config_file.file_hash == remote.config_file.file_hash:
|
||||||
|
return local
|
||||||
|
|
||||||
|
if strategy == "remote_wins":
|
||||||
|
return remote
|
||||||
|
elif strategy == "local_wins":
|
||||||
|
return local
|
||||||
|
|
||||||
|
return local
|
||||||
|
|
||||||
|
def get_summary(self, manifest: Manifest) -> Dict[str, Any]:
|
||||||
|
"""Get a summary of the manifest contents."""
|
||||||
|
summary: Dict[str, Any] = {
|
||||||
|
"total_files": len(manifest.entries),
|
||||||
|
"by_category": {},
|
||||||
|
"by_tool": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in manifest.entries.values():
|
||||||
|
category = entry.config_file.category.value
|
||||||
|
tool = entry.config_file.tool_name
|
||||||
|
|
||||||
|
summary["by_category"][category] = summary["by_category"].get(category, 0) + 1
|
||||||
|
summary["by_tool"][tool] = summary["by_tool"].get(tool, 0) + 1
|
||||||
|
|
||||||
|
return summary
|
||||||
Reference in New Issue
Block a user