config.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import re
  5. import shutil
  6. import tempfile
  7. from pathlib import Path
  8. from typing import Any, Dict, Optional, Union
  9. import yaml
  10. from rich.console import Console
  11. from .variable import Variable
  12. from .section import VariableSection
  13. from .collection import VariableCollection
  14. from .exceptions import ConfigError, ConfigValidationError, YAMLParseError
  15. logger = logging.getLogger(__name__)
  16. console = Console()
  17. # Valid Python identifier pattern for variable names
  18. VALID_IDENTIFIER_PATTERN = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
  19. # Valid path pattern - prevents path traversal attempts
  20. VALID_PATH_PATTERN = re.compile(r'^[^\x00-\x1f<>:"|?*]+$')
  21. # Maximum allowed string lengths to prevent DOS attacks
  22. MAX_STRING_LENGTH = 1000
  23. MAX_PATH_LENGTH = 4096
  24. MAX_LIST_LENGTH = 100
  25. class ConfigManager:
  26. """Manages configuration for the CLI application."""
  27. def __init__(self, config_path: Optional[Union[str, Path]] = None) -> None:
  28. """Initialize the configuration manager.
  29. Args:
  30. config_path: Path to the configuration file. If None, uses default location.
  31. """
  32. if config_path is None:
  33. # Default to ~/.config/boilerplates/config.yaml
  34. config_dir = Path.home() / ".config" / "boilerplates"
  35. config_dir.mkdir(parents=True, exist_ok=True)
  36. self.config_path = config_dir / "config.yaml"
  37. else:
  38. self.config_path = Path(config_path)
  39. # Create default config if it doesn't exist
  40. if not self.config_path.exists():
  41. self._create_default_config()
  42. else:
  43. # Migrate existing config if needed
  44. self._migrate_config_if_needed()
  45. def _create_default_config(self) -> None:
  46. """Create a default configuration file."""
  47. default_config = {
  48. "defaults": {},
  49. "preferences": {
  50. "editor": "vim",
  51. "output_dir": None,
  52. "library_paths": []
  53. },
  54. "libraries": [
  55. {
  56. "name": "default",
  57. "url": "https://github.com/christianlempa/boilerplates.git",
  58. "branch": "main",
  59. "directory": "library",
  60. "enabled": True
  61. }
  62. ]
  63. }
  64. self._write_config(default_config)
  65. logger.info(f"Created default configuration at {self.config_path}")
  66. def _migrate_config_if_needed(self) -> None:
  67. """Migrate existing config to add missing sections like libraries."""
  68. try:
  69. config = self._read_config()
  70. needs_migration = False
  71. # Add libraries section if missing
  72. if "libraries" not in config:
  73. logger.info("Migrating config: adding libraries section")
  74. config["libraries"] = [
  75. {
  76. "name": "default",
  77. "url": "https://github.com/christianlempa/boilerplates.git",
  78. "branch": "refactor/boilerplates-v2",
  79. "directory": "library",
  80. "enabled": True
  81. }
  82. ]
  83. needs_migration = True
  84. # Write back if migration was needed
  85. if needs_migration:
  86. self._write_config(config)
  87. logger.info("Config migration completed")
  88. except Exception as e:
  89. logger.warning(f"Config migration failed: {e}")
  90. @staticmethod
  91. def _validate_string_length(value: str, field_name: str, max_length: int = MAX_STRING_LENGTH) -> None:
  92. """Validate string length to prevent DOS attacks.
  93. Args:
  94. value: String value to validate
  95. field_name: Name of the field for error messages
  96. max_length: Maximum allowed length
  97. Raises:
  98. ConfigValidationError: If string exceeds maximum length
  99. """
  100. if len(value) > max_length:
  101. raise ConfigValidationError(
  102. f"{field_name} exceeds maximum length of {max_length} characters "
  103. f"(got {len(value)} characters)"
  104. )
  105. @staticmethod
  106. def _validate_path_string(path: str, field_name: str) -> None:
  107. """Validate path string for security concerns.
  108. Args:
  109. path: Path string to validate
  110. field_name: Name of the field for error messages
  111. Raises:
  112. ConfigValidationError: If path contains invalid characters or patterns
  113. """
  114. # Check length
  115. if len(path) > MAX_PATH_LENGTH:
  116. raise ConfigValidationError(
  117. f"{field_name} exceeds maximum path length of {MAX_PATH_LENGTH} characters"
  118. )
  119. # Check for null bytes and control characters
  120. if '\x00' in path or any(ord(c) < 32 for c in path if c not in '\t\n\r'):
  121. raise ConfigValidationError(
  122. f"{field_name} contains invalid control characters"
  123. )
  124. # Check for path traversal attempts
  125. if '..' in path.split('/'):
  126. logger.warning(f"Path '{path}' contains '..' - potential path traversal attempt")
  127. @staticmethod
  128. def _validate_list_length(lst: list, field_name: str, max_length: int = MAX_LIST_LENGTH) -> None:
  129. """Validate list length to prevent DOS attacks.
  130. Args:
  131. lst: List to validate
  132. field_name: Name of the field for error messages
  133. max_length: Maximum allowed length
  134. Raises:
  135. ConfigValidationError: If list exceeds maximum length
  136. """
  137. if len(lst) > max_length:
  138. raise ConfigValidationError(
  139. f"{field_name} exceeds maximum length of {max_length} items (got {len(lst)} items)"
  140. )
  141. def _read_config(self) -> Dict[str, Any]:
  142. """Read configuration from file.
  143. Returns:
  144. Dictionary containing the configuration.
  145. Raises:
  146. YAMLParseError: If YAML parsing fails.
  147. ConfigValidationError: If configuration structure is invalid.
  148. ConfigError: If reading fails for other reasons.
  149. """
  150. try:
  151. with open(self.config_path, 'r') as f:
  152. config = yaml.safe_load(f) or {}
  153. # Validate config structure
  154. self._validate_config_structure(config)
  155. return config
  156. except yaml.YAMLError as e:
  157. logger.error(f"Failed to parse YAML configuration: {e}")
  158. raise YAMLParseError(str(self.config_path), e)
  159. except ConfigValidationError:
  160. # Re-raise validation errors as-is
  161. raise
  162. except (IOError, OSError) as e:
  163. logger.error(f"Failed to read configuration file: {e}")
  164. raise ConfigError(f"Failed to read configuration file '{self.config_path}': {e}")
  165. def _write_config(self, config: Dict[str, Any]) -> None:
  166. """Write configuration to file atomically using temp file + rename pattern.
  167. This prevents config file corruption if write operation fails partway through.
  168. Args:
  169. config: Dictionary containing the configuration to write.
  170. Raises:
  171. ConfigValidationError: If configuration structure is invalid.
  172. ConfigError: If writing fails for any reason.
  173. """
  174. tmp_path = None
  175. try:
  176. # Validate config structure before writing
  177. self._validate_config_structure(config)
  178. # Ensure parent directory exists
  179. self.config_path.parent.mkdir(parents=True, exist_ok=True)
  180. # Write to temporary file in same directory for atomic rename
  181. with tempfile.NamedTemporaryFile(
  182. mode='w',
  183. delete=False,
  184. dir=self.config_path.parent,
  185. prefix='.config_',
  186. suffix='.tmp'
  187. ) as tmp_file:
  188. yaml.dump(config, tmp_file, default_flow_style=False)
  189. tmp_path = tmp_file.name
  190. # Atomic rename (overwrites existing file on POSIX systems)
  191. shutil.move(tmp_path, self.config_path)
  192. logger.debug(f"Configuration written atomically to {self.config_path}")
  193. except ConfigValidationError:
  194. # Re-raise validation errors as-is
  195. if tmp_path:
  196. Path(tmp_path).unlink(missing_ok=True)
  197. raise
  198. except (IOError, OSError, yaml.YAMLError) as e:
  199. # Clean up temp file if it exists
  200. if tmp_path:
  201. try:
  202. Path(tmp_path).unlink(missing_ok=True)
  203. except (IOError, OSError):
  204. logger.warning(f"Failed to clean up temporary file: {tmp_path}")
  205. logger.error(f"Failed to write configuration file: {e}")
  206. raise ConfigError(f"Failed to write configuration to '{self.config_path}': {e}")
  207. def _validate_config_structure(self, config: Dict[str, Any]) -> None:
  208. """Validate the configuration structure with comprehensive checks.
  209. Args:
  210. config: Configuration dictionary to validate.
  211. Raises:
  212. ConfigValidationError: If configuration structure is invalid.
  213. """
  214. if not isinstance(config, dict):
  215. raise ConfigValidationError("Configuration must be a dictionary")
  216. # Check top-level structure
  217. if "defaults" in config and not isinstance(config["defaults"], dict):
  218. raise ConfigValidationError("'defaults' must be a dictionary")
  219. if "preferences" in config and not isinstance(config["preferences"], dict):
  220. raise ConfigValidationError("'preferences' must be a dictionary")
  221. # Validate defaults structure
  222. if "defaults" in config:
  223. for module_name, module_defaults in config["defaults"].items():
  224. if not isinstance(module_name, str):
  225. raise ConfigValidationError(f"Module name must be a string, got {type(module_name).__name__}")
  226. # Validate module name length
  227. self._validate_string_length(module_name, "Module name", max_length=100)
  228. if not isinstance(module_defaults, dict):
  229. raise ConfigValidationError(f"Defaults for module '{module_name}' must be a dictionary")
  230. # Validate number of defaults per module
  231. self._validate_list_length(
  232. list(module_defaults.keys()),
  233. f"Defaults for module '{module_name}'"
  234. )
  235. # Validate variable names are valid Python identifiers
  236. for var_name, var_value in module_defaults.items():
  237. if not isinstance(var_name, str):
  238. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  239. # Validate variable name length
  240. self._validate_string_length(var_name, "Variable name", max_length=100)
  241. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  242. raise ConfigValidationError(
  243. f"Invalid variable name '{var_name}' in module '{module_name}'. "
  244. f"Variable names must be valid Python identifiers (letters, numbers, underscores, "
  245. f"cannot start with a number)"
  246. )
  247. # Validate variable value types and lengths
  248. if isinstance(var_value, str):
  249. self._validate_string_length(
  250. var_value,
  251. f"Value for '{module_name}.{var_name}'"
  252. )
  253. elif isinstance(var_value, list):
  254. self._validate_list_length(
  255. var_value,
  256. f"Value for '{module_name}.{var_name}'"
  257. )
  258. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  259. raise ConfigValidationError(
  260. f"Invalid value type for '{module_name}.{var_name}': "
  261. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  262. )
  263. # Validate preferences structure and types
  264. if "preferences" in config:
  265. preferences = config["preferences"]
  266. # Validate known preference types
  267. if "editor" in preferences:
  268. if not isinstance(preferences["editor"], str):
  269. raise ConfigValidationError("Preference 'editor' must be a string")
  270. self._validate_string_length(preferences["editor"], "Preference 'editor'", max_length=100)
  271. if "output_dir" in preferences:
  272. output_dir = preferences["output_dir"]
  273. if output_dir is not None:
  274. if not isinstance(output_dir, str):
  275. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  276. self._validate_path_string(output_dir, "Preference 'output_dir'")
  277. if "library_paths" in preferences:
  278. if not isinstance(preferences["library_paths"], list):
  279. raise ConfigValidationError("Preference 'library_paths' must be a list")
  280. self._validate_list_length(preferences["library_paths"], "Preference 'library_paths'")
  281. for i, path in enumerate(preferences["library_paths"]):
  282. if not isinstance(path, str):
  283. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  284. self._validate_path_string(path, f"Library path at index {i}")
  285. # Validate libraries structure
  286. if "libraries" in config:
  287. libraries = config["libraries"]
  288. if not isinstance(libraries, list):
  289. raise ConfigValidationError("'libraries' must be a list")
  290. self._validate_list_length(libraries, "Libraries list")
  291. for i, library in enumerate(libraries):
  292. if not isinstance(library, dict):
  293. raise ConfigValidationError(f"Library at index {i} must be a dictionary")
  294. # Validate required fields
  295. required_fields = ["name", "url", "directory"]
  296. for field in required_fields:
  297. if field not in library:
  298. raise ConfigValidationError(f"Library at index {i} missing required field '{field}'")
  299. if not isinstance(library[field], str):
  300. raise ConfigValidationError(f"Library '{field}' at index {i} must be a string")
  301. self._validate_string_length(library[field], f"Library '{field}' at index {i}", max_length=500)
  302. # Validate optional branch field
  303. if "branch" in library:
  304. if not isinstance(library["branch"], str):
  305. raise ConfigValidationError(f"Library 'branch' at index {i} must be a string")
  306. self._validate_string_length(library["branch"], f"Library 'branch' at index {i}", max_length=200)
  307. # Validate optional enabled field
  308. if "enabled" in library and not isinstance(library["enabled"], bool):
  309. raise ConfigValidationError(f"Library 'enabled' at index {i} must be a boolean")
  310. def get_config_path(self) -> Path:
  311. """Get the path to the configuration file.
  312. Returns:
  313. Path to the configuration file.
  314. """
  315. return self.config_path
  316. def get_defaults(self, module_name: str) -> Dict[str, Any]:
  317. """Get default variable values for a module.
  318. Returns defaults in a flat format:
  319. {
  320. "var_name": "value",
  321. "var2_name": "value2"
  322. }
  323. Args:
  324. module_name: Name of the module
  325. Returns:
  326. Dictionary of default values (flat key-value pairs)
  327. """
  328. config = self._read_config()
  329. defaults = config.get("defaults", {})
  330. return defaults.get(module_name, {})
  331. def set_defaults(self, module_name: str, defaults: Dict[str, Any]) -> None:
  332. """Set default variable values for a module with comprehensive validation.
  333. Args:
  334. module_name: Name of the module
  335. defaults: Dictionary of defaults (flat key-value pairs):
  336. {"var_name": "value", "var2_name": "value2"}
  337. Raises:
  338. ConfigValidationError: If module name or variable names are invalid.
  339. """
  340. # Validate module name
  341. if not isinstance(module_name, str) or not module_name:
  342. raise ConfigValidationError("Module name must be a non-empty string")
  343. self._validate_string_length(module_name, "Module name", max_length=100)
  344. # Validate defaults dictionary
  345. if not isinstance(defaults, dict):
  346. raise ConfigValidationError("Defaults must be a dictionary")
  347. # Validate number of defaults
  348. self._validate_list_length(list(defaults.keys()), "Defaults dictionary")
  349. # Validate variable names and values
  350. for var_name, var_value in defaults.items():
  351. if not isinstance(var_name, str):
  352. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  353. self._validate_string_length(var_name, "Variable name", max_length=100)
  354. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  355. raise ConfigValidationError(
  356. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  357. f"(letters, numbers, underscores, cannot start with a number)"
  358. )
  359. # Validate value types and lengths
  360. if isinstance(var_value, str):
  361. self._validate_string_length(var_value, f"Value for '{var_name}'")
  362. elif isinstance(var_value, list):
  363. self._validate_list_length(var_value, f"Value for '{var_name}'")
  364. elif var_value is not None and not isinstance(var_value, (bool, int, float)):
  365. raise ConfigValidationError(
  366. f"Invalid value type for '{var_name}': "
  367. f"must be string, number, boolean, list, or null (got {type(var_value).__name__})"
  368. )
  369. config = self._read_config()
  370. if "defaults" not in config:
  371. config["defaults"] = {}
  372. config["defaults"][module_name] = defaults
  373. self._write_config(config)
  374. logger.info(f"Updated defaults for module '{module_name}'")
  375. def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
  376. """Set a single default variable value with comprehensive validation.
  377. Args:
  378. module_name: Name of the module
  379. var_name: Name of the variable
  380. value: Default value to set
  381. Raises:
  382. ConfigValidationError: If module name or variable name is invalid.
  383. """
  384. # Validate inputs
  385. if not isinstance(module_name, str) or not module_name:
  386. raise ConfigValidationError("Module name must be a non-empty string")
  387. self._validate_string_length(module_name, "Module name", max_length=100)
  388. if not isinstance(var_name, str):
  389. raise ConfigValidationError(f"Variable name must be a string, got {type(var_name).__name__}")
  390. self._validate_string_length(var_name, "Variable name", max_length=100)
  391. if not VALID_IDENTIFIER_PATTERN.match(var_name):
  392. raise ConfigValidationError(
  393. f"Invalid variable name '{var_name}'. Variable names must be valid Python identifiers "
  394. f"(letters, numbers, underscores, cannot start with a number)"
  395. )
  396. # Validate value type and length
  397. if isinstance(value, str):
  398. self._validate_string_length(value, f"Value for '{var_name}'")
  399. elif isinstance(value, list):
  400. self._validate_list_length(value, f"Value for '{var_name}'")
  401. elif value is not None and not isinstance(value, (bool, int, float)):
  402. raise ConfigValidationError(
  403. f"Invalid value type for '{var_name}': "
  404. f"must be string, number, boolean, list, or null (got {type(value).__name__})"
  405. )
  406. defaults = self.get_defaults(module_name)
  407. defaults[var_name] = value
  408. self.set_defaults(module_name, defaults)
  409. logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
  410. def get_default_value(self, module_name: str, var_name: str) -> Optional[Any]:
  411. """Get a single default variable value.
  412. Args:
  413. module_name: Name of the module
  414. var_name: Name of the variable
  415. Returns:
  416. Default value or None if not set
  417. """
  418. defaults = self.get_defaults(module_name)
  419. return defaults.get(var_name)
  420. def clear_defaults(self, module_name: str) -> None:
  421. """Clear all defaults for a module.
  422. Args:
  423. module_name: Name of the module
  424. """
  425. config = self._read_config()
  426. if "defaults" in config and module_name in config["defaults"]:
  427. del config["defaults"][module_name]
  428. self._write_config(config)
  429. logger.info(f"Cleared defaults for module '{module_name}'")
  430. def get_preference(self, key: str) -> Optional[Any]:
  431. """Get a user preference value.
  432. Args:
  433. key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
  434. Returns:
  435. Preference value or None if not set
  436. """
  437. config = self._read_config()
  438. preferences = config.get("preferences", {})
  439. return preferences.get(key)
  440. def set_preference(self, key: str, value: Any) -> None:
  441. """Set a user preference value with comprehensive validation.
  442. Args:
  443. key: Preference key
  444. value: Preference value
  445. Raises:
  446. ConfigValidationError: If key or value is invalid for known preference types.
  447. """
  448. # Validate key
  449. if not isinstance(key, str) or not key:
  450. raise ConfigValidationError("Preference key must be a non-empty string")
  451. self._validate_string_length(key, "Preference key", max_length=100)
  452. # Validate known preference types
  453. if key == "editor":
  454. if not isinstance(value, str):
  455. raise ConfigValidationError("Preference 'editor' must be a string")
  456. self._validate_string_length(value, "Preference 'editor'", max_length=100)
  457. elif key == "output_dir":
  458. if value is not None:
  459. if not isinstance(value, str):
  460. raise ConfigValidationError("Preference 'output_dir' must be a string or null")
  461. self._validate_path_string(value, "Preference 'output_dir'")
  462. elif key == "library_paths":
  463. if not isinstance(value, list):
  464. raise ConfigValidationError("Preference 'library_paths' must be a list")
  465. self._validate_list_length(value, "Preference 'library_paths'")
  466. for i, path in enumerate(value):
  467. if not isinstance(path, str):
  468. raise ConfigValidationError(f"Library path must be a string, got {type(path).__name__}")
  469. self._validate_path_string(path, f"Library path at index {i}")
  470. # For unknown preference keys, apply basic validation
  471. else:
  472. if isinstance(value, str):
  473. self._validate_string_length(value, f"Preference '{key}'")
  474. elif isinstance(value, list):
  475. self._validate_list_length(value, f"Preference '{key}'")
  476. config = self._read_config()
  477. if "preferences" not in config:
  478. config["preferences"] = {}
  479. config["preferences"][key] = value
  480. self._write_config(config)
  481. logger.info(f"Set preference '{key}' = '{value}'")
  482. def get_all_preferences(self) -> Dict[str, Any]:
  483. """Get all user preferences.
  484. Returns:
  485. Dictionary of all preferences
  486. """
  487. config = self._read_config()
  488. return config.get("preferences", {})
  489. def get_libraries(self) -> list[Dict[str, Any]]:
  490. """Get all configured libraries.
  491. Returns:
  492. List of library configurations
  493. """
  494. config = self._read_config()
  495. return config.get("libraries", [])
  496. def get_library_by_name(self, name: str) -> Optional[Dict[str, Any]]:
  497. """Get a specific library by name.
  498. Args:
  499. name: Name of the library
  500. Returns:
  501. Library configuration dictionary or None if not found
  502. """
  503. libraries = self.get_libraries()
  504. for library in libraries:
  505. if library.get("name") == name:
  506. return library
  507. return None
  508. def add_library(self, name: str, url: str, directory: str = "library", branch: str = "main", enabled: bool = True) -> None:
  509. """Add a new library to the configuration.
  510. Args:
  511. name: Unique name for the library
  512. url: Git repository URL
  513. directory: Directory within the repo containing templates
  514. branch: Git branch to use
  515. enabled: Whether the library is enabled
  516. Raises:
  517. ConfigValidationError: If library with the same name already exists or validation fails
  518. """
  519. # Validate inputs
  520. if not isinstance(name, str) or not name:
  521. raise ConfigValidationError("Library name must be a non-empty string")
  522. self._validate_string_length(name, "Library name", max_length=100)
  523. if not isinstance(url, str) or not url:
  524. raise ConfigValidationError("Library URL must be a non-empty string")
  525. self._validate_string_length(url, "Library URL", max_length=500)
  526. if not isinstance(directory, str) or not directory:
  527. raise ConfigValidationError("Library directory must be a non-empty string")
  528. self._validate_string_length(directory, "Library directory", max_length=200)
  529. if not isinstance(branch, str) or not branch:
  530. raise ConfigValidationError("Library branch must be a non-empty string")
  531. self._validate_string_length(branch, "Library branch", max_length=200)
  532. # Check if library already exists
  533. if self.get_library_by_name(name):
  534. raise ConfigValidationError(f"Library '{name}' already exists")
  535. config = self._read_config()
  536. if "libraries" not in config:
  537. config["libraries"] = []
  538. config["libraries"].append({
  539. "name": name,
  540. "url": url,
  541. "branch": branch,
  542. "directory": directory,
  543. "enabled": enabled
  544. })
  545. self._write_config(config)
  546. logger.info(f"Added library '{name}'")
  547. def remove_library(self, name: str) -> None:
  548. """Remove a library from the configuration.
  549. Args:
  550. name: Name of the library to remove
  551. Raises:
  552. ConfigError: If library is not found
  553. """
  554. config = self._read_config()
  555. libraries = config.get("libraries", [])
  556. # Find and remove the library
  557. new_libraries = [lib for lib in libraries if lib.get("name") != name]
  558. if len(new_libraries) == len(libraries):
  559. raise ConfigError(f"Library '{name}' not found")
  560. config["libraries"] = new_libraries
  561. self._write_config(config)
  562. logger.info(f"Removed library '{name}'")
  563. def update_library(self, name: str, **kwargs: Any) -> None:
  564. """Update a library's configuration.
  565. Args:
  566. name: Name of the library to update
  567. **kwargs: Fields to update (url, branch, directory, enabled)
  568. Raises:
  569. ConfigError: If library is not found
  570. ConfigValidationError: If validation fails
  571. """
  572. config = self._read_config()
  573. libraries = config.get("libraries", [])
  574. # Find the library
  575. library_found = False
  576. for library in libraries:
  577. if library.get("name") == name:
  578. library_found = True
  579. # Update allowed fields
  580. if "url" in kwargs:
  581. url = kwargs["url"]
  582. if not isinstance(url, str) or not url:
  583. raise ConfigValidationError("Library URL must be a non-empty string")
  584. self._validate_string_length(url, "Library URL", max_length=500)
  585. library["url"] = url
  586. if "branch" in kwargs:
  587. branch = kwargs["branch"]
  588. if not isinstance(branch, str) or not branch:
  589. raise ConfigValidationError("Library branch must be a non-empty string")
  590. self._validate_string_length(branch, "Library branch", max_length=200)
  591. library["branch"] = branch
  592. if "directory" in kwargs:
  593. directory = kwargs["directory"]
  594. if not isinstance(directory, str) or not directory:
  595. raise ConfigValidationError("Library directory must be a non-empty string")
  596. self._validate_string_length(directory, "Library directory", max_length=200)
  597. library["directory"] = directory
  598. if "enabled" in kwargs:
  599. enabled = kwargs["enabled"]
  600. if not isinstance(enabled, bool):
  601. raise ConfigValidationError("Library enabled must be a boolean")
  602. library["enabled"] = enabled
  603. break
  604. if not library_found:
  605. raise ConfigError(f"Library '{name}' not found")
  606. config["libraries"] = libraries
  607. self._write_config(config)
  608. logger.info(f"Updated library '{name}'")
  609. def get_libraries_path(self) -> Path:
  610. """Get the path to the libraries directory.
  611. Returns:
  612. Path to the libraries directory (same directory as config file)
  613. """
  614. return self.config_path.parent / "libraries"