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}"))
}
아직 각 셀에서 호출하는 함수를 정의하지 않았기 때문에 컴파일 에러가 발생할 것이다. 지금부터 함수를 하나씩 정의해 보자.
파일 타입
Path
의 is_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()
메서드를 통해 알 수 있다. 따로 함수로 만들지 않았다.
오너:그룹
오너와 그룹은 users의 get_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_size의 terminal_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
외에도 몇 가지 옵션을 더 구현했다.