Rust · 9972 bytes Raw Blame History
1 //! Smoke test against the host's real Claude Code data. Skipped if
2 //! `~/.claude/projects/` is missing. Not deterministic, not run in CI —
3 //! here as a manual sanity check during development.
4
5 use std::path::PathBuf;
6
7 use claudex_lib::core::discovery::scan_projects;
8 use claudex_lib::core::grouping::build_projects;
9 use claudex_lib::core::metadata::summarize;
10 use claudex_lib::core::paths::projects_dir;
11 use claudex_lib::core::reader::read_session;
12 use claudex_lib::core::schema::{Message, ProjectCategory};
13
14 #[test]
15 fn summarizes_every_session_on_host_without_panicking() {
16 let root = match projects_dir() {
17 Ok(p) if p.exists() => p,
18 _ => {
19 eprintln!("skipping: no ~/.claude/projects/");
20 return;
21 }
22 };
23
24 let projects = scan_projects(&root).expect("scan");
25 let mut total = 0usize;
26 let mut with_title = 0usize;
27 let mut with_timestamp = 0usize;
28
29 for project in &projects {
30 for session in &project.sessions {
31 total += 1;
32 let summary = match summarize(session.as_path(), &project.id) {
33 Ok(s) => s,
34 Err(e) => panic!("summarize failed on {:?}: {e}", session),
35 };
36 if summary.title != "(untitled)" {
37 with_title += 1;
38 }
39 if summary.last_activity_at.is_some() {
40 with_timestamp += 1;
41 }
42 }
43 }
44
45 eprintln!(
46 "host smoke (summarize): {total} sessions across {} raw dirs, {with_title} titled, {with_timestamp} with timestamp",
47 projects.len()
48 );
49 assert!(total > 0, "expected at least one real session on host");
50 let _ = PathBuf::new();
51 }
52
53 #[test]
54 fn groups_real_projects_via_git_root_and_prefix_fallback() {
55 let root = match projects_dir() {
56 Ok(p) if p.exists() => p,
57 _ => {
58 eprintln!("skipping: no ~/.claude/projects/");
59 return;
60 }
61 };
62
63 let dirs = scan_projects(&root).expect("scan");
64 let mut pairs = Vec::new();
65 for dir in dirs {
66 for session in &dir.sessions {
67 if let Ok(summary) = summarize(session, &dir.id) {
68 pairs.push((summary, dir.id.clone()));
69 }
70 }
71 }
72
73 let before = pairs
74 .iter()
75 .map(|(_, e)| e.clone())
76 .collect::<std::collections::BTreeSet<_>>()
77 .len();
78
79 let projects = build_projects(pairs);
80
81 let regular: Vec<_> = projects
82 .iter()
83 .filter(|p| p.category == ProjectCategory::Regular)
84 .collect();
85 let observers: Vec<_> = projects
86 .iter()
87 .filter(|p| p.category == ProjectCategory::Observer)
88 .collect();
89
90 eprintln!(
91 "host smoke (grouping): {before} raw encoded dirs collapsed into {} projects ({} regular + {} observer)",
92 projects.len(),
93 regular.len(),
94 observers.len()
95 );
96 eprintln!("top 10 regular projects by recency:");
97 for p in regular.iter().take(10) {
98 let mut source_dirs = p.source_dirs.clone();
99 source_dirs.sort();
100 eprintln!(
101 " {:>4} sess [{}] {}",
102 p.session_count,
103 source_dirs.len(),
104 p.display_name
105 );
106 if source_dirs.len() > 1 {
107 for d in &source_dirs {
108 eprintln!(" ↳ {d}");
109 }
110 }
111 }
112 eprintln!("observer projects:");
113 for p in &observers {
114 eprintln!(" {:>4} sess {}", p.session_count, p.display_name);
115 }
116 }
117
118 #[test]
119 fn shows_real_titles_after_sanitizer_and_claude_mem_enrichment() {
120 use claudex_lib::core::claude_mem;
121 use claudex_lib::core::schema::SessionSummary;
122
123 let root = match projects_dir() {
124 Ok(p) if p.exists() => p,
125 _ => {
126 eprintln!("skipping: no ~/.claude/projects/");
127 return;
128 }
129 };
130
131 let dirs = scan_projects(&root).expect("scan");
132 let mut pairs: Vec<(SessionSummary, String)> = Vec::new();
133 for dir in dirs {
134 for session in &dir.sessions {
135 if let Ok(summary) = summarize(session, &dir.id) {
136 pairs.push((summary, dir.id.clone()));
137 }
138 }
139 }
140
141 // Mirror the command-layer enrichment path.
142 if let Some(db_path) = claude_mem::default_db_path() {
143 let map = claude_mem::load_title_map(&db_path);
144 eprintln!(
145 "claude-mem title map: {} entries (db present: {})",
146 map.len(),
147 db_path.exists()
148 );
149 if !map.is_empty() {
150 for (s, _) in pairs.iter_mut() {
151 if s.custom_title.is_some() {
152 continue;
153 }
154 if let Some(t) = map.get(&s.id).and_then(|t| t.best()) {
155 s.title = t.to_string();
156 }
157 }
158 }
159 }
160
161 let projects = build_projects(pairs);
162 let regular: Vec<_> = projects
163 .iter()
164 .filter(|p| p.category == ProjectCategory::Regular)
165 .collect();
166
167 eprintln!("titles for regular projects (top 10 by recency):");
168 for p in regular.iter().take(10) {
169 eprintln!(" === {} ({} sessions)", p.display_name, p.session_count);
170 for s in p.sessions.iter().take(3) {
171 eprintln!(" {}", s.title);
172 }
173 }
174 }
175
176 #[test]
177 fn discovers_archive_projects_from_host_history_log() {
178 use claudex_lib::core::grouping::build_archive_projects;
179 use claudex_lib::core::history_log::{default_history_path, HistoryLog};
180
181 let root = match projects_dir() {
182 Ok(p) if p.exists() => p,
183 _ => {
184 eprintln!("skipping: no ~/.claude/projects/");
185 return;
186 }
187 };
188 let Some(history_path) = default_history_path() else {
189 eprintln!("skipping: no home dir");
190 return;
191 };
192 if !history_path.exists() {
193 eprintln!("skipping: no ~/.claude/history.jsonl");
194 return;
195 }
196
197 // Build the disk projects.
198 let dirs = scan_projects(&root).expect("scan");
199 let mut pairs = Vec::new();
200 for dir in dirs {
201 for session in &dir.sessions {
202 if let Ok(s) = summarize(session, &dir.id) {
203 pairs.push((s, dir.id.clone()));
204 }
205 }
206 }
207 let disk_projects = claudex_lib::core::grouping::build_projects(pairs);
208
209 // Load the history log.
210 let log = HistoryLog::load(&history_path);
211 eprintln!(
212 "host history.jsonl: {} projects, {} entries total",
213 log.project_count(),
214 log.total_entries()
215 );
216
217 // Build archive projects.
218 let archive = build_archive_projects(&log, &disk_projects);
219 eprintln!("\narchive projects discovered: {}", archive.len());
220 for p in archive.iter().take(20) {
221 let sess = &p.sessions[0];
222 eprintln!(
223 " [{:>4} prompts] {}{}",
224 sess.message_count, p.display_name, sess.title
225 );
226 }
227
228 assert!(
229 log.project_count() > 0,
230 "history.jsonl should have at least one project on a real machine"
231 );
232
233 // Assert that disk projects don't leak into archive (no
234 // duplicates).
235 for ap in &archive {
236 for dp in &disk_projects {
237 assert_ne!(
238 ap.id, dp.id,
239 "archive project {:?} collides with disk project",
240 ap.id
241 );
242 }
243 }
244
245 // All archive projects should have category=Archive and exactly
246 // one synthetic session with source=Archive.
247 for p in &archive {
248 assert_eq!(
249 p.category,
250 claudex_lib::core::schema::ProjectCategory::Archive
251 );
252 assert_eq!(p.sessions.len(), 1);
253 assert_eq!(
254 p.sessions[0].source,
255 claudex_lib::core::schema::SessionSource::Archive
256 );
257 assert!(p.sessions[0].message_count > 0);
258 }
259 }
260
261 #[test]
262 fn reads_every_session_fully_without_panicking() {
263 let root = match projects_dir() {
264 Ok(p) if p.exists() => p,
265 _ => {
266 eprintln!("skipping: no ~/.claude/projects/");
267 return;
268 }
269 };
270
271 let projects = scan_projects(&root).expect("scan");
272 let mut total_sessions = 0usize;
273 let mut total_messages = 0usize;
274 let mut unknown_types: std::collections::BTreeMap<String, usize> =
275 std::collections::BTreeMap::new();
276 let mut user_count = 0usize;
277 let mut assistant_count = 0usize;
278 let mut tool_use_count = 0usize;
279
280 for project in &projects {
281 for session in &project.sessions {
282 total_sessions += 1;
283 let detail = match read_session(session.as_path(), &project.id) {
284 Ok(d) => d,
285 Err(e) => panic!("read_session failed on {:?}: {e}", session),
286 };
287 total_messages += detail.messages.len();
288 for m in &detail.messages {
289 match m {
290 Message::User { .. } => user_count += 1,
291 Message::Assistant { blocks, .. } => {
292 assistant_count += 1;
293 for b in blocks {
294 if let claudex_lib::core::schema::ContentBlock::ToolUse { .. } = b {
295 tool_use_count += 1;
296 }
297 }
298 }
299 Message::Unknown { raw_type, .. } => {
300 *unknown_types.entry(raw_type.clone()).or_default() += 1;
301 }
302 _ => {}
303 }
304 }
305 }
306 }
307
308 eprintln!(
309 "host smoke (read_session): {total_sessions} sessions, {total_messages} messages, \
310 {user_count} user, {assistant_count} assistant, {tool_use_count} tool_use blocks"
311 );
312 if !unknown_types.is_empty() {
313 eprintln!("unknown event types encountered (would render as Unknown cards):");
314 for (t, c) in &unknown_types {
315 eprintln!(" {t}: {c}");
316 }
317 }
318 }
319