Rust로 ls 명령 구현하기 2

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Rust로 ls 명령 구현하기 2

Rust로 ls 명령 구현하기 1에서 명령행 인자를 처리하는 방법과 디렉터리 안의 파일 목록을 구하는 방법을 설명했다. 2부에서는 목록을 표시할 때 ls와 비슷하게 표시하는 방법을 설명할 것이다.

파일 이름

1부에서 파일 목록을 표시할 때 PathBuf의 디버그 포맷을 사용해 파일 이름이 "./Cargo.toml"과 같은 식으로 표시되었다. 파일/디렉터리 이름이 제대로 표시되도록 수정해보자. 다음과 같이 파일/디렉터리 이름을 얻는 함수를 작성한다.

fn file_name(path: &Path) -> String {
    if path == PathBuf::from(".") {
        return ".".into();
    } else if path == PathBuf::from("..") {
        return "..".into();
    }
    let mut name = path
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_default();
    if path.is_dir() {
        name.push('/');
    }
    return name;
}

인자가 . 또는 ..인 경우 path.file_name()None을 리턴하므로 함수 첫 부분에서 if를 추가해 별도 처리했다. 또 인자가 디렉터리인 경우에는 이름 뒤에 /를 추가하도록 했다.

main 함수에서 파일/디렉터리 이름을 출력하는 부분을 모두 file_name()을 이용하도록 수정한 다음, 프로그램을 실행해보면 다음과 같은 결과를 확인할 수 있다.

 cargo run
   Compiling lsr v0.1.0 (/Users/ntalbs/ws/lsr)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.79s
     Running `target/debug/lsr`
./
    Cargo.lock
    Cargo.toml
    src/
    target/

테이블 형식 출력

ls 명령에 -l 옵션을 주면 다음 부가정보와 함께 파일/디렉터리 목록을 테이블 형식으로 표시한다.

  • 타입: 파일 타입에 따라 l(심볼릭 링크), d(디렉터리), -(파일)를 표시. 여기서는 이 셋 외에는 ?(unknown)로 표시할 것이다.
  • 모드: rwxr-xr-x와 같이 파일 접근권한을 표시
  • 링크 수: 하드링크 수
  • 오너: 파일/디렉터리 소유자 아이디
  • 그룹: 파일/디렉터리 소유 그룹 아이디
  • 크기: 파일 크기
  • 수정일시: 파일 최종 수정 일시
  • 이름: 파일 이름. 심볼릭 링크인 경우는 링크 타깃도 함께 표시

테이블 형식 출력에는 tabular를 사용할 것이라 1부 도입부에서 언급했다. 다음 format_output_long() 함수는 파일 목록을 인자로 받아 테이블 형식의 출력 문자열을 생성한다. -l 옵션이 주어진 경우 이 함수를 호출할 것이다.

Table 사용법은 직관적이다.

fn format_output_long(paths: &[PathBuf]) -> io::Result<String> {
    let fmt = "{:<}{:<}  {:>}  {:<}  {:<}  {:>}  {:<}  {:<}";
    let mut table = Table::new(fmt);

    for path in paths {
        let md = path.metadata()?;
        table.add_row(
            Row::new()
                .with_cell(file_type(path))
                .with_cell(format_mode(md.mode()))
                .with_cell(md.nlink())
                .with_cell(user_name(md.uid()))
                .with_cell(group_name(md.gid()))
                .with_cell(md.len())
                .with_cell(modified_date(&md))
                .with_cell(file_name(path)),
        );
    }
    Ok(format!("{table}"))
}

아직 각 셀에서 호출하는 함수를 정의하지 않았기 때문에 컴파일 에러가 발생할 것이다. 지금부터 함수를 하나씩 정의해 보자.

파일 타입

Pathis_symlink(), is_dir(), is_file() 메서드를 이용해 파일 타입을 구한다. 심볼릭 링크인 경우에는 "l"을, 디렉터리인 경우에는 "d"를, 파일인 경우에는 "-"를 리턴한다. 세 가지 모두 아닌 경우에는 "?"를 리턴하도록 했다.

fn file_type(path: &Path) -> String {
    if path.is_symlink() {
        "l".to_string()
    } else if path.is_dir() {
        "d".to_string()
    } else if path.is_file() {
        "-".to_string()
    } else {
        "?".to_string()
    }
}

순서가 중요함에 유의한다. 링크 타깃이 디렉터리인 경우에는 path.is_dir()true를 리턴한다. 디렉터리인지 먼저 확인한다면 링크 타깃이 디렉터리인 경우 링크가 아닌 디렉터리로 표시될 것이다. 여기서는 링크인지 먼저 확인하므로 링크는 링크로 표시될 것이다.

