Coverage for src/somesy/rust/writer.py: 89%

89 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:31 +0000

1"""Cargo.toml writer for Rust projects.""" 

2 

3import logging 

4from collections.abc import Mapping 

5from pathlib import Path 

6from typing import Any 

7 

8from rich.pretty import pretty_repr 

9from tomlkit import dump, load, string, table 

10 

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) 

18 

19from .models import RustConfig, check_keyword 

20 

21logger = logging.getLogger("somesy") 

22 

23 

24class Rust(ProjectMetadataWriter): 

25 """Rust config file handler parsed from Cargo.toml.""" 

26 

27 def __init__( 

28 self, 

29 path: Path, 

30 pass_validation: bool | None = False, 

31 ): 

32 """Rust config file handler parsed from Cargo.toml. 

33 

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 ) 

46 

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) 

51 

52 def _validate(self) -> None: 

53 """Validate rust config using pydantic class. 

54 

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) 

63 

64 def save(self, path: Path | None = None) -> None: 

65 """Save the Cargo.toml file.""" 

66 path = path or self.path 

67 

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 ) 

75 

76 with open(path, "w", encoding="utf-8") as f: 

77 dump(self._data, f) 

78 

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 

91 

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 

96 

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 

102 

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 

108 

109 if not value: # remove value and clean up the sub-dict 

110 self._get_property(key_path, remove=True) 

111 return 

112 

113 # get the tomlkit object of the section 

114 dat = self._get_property([]) 

115 

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] 

122 

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 

129 

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() 

134 

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. 

138 

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) 

142 

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 []))) 

147 

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")) 

152 

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}") 

163 

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) 

168 

169 def sync(self, metadata: ProjectMetadata) -> None: 

170 """Sync the rust config with the project metadata.""" 

171 super().sync(metadata) 

172 

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