Claude Code 프로젝트 폴더명 디코딩: `~/.claude/projects/`의 손실 인코딩 복원기
`~/.claude/projects/`의 이상한 폴더 이름을 사람이 읽을 수 있는 프로젝트명으로 되돌리는 알고리즘. 손실 인코딩 복원, greedy 파일시스템 매칭, 중복 제거까지 duru의 scan.rs가 세 번의 리팩토링을 거치는 과정을 정리합니다.
1. 문제 상황
Claude Code를 한참 쓰고 있던 어느 날, 심심해서 ~/.claude/projects/ 안을 열어봤습니다. 그리고 이런 광경과 마주쳤습니다.
$ ls ~/.claude/projects/ | head
-Users-kim-Desktop-Project--active-clavis
-Users-kim-Desktop-Project--active-duru
-Users-kim-Desktop-Project--active-chrome-secret
-Users-kim-Desktop-Project--paused-n8n-nodes-fixed-sender-email
-Users-kim-Desktop-Project--reference-n8n-mcp
-Users-kim-Desktop-Project--reference-crypto-trading-bot
...
앞에 -가 하나 붙어 있고, 경로 구분자들이 모두 -로 바뀌어 있습니다. 슬래시만 바꾼 거라면 이해되는데, 자세히 보면 이상한 -- 연속이 섞여 있습니다.
-Users-kim-Desktop-Project까지는/Users/kim/Desktop/Project가 분명합니다--active-clavis는...-active-clavis? 아니면_active/clavis? 뭘까요?
TUI 프로젝트인 duru를 만들면서, 이 폴더 이름들을 사람이 읽을 수 있는 프로젝트 이름으로 되돌리는 함수가 필요했습니다. "Claude Code 프로젝트 목록"을 TUI 패널에 보여줘야 하는데, -Users-kim-Desktop-Project--active-duru를 그대로 출력하면 아무도 못 알아볼 테니까요.
처음엔 10분이면 끝날 줄 알았습니다. split('-')해서 마지막 세그먼트만 취하면 될 거라 생각했거든요. 실제로는 이틀에 걸쳐 알고리즘을 세 번 갈아엎는 여정이 기다리고 있었습니다.
2. 원인 분석
2.1 첫 가설 — 슬래시만 -로 바뀐 것
가장 순진한 가설은 이랬습니다.
Claude Code는 경로의
/를-로 바꿔서 디렉토리 이름으로 쓴다. 앞에 붙은-는 맨 앞의/다.
이 가설이 맞다면, -를 /로 다시 바꾸면 원래 경로가 복원됩니다.
// 첫 번째 시도 — 단순한 역치환
fn decode_project_name(encoded: &str) -> String {
// 맨 앞 `-` → `/` 로 복원 시도
let decoded_path = encoded.replacen('-', "/", 1);
let path = Path::new(&decoded_path);
if path.exists()
&& let Some(name) = path.file_name()
{
return name.to_string_lossy().to_string();
}
// Fallback: 비어 있지 않은 마지막 세그먼트
encoded
.rsplit('-')
.find(|s| !s.is_empty())
.unwrap_or(encoded)
.to_string()
}
문제는 crypto-trading-bot처럼 이름에 하이픈이 들어간 디렉토리입니다. 위 코드는 경로를 crypto/trading/bot으로 잘못 해석하거나, 아니면 fallback에 떨어져서 bot만 반환합니다. 둘 다 우리가 원하는 결과가 아닙니다.
그래도 일단은 넘어갔습니다. "내 프로젝트에는 하이픈 디렉토리가 없을 거야"라는 근거 없는 낙관을 품은 채로요. 기쁜 마음으로 duru를 띄웠더니, 실제로 몇몇 프로젝트들이 목록에 뜨지 않았습니다. 그것도 가장 자주 쓰는 프로젝트들이요.
2.2 첫 번째 폭탄 — _active가 -active가 아니다
화면에 뜨지 않는 프로젝트 하나는 /Users/kim/Desktop/Project/_active/duru 자체였습니다. 문제의 디렉토리 이름은 이랬습니다.
-Users-kim-Desktop-Project--active-duru
--가 눈에 띕니다. 처음엔 "-가 두 번 연속된 건가?" 싶어서 무시했는데, /Users/kim/Desktop/Project/-active/duru를 find로 찾아봐도 아무것도 안 나왔습니다. 디스크에는 _active만 있거든요.
여기서 두 번째 진실과 마주합니다.
Claude Code는
/뿐만 아니라_도-로 인코딩합니다.
다시 말해:
| 원본 문자 | 인코딩 후 |
|---|---|
/ |
- |
_ |
- |
그러니 /Users/kim/Desktop/Project/_active/duru는 이렇게 변환됩니다.
/ → -
Users
/ → -
kim
/ → -
Desktop
/ → -
Project
/ → - ← 여기서 `/`가 `-`로
_ → - ← 그리고 `_`도 `-`로 → 연속된 `--` 발생
active
/ → -
duru
결과: -Users-kim-Desktop-Project--active-duru
이 인코딩은 문자열만으로는 되돌릴 수 없습니다. -가 원래 /였는지, _였는지, 아니면 디렉토리 이름 안에 있던 -였는지 구별할 방법이 없거든요. 정보가 이미 손실된 상태입니다.
2.3 두 번째 폭탄 — 하이픈 디렉토리의 모호함
crypto-trading-bot 같은 디렉토리는 또 다른 종류의 모호함을 일으킵니다. -Users-kim-Desktop-crypto-trading-bot를 봤을 때, 이게 무엇을 의미할까요?
/Users/kim/Desktop/crypto/trading/bot? (3단 디렉토리)/Users/kim/Desktop/crypto-trading-bot? (1단 디렉토리)/Users/kim/Desktop/crypto-trading/bot? (2단 + 이름에 하이픈)
문자열만 보면 어느 것도 배제할 수 없습니다. 이 시점에서 깨달았습니다.
문자열 수준에서 이 문제를 풀 수 없습니다. 정답은 오직 한 장소에만 있습니다.
어디에도 정답이 명시되어 있지 않거든요. 정답은 실제 파일시스템뿐입니다. /Users/kim/Desktop/에 실제로 뭐가 있는지 한 칸씩 물어보는 것만이 이 퀴즈를 풀 수 있는 방법입니다.
3. 해결 방법
3.1 전체 전략
해결책은 세 단계로 구성됩니다.
입력: "-Users-kim-Desktop-Project--active-duru"
│
▼
┌─ 1단계: `--` → `/_` 치환 ────────────────────────────┐
│ 언더스코어 복원: `_X`는 `-X`로 인코딩되지만 │
│ 앞에 있는 `/`도 `-`로 인코딩되므로 │
│ `_X` 앞에 있는 `/`와 합쳐 `--X`가 됨 │
│ 따라서 `--`가 보이면 `/_` 로 되돌릴 수 있음 │
│ │
│ "-Users-kim-Desktop-Project/_active-duru" │
└────────────────────────────────────────────────────────┘
│
▼
┌─ 2단계: `-` 단위로 split ────────────────────────────┐
│ `/`가 복원된 세그먼트는 그대로 유지 │
│ 빈 세그먼트 제거 (맨 앞 `-` 때문에 생김) │
│ │
│ ["Users", "kim", "Desktop", "Project/_active", "duru"]│
└────────────────────────────────────────────────────────┘
│
▼
┌─ 3단계: Greedy 파일시스템 매칭 ──────────────────────┐
│ 현재 경로 = "/" │
│ 각 위치에서 가능한 최대 너비부터 줄여가며 시도 │
│ is_dir()이 true면 다음 위치로 전진 │
│ 매칭 실패 시 전체 경로가 유효하지 않음 → None │
└────────────────────────────────────────────────────────┘
│
▼
결과: "duru" (path.file_name())
3.2 1단계: -- 패턴으로 언더스코어 복원
-- 시퀀스가 보이면, 두 가지 해석이 가능합니다.
- 경로 구분자 + 언더스코어 시작:
/_active처럼_로 시작하는 디렉토리 - 연속된 하이픈 두 개:
--foo같은 디렉토리 (드물지만 존재 가능)
duru는 첫 번째 해석을 선택합니다. 현실에서 --로 시작하는 디렉토리는 극도로 드물고, _active, _reference, _paused 같은 폴더 네이밍은 개인 개발자들이 흔히 쓰기 때문입니다.
fn decode_project_name(encoded: &str) -> Option<String> {
// Step 1: `--` → `/_` (restores underscore-prefixed dirs like `_active`)
let normalized = encoded.replace("--", "/_");
// ^^ ^^^
// 연속 하이픈 경로 구분자 + 언더스코어 접두사
이 한 줄로 -Users-kim-Desktop-Project--active-duru가 -Users-kim-Desktop-Project/_active-duru로 바뀝니다. 맨 앞의 -는 여전히 남아 있지만, _active라는 정보는 복원되었습니다.
3.3 2단계: - split과 빈 세그먼트 제거
normalize된 문자열을 -로 자릅니다. 맨 앞 - 때문에 첫 세그먼트가 빈 문자열이 되므로 필터링합니다.
// Step 2: Split by `-`, keeping segments that may contain `/` from step 1
let segments: Vec<&str> = normalized
.split('-')
.filter(|s| !s.is_empty())
.collect();
if segments.is_empty() {
return None;
}
주목할 점: 1단계에서 /_로 바뀐 위치는 -가 없으므로 split에 영향받지 않습니다. "Project/_active"는 하나의 세그먼트로 남습니다.
결과적으로 segments는 이렇게 됩니다.
["Users", "kim", "Desktop", "Project/_active", "duru"]
아직 crypto-trading-bot처럼 원래 하이픈이 있는 디렉토리는 여전히 분리된 상태입니다. 이걸 복원하는 게 3단계의 역할입니다.
3.4 3단계: Greedy 파일시스템 매칭
이 단계가 디코더의 핵심입니다. 순서대로 살펴보겠습니다.
기본 아이디어:
/부터 시작- 현재 위치에서 세그먼트를 가장 넓게 합쳐서 디렉토리가 있는지 확인
- 있으면 그 너비만큼 전진, 없으면 한 칸 좁혀서 재시도
- 너비 1까지 떨어져도 없으면 경로가 유효하지 않음 →
None
코드 전체:
// Step 3: Greedy filesystem matching — every segment must resolve
let mut path = PathBuf::from("/");
let mut i = 0;
while i < segments.len() {
let remaining = segments.len() - i;
let max_width = remaining.min(8); // ← 최대 8개 세그먼트까지 합치기
let mut matched = false;
for width in (1..=max_width).rev() {
// ^^^^^^^^^^^^^^^^^^^^
// 넓은 것부터 좁은 것 순서 (greedy)
let candidate_name = segments[i..i + width].join("-");
// ^^^^^^^
// 원래 하이픈 복원
let candidate_path = path.join(&candidate_name);
if candidate_path.is_dir() {
path = candidate_path;
i += width;
matched = true;
break;
}
}
if !matched {
// Path doesn't fully resolve — project directory likely deleted/moved
return None;
}
}
path.file_name().map(|n| n.to_string_lossy().to_string())
}
핵심 포인트 세 가지:
(1..=max_width).rev()가 "greedy"의 근거입니다. 가장 넓은 매칭부터 시도하므로,crypto-trading-bot이 실제 디렉토리라면 3개 세그먼트를 한 번에 소비합니다. 만약crypto부터 시도했다면/Users/kim/Desktop/crypto같은 틀린 경로로 잘못 진입할 위험이 있습니다.max_width = remaining.min(8)는 안전 상한입니다. 현실적으로 단일 디렉토리 이름에 하이픈이 7-8개 이상 들어가는 경우는 거의 없습니다. 이 상한이 없으면 수백 개 세그먼트에 대해O(n²)탐색이 발생할 수 있습니다.is_dir()은 시스템 콜입니다. 각 후보마다 파일시스템을 한 번씩stat(2)합니다. 경로가 깊을수록 비용이 커지는데, Claude Code 프로젝트는 보통 50개 이하여서 실측하면 무시할 수준입니다.
실행 예: -Users-kim-Desktop-Project--active-duru
단계별로 추적해봅니다.
초기 path = /
segments = ["Users", "kim", "Desktop", "Project/_active", "duru"]
i = 0
┌─ i=0 (remaining=5, max_width=5) ───────────────
│ width=5: /Users-kim-Desktop-Project/_active-duru? ❌
│ width=4: /Users-kim-Desktop-Project/_active? ❌
│ width=3: /Users-kim-Desktop? ❌
│ width=2: /Users-kim? ❌
│ width=1: /Users? ✅
│ → path = /Users, i = 1
└────────────────────────────────────────────────
┌─ i=1 (remaining=4, max_width=4) ───────────────
│ width=4: /Users/kim-Desktop-Project/_active-duru? ❌
│ width=3: /Users/kim-Desktop-Project/_active? ❌
│ width=2: /Users/kim-Desktop? ❌
│ width=1: /Users/kim? ✅
│ → path = /Users/kim, i = 2
└────────────────────────────────────────────────
┌─ i=2 (remaining=3, max_width=3) ───────────────
│ width=3: /Users/kim/Desktop-Project/_active-duru? ❌
│ width=2: /Users/kim/Desktop-Project/_active? ❌
│ width=1: /Users/kim/Desktop? ✅
│ → path = /Users/kim/Desktop, i = 3
└────────────────────────────────────────────────
┌─ i=3 (remaining=2, max_width=2) ───────────────
│ width=2: /Users/kim/Desktop/Project/_active-duru? ❌
│ width=1: /Users/kim/Desktop/Project/_active? ✅
│ ^^^^^^^^^^^^^^^^^
│ 1단계에서 복원된 세그먼트가 통째로 매칭
│ → path = /Users/kim/Desktop/Project/_active, i = 4
└────────────────────────────────────────────────
┌─ i=4 (remaining=1, max_width=1) ───────────────
│ width=1: /Users/kim/Desktop/Project/_active/duru? ✅
│ → path = /Users/kim/Desktop/Project/_active/duru, i = 5
└────────────────────────────────────────────────
루프 종료: i = 5 == segments.len()
path.file_name() = "duru" ✅
한 가지 주목할 점: 세그먼트 "Project/_active"는 문자열 안에 /가 포함되어 있습니다. PathBuf::join("Project/_active")는 이 /를 경로 구분자로 해석하므로, 결과적으로 path에 두 레벨이 한 번에 추가되는 효과가 있습니다. 이게 1단계 정규화가 정확히 의도한 동작입니다.
하이픈 디렉토리 예: -Users-kim-Desktop-crypto-trading-bot
segments = ["Users", "kim", "Desktop", "crypto", "trading", "bot"]
... (i=0,1,2는 위와 동일)
┌─ i=3 (remaining=3, max_width=3) ───────────────
│ width=3: /Users/kim/Desktop/crypto-trading-bot? ✅
│ ^^^^^^^^^^^^^^^^^^^^^^^
│ 한 번에 3개 세그먼트 소비
│ → path = /Users/kim/Desktop/crypto-trading-bot, i = 6
└────────────────────────────────────────────────
루프 종료: file_name = "crypto-trading-bot" ✅
width=3이 먼저 시도되기 때문에, 실제 디렉토리 crypto-trading-bot이 있다면 한 번에 매칭됩니다. 만약 width=1부터 시도했다면 /Users/kim/Desktop/crypto 같은 가짜 경로로 잘못 진입할 수 있었습니다. 바로 이것이 greedy (widest-first) 전략이 필요한 이유입니다.
3.5 후속 문제 1 — 사라진 프로젝트
알고리즘이 돌아가기 시작하자 다른 문제가 드러났습니다. 이미 삭제된 프로젝트의 엔트리가 ~/.claude/projects/에 남아 있는 경우입니다.
Claude Code는 프로젝트를 처음 열면 ~/.claude/projects/<인코딩>/ 아래에 세션 로그와 관련 파일들을 만드는데, 원본 프로젝트 디렉토리가 삭제되어도 이 항목은 자동으로 정리되지 않습니다. duru의 목록에 "이미 없는 프로젝트"가 -Users-kim-Desktop-old-archived-stuff 같은 이름으로 섞여 있는 건 분명 나쁜 UX입니다.
다행히 해결책은 이미 알고리즘 안에 내장되어 있었습니다. greedy 매칭이 실패하면 None을 반환하게 만들면 됩니다. 기존 코드는 "매칭 실패 시 남은 세그먼트를 join해서 반환"하는 관대한 동작이었는데, 이걸 엄격 모드로 바꿉니다.
if !matched {
// Path doesn't fully resolve — project directory likely deleted/moved
return None;
}
그리고 호출하는 쪽에서 ?로 전파해서 해당 프로젝트를 스킵합니다.
// scan_claude_dir 내부
let name = decode_project_name(&dir_name)?; // ← None이면 이 프로젝트 스킵
Some(Project {
name,
path: project_path,
files,
})
이 변경 하나로 "실제로 존재하는 프로젝트만" 목록에 나타나게 됩니다. 타입 시스템(Option<String>)이 "실패할 수 있음"을 기록하고, ? 연산자가 자동 필터링을 해주는 것이 Rust스러운 방식이라 마음에 듭니다.
3.6 후속 문제 2 — 같은 프로젝트가 두 번 보인다
여기서 끝나면 좋았을 텐데, 또 다른 이슈가 떠올랐습니다. 똑같은 duru 프로젝트가 목록에 두 번 표시되는 경우가 있었습니다. 원인을 파악해보니 Claude Code가 과거에는 언더스코어를 다른 방식으로 인코딩했고, 어느 시점에 현재의 -- 스킴으로 바꾼 흔적이었습니다. 사용자 입장에서는 같은 프로젝트지만, ~/.claude/projects/에는 서로 다른 두 개의 디렉토리가 남아 있게 됩니다.
-Users-kim-Desktop-Project-_active-duru(old scheme,_가 그대로 남아 있음)-Users-kim-Desktop-Project--active-duru(new scheme,_가-로 인코딩됨)
두 개 모두 decode 결과는 duru가 나옵니다. 이름이 같으니 dedup하면 되는데, 어느 쪽을 남길지가 새로운 문제입니다.
첫 번째 시도: 파일 개수가 많은 쪽 남기기
처음엔 이렇게 접근했습니다.
project_entries.dedup_by(|b, a| {
if a.name == b.name {
if b.files.len() > a.files.len() {
*a = std::mem::take(b); // b를 a 자리에 대체
}
true // ← 같은 이름 → 뒤쪽 원소 제거
} else {
false
}
});
로직은 단순합니다. "한쪽에 메모리 파일이 더 많다면 그게 활성 상태에 가까울 것"이라는 heuristic입니다. 하지만 실전에서 문제가 생겼습니다. 두 엔트리 모두 CLAUDE.md 하나씩만 있는 경우, 어느 걸 남길지 일관된 기준이 없어서 호출마다 결과가 달라질 수 있었습니다.
두 번째 시도: 최근 수정 시간 기준
진짜 답은 **"최근에 사용된 쪽"**입니다. 사용자가 실제로 건드린 쪽이 현재 유효한 스킴이니까요.
// Deduplicate: Claude Code has two encoding schemes (old: `_active`, new: `--active`)
// Keep the more recently modified entry
project_entries.dedup_by(|b, a| {
if a.name == b.name {
let a_mod = fs::metadata(&a.path).and_then(|m| m.modified()).ok();
let b_mod = fs::metadata(&b.path).and_then(|m| m.modified()).ok();
if b_mod > a_mod {
*a = std::mem::take(b);
}
true
} else {
false
}
});
Option<SystemTime>끼리 비교할 때 None은 항상 Some보다 작다는 Ord 구현을 그대로 활용했습니다. metadata 조회가 실패한 쪽이 자동으로 떨어지는 셈입니다.
dedup_by의 인자 순서 함정
Rust 표준 라이브러리의 slice::dedup_by 시그니처는 FnMut(&mut T, &mut T) -> bool인데, 첫 번째 인자가 뒤쪽, 두 번째가 앞쪽 원소입니다. 문서에는 "same_bucket(a, b)가 true면 a가 제거된다"라고 적혀 있어서 더 헷갈립니다.
그래서 관행상 클로저 파라미터를 (b, a)라고 이름 지어서 "b는 검사 대상(뒤), a는 유지되는 쪽(앞)"임을 시각적으로 드러냅니다. *a = std::mem::take(b)는 "뒤쪽 b의 소유권을 앞쪽 a 자리로 이동"시키는 동작이고, 그 후 true를 반환하면 기존 b(이제 Default 값) 위치가 제거됩니다. 결과적으로 "b의 내용이 담긴 a"가 남습니다.
이 패턴을 쓰려면 Project 구조체에 #[derive(Default)]를 추가해야 합니다. std::mem::take가 Default::default()로 치환하면서 소유권을 가져가기 때문입니다.
#[derive(Debug, Clone, Default)] // ← Default 추가
pub struct Project {
pub name: String,
pub path: PathBuf,
pub files: Vec<MemoryFile>,
}
3.7 테스트로 불변식 고정하기
이 알고리즘의 불변식을 테스트 세 개로 못박습니다.
#[test]
fn decode_nonexistent_path_returns_none() {
// 디스크에 없는 경로는 None
let result = decode_project_name("-Users-test-myproject");
assert_eq!(result, None);
}
#[test]
fn decode_real_path_returns_some_basename() {
// 디스크에 있으면 basename 반환
if let Some(home) = dirs::home_dir() {
let home_str = home.to_string_lossy().replace('/', "-");
let result = decode_project_name(&home_str);
let expected = home.file_name().unwrap().to_string_lossy().to_string();
assert_eq!(result, Some(expected));
}
}
#[test]
fn scan_excludes_deleted_projects() {
// 삭제된 프로젝트는 목록에서 제외
let (_tmp, claude_dir) = create_test_dir();
let project_dir = claude_dir.join("projects").join("-Users-fake-deleted");
fs::create_dir_all(&project_dir).unwrap();
fs::write(project_dir.join("CLAUDE.md"), "# Stale").unwrap();
let projects = scan_claude_dir(&claude_dir);
assert!(projects.is_empty()); // excluded because /Users/fake/deleted doesn't exist
}
특히 까다로운 건 "정상 프로젝트를 찾는" 테스트입니다. greedy 매칭이 성공하려면 실제로 디스크에 해당 디렉토리가 있어야 하기 때문에, 테스트 안에서 먼저 가짜 프로젝트 디렉토리를 tempdir 안에 만들고, 그것의 인코딩을 동적으로 계산해서 projects/ 아래에 넣어줘야 합니다.
#[test]
fn scan_finds_project_files() {
let (_tmp, claude_dir) = create_test_dir();
// Create a "project" dir inside the temp dir so greedy decode resolves it
let real_project = claude_dir.join("testproject");
fs::create_dir_all(&real_project).unwrap();
// Encode the project path as Claude Code would
let encoded = claude_dir
.join("testproject")
.to_string_lossy()
.replace('/', "-");
let project_dir = claude_dir.join("projects").join(&encoded);
let memory_dir = project_dir.join("memory");
fs::create_dir_all(&memory_dir).unwrap();
fs::write(project_dir.join("CLAUDE.md"), "# Project").unwrap();
fs::write(memory_dir.join("MEMORY.md"), "# Index").unwrap();
fs::write(memory_dir.join("notes.md"), "# Notes").unwrap();
let projects = scan_claude_dir(&claude_dir);
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "testproject");
}
테스트가 파일시스템 상태에 의존하는 건 일반적으로 피하는 패턴입니다. 하지만 알고리즘 자체가 "현실 세계에 물어보기"이기 때문에, 테스트도 현실 세계의 한 조각(tempfile::tempdir())을 빌려와야 합니다. 이건 타협이 아니라 알고리즘의 본질에 맞는 검증 방법이라고 봅니다. 순수 함수 테스트에 집착하다가 핵심 로직을 덮지 못하는 것보다 훨씬 낫습니다.
4. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
| Claude Code 프로젝트 인코딩 | ~/.claude/projects/에 저장되는 디렉토리명. / → -, _ → - 양방향 손실 치환 |
-- 패턴 |
경로 구분자(-) 뒤에 언더스코어 유래 -가 오는 경우. /_ 시퀀스의 흔적 |
| Greedy (widest-first) 매칭 | 가능한 최대 너비부터 시도해서 가장 긴 실제 디렉토리와 매칭. 하이픈 포함 이름을 복원하는 유일한 방법 |
| 파일시스템을 정답지로 쓰기 | 인코딩이 손실적일 때, 실제 디스크 상태가 유일하게 신뢰 가능한 참조원 |
Option<String> 반환 |
삭제된 프로젝트 필터링을 타입 레벨로 강제 |
slice::dedup_by |
정렬된 slice의 인접 중복 제거. true 반환 시 뒤쪽 원소 제거 |
std::mem::take |
소유권 이동 후 원래 자리에 Default 삽입. dedup에서 "이긴 쪽" 보존 패턴 |
| mtime 기반 tiebreak | 같은 이름의 두 엔트리 중 최근 수정본 선택. 활성 상태의 신호 |
5. 베스트 프랙티스
손실 인코딩을 복원할 때의 체크리스트
- [ ] 인코딩이 정말 손실적인지 확인하세요. 단방향 치환(
/→-)은 복원 가능해도, 다대일 치환(/,_모두 →-)은 문자열만으로 안 됩니다. - [ ] "정답지"가 어디에 있는지 찾으세요. 파일시스템, DB, API 등 원본 상태를 보관하는 곳이 있다면 거기에 되물어보는 게 가장 안전합니다.
- [ ] Greedy 매칭은 "가장 넓은 매칭부터" 시도하세요. 좁은 매칭이 선행되면 잘못된 분기로 들어갑니다.
- [ ] 탐색 너비에 상한을 두세요. 최악의 경우 입력 크기만큼 시도하게 되면
O(n²)또는 그 이상의 비용이 발생합니다. - [ ] 복원 불가능한 경우(삭제됨, 이동됨)를
Option/Result로 명시적으로 표현하세요. 타입이 실패 가능성을 기록하면 호출자가 누락할 수 없습니다.
Rust에서 파일시스템 의존 코드 쓰기
- [ ] 디스크 상태에 의존하는 함수는 순수 함수가 아닙니다. 테스트는
tempfile::tempdir()로 격리 환경을 만들어 실제 디렉토리를 준비하세요. - [ ]
Path::is_dir()은 실제stat(2)호출입니다. 네트워크 마운트 경로에서는 비용이 큽니다. 반복 호출이 많다면 캐시를 고려하세요. - [ ]
std::mem::take는Default구현이 필요합니다. dedup에서 "이긴 쪽 보존" 패턴을 쓰려면 대상 타입에#[derive(Default)]를 추가하세요. - [ ]
dedup_by는 slice가 정렬되어 있어야 의미가 있습니다. 정렬 없이 호출하면 인접하지 않은 중복은 제거되지 않습니다. - [ ]
Option<SystemTime>비교는 mtime 조회 실패에 안전합니다.None < Some(_)이므로 metadata가 없는 쪽이 자동으로 떨어집니다.
6. FAQ
Q: 왜 _도 인코딩하는 거죠? Claude Code의 설계 이유가 있나요?
A: 공식 문서에는 명시되어 있지 않지만, 한 가지 추정은 파일시스템 안전성입니다. 일부 레거시 파일시스템이나 쉘 스크립트에서 언더스코어가 특수하게 취급되는 경우를 피하기 위한 보수적인 선택일 수 있습니다. 다만 _는 현대 OS에서 완전히 안전한 문자이므로, 이 인코딩이 손실이라는 대가는 불필요해 보입니다. 이후 버전에서 되돌려지지 않을까 개인적으로 기대하고 있습니다.
Q: -- → /_ 치환이 항상 옳나요? 실제로 --로 시작하는 디렉토리가 있으면 어쩌죠?
A: 이 경우에도 greedy 매칭이 막아줍니다. 예를 들어 --weird-dir가 실제 디렉토리라면, 1단계에서 /_weird/dir로 바뀌는데 3단계에서 이 경로가 디스크에 없으므로 매칭 실패 → None을 반환합니다. 결과적으로 "이상한 디렉토리"는 목록에서 빠지지만 크래시는 나지 않습니다. 완벽하진 않아도 fail-safe는 보장됩니다.
Q: 너비 상한 max_width = 8의 근거는 무엇인가요?
A: 경험적 선택입니다. 실제 프로젝트 디렉토리 이름에 하이픈이 8개 이상 들어가는 경우는 거의 없습니다 (my-super-long-project-with-many-hyphens가 이미 7개). 상한을 정하지 않으면 세그먼트 수에 따라 O(n²)이 되고, 인코딩이 이상하게 망가진 경우 탐색 비용이 폭주할 수 있습니다. 8은 "실용적 커버리지 + 성능 안전망"의 균형점입니다. 필요하면 상수로 빼서 조정 가능하게 만들 수도 있습니다.
Q: dedup_by가 왜 b, a 순서로 받나요?
A: Rust 표준 라이브러리의 slice::dedup_by 시그니처가 FnMut(&mut T, &mut T) -> bool인데, 첫 번째 인자가 뒤쪽, 두 번째가 앞쪽 원소입니다. 관행상 클로저 파라미터를 (b, a)라고 네이밍해서 "b는 뒤쪽(검사 대상), a는 앞쪽(유지되는 쪽)"임을 시각적으로 드러냅니다. 이 네이밍을 따르지 않으면 나중에 코드를 읽을 때 자신도 헷갈립니다.
Q: fs::metadata(..).modified()가 신뢰할 수 있나요?
A: 대부분의 경우 안정적이지만 두 가지 함정이 있습니다. 첫째, 일부 파일시스템(예: FAT32)은 mtime 해상도가 2초입니다. 둘째, rsync나 cp --preserve로 복사된 파일은 원본의 mtime을 유지합니다. 하지만 Claude Code 프로젝트 디렉토리는 실시간 세션 로그가 쌓이는 구조라 이 두 함정이 실질적인 문제가 되지 않습니다. 완벽한 순위가 필요하다면 ctime까지 고려할 수 있지만, 대부분의 경우 오버엔지니어링입니다.
Q: 같은 접근을 다른 언어로도 할 수 있나요?
A: 네, 어떤 언어에서도 가능합니다. 필요한 건 (1) 문자열 치환 (2) 파일시스템 상태 확인 (3) 반복 루프뿐입니다. Python이라면 os.path.isdir, Node.js라면 fs.existsSync, Go라면 os.Stat을 써서 동일한 greedy 매칭을 구현할 수 있습니다. Rust를 고른 건 단순히 duru가 Rust 프로젝트이기 때문이고, 알고리즘 자체는 언어 중립적입니다.
7. 참고 자료
- duru — 이 글의 코드가 있는 오픈소스 프로젝트
- Rust
std::path::PathBuf공식 문서 - Rust
slice::dedup_by공식 문서 tempfilecrate — 파일시스템 의존 테스트용- Rust CLI 배포 자동화: Homebrew, Scoop, Shell Installer를 GitHub Actions로 한 번에 구축하기 — 이 글의 duru가 배포되는 파이프라인 이야기
- Rust TUI 스크린샷과 데모 GIF 만들기: ratatui와 Alternate Screen의 함정 — 같은 duru 프로젝트의 다른 디깅 여정