파일 모드

파일 모드는 파일 메타데이터의 mode() 메서드를 통해 알 수 있다. 그러나 mode()의 리턴값은 u32 타입의 정수이므로 우리가 원하는 모드 문자열을 만들기 위해서는 다음과 같이 작업을 해줘야 한다.

fn format_mode(mode: u32) -> String {
    let mut perms = String::new();

    perms.push_str(if mode & 0b100000000 != 0 { "r" } else { "-" });
    perms.push_str(if mode & 0b010000000 != 0 { "w" } else { "-" });
    perms.push_str(if mode & 0b001000000 != 0 { "x" } else { "-" });
    perms.push_str(if mode & 0b000100000 != 0 { "r" } else { "-" });
    perms.push_str(if mode & 0b000010000 != 0 { "w" } else { "-" });
    perms.push_str(if mode & 0b000001000 != 0 { "x" } else { "-" });
    perms.push_str(if mode & 0b000000100 != 0 { "r" } else { "-" });
    perms.push_str(if mode & 0b000000010 != 0 { "w" } else { "-" });
    perms.push_str(if mode & 0b000000001 != 0 { "x" } else { "-" });

    perms
}

링크 수

사실 이게 얼마나 유용한 정보인지는 잘 모르겠다. eza에서는 별도 옵션을 주지 않으면 보여주지 않는다. 여기서는 ls와 같게 표시한다. 링크 수는 메터데이터의 nlink() 메서드를 통해 알 수 있다. 따로 함수로 만들지 않았다.

오너:그룹

오너와 그룹은 usersget_user_by_uid()get_group_by_gid() 함수를 사용했다. 오너 이름이나 그룹 이름을 얻지 못한 경우에는 그냥 uid 또는 gid를 표시하도록 했다.

fn user_name(uid: u32) -> String {
    get_user_by_uid(uid)
        .map(|u| u.name().to_string_lossy().to_string())
        .unwrap_or_else(|| uid.to_string())
}

fn group_name(gid: u32) -> String {
    get_group_by_gid(gid)
        .map(|g| g.name().to_string_lossy().to_string())
        .unwrap_or_else(|| gid.to_string())
}

파일 크기

파일 크기는 메타데이터의 len() 메서드를 통해 알 수 있다. 여기서는 별도 함수를 만들지 않았지만, 120k, 2.5M와 같이 사람이 읽기 편한 형식으로 표시하는 것을 고려한다면 별도 함수로 만들어 처리할 수 있다.

최종 수정 일시

파일 수정 일시는 메타데이터의 modified() 메서드로 알 수 있다. modified()Result<SystemTime>을 리턴하는데, 다음과 같이 chrono를 이용해 날짜를 원하는 형식으로 포매팅 해준다. 여기서는 yyyy-MM-dd hh:mm 형식으로 포매팅한다.

fn modified_date(md: &Metadata) -> String {
    let modified: DateTime<Local> = DateTime::from(md.modified().unwrap());
    modified.format("%Y-%m-%d %H:%M").to_string()
}

파일 이름 (개선)

ls 명령의 출력을 살펴보면, -l 옵션을 준 경우 링크 이름과 링크 타깃이 함께 표시된다. 이건 위에서 구현한 file_name()을 다음과 같이 조금 수정해 구현할 수 있다.

fn file_name(path: &Path, long: bool) -> String {
    if path == PathBuf::from(".") {
        return "./".into();
    } else if path == PathBuf::from("..") {
        return "../".into();
    }

    let mut name = path
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_default();
    if long && path.is_symlink() {
        if let Ok(target) = fs::read_link(path) {
            name.push_str(" -> ");
            name.push_str(&target.to_string_lossy());
        }
    } else if path.is_dir() {
        name.push('/');
    }
    name
}

-l 옵션이 주어진 경우 (long==true) 표시할 파일이 링크면 링크 타깃도 함게 리턴하도록 했다.

파라미터를 추가했으므로 format_output_long()에서 다음과 같이 수정한다.

    ...
                .with_cell(file_name(path, true)),

그리드 형식 출력

그리드 형식으로 출력하려면 터미널 크기를 알아야 한다. 터미널 크기는 terminal_sizeterminal_size() 함수를 통해 알 수 있다. 그리드는 Grid::new()로 생성하는데, 파일이름 목록과 옵션(그리드 방향, 폭 등)을 넘기면 된다. 이렇게 생성한 그리드를 format!("{grid}")로 포매팅된 문자열로 바꾼다.

