Coverage for src/somesy/rust/writer.py: 89%
89 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +0000
1"""Cargo.toml writer for Rust projects."""
3import logging
4from collections.abc import Mapping
5from pathlib import Path
6from typing import Any
8from rich.pretty import pretty_repr
9from tomlkit import dump, load, string, table
11from somesy.core.models import Entity, Person, ProjectMetadata, parse_name_email
12from somesy.core.writer import (
13 FieldKeyMapping,
14 IgnoreKey,
15 ProjectMetadataWriter,
16 toml_array,
17)
19from .models import RustConfig, check_keyword
21logger = logging.getLogger("somesy")
24class Rust(ProjectMetadataWriter):
25 """Rust config file handler parsed from Cargo.toml."""
27 def __init__(
28 self,
29 path: Path,
30 pass_validation: bool | None = False,
31 ):
32 """Rust config file handler parsed from Cargo.toml.
34 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
35 """
36 self._section = ["package"]
37 mappings: FieldKeyMapping = {
38 "maintainers": IgnoreKey(),
39 }
40 super().__init__(
41 path,
42 create_if_not_exists=False,
43 direct_mappings=mappings,
44 pass_validation=pass_validation,
45 )
47 def _load(self) -> None:
48 """Load Cargo.toml file."""
49 with open(self.path, encoding="utf-8") as f:
50 self._data = load(f)
52 def _validate(self) -> None:
53 """Validate rust config using pydantic class.
55 In order to preserve toml comments and structure, tomlkit library is used.
56 Pydantic class only used for validation.
57 """
58 config = dict(self._get_property([]))
59 logger.debug(
60 f"Validating config using {RustConfig.__name__}: {pretty_repr(config)}"
61 )
62 RustConfig(**config)
64 def save(self, path: Path | None = None) -> None:
65 """Save the Cargo.toml file."""
66 path = path or self.path
68 if (
69 "description" in self._data["package"]
70 and "\n" in self._data["package"]["description"]
71 ):
72 self._data["package"]["description"] = string(
73 self._data["package"]["description"], multiline=True
74 )
76 with open(path, "w", encoding="utf-8") as f:
77 dump(self._data, f)
79 def _get_property(
80 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs
81 ) -> Any:
82 """Get a property from the Cargo.toml file."""
83 if isinstance(key, IgnoreKey):
84 return None
85 key_path = [key] if isinstance(key, str) else key
86 full_path = self._section + key_path
87 value = super()._get_property(full_path, remove=remove, **kwargs)
88 if not remove and self._is_workspace_ref(value):
89 return None # inherited from the workspace, treat as absent
90 return value
92 @staticmethod
93 def _is_workspace_ref(value: Any) -> bool:
94 """Check whether value is a workspace inheritance table `{workspace = true}`."""
95 return isinstance(value, Mapping) and value.get("workspace") is True
97 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None:
98 """Set a property in the Cargo.toml file."""
99 if isinstance(key, IgnoreKey):
100 return
101 key_path = [key] if isinstance(key, str) else key
103 # keep workspace inheritance, e.g. `version.workspace = true`
104 existing = super()._get_property(self._section + key_path)
105 if self._is_workspace_ref(existing):
106 logger.debug(f"Not setting {key_path}, inherited from workspace.")
107 return
109 if not value: # remove value and clean up the sub-dict
110 self._get_property(key_path, remove=True)
111 return
113 # get the tomlkit object of the section
114 dat = self._get_property([])
116 # dig down, create missing nested objects on the fly
117 curr = dat
118 for path_key in key_path[:-1]:
119 if path_key not in curr:
120 curr.add(path_key, table())
121 curr = curr[path_key]
123 # Handle arrays with proper formatting
124 if isinstance(value, list):
125 arr = toml_array(value)
126 curr[key_path[-1]] = arr
127 else:
128 curr[key_path[-1]] = value
130 @staticmethod
131 def _from_person(person: Person | Entity):
132 """Convert project metadata person object to rust string for person format "full name <email>."""
133 return person.to_name_email_string()
135 @staticmethod
136 def _to_person(person_obj: str) -> Person | Entity | None:
137 """Parse rust person string to a Person. It has format "full name <email>." but email is optional.
139 Since there is no way to know whether this entry is a person or an entity, we will directly convert to Person.
140 """
141 return parse_name_email(person_obj)
143 @classmethod
144 def _parse_people(cls, people: list[Any] | None) -> list[Person | Entity]:
145 """Return a list of Persons parsed from list of format-specific people representations. to_person can return None, so filter out None values."""
146 return list(filter(None, map(cls._to_person, people or [])))
148 @property
149 def keywords(self) -> list[str] | None:
150 """Return the keywords of the project."""
151 return self._get_property(self._get_key("keywords"))
153 @keywords.setter
154 def keywords(self, keywords: list[str]) -> None:
155 """Set the keywords of the project."""
156 validated_keywords = []
157 for keyword in keywords:
158 try:
159 check_keyword(keyword)
160 validated_keywords.append(keyword)
161 except ValueError as e:
162 logger.debug(f"Invalid keyword {keyword}: {e}")
164 # keyword count should max 5, so delete the rest
165 if len(validated_keywords) > 5:
166 validated_keywords = validated_keywords[:5]
167 self._set_property(self._get_key("keywords"), validated_keywords)
169 def sync(self, metadata: ProjectMetadata) -> None:
170 """Sync the rust config with the project metadata."""
171 super().sync(metadata)
173 # if there is a license file, remove the license field
174 if self._get_property(self._get_key("license_file")):
175 self.license = None