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
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
//! Project discovery from the Claude Code `~/.claude/projects/` directory.
//!
//! Why: Claude Code records one subdirectory per project it has been launched
//! in, named with the project's absolute path encoded (each `/` becomes `-`).
//! trusty-mpm can mine that directory to offer the operator a ready-made list
//! of projects to register — no manual `project init` per repo.
//! What: [`ProjectDiscovery::discover`] enumerates `~/.claude/projects/`,
//! decodes each directory name back into a filesystem path, verifies that path
//! exists, counts the session transcripts (`.jsonl` files) inside, and reports
//! the most recent session time. Results sort newest-session-first.
//! Test: `cargo test -p trusty-mpm-core project_discovery` covers path decoding
//! (including the ambiguous-hyphen case) against a temp directory tree.
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// One project discovered under `~/.claude/projects/`.
///
/// Why: the discovery endpoint and the Telegram `/projects` command need a
/// structured row per project — its path, how recently it was used, and how
/// many recorded sessions it has.
/// What: the decoded absolute project path, the mtime of the most recent
/// `.jsonl` transcript (or `None` when the directory holds none), and the
/// transcript count.
/// Test: `discovers_and_counts_sessions`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredProject {
/// Absolute path to the project's working directory.
pub path: PathBuf,
/// Modification time of the most recent `.jsonl` transcript, if any.
pub last_session: Option<SystemTime>,
/// Number of `.jsonl` session transcripts recorded for the project.
pub session_count: usize,
}
/// Pure-ish discoverer of Claude Code projects.
///
/// Why: isolating the enumeration logic as a unit type keeps it testable —
/// [`ProjectDiscovery::discover_in`] takes an explicit base directory so tests
/// never touch the real `~/.claude/projects/`.
/// What: [`discover`](ProjectDiscovery::discover) scans the real directory;
/// [`discover_in`](ProjectDiscovery::discover_in) scans an arbitrary one.
/// Test: `discovers_and_counts_sessions`, `decodes_path_with_hyphens`.
pub struct ProjectDiscovery;
impl ProjectDiscovery {
/// Discover projects under `~/.claude/projects/`.
///
/// Why: production callers want the real Claude Code projects directory
/// without resolving the home directory themselves.
/// What: resolves `~/.claude/projects` and delegates to
/// [`discover_in`](Self::discover_in); an unresolvable home yields an empty
/// list rather than an error.
/// Test: `discover_on_missing_dir_is_empty` covers the absent-directory path.
pub fn discover() -> Vec<DiscoveredProject> {
let Some(home) = dirs::home_dir() else {
return Vec::new();
};
Self::discover_in(&home.join(".claude").join("projects"))
}
/// Discover projects under an arbitrary Claude Code projects directory.
///
/// Why: tests must exercise decoding and counting against a temp tree; a
/// missing or unreadable directory must degrade to an empty list.
/// What: reads each subdirectory entry, decodes its name into a path via
/// [`decode_project_path`], keeps only paths that exist on disk, gathers the
/// `.jsonl` transcript count and newest mtime, and returns the rows sorted
/// newest-session-first.
/// Test: `discovers_and_counts_sessions`, `discover_on_missing_dir_is_empty`.
pub fn discover_in(projects_dir: &Path) -> Vec<DiscoveredProject> {
let Ok(entries) = fs::read_dir(projects_dir) else {
return Vec::new();
};
let mut projects: Vec<DiscoveredProject> = entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|entry| {
let dir_name = entry.file_name();
let dir_name = dir_name.to_str()?;
let path = decode_project_path(dir_name)?;
let (session_count, last_session) = scan_sessions(&entry.path());
Some(DiscoveredProject {
path,
last_session,
session_count,
})
})
.collect();
// Newest session first; projects with no session sort last (None < Some
// under Option's ordering, so reverse-compare on the option).
projects.sort_by_key(|p| std::cmp::Reverse(p.last_session));
projects
}
}
/// Count `.jsonl` transcripts in a project directory and find the newest mtime.
///
/// Why: discovery reports both how many sessions a project has and how recently
/// it was used; computing them in one directory pass keeps it cheap.
/// What: walks the directory's immediate entries, counting files ending in
/// `.jsonl` and tracking the maximum modification time.
/// Test: covered by `discovers_and_counts_sessions`.
fn scan_sessions(dir: &Path) -> (usize, Option<SystemTime>) {
let Ok(entries) = fs::read_dir(dir) else {
return (0, None);
};
let mut count = 0;
let mut newest: Option<SystemTime> = None;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
count += 1;
if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
newest = Some(match newest {
Some(current) if current >= mtime => current,
_ => mtime,
});
}
}
(count, newest)
}
/// Decode a Claude Code projects directory name back into a filesystem path.
///
/// Why: Claude Code encodes a project's absolute path by replacing every
/// character outside `[A-Za-z0-9]` with `-` (#6777 — not `/` alone, as this
/// doc used to say). Because real directory names may *also* contain `-`, a
/// blind "replace all `-` with `/`" can produce a path that does not exist;
/// the decoder must verify the result and fall back to a smarter search.
///
/// // #6777: the greedy walk below only ever re-attaches a segment with a
/// literal `-`, so it cannot reconstruct a `.`-prefixed component such as
/// `.worktrees`. Decoding is best-effort and lossy by construction (the
/// encoding is not injective); widening the candidate set is a separate
/// change. `encode_project_dir` in `runtime::claude_code` is the exact
/// forward direction and is the one used for every store lookup.
/// What: the directory name begins with `-` (the leading `/`). First tries the
/// simple decoding (all `-` → `/`) and returns it if that path exists. Otherwise
/// walks the segments greedily: starting from `/`, it joins segments one at a
/// time, but when the next candidate directory does not exist it re-attaches the
/// segment to the previous one with a literal `-` — reconstructing hyphenated
/// directory names. If the greedy walk resolves nothing on disk (no prefix
/// exists), it falls back to the simple all-`-`-as-`/` decoding so a
/// never-launched project still decodes deterministically. Returns `None` when
/// the name does not start with `-`.
/// Test: `decodes_simple_path`, `decodes_path_with_hyphens`, `rejects_bad_name`,
/// `unresolved_path_still_decoded_best_effort`.
pub fn decode_project_path(dir_name: &str) -> Option<PathBuf> {
// The encoding always starts with `-` representing the leading `/`.
let body = dir_name.strip_prefix('-')?;
if body.is_empty() {
return None;
}
// Fast path: treat every `-` as `/`. If that exact path exists, done.
let simple = PathBuf::from(format!("/{}", body.replace('-', "/")));
if simple.exists() {
return Some(simple);
}
// Greedy reconstruction: build the path segment by segment, re-joining a
// segment to the previous one with a literal `-` when the `/`-separated
// candidate does not exist on disk.
let segments: Vec<&str> = body.split('-').collect();
let mut path = PathBuf::from("/");
let mut pending = String::new();
let mut any_resolved = false;
for (idx, segment) in segments.iter().enumerate() {
let candidate_name = if pending.is_empty() {
(*segment).to_string()
} else {
format!("{pending}-{segment}")
};
let candidate = path.join(&candidate_name);
let is_last = idx + 1 == segments.len();
if candidate.exists() {
// This component resolved — commit it and reset the pending buffer.
path = candidate;
pending.clear();
any_resolved = true;
} else if is_last {
// Last segment never resolved on disk; commit the best-effort name
// so callers still get a usable (if unverified) path.
path = candidate;
pending.clear();
} else {
// Ambiguous: the `-` was part of a directory name. Carry the
// accumulated name forward and try to attach the next segment.
pending = candidate_name;
}
}
// If no prefix at all resolved on disk, the greedy walk had nothing to go
// on — fall back to the deterministic all-`-`-as-`/` decoding.
if any_resolved {
Some(path)
} else {
Some(simple)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn decodes_simple_path() {
// A directory whose decoded path exists is decoded by the fast path.
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
std::fs::create_dir_all(base.join("Projects").join("app")).unwrap();
// Encode `<base>/Projects/app` — base may itself contain separators.
let encoded = format!("{}", base.join("Projects").join("app").display()).replace('/', "-");
let decoded = decode_project_path(&encoded).expect("decodes");
assert_eq!(decoded, base.join("Projects").join("app"));
}
#[test]
fn decodes_path_with_hyphens() {
// A real directory name containing a hyphen must be reconstructed.
let dir = tempfile::tempdir().unwrap();
let base = dir.path();
let project = base.join("Projects").join("trusty-mpm");
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let decoded = decode_project_path(&encoded).expect("decodes");
assert_eq!(decoded, project);
}
#[test]
fn rejects_bad_name() {
// A name that does not start with `-` is not a valid encoding.
assert!(decode_project_path("Users-masa").is_none());
// An empty body after the leading `-` is rejected.
assert!(decode_project_path("-").is_none());
}
#[test]
fn unresolved_path_still_decoded_best_effort() {
// A path that does not exist on disk falls back to the simple decoding.
let decoded = decode_project_path("-nonexistent-deep-path").expect("decodes");
assert_eq!(decoded, PathBuf::from("/nonexistent/deep/path"));
}
#[test]
fn discover_on_missing_dir_is_empty() {
let missing = PathBuf::from("/no/such/projects/dir");
assert!(ProjectDiscovery::discover_in(&missing).is_empty());
}
#[test]
fn discovers_and_counts_sessions() {
// Build a fake `~/.claude/projects` tree with one project holding two
// `.jsonl` transcripts and verify discovery counts them.
let home = tempfile::tempdir().unwrap();
let projects_dir = home.path().join(".claude").join("projects");
std::fs::create_dir_all(&projects_dir).unwrap();
// A real project directory the encoded name must point back at.
let project = home.path().join("work").join("demo");
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let claude_dir = projects_dir.join(&encoded);
std::fs::create_dir_all(&claude_dir).unwrap();
for name in ["a.jsonl", "b.jsonl"] {
let mut f = std::fs::File::create(claude_dir.join(name)).unwrap();
writeln!(f, "{{}}").unwrap();
}
// A non-transcript file must not be counted.
std::fs::File::create(claude_dir.join("notes.txt")).unwrap();
let found = ProjectDiscovery::discover_in(&projects_dir);
assert_eq!(found.len(), 1);
assert_eq!(found[0].path, project);
assert_eq!(found[0].session_count, 2);
assert!(found[0].last_session.is_some());
}
#[test]
fn discover_sorts_newest_session_first() {
// Two projects: the one whose transcript was written later sorts first.
let home = tempfile::tempdir().unwrap();
let projects_dir = home.path().join(".claude").join("projects");
std::fs::create_dir_all(&projects_dir).unwrap();
let make = |name: &str| -> PathBuf {
let project = home.path().join(name);
std::fs::create_dir_all(&project).unwrap();
let encoded = format!("{}", project.display()).replace('/', "-");
let claude_dir = projects_dir.join(encoded);
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::File::create(claude_dir.join("s.jsonl")).unwrap();
project
};
let older = make("older");
std::thread::sleep(std::time::Duration::from_millis(20));
let newer = make("newer");
let found = ProjectDiscovery::discover_in(&projects_dir);
assert_eq!(found.len(), 2);
assert_eq!(found[0].path, newer);
assert_eq!(found[1].path, older);
}
}