fn format_output_short(paths: &[PathBuf]) -> io::Result<String> {
    let term_size = terminal_size();
    if let Some((Width(w), _)) = term_size {
        let cells = paths.iter().map(|p| file_name(p, false)).collect();
        let grid = Grid::new(
            cells,
            GridOptions {
                filling: Filling::Spaces(2),
                direction: Direction::TopToBottom,
                width: w as usize,
            },
        );
        Ok(format!("{grid}"))
    } else {
        Err(Error::new(
            io::ErrorKind::Other,
            "Failed to get terminal width.",
        ))
    }
}

main 함수 수정

이제 main 함수를 수정할 차례다. 파일 목록을 출력하는 부분을 다음과 같이 수정한다.

fn main() -> io::Result<()> {
    ...

    if args.long {
        println!("{}", format_output_long(&files)?)
    } else {
        println!("{}", format_output_short(&files)?)
    }

    for path in &directories {
        let paths = files_in(path, args.all)?;
        if directories.len() > 1 {
            println!("\n{}:", file_name(path, false));
        }
        if args.long {
            print!("{}", format_output_long(&paths)?);
        } else {
            print!("{}", format_output_short(&paths)?);
        }
    }

    Ok(())
}

프로그램을 실행시키면 다음과 같은 결과를 볼 수 있다.

$ cargo run  -- -al
   Compiling lsr v0.1.0 (/Users/ntalbs/ws/lsr)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.72s
     Running `target/debug/lsr -al`

drwxr-xr-x  9  ntalbs  staff    288  2024-06-02 11:47  ./
drwxr-xr-x  3  ntalbs  staff     96  2024-05-05 20:56  ../
drwxr-xr-x  9  ntalbs  staff    288  2024-05-05 20:56  .git/
-rw-r--r--  1  ntalbs  staff      8  2024-05-05 20:56  .gitignore
drwxr-xr-x  3  ntalbs  staff     96  2024-06-02 11:47  .vscode/
-rw-r--r--  1  ntalbs  staff  15958  2024-06-02 19:38  Cargo.lock
-rw-r--r--  1  ntalbs  staff    215  2024-06-02 19:38  Cargo.toml
drwxr-xr-x  3  ntalbs  staff     96  2024-05-05 20:56  src/
drwxr-xr-x  5  ntalbs  staff    160  2024-05-11 14:18  target/

버그 수정

사실 이 코드에는 한 가지 버그가 숨어있다. 인자 중 존재하지 않는 파일이 있는 경우 ls는 해당 파일(또는 디렉터리)에 대해서 에러 메시지를 표시하고 다른 인자는 제대로 처리한다.

$ ls -al xxx src target
ls -al xxx src target
ls: xxx: No such file or directory
src:
total 16
drwxr-xr-x  3 ntalbs  staff    96  5 May 20:56 .
drwxr-xr-x  9 ntalbs  staff   288  2 Jun 11:47 ..
-rw-r--r--  1 ntalbs  staff  5986  2 Jun 19:54 main.rs

target:
total 16
drwxr-xr-x@  5 ntalbs  staff   160 11 May 14:18 .
drwxr-xr-x   9 ntalbs  staff   288  2 Jun 11:47 ..
-rw-r--r--   1 ntalbs  staff  1173  2 Jun 19:54 .rustc_info.json
-rw-r--r--   1 ntalbs  staff   177 11 May 14:18 CACHEDIR.TAG
drwxr-xr-x  10 ntalbs  staff   320  2 Jun 19:53 debug

그러나 우리가 작성한 코드는 그냥 에러 메시지만 표시할 뿐이다.

$ cargo run  -- -al src xxx src target
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/lsr -al src xxx src target`
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

다행히 이 문제는 간단히 해결할 수 있다. 다음과 같이 인자를 Vec<PathBuf>로 바꿀 때 filter를 적용해 파일이 존재하지 않는 경우 에러 메시지를 표시하고 순회할 목록에서 제외하면 된다.

fn main() -> io::Result<()> {
    let args = Args::parse();

    let mut paths = args.paths
        .iter()
        .map(PathBuf::from)
        .filter(|p| {
            if p.exists() {
                true
            } else {
                eprintln!("{}: No such file or directory.", file_name(p, false));
                false
            }
        })
        .collect::<Vec<PathBuf>>();

마무리

지금까지 간단히 ls 명령을 구현해 보았다. 사실 ls 명령의 기능에 비하면 보잘 것 없는 데다가, 어려운 부분은 외부 라이브러리로 해결했기 때문에 아쉬움이 있다. 그러나 ls를 어떤 식으로 구현할 수 있는지 확인하는 데는 충분했다고 믿는다.

참고

  • lsr lsr의 전체 소스 코드를 확인할 수 있다. -a, -l 외에도 몇 가지 옵션을 더 구현했다.