aboutsummaryrefslogtreecommitdiffstats
path: root/src/xdg_basedir.cr
blob: b40684187d3927c4774cd11038b689fe972fa006 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# This module implements an interface to the XDG Base Directories, compliant
# with version 0.7 of the XDG Base Directory Specification.
#
# Provided are methods for locating XDG Base Directories at runtime, as well as
# a helper method for easily building full file paths relative to them.
#
# Each of the operations below can be performed on one of four categories of
# directories, designated via an argument *type* of value `:data`, `:config`,
# `:cache`, or `:runtime`.
#
# - `:data` directories are used for storing and retrieving persistent files
# across multiple runs of a program.
# - `:config` directories are used for storing and retrieving a program's
# configuration files.
# - `:cache` directories are used for storing non-essential data which may or
# may not be retained
# - `:runtime` directories are used for storing runtime files (e.g. lock files
# or sockets)
#
# For more details, please refer to the specification, which is available
# online [here](https://specifications.freedesktop.org/basedir-spec/0.7/).
module XDGBasedir
  VERSION = "1.0.0"

  # get the base directory into which files of a given *type* should be written
  #
  # Given a *type* of `:data`, `:config`, `:cache`, or `:runtime`, this method
  # returns a single directory into which data of that type should be written,
  # or else `nil`, if no appropriate candidate is found.
  #
  # If a directory is returned, it is guaranteed to always have a terminating
  # '/' character, meaning that, when trying to write a file, it is safe to
  # directly concatenate the directory and the file path that is to be written
  # to within it.
  #
  # ### Example
  #
  #     dir = XDGBasedir.write_dir :config
  #     if dir
  #       File.write "#{dir}created/file.conf", "contents"
  #     end
  #
  def self.write_dir(type = :config) : String?
    case type
    when :data
      if ENV["XDG_DATA_HOME"]? && ENV["XDG_DATA_HOME"] != ""
        s = "#{ENV["XDG_DATA_HOME"]}/"
      else
        if ENV["HOME"]?
          s = "#{ENV["HOME"]}/.local/share/"
        else
          return nil
        end
      end

    when :config
      if ENV["XDG_CONFIG_HOME"]? && ENV["XDG_CONFIG_HOME"] != ""
        s = "#{ENV["XDG_CONFIG_HOME"]}/"
      else
        if ENV["HOME"]?
          s = "#{ENV["HOME"]}/.config/"
        else
          return nil
        end
      end

    when :cache
      if ENV["XDG_CACHE_HOME"]? && ENV["XDG_CACHE_HOME"] != ""
        s = "#{ENV["XDG_CACHE_HOME"]}/"
      else
        if ENV["HOME"]?
          s = "#{ENV["HOME"]}/.cache/"
        else
          return nil
        end
      end

    when :runtime
      if ENV["XDG_RUNTIME_DIR"]? && ENV["XDG_RUNTIME_DIR"] != ""
        s = "#{ENV["XDG_RUNTIME_DIR"]}/"
      else
        # runtime dir has no fallback
        return nil
      end

      # runtime dir must necessarily have certain permissions
      unless File.directory?(s) && File.info(s).permissions.value == 0o0700
        return nil
      end

    else
      raise ArgumentError.new(
        "type must be one of: :data, :config, :cache, :runtime"
      )
    end

    # return result with slashes deduplicated. nil check required because the
    # compiler can't work out that s will have a value here...
    s ? s.gsub(/\/+/, "/") : nil

  end

  # get a list of base directories from which files of a given *type* can be
  # read
  #
  # Given a type of `:data`, `:config`, `:cache`, or `:runtime`, this method
  # returns a string array of directories from which data of that type should
  # be read. If no appropriate candidates are found, it instead returns `nil`.
  #
  # The returned list will be ordered according to precedence. That is, given a
  # returned list `l`, accessing a file should first be attempted from within
  # `l[0]`, if that fails, be attempted from within `l[1]`, and so on.
  #
  # If a directory list is returned, those directories are guaranteed to always
  # have a terminating '/' character, meaning that, when trying to access a
  # file, it is safe to directly concatenate the directory and the file path
  # that is to be accessed within it.
  #
  # ### Example
  #
  #     contents = nil
  #     dir_list = XDGBasedir.read_dirs :config
  #     
  #     if dir_list
  #       dir_list.each { |dir|
  #         if File.file? "#{dir}target/file.conf"
  #           contents = File.read "#{dir}target/file.conf"
  #           break
  #         end
  #       }
  #     end
  #
  def self.read_dirs(type = :config) : Array(String)?
    case type
    when :data
      # first entry is the write dir, if it exists
      if s = self.write_dir(:data)
        l = [s]
      else
        l = [] of String
      end

      if ENV["XDG_DATA_DIRS"]? && !/^:*$/.match(ENV["XDG_DATA_DIRS"])
        ENV["XDG_DATA_DIRS"].split(":").reject{|s| s == ""}.each{|s| l << s}
      else
        l << "/usr/local/share/"
        l << "/usr/share/"
      end

    when :config
      # first entry is the write dir, if it exists
      if s = self.write_dir(:config)
        l = [s]
      else
        l = [] of String
      end

      if ENV["XDG_CONFIG_DIRS"]? && !/^:*$/.match(ENV["XDG_CONFIG_DIRS"])
        ENV["XDG_CONFIG_DIRS"].split(":").reject{|d| d == ""}.each{|s| l << s}
      else
        l << "/etc/xdg/"
      end

    when :cache
      if ENV["XDG_CACHE_HOME"]? && ENV["XDG_CACHE_HOME"] != ""
        l = ["#{ENV["XDG_CACHE_HOME"]}/"]
      else
        if ENV["HOME"]?
          l = ["#{ENV["HOME"]}/.cache/"]
        else
          return nil
        end
      end

    when :runtime
      if ENV["XDG_RUNTIME_DIR"]? && ENV["XDG_RUNTIME_DIR"] != ""
        s = "#{ENV["XDG_RUNTIME_DIR"]}/"
      else
        # runtime dir has no fallback
        return nil
      end

      # runtime dir must necessarily have certain permissions
      unless File.directory?(s) && File.info(s).permissions.value == 0o0700
        return nil
      else
        l = [s]
      end

    else
      raise ArgumentError.new(
        "type must be one of: :data, :config, :cache, :runtime"
      )
    end

    # return result with slashes deduplicated and a trailing slash present.
    l ? l.map { |s| s.gsub(/\/+/, "/").sub(/[^\/]$/, "\\0/") } : nil

  end

  # for a given *relative_path*, get a full file path built against an
  # appropriate base directory
  # 
  # This method takes a *relative_path*, the *type* of that path, and the
  # *action* you intend to perform on it, and returns a full path, constructed
  # by concatenating the *relative_path* to the most appropriate base
  # directory. If an appropriate directory cannot be found, `nil` is returned.
  #
  # The *type* argument indicates what sort of file you intend to access at the
  # full path (`:config`, `:data` ...), and the *action* argument indicates
  # what you intend to to do with it (either `:read` or `:write`). Note that
  # `:write` takes precedence over `:read`, so that if you intend to both read
  # and write, choose `:write`
  #
  # ### Example
  #
  #     data = nil
  #     path = XDGBasedir.full_path "relative/path.dat", :data, :read
  #     
  #     if path
  #       data = File.read path
  #     end
  #
  def self.full_path(relative_path, type = :config,
                     action : Symbol = :read) : String?
    unless action == :read || action == :write
      raise ArgumentError.new(
        "action must be one of: :read, :write"
      )
    end

    case type
    when :data, :config, :cache, :runtime
      if action == :read
        unless l = self.read_dirs(type)
          return nil
        end

        # search the read dirs until relative_path is found
        l.each { |d|
          if File.exists?(d + relative_path)
            return d + relative_path
          end
        }

        # or else just return against the first read dir
        l[0] + relative_path
      else
        d = self.write_dir(type)
        d ? d + relative_path : nil
      end
    else
      raise ArgumentError.new(
        "type must be one of: :data, :config, :cache, :runtime"
      )
    end
  end

  # for a given *relative_path*, get a full file path built against an
  # appropriate base directory
  # 
  # This method takes a *relative_path*, the *type* of that path, and the
  # access *mode* with which that file is to be opened, and returns a full
  # path, constructed by concatenating the *relative_path* to the most
  # appropriate base directory. If an appropriate directory cannot be found,
  # `nil` is returned.
  #
  # The *type* indicates what sort of file you intend to access at the full
  # path (`:config`, `:data` ...), and the *mode* argument is a string, in the
  # common format used by both C's `fopen` and Crystal's `File.open`. For more
  # information, refer to `man fopen` or the Crystal standard library
  # documentation for [File](https://crystal-lang.org/api/latest/File.html).
  #
  # This overloaded version of `full_path` is added for convenience, as, if you
  # intend to call `File.open` on the produced full path, it might be easier to
  # use the same *mode* argument for both methods.
  #
  # ### Example
  #
  #     data = nil
  #     mode = "r"
  #     path = XDGBasedir.full_path "relative/path.dat", :data, mode
  #     
  #     if path
  #       data = File.open(path, mode) do |file|
  #         file.gets_to_end
  #       end
  #     end
  #
  def self.full_path(relative_path, type = :config,
                     mode : String = "r") : String?
    if /^r[^+]*$/.match(mode)
      self.full_path(relative_path, type, :read)
    else
      self.full_path(relative_path, type, :write)
    end
  end

end