1mod model;
10mod navigator;
11mod render;
12mod events;
13pub mod archive_navigator;
14
15pub use model::{ArchiveModel, BrowserApp, BrowserMode};
16pub use archive_navigator::ArchiveNavigator;
17
18#[cfg(test)]
19#[allow(clippy::unwrap_used, clippy::expect_used)]
20mod archive_tree_tests {
21 #[cfg(feature = "tui")]
22 #[test]
23 fn test_tui_tree_widget_imports() {
24 use tui_tree_widget::TreeState;
25 let _state: TreeState<String> = TreeState::default();
26 }
27}
28
29#[cfg(test)]
30#[allow(clippy::unwrap_used, clippy::expect_used)]
31mod archive_event_tests {
32 use super::events;
33 use super::model::{ArchiveModel, BrowserModel, BrowserMode};
34 use super::archive_navigator::ArchiveNavigator;
35 use crate::fxar::FxarManifest;
36 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
37
38 fn dummy_manifest() -> FxarManifest {
39 FxarManifest {
40 version: 2,
41 created: "2026-01-01T00:00:00Z".into(),
42 files: vec![],
43 snapshots: vec!["snap1".into(), "snap2".into()],
44 }
45 }
46
47 fn make_model() -> BrowserModel {
48 let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
49 let dummy = std::path::Path::new(".");
50 let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
51 model.archive = Some(ArchiveModel::new(nav));
52 model
53 }
54
55 fn key(code: KeyCode) -> KeyEvent {
56 KeyEvent::new(code, KeyModifiers::NONE)
57 }
58
59 #[test]
60 fn test_archive_event_quit() {
61 let mut model = make_model();
62 let quit = events::handle_key(&mut model, key(KeyCode::Char('Q')));
63 assert!(quit);
64 assert!(model.should_quit);
65 }
66
67 #[test]
68 fn test_archive_event_search_mode() {
69 let mut model = make_model();
70 events::handle_key(&mut model, key(KeyCode::Char('/')));
71 assert!(model.archive.as_ref().unwrap().search_mode);
72
73 events::handle_key(&mut model, key(KeyCode::Char('a')));
74 assert_eq!(model.archive.as_ref().unwrap().search_query, "a");
75
76 events::handle_key(&mut model, key(KeyCode::Esc));
77 assert!(!model.archive.as_ref().unwrap().search_mode);
78 assert!(model.archive.as_ref().unwrap().search_query.is_empty());
79 }
80
81 #[test]
82 fn test_archive_event_snapshot_popup() {
83 let mut model = make_model();
84 events::handle_key(&mut model, key(KeyCode::Char('S')));
85 assert!(model.archive.as_ref().unwrap().snapshot_popup);
86
87 events::handle_key(&mut model, key(KeyCode::Esc));
88 assert!(!model.archive.as_ref().unwrap().snapshot_popup);
89 }
90
91 #[test]
92 fn test_archive_event_detail_toggle() {
93 let mut model = make_model();
94 assert!(!model.archive.as_ref().unwrap().detail_popup);
95 events::handle_key(&mut model, key(KeyCode::Char('i')));
96 assert!(model.archive.as_ref().unwrap().detail_popup);
97 events::handle_key(&mut model, key(KeyCode::Char('i')));
98 assert!(!model.archive.as_ref().unwrap().detail_popup);
99 }
100
101 #[test]
102 fn test_archive_event_metadata_toggle() {
103 let mut model = make_model();
104 assert!(model.archive.as_ref().unwrap().show_metadata);
105 events::handle_key(&mut model, key(KeyCode::Char('m')));
106 assert!(!model.archive.as_ref().unwrap().show_metadata);
107 }
108
109 #[test]
110 fn test_archive_snapshot_popup_select() {
111 let mut model = make_model();
112 events::handle_key(&mut model, key(KeyCode::Char('s')));
113 assert!(model.archive.as_ref().unwrap().snapshot_popup);
114
115 events::handle_key(&mut model, key(KeyCode::Down));
116 assert_eq!(model.archive.as_ref().unwrap().snapshot_popup_selected, 1);
117
118 events::handle_key(&mut model, key(KeyCode::Enter));
119 assert!(!model.archive.as_ref().unwrap().snapshot_popup);
120 assert_eq!(model.archive.as_ref().unwrap().snapshot_filter, Some("snap1".into()));
121 }
122
123 #[test]
124 fn test_archive_preview_toggle() {
125 let mut model = make_model();
126 assert!(!model.archive.as_ref().unwrap().preview_mode);
127
128 events::handle_key(&mut model, key(KeyCode::Char('v')));
129 assert!(model.archive.as_ref().unwrap().preview_mode);
130
131 events::handle_key(&mut model, key(KeyCode::Char('V')));
132 assert!(!model.archive.as_ref().unwrap().preview_mode);
133 assert!(model.archive.as_ref().unwrap().preview_content_raw.is_none());
134 }
135
136 #[test]
137 fn test_archive_preview_esc_clears() {
138 let mut model = make_model();
139 events::handle_key(&mut model, key(KeyCode::Char('v')));
140 {
141 let a = model.archive.as_mut().unwrap();
142 a.preview_content_raw = Some(vec![1, 2, 3]);
143 a.preview_scroll = 5;
144 }
145 events::handle_key(&mut model, key(KeyCode::Esc));
146 let a = model.archive.as_ref().unwrap();
147 assert!(!a.preview_mode);
148 assert!(a.preview_content_raw.is_none());
149 assert_eq!(a.preview_scroll, 0);
150 }
151
152 #[test]
153 fn test_archive_preview_scroll() {
154 let mut model = make_model();
155 events::handle_key(&mut model, key(KeyCode::Char('v')));
156 assert!(model.archive.as_ref().unwrap().preview_mode);
157
158 events::handle_key(&mut model, key(KeyCode::Down));
159 assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 1);
160 events::handle_key(&mut model, key(KeyCode::Down));
161 assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 2);
162 events::handle_key(&mut model, key(KeyCode::Up));
163 assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 1);
164 events::handle_key(&mut model, key(KeyCode::Up));
165 assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 0);
166 events::handle_key(&mut model, key(KeyCode::Up));
167 assert_eq!(model.archive.as_ref().unwrap().preview_scroll, 0);
168 }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used, clippy::expect_used)]
173mod preview_content_tests {
174 use super::render::{is_text_content, hex_dump};
175
176 #[test]
177 fn test_preview_text_detection() {
178 let bytes = b"Hello, world!\nLine 2\n".to_vec();
179 assert!(is_text_content(&bytes));
180 }
181
182 #[test]
183 fn test_preview_binary_detection() {
184 let bytes = vec![0x7f, 0x45, 0x4c, 0x46, 0x00, 0x01];
185 assert!(!is_text_content(&bytes));
186 }
187
188 #[test]
189 fn test_hex_format() {
190 let bytes: Vec<u8> = (0..16).collect();
191 let lines = hex_dump(&bytes);
192 assert_eq!(lines.len(), 1);
193 let line_str = format!("{:?}", lines[0]);
194 assert!(line_str.contains("00000000"));
195 assert!(line_str.contains("00 01 02 03"));
196 }
197
198 #[test]
199 fn test_hex_dump_truncation() {
200 let bytes: Vec<u8> = (0..=255).cycle().take(512).collect();
201 let lines = hex_dump(&bytes);
202 let last = format!("{:?}", lines.last().unwrap());
203 assert!(last.contains("512 bytes total"));
204 }
205
206 #[test]
207 fn test_text_with_tabs() {
208 let bytes = b"fn main() {\n\tprintln!(\"hi\");\n}\n".to_vec();
209 assert!(is_text_content(&bytes));
210 }
211
212 #[test]
213 fn test_empty_is_text() {
214 assert!(is_text_content(&[]));
215 }
216}
217
218#[cfg(test)]
219#[allow(clippy::unwrap_used, clippy::expect_used)]
220mod extract_tests {
221 use super::events;
222 use super::model::{ArchiveModel, BrowserModel, BrowserMode};
223 use super::archive_navigator::ArchiveNavigator;
224 use crate::fxar::{FxarManifest, FxarManifestEntry};
225 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
226 use std::collections::HashMap;
227
228 fn make_manifest_with_files() -> FxarManifest {
229 FxarManifest {
230 version: 2,
231 created: "2026-01-01T00:00:00Z".into(),
232 files: vec![
233 FxarManifestEntry {
234 path: "snap1/tree/hello.txt".into(),
235 size: 13,
236 mode: 0o100644,
237 mtime: 1700000000,
238 uid: 1000,
239 gid: 1000,
240 blake3: "0".repeat(64),
241 chunks: vec![0],
242 xattr: HashMap::new(),
243 },
244 ],
245 snapshots: vec!["snap1".into()],
246 }
247 }
248
249 fn make_model() -> BrowserModel {
250 let manifest = make_manifest_with_files();
251 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
252 let dummy = std::path::Path::new(".");
253 let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
254 model.archive = Some(ArchiveModel::new(nav));
255 model
256 }
257
258 fn key(code: KeyCode) -> KeyEvent {
259 KeyEvent::new(code, KeyModifiers::NONE)
260 }
261
262 #[test]
263 fn test_extract_prompt_default_dest() {
264 let mut model = make_model();
265 events::handle_key(&mut model, key(KeyCode::Char('E')));
266 let a = model.archive.as_ref().unwrap();
267 assert!(a.extract_prompt);
268 assert!(!a.extract_dest.is_empty());
269 }
270
271 #[test]
272 fn test_extract_prompt_input() {
273 let mut model = make_model();
274 {
275 let a = model.archive.as_mut().unwrap();
276 a.extract_prompt = true;
277 a.extract_dest = "/tmp/test".to_string();
278 }
279
280 events::handle_key(&mut model, key(KeyCode::Char('x')));
281 assert_eq!(model.archive.as_ref().unwrap().extract_dest, "/tmp/testx");
282
283 events::handle_key(&mut model, key(KeyCode::Backspace));
284 assert_eq!(model.archive.as_ref().unwrap().extract_dest, "/tmp/test");
285
286 events::handle_key(&mut model, key(KeyCode::Esc));
287 let a = model.archive.as_ref().unwrap();
288 assert!(!a.extract_prompt);
289 assert!(a.extract_dest.is_empty());
290 }
291
292 #[test]
293 fn test_extract_prompt_enter_sets_pending() {
294 let mut model = make_model();
295 {
296 let a = model.archive.as_mut().unwrap();
297 a.extract_prompt = true;
298 a.extract_dest = "/tmp/output.txt".to_string();
299 }
300
301 events::handle_key(&mut model, key(KeyCode::Enter));
302 let a = model.archive.as_ref().unwrap();
303 assert!(!a.extract_prompt);
304 assert_eq!(a.pending_extract, Some("/tmp/output.txt".to_string()));
305 }
306}
307
308#[cfg(test)]
309#[allow(clippy::unwrap_used, clippy::expect_used)]
310mod vector_search_tests {
311 use super::events;
312 use super::model::{ArchiveModel, BrowserApp, BrowserModel, BrowserMode};
313 use super::archive_navigator::ArchiveNavigator;
314 use crate::fxar::FxarManifest;
315 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
316
317 fn dummy_manifest() -> FxarManifest {
318 FxarManifest {
319 version: 2,
320 created: "2026-01-01T00:00:00Z".into(),
321 files: vec![],
322 snapshots: vec!["snap1".into()],
323 }
324 }
325
326 fn make_browser_app() -> BrowserApp {
327 let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
328 let archive_model = ArchiveModel::new(nav);
329 let dummy = std::path::Path::new(".");
330 let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
331 model.archive = Some(archive_model);
332 BrowserApp {
333 model,
334 fxar_path: None,
335 vector_search_fn: None,
336 }
337 }
338
339 fn make_model() -> BrowserModel {
340 let nav = ArchiveNavigator::from_manifest(dummy_manifest()).unwrap();
341 let dummy = std::path::Path::new(".");
342 let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
343 model.archive = Some(ArchiveModel::new(nav));
344 model
345 }
346
347 fn key(code: KeyCode) -> KeyEvent {
348 KeyEvent::new(code, KeyModifiers::NONE)
349 }
350
351 fn ctrl_key(code: KeyCode) -> KeyEvent {
352 KeyEvent::new(code, KeyModifiers::CONTROL)
353 }
354
355 #[test]
356 fn test_vector_search_callback_mock() {
357 let mut app = make_browser_app();
358 app.set_vector_search_fn(|_query| {
359 Ok(vec![
360 (0.80, "snap1/tree/lib.rs".to_string()),
361 (0.95, "snap1/tree/main.rs".to_string()),
362 ])
363 });
364
365 if let Some(archive) = &mut app.model.archive {
366 archive.vector_search_pending = true;
367 archive.vector_query = "rust main function".to_string();
368 }
369
370 app.run_vector_search();
371
372 let archive = app.model.archive.as_ref().unwrap();
373 assert_eq!(archive.vector_results.len(), 2);
374 assert!(archive.vector_results[0].0 >= archive.vector_results[1].0);
375 assert_eq!(archive.vector_results[0].1, "snap1/tree/main.rs");
376 assert_eq!(archive.vector_results[1].1, "snap1/tree/lib.rs");
377 assert!(archive.vector_status.as_deref().unwrap().contains("Found 2"));
378 }
379
380 #[test]
381 fn test_vector_search_no_fn() {
382 let mut app = make_browser_app();
383 if let Some(archive) = &mut app.model.archive {
384 archive.vector_search_pending = true;
385 archive.vector_query = "test query".to_string();
386 }
387
388 app.run_vector_search();
389
390 let archive = app.model.archive.as_ref().unwrap();
391 assert!(archive.vector_results.is_empty());
392 assert!(archive.vector_status.as_deref().unwrap().contains("--ai"));
393 }
394
395 #[test]
396 fn test_vector_search_esc_clears() {
397 let mut model = make_model();
398 {
399 let a = model.archive.as_mut().unwrap();
400 a.vector_search_mode = true;
401 a.vector_query = "test".to_string();
402 a.vector_results = vec![(0.9, "file.rs".to_string())];
403 a.vector_status = Some("Found 1 results".to_string());
404 }
405
406 events::handle_key(&mut model, key(KeyCode::Esc));
407
408 let a = model.archive.as_ref().unwrap();
409 assert!(!a.vector_search_mode);
410 assert!(a.vector_query.is_empty());
411 assert!(a.vector_results.is_empty());
412 assert!(a.vector_status.is_none());
413 }
414
415 #[test]
416 fn test_vector_search_ctrl_slash_toggle() {
417 let mut model = make_model();
418 assert!(!model.archive.as_ref().unwrap().vector_search_mode);
419
420 events::handle_key(&mut model, ctrl_key(KeyCode::Char('/')));
421 assert!(model.archive.as_ref().unwrap().vector_search_mode);
422
423 events::handle_key(&mut model, ctrl_key(KeyCode::Char('/')));
424 assert!(!model.archive.as_ref().unwrap().vector_search_mode);
425 }
426
427 #[test]
428 fn test_vector_search_typing() {
429 let mut model = make_model();
430 {
431 let a = model.archive.as_mut().unwrap();
432 a.vector_search_mode = true;
433 }
434
435 events::handle_key(&mut model, key(KeyCode::Char('h')));
436 events::handle_key(&mut model, key(KeyCode::Char('i')));
437 assert_eq!(model.archive.as_ref().unwrap().vector_query, "hi");
438
439 events::handle_key(&mut model, key(KeyCode::Backspace));
440 assert_eq!(model.archive.as_ref().unwrap().vector_query, "h");
441 }
442
443 #[test]
444 fn test_vector_search_enter_sets_pending() {
445 let mut model = make_model();
446 {
447 let a = model.archive.as_mut().unwrap();
448 a.vector_search_mode = true;
449 a.vector_query = "find me".to_string();
450 }
451
452 events::handle_key(&mut model, key(KeyCode::Enter));
453
454 let a = model.archive.as_ref().unwrap();
455 assert!(a.vector_search_pending);
456 assert_eq!(a.vector_status.as_deref(), Some("Searching..."));
457 assert!(a.vector_results.is_empty());
458 }
459
460 #[test]
461 fn test_vector_search_enter_empty_query_no_pending() {
462 let mut model = make_model();
463 {
464 let a = model.archive.as_mut().unwrap();
465 a.vector_search_mode = true;
466 }
467
468 events::handle_key(&mut model, key(KeyCode::Enter));
469
470 let a = model.archive.as_ref().unwrap();
471 assert!(!a.vector_search_pending);
472 }
473
474 #[test]
475 fn test_vector_search_error_handling() {
476 let mut app = make_browser_app();
477 app.set_vector_search_fn(|_query| {
478 Err(std::io::Error::other("model not loaded"))
479 });
480
481 if let Some(archive) = &mut app.model.archive {
482 archive.vector_search_pending = true;
483 archive.vector_query = "test".to_string();
484 }
485
486 app.run_vector_search();
487
488 let archive = app.model.archive.as_ref().unwrap();
489 assert!(archive.vector_results.is_empty());
490 assert!(archive.vector_status.as_deref().unwrap().contains("Search error"));
491 }
492}
493
494#[cfg(test)]
495#[allow(clippy::unwrap_used, clippy::expect_used)]
496mod degradation_tests {
497 use super::model::{ArchiveModel, BrowserApp, BrowserModel, BrowserMode};
498 use super::archive_navigator::ArchiveNavigator;
499 use crate::fxar::{FxarManifest, FxarManifestEntry};
500 use std::collections::HashMap;
501
502 fn make_manifest(files: Vec<FxarManifestEntry>, snapshots: Vec<String>) -> FxarManifest {
503 FxarManifest {
504 version: 2,
505 created: "2026-04-29T00:00:00Z".into(),
506 files,
507 snapshots,
508 }
509 }
510
511 fn make_entry(path: &str) -> FxarManifestEntry {
512 FxarManifestEntry {
513 path: path.into(),
514 size: 42,
515 mode: 0o100644,
516 mtime: 1700000000,
517 uid: 1000,
518 gid: 1000,
519 blake3: "0".repeat(64),
520 chunks: vec![0],
521 xattr: HashMap::new(),
522 }
523 }
524
525 #[test]
526 fn test_empty_archive_model() {
527 let manifest = make_manifest(vec![], vec![]);
528 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
529 let model = ArchiveModel::new(nav);
530 let items = model.build_tree_items();
531 assert!(items.is_empty(), "Empty archive should yield empty tree items");
532 assert_eq!(model.navigator.entries.len(), 1, "Only root node");
533 }
534
535 #[test]
536 fn test_unicode_filename() {
537 let manifest = make_manifest(
538 vec![make_entry("snap1/tree/日本語ファイル.txt")],
539 vec!["snap1".into()],
540 );
541 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
542 let file_entry = nav.entries.iter()
543 .find(|e| !e.is_dir && e.name.contains("日本語"));
544 assert!(file_entry.is_some(), "Unicode filename entry should exist");
545 assert_eq!(file_entry.unwrap().name, "日本語ファイル.txt");
546 }
547
548 #[test]
549 fn test_extract_status_on_failure() {
550 let manifest = make_manifest(
551 vec![make_entry("snap1/tree/hello.txt")],
552 vec!["snap1".into()],
553 );
554 let nav = ArchiveNavigator::from_manifest(manifest).unwrap();
555 let dummy = std::path::Path::new(".");
556 let mut model = BrowserModel::new(dummy, dummy, BrowserMode::ArchiveBrowse);
557 model.archive = Some(ArchiveModel::new(nav));
558
559 let mut app = BrowserApp {
560 model,
561 fxar_path: None,
562 vector_search_fn: None,
563 };
564
565 {
566 let a = app.model.archive.as_mut().unwrap();
567 a.pending_extract = Some("/nonexistent/nested/path".to_string());
568 }
569
570 app.do_extract("/nonexistent/nested/path");
571
572 let status = app.model.archive.as_ref().unwrap().extract_status.as_deref().unwrap();
573 assert!(
574 status.contains("No archive path") || status.contains("Error"),
575 "Expected error status, got: {}",
576 status
577 );
578 }
579}