@@ -74,6 +74,28 @@ defmodule Systemd.UnitFile do
7474 :ok | { :error , [ Systemd.UnitFile.ValidationError . t ( ) ] }
7575 defdelegate validate ( unit_file , type \\ nil ) , to: Validator
7676
77+ @ doc """
78+ Returns a normalized representation suitable for semantic-ish comparison.
79+
80+ This intentionally ignores trivia, directive ordering, and equivalent list
81+ spellings for directives such as `Wants=` and `ReadWritePaths=`.
82+ """
83+ @ spec normalize ( t ( ) | String . t ( ) ) :: map ( )
84+ def normalize ( text ) when is_binary ( text ) , do: text |> parse! ( ) |> normalize ( )
85+
86+ def normalize ( % __MODULE__ { entries: entries } ) do
87+ entries
88+ |> entries_with_sections ( )
89+ |> Enum . reduce ( % { } , & collect_normalized_entry / 2 )
90+ |> Map . new ( fn { section , directives } -> { section , drop_defaults ( directives ) } end )
91+ end
92+
93+ @ doc """
94+ Compares two unit files after normalization.
95+ """
96+ @ spec equivalent? ( t ( ) | String . t ( ) , t ( ) | String . t ( ) ) :: boolean ( )
97+ def equivalent? ( left , right ) , do: normalize ( left ) == normalize ( right )
98+
7799 @ doc """
78100 Renders a unit file.
79101 """
@@ -150,6 +172,66 @@ defmodule Systemd.UnitFile do
150172 defp entry_to_iodata ( % Directive { name: name , value: value } ) , do: [ name , "=" , value , "\n " ]
151173 defp entry_to_iodata ( % Raw { content: content } ) , do: [ content , "\n " ]
152174
175+ @ list_directives MapSet . new ( [
176+ "after" ,
177+ "before" ,
178+ "bindsto" ,
179+ "conflicts" ,
180+ "documentation" ,
181+ "environmentfile" ,
182+ "readwritepaths" ,
183+ "requires" ,
184+ "requiredby" ,
185+ "wants" ,
186+ "wantedby"
187+ ] )
188+
189+ defp collect_normalized_entry ( { section , % Directive { } } , acc ) when is_nil ( section ) , do: acc
190+
191+ defp collect_normalized_entry ( { section , % Directive { } = directive } , acc ) do
192+ section = normalize_name ( section )
193+ key = normalize_name ( directive . name )
194+ values = normalize_directive_values ( key , directive . value )
195+
196+ update_in ( acc , [ Access . key ( section , % { } ) , Access . key ( key , [ ] ) ] , & ( values ++ & 1 ) )
197+ end
198+
199+ defp collect_normalized_entry ( _entry , acc ) , do: acc
200+
201+ defp normalize_directive_values ( key , value ) do
202+ value = normalize_value ( value )
203+
204+ values =
205+ if MapSet . member? ( @ list_directives , key ) do
206+ String . split ( value , ~r/ \s +/ , trim: true )
207+ else
208+ [ value ]
209+ end
210+
211+ Enum . sort ( values )
212+ end
213+
214+ defp drop_defaults ( % { "type" => [ "simple" ] } = directives ) ,
215+ do: directives |> Map . delete ( "type" ) |> sort_values ( )
216+
217+ defp drop_defaults ( directives ) , do: sort_values ( directives )
218+
219+ defp sort_values ( directives ) ,
220+ do: Map . new ( directives , fn { key , values } -> { key , Enum . sort ( values ) } end )
221+
222+ defp normalize_name ( name ) do
223+ name
224+ |> String . trim ( )
225+ |> String . replace ( "_" , "" )
226+ |> String . downcase ( )
227+ end
228+
229+ defp normalize_value ( value ) do
230+ value
231+ |> String . trim ( )
232+ |> String . replace ( ~r/ \s +/ , " " )
233+ end
234+
153235 defp entries_with_sections ( entries ) do
154236 { _section , entries } =
155237 Enum . reduce ( entries , { nil , [ ] } , fn
0 commit comments