src/z_filelib.erl

%% @author Marc Worrell <marc@worrell.nl>
%% @copyright 2017-2025 Marc Worrell
%% @doc Extra file functions to complement the standard library.
%% Rename files between different filesystems, and ensure directories of
%% a path exist. Also escape routines to safely handle filenames as command
%% line arguments.
%% @end

%% Copyright 2017-2025 Marc Worrell
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%%     http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.

-module(z_filelib).

-export([
    rename/2,
    ensure_dir/1,
    os_filename/1,
    os_escape/1
    ]).


%% @doc Rename a file. Copy the file on a cross-fs error.
-spec rename(From, To) -> ok | {error, term()} when
    From :: file:filename_all(),
    To :: file:filename_all().
rename(From, To) ->
    case file:rename(From, To) of
        ok ->
            ok;
        {error, exdev} ->
            % cross-fs rename is not supported by erlang, so copy and delete the file
            case file:copy(From, To) of
                {ok, _BytesCopied} ->
                    ok = file:delete(From);
                {error, _} = Error ->
                    Error
            end;
        {error, _} = Error ->
            Error
    end.

%% @doc Ensure the directory of a file is present. This will still work
%%      if a soft-link in the path refers to a missing directory.
-spec ensure_dir(file:filename_all()) -> ok | {error, term()}.
ensure_dir(Filename) ->
    case filelib:ensure_dir(Filename) of
        ok -> ok;
        {error, enoent} ->
            % Could be that there is a softlink to a missing directory in the path
            {LinkFile, Rest} = first_missing(filename:split(Filename), []),
            case file:read_link(LinkFile) of
                {ok, Link} ->
                    case make_link_target(Link, LinkFile, Rest) of
                        ok -> ensure_dir(Filename);
                        {error, _} = Error -> Error
                    end;
                {error, _} = Error ->
                    Error
            end;
        {error, _} = Error ->
            Error
    end.

make_link_target(Link, LinkFile, Rest) ->
    Target = case is_relative(Link) of
        true -> filename:join([ filename:dirname(LinkFile), Link ]);
        false -> Link
    end,
    case Rest of
        [] -> filelib:ensure_dir(filename:join([ Target ]));
        _ -> filelib:ensure_dir(filename:join([ Target, hd(Rest) ]))
    end.

is_relative(Path) ->
    case filename:split(Path) of
        [".."|_] -> true;
        ["."|_] -> true;
        [<<"..">>|_] -> true;
        [<<".">>|_] -> true;
        _ -> false
    end.


first_missing([], Acc) ->
    lists:reverse(Acc);
first_missing([P|Ps], Acc) ->
    Acc1 = [P|Acc],
    Path = filename:join(lists:reverse(Acc1)),
    case filelib:is_file(Path) of
        true -> first_missing(Ps, [P|Acc]);
        false -> {Path, Ps}
    end.


%% @doc Simple escape function for filenames as commandline arguments.
%% foo/"bar.jpg -> "foo/\"bar.jpg"; on windows "foo\\\"bar.jpg" (both including quotes!)
-spec os_filename( string()|binary() ) -> string().
os_filename(A) when is_binary(A) ->
    os_filename(binary_to_list(A));
os_filename(A) when is_list(A) ->
    os_filename(lists:flatten(A), []).

os_filename([], Acc) ->
    filename:nativename([$'] ++ lists:reverse(Acc) ++ [$']);
os_filename([$\\|Rest], Acc) ->
    os_filename_bs(Rest, Acc);
os_filename([$'|Rest], Acc) ->
    os_filename(Rest, [$', $\\ | Acc]);
os_filename([C|Rest], Acc) ->
    os_filename(Rest, [C|Acc]).

os_filename_bs([$\\|Rest], Acc) ->
    os_filename(Rest, [$\\,$\\|Acc]);
os_filename_bs([$'|Rest], Acc) ->
    os_filename(Rest, [$',$\\,$\\,$\\|Acc]);
os_filename_bs([C|Rest], Acc) ->
    os_filename(Rest, [C,$\\|Acc]).


%% @doc Simple escape function for command line arguments. Escapes special characters
%% in the filename using backslashes. The path is not quoted, use os_filename/1 for that.
-spec os_escape(string()|binary()|undefined) -> string().
os_escape(undefined) ->
    "";
os_escape(A) when is_binary(A) ->
    os_escape(binary_to_list(A));
os_escape(A) when is_list(A) ->
    {Family, _} = os:type(),
    os_escape(Family, lists:flatten(A), []).

os_escape(_, [], Acc) ->
    lists:reverse(Acc);
os_escape(unix, [C|Rest], Acc) when
      (C >= $A andalso C =< $Z)
      orelse (C >= $a andalso C =< $z)
      orelse (C >= $0 andalso C =< $9)
      orelse C == $_
      orelse C == $.
      orelse C == $-
      orelse C == $+
      orelse C == $/
      ->
    os_escape(unix, Rest, [C|Acc]);
os_escape(unix, [C|Rest], Acc) when
      C >= 32
      orelse  C == $\r
      orelse  C == $\n
      orelse  C == $\t
      ->
    os_escape(unix, Rest, [C,$\\|Acc]);

%% Win32 escaping, see: http://www.microsoft.com/resources/documentation/windows/xp/all/proddocs/en-us/ntcmds_shelloverview.mspx
os_escape(win32, [C|Rest], Acc) when
      C == $&
      orelse C == $|
      orelse C == $;
      orelse C == $,
      orelse C == $%
      orelse C == $(
      orelse C == $)
      orelse C == $"
      orelse C == $'
      orelse C == $=
      orelse C == $^
      orelse C == 32
      ->
    os_escape(win32, Rest, [C,$^|Acc]);
os_escape(win32, [C|Rest], Acc) ->
    os_escape(win32, Rest, [C|Acc]).