1use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5use std::fs;
6use std::path::Path;
7
8use crate::{IndexUrl, Origin, UrlError};
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct SessionId(String);
13
14impl SessionId {
15 #[must_use]
17 pub fn new(input: impl Into<String>) -> Self {
18 Self(input.into())
19 }
20
21 #[must_use]
23 pub fn as_str(&self) -> &str {
24 &self.0
25 }
26}
27
28impl Display for SessionId {
29 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
30 f.write_str(self.as_str())
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HistoryEntry {
37 pub requested_url: IndexUrl,
39 pub final_url: IndexUrl,
41 pub redirects: Vec<IndexUrl>,
43}
44
45impl HistoryEntry {
46 #[must_use]
48 pub fn new(url: IndexUrl) -> Self {
49 Self {
50 requested_url: url.clone(),
51 final_url: url,
52 redirects: Vec::new(),
53 }
54 }
55
56 #[must_use]
58 pub fn with_redirects(
59 requested_url: IndexUrl,
60 final_url: IndexUrl,
61 redirects: Vec<IndexUrl>,
62 ) -> Self {
63 Self {
64 requested_url,
65 final_url,
66 redirects,
67 }
68 }
69
70 #[must_use]
72 pub fn origin(&self) -> Option<Origin> {
73 self.final_url.origin()
74 }
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct HistoryStack {
80 back: Vec<HistoryEntry>,
81 current: Option<HistoryEntry>,
82 forward: Vec<HistoryEntry>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ResponseLogEntry {
88 pub sequence: u64,
90 pub method: String,
92 pub requested_url: String,
94 pub final_url: String,
96 pub mime_type: Option<String>,
98 pub body_preview: String,
100 pub truncated: bool,
102}
103
104impl ResponseLogEntry {
105 #[must_use]
107 pub fn new(
108 sequence: u64,
109 method: impl Into<String>,
110 requested_url: impl AsRef<str>,
111 final_url: impl AsRef<str>,
112 mime_type: Option<&str>,
113 body: &str,
114 preview_limit: usize,
115 ) -> Self {
116 let requested_url = redact_log_text(requested_url.as_ref());
117 let final_url = redact_log_text(final_url.as_ref());
118 let mime_type = mime_type.map(redact_log_text);
119 let redacted_body = redact_log_text(body);
120 let (body_preview, truncated) = bounded_preview(&redacted_body, preview_limit);
121 Self {
122 sequence,
123 method: method.into(),
124 requested_url,
125 final_url,
126 mime_type,
127 body_preview,
128 truncated,
129 }
130 }
131
132 #[must_use]
134 pub fn title(&self) -> String {
135 format!("#{} {} {}", self.sequence, self.method, self.final_url)
136 }
137}
138
139fn bounded_preview(input: &str, limit: usize) -> (String, bool) {
140 if limit == 0 {
141 return (String::new(), !input.is_empty());
142 }
143 let mut output = String::new();
144 for (count, ch) in input.chars().enumerate() {
145 if count >= limit {
146 return (output, true);
147 }
148 output.push(ch);
149 }
150 (output, false)
151}
152
153fn redact_log_text(input: &str) -> String {
154 let mut output = String::with_capacity(input.len());
155 let mut chars = input.chars().peekable();
156 while let Some(ch) = chars.next() {
157 output.push(ch);
158 if is_sensitive_prefix(&output) {
159 while matches!(chars.peek(), Some(' ' | '=' | ':' | '"' | '\'')) {
160 if let Some(separator) = chars.next() {
161 output.push(separator);
162 }
163 }
164 while matches!(
165 chars.peek(),
166 Some(candidate)
167 if !matches!(candidate, '&' | ';' | '\n' | '\r' | '\t' | ' ' | '"' | '\'')
168 ) {
169 let _ = chars.next();
170 }
171 output.push_str("[REDACTED]");
172 }
173 }
174 output
175}
176
177fn is_sensitive_prefix(input: &str) -> bool {
178 let lower = input.to_ascii_lowercase();
179 lower.ends_with("authorization")
180 || lower.ends_with("cookie")
181 || lower.ends_with("set-cookie")
182 || lower.ends_with("token")
183 || lower.ends_with("password")
184 || lower.ends_with("secret")
185 || lower.ends_with("api_key")
186 || lower.ends_with("apikey")
187}
188
189impl HistoryStack {
190 #[must_use]
192 pub fn new() -> Self {
193 Self::default()
194 }
195
196 pub fn visit(&mut self, url: IndexUrl) {
198 self.visit_entry(HistoryEntry::new(url));
199 }
200
201 pub fn visit_entry(&mut self, entry: HistoryEntry) {
203 if let Some(current) = self.current.take() {
204 self.back.push(current);
205 }
206 self.current = Some(entry);
207 self.forward.clear();
208 }
209
210 pub fn go_back(&mut self) -> Option<&HistoryEntry> {
212 let previous = self.back.pop()?;
213 if let Some(current) = self.current.take() {
214 self.forward.push(current);
215 }
216 self.current = Some(previous);
217 self.current.as_ref()
218 }
219
220 pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
222 let next = self.forward.pop()?;
223 if let Some(current) = self.current.take() {
224 self.back.push(current);
225 }
226 self.current = Some(next);
227 self.current.as_ref()
228 }
229
230 #[must_use]
232 pub fn current(&self) -> Option<&HistoryEntry> {
233 self.current.as_ref()
234 }
235
236 #[must_use]
238 pub fn can_go_back(&self) -> bool {
239 !self.back.is_empty()
240 }
241
242 #[must_use]
244 pub fn can_go_forward(&self) -> bool {
245 !self.forward.is_empty()
246 }
247
248 #[must_use]
250 pub fn entries(&self) -> Vec<&HistoryEntry> {
251 self.back
252 .iter()
253 .chain(self.current.iter())
254 .chain(self.forward.iter().rev())
255 .collect()
256 }
257
258 #[must_use]
260 pub fn current_index(&self) -> Option<usize> {
261 self.current.as_ref().map(|_current| self.back.len())
262 }
263
264 fn from_entries(
265 entries: Vec<HistoryEntry>,
266 current_index: Option<usize>,
267 ) -> Result<Self, SessionError> {
268 let Some(current_index) = current_index else {
269 return Ok(Self::new());
270 };
271 if current_index >= entries.len() {
272 return Err(SessionError::Parse(
273 "history current index is out of range".to_owned(),
274 ));
275 }
276
277 let mut back = Vec::new();
278 let mut current = None;
279 let mut forward = Vec::new();
280 for (index, entry) in entries.into_iter().enumerate() {
281 if index < current_index {
282 back.push(entry);
283 } else if index == current_index {
284 current = Some(entry);
285 } else {
286 forward.push(entry);
287 }
288 }
289 forward.reverse();
290
291 Ok(Self {
292 back,
293 current,
294 forward,
295 })
296 }
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct Bookmark {
302 pub title: String,
304 pub url: IndexUrl,
306 pub note: Option<String>,
308 pub tags: Vec<String>,
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Eq)]
314pub struct BookmarkStore {
315 bookmarks: BTreeMap<String, Bookmark>,
316}
317
318impl BookmarkStore {
319 #[must_use]
321 pub fn new() -> Self {
322 Self::default()
323 }
324
325 pub fn add(&mut self, title: impl Into<String>, url: IndexUrl) {
327 self.add_with_details(title, url, None, Vec::<String>::new());
328 }
329
330 pub fn add_with_details(
332 &mut self,
333 title: impl Into<String>,
334 url: IndexUrl,
335 note: Option<String>,
336 tags: Vec<String>,
337 ) {
338 self.bookmarks.insert(
339 url.as_str().to_owned(),
340 Bookmark {
341 title: title.into(),
342 url,
343 note: note.filter(|note| !note.trim().is_empty()),
344 tags: normalized_tags(tags),
345 },
346 );
347 }
348
349 pub fn update_details(
351 &mut self,
352 url: &IndexUrl,
353 note: Option<String>,
354 tags: Vec<String>,
355 ) -> Option<()> {
356 let bookmark = self.bookmarks.get_mut(url.as_str())?;
357 bookmark.note = note.filter(|note| !note.trim().is_empty());
358 bookmark.tags = normalized_tags(tags);
359 Some(())
360 }
361
362 pub fn remove(&mut self, url: &IndexUrl) -> Option<Bookmark> {
364 self.bookmarks.remove(url.as_str())
365 }
366
367 #[must_use]
369 pub fn contains(&self, url: &IndexUrl) -> bool {
370 self.bookmarks.contains_key(url.as_str())
371 }
372
373 pub fn iter(&self) -> impl Iterator<Item = &Bookmark> {
375 self.bookmarks.values()
376 }
377
378 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), BookmarkError> {
380 let mut lines = Vec::new();
381 lines.push("index-bookmarks-v1".to_owned());
382 for bookmark in self.iter() {
383 lines.push(serialized_bookmark_fields(bookmark).join("\t"));
384 }
385 fs::write(path, lines.join("\n")).map_err(BookmarkError::from)
386 }
387
388 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, BookmarkError> {
390 let contents = fs::read_to_string(path).map_err(BookmarkError::from)?;
391 Self::from_serialized(&contents)
392 }
393
394 fn from_serialized(contents: &str) -> Result<Self, BookmarkError> {
395 let mut lines = contents.lines();
396 if lines.next() != Some("index-bookmarks-v1") {
397 return Err(BookmarkError::Parse("missing bookmark header".to_owned()));
398 }
399
400 let mut store = Self::new();
401 for line in lines {
402 if line.is_empty() {
403 continue;
404 }
405 let fields: Vec<&str> = line.split('\t').collect();
406 if fields.len() < 2 {
407 return Err(BookmarkError::Parse("invalid bookmark record".to_owned()));
408 }
409 let url = IndexUrl::parse(unescape_field(fields[0]).map_err(BookmarkError::Parse)?)
410 .map_err(BookmarkError::Url)?;
411 let title = unescape_field(fields[1]).map_err(BookmarkError::Parse)?;
412 let note = if fields.len() >= 3 {
413 Some(unescape_field(fields[2]).map_err(BookmarkError::Parse)?)
414 .filter(|note| !note.trim().is_empty())
415 } else {
416 None
417 };
418 let mut tags = Vec::new();
419 for field in &fields[3..] {
420 tags.push(unescape_field(field).map_err(BookmarkError::Parse)?);
421 }
422 store.add_with_details(title, url, note, tags);
423 }
424 Ok(store)
425 }
426}
427
428fn serialized_bookmark_fields(bookmark: &Bookmark) -> Vec<String> {
429 let mut fields = vec![
430 escape_field(bookmark.url.as_str()),
431 escape_field(&bookmark.title),
432 ];
433 if bookmark.note.is_some() || !bookmark.tags.is_empty() {
434 fields.push(escape_field(bookmark.note.as_deref().unwrap_or_default()));
435 fields.extend(bookmark.tags.iter().map(|tag| escape_field(tag)));
436 }
437 fields
438}
439
440fn normalized_tags(tags: Vec<String>) -> Vec<String> {
441 let mut tags = tags
442 .into_iter()
443 .map(|tag| tag.trim().to_owned())
444 .filter(|tag| !tag.is_empty())
445 .collect::<Vec<_>>();
446 tags.sort();
447 tags.dedup();
448 tags
449}
450
451#[derive(Debug)]
453pub enum BookmarkError {
454 Io(std::io::Error),
456 Parse(String),
458 Url(UrlError),
460}
461
462impl From<std::io::Error> for BookmarkError {
463 fn from(value: std::io::Error) -> Self {
464 Self::Io(value)
465 }
466}
467
468impl Display for BookmarkError {
469 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
470 match self {
471 Self::Io(error) => write!(f, "bookmark IO failed: {error}"),
472 Self::Parse(reason) => write!(f, "bookmark data is invalid: {reason}"),
473 Self::Url(error) => write!(f, "bookmark URL is invalid: {error}"),
474 }
475 }
476}
477
478impl std::error::Error for BookmarkError {}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct ShelfRecord {
483 pub id: String,
485 pub title: String,
487 pub source_url: Option<String>,
489 pub quality: Option<String>,
491 pub saved_at: String,
493 pub tags: Vec<String>,
495 pub note: Option<String>,
497 pub citations: Vec<String>,
499 pub markdown_path: String,
501 pub json_path: String,
503}
504
505impl ShelfRecord {
506 #[must_use]
508 pub fn new(
509 title: impl Into<String>,
510 source_url: Option<String>,
511 quality: Option<String>,
512 saved_at: impl Into<String>,
513 citations: Vec<String>,
514 markdown_path: impl Into<String>,
515 json_path: impl Into<String>,
516 ) -> Self {
517 let title = title.into();
518 let id = shelf_id(&title, source_url.as_deref());
519 Self {
520 id,
521 title,
522 source_url,
523 quality,
524 saved_at: saved_at.into(),
525 tags: Vec::new(),
526 note: None,
527 citations,
528 markdown_path: markdown_path.into(),
529 json_path: json_path.into(),
530 }
531 }
532
533 pub fn add_tag(&mut self, tag: impl Into<String>) {
535 let tag = tag.into();
536 if tag.trim().is_empty() || self.tags.iter().any(|existing| existing == &tag) {
537 return;
538 }
539 self.tags.push(tag);
540 self.tags.sort();
541 }
542
543 pub fn set_note(&mut self, note: impl Into<String>) {
545 self.note = Some(note.into());
546 }
547}
548
549#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct ShelfSearchResult {
552 pub id: String,
554 pub title: String,
556 pub source_url: Option<String>,
558 pub score: u16,
560 pub matched_fields: Vec<String>,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Default)]
566pub struct KnowledgeShelf {
567 records: BTreeMap<String, ShelfRecord>,
568}
569
570impl KnowledgeShelf {
571 #[must_use]
573 pub fn new() -> Self {
574 Self::default()
575 }
576
577 pub fn upsert(&mut self, record: ShelfRecord) {
579 self.records.insert(record.id.clone(), record);
580 }
581
582 #[must_use]
584 pub fn get(&self, id: &str) -> Option<&ShelfRecord> {
585 self.records.get(id)
586 }
587
588 pub fn get_mut(&mut self, id: &str) -> Option<&mut ShelfRecord> {
590 self.records.get_mut(id)
591 }
592
593 pub fn iter(&self) -> impl Iterator<Item = &ShelfRecord> {
595 self.records.values()
596 }
597
598 pub fn search<F>(&self, query: &str, mut markdown_for: F) -> Vec<ShelfSearchResult>
603 where
604 F: FnMut(&ShelfRecord) -> Option<String>,
605 {
606 let normalized_query = normalize_search_text(query);
607 if normalized_query.is_empty() {
608 return Vec::new();
609 }
610 let tokens = normalized_query
611 .split_whitespace()
612 .map(ToOwned::to_owned)
613 .collect::<Vec<_>>();
614 let mut results = Vec::new();
615
616 for record in self.iter() {
617 let markdown = markdown_for(record).unwrap_or_default();
618 if let Some(result) = score_shelf_record(record, &normalized_query, &tokens, &markdown)
619 {
620 results.push(result);
621 }
622 }
623
624 results.sort_by(|left, right| {
625 right
626 .score
627 .cmp(&left.score)
628 .then_with(|| left.title.cmp(&right.title))
629 .then_with(|| left.id.cmp(&right.id))
630 });
631 results
632 }
633
634 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ShelfError> {
636 let mut lines = vec!["index-shelf-v1".to_owned()];
637 for record in self.iter() {
638 lines.push(serialized_shelf_record(record).join("\t"));
639 }
640 fs::write(path, lines.join("\n")).map_err(ShelfError::from)
641 }
642
643 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ShelfError> {
645 match fs::read_to_string(path) {
646 Ok(contents) => Self::from_serialized(&contents),
647 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
648 Err(error) => Err(ShelfError::Io(error.to_string())),
649 }
650 }
651
652 fn from_serialized(contents: &str) -> Result<Self, ShelfError> {
653 let mut lines = contents.lines();
654 if lines.next() != Some("index-shelf-v1") {
655 return Err(ShelfError::Parse("missing shelf header".to_owned()));
656 }
657 let mut shelf = Self::new();
658 for line in lines {
659 if line.is_empty() {
660 continue;
661 }
662 let fields = line.split('\t').collect::<Vec<_>>();
663 if fields.len() != 10 {
664 return Err(ShelfError::Parse("invalid shelf record".to_owned()));
665 }
666 let mut record = ShelfRecord {
667 id: unescape_field(fields[0]).map_err(ShelfError::Parse)?,
668 title: unescape_field(fields[1]).map_err(ShelfError::Parse)?,
669 source_url: optional_unescape_field(fields[2])?,
670 quality: optional_unescape_field(fields[3])?,
671 saved_at: unescape_field(fields[4]).map_err(ShelfError::Parse)?,
672 tags: split_list_field(fields[5])?,
673 note: optional_unescape_field(fields[6])?,
674 citations: split_list_field(fields[7])?,
675 markdown_path: unescape_field(fields[8]).map_err(ShelfError::Parse)?,
676 json_path: unescape_field(fields[9]).map_err(ShelfError::Parse)?,
677 };
678 record.tags.sort();
679 record.tags.dedup();
680 shelf.upsert(record);
681 }
682 Ok(shelf)
683 }
684}
685
686fn score_shelf_record(
687 record: &ShelfRecord,
688 query: &str,
689 tokens: &[String],
690 markdown: &str,
691) -> Option<ShelfSearchResult> {
692 let mut score = 0;
693 let mut matched_fields = Vec::new();
694
695 score += score_field(
696 "title",
697 &record.title,
698 query,
699 tokens,
700 60,
701 &mut matched_fields,
702 );
703 if let Some(source_url) = &record.source_url {
704 score += score_field(
705 "source_url",
706 source_url,
707 query,
708 tokens,
709 20,
710 &mut matched_fields,
711 );
712 }
713 if !record.tags.is_empty() {
714 score += score_field(
715 "tags",
716 &record.tags.join(" "),
717 query,
718 tokens,
719 50,
720 &mut matched_fields,
721 );
722 }
723 if let Some(note) = &record.note {
724 score += score_field("note", note, query, tokens, 40, &mut matched_fields);
725 }
726 if !record.citations.is_empty() {
727 score += score_field(
728 "citations",
729 &record.citations.join(" "),
730 query,
731 tokens,
732 30,
733 &mut matched_fields,
734 );
735 }
736 score += score_field(
737 "markdown_headings",
738 &markdown_headings(markdown),
739 query,
740 tokens,
741 45,
742 &mut matched_fields,
743 );
744 score += score_field("markdown", markdown, query, tokens, 10, &mut matched_fields);
745
746 (score > 0).then(|| ShelfSearchResult {
747 id: record.id.clone(),
748 title: record.title.clone(),
749 source_url: record.source_url.clone(),
750 score,
751 matched_fields,
752 })
753}
754
755fn score_field(
756 field: &str,
757 value: &str,
758 query: &str,
759 tokens: &[String],
760 weight: u16,
761 matched_fields: &mut Vec<String>,
762) -> u16 {
763 if field_matches(value, query, tokens) {
764 matched_fields.push(field.to_owned());
765 weight
766 } else {
767 0
768 }
769}
770
771fn field_matches(value: &str, query: &str, tokens: &[String]) -> bool {
772 let value = normalize_search_text(value);
773 !value.is_empty() && (value.contains(query) || tokens.iter().all(|token| value.contains(token)))
774}
775
776fn markdown_headings(markdown: &str) -> String {
777 markdown
778 .lines()
779 .filter_map(|line| {
780 let trimmed = line.trim_start();
781 trimmed
782 .strip_prefix('#')
783 .map(|heading| heading.trim_start_matches('#').trim())
784 })
785 .filter(|heading| !heading.is_empty())
786 .collect::<Vec<_>>()
787 .join(" ")
788}
789
790fn normalize_search_text(input: &str) -> String {
791 input
792 .split_whitespace()
793 .flat_map(|part| part.chars().flat_map(char::to_lowercase).chain([' ']))
794 .collect::<String>()
795 .trim_end()
796 .to_owned()
797}
798
799fn shelf_id(title: &str, source_url: Option<&str>) -> String {
800 let mut hash: u64 = 0xcbf29ce484222325;
801 for byte in source_url.unwrap_or("").bytes().chain(title.bytes()) {
802 hash ^= u64::from(byte);
803 hash = hash.wrapping_mul(0x100000001b3);
804 }
805 format!("shelf-{hash:016x}")
806}
807
808fn serialized_shelf_record(record: &ShelfRecord) -> Vec<String> {
809 vec![
810 escape_field(&record.id),
811 escape_field(&record.title),
812 record
813 .source_url
814 .as_ref()
815 .map_or_else(String::new, |value| escape_field(value)),
816 record
817 .quality
818 .as_ref()
819 .map_or_else(String::new, |value| escape_field(value)),
820 escape_field(&record.saved_at),
821 join_list_field(&record.tags),
822 record
823 .note
824 .as_ref()
825 .map_or_else(String::new, |value| escape_field(value)),
826 join_list_field(&record.citations),
827 escape_field(&record.markdown_path),
828 escape_field(&record.json_path),
829 ]
830}
831
832fn optional_unescape_field(field: &str) -> Result<Option<String>, ShelfError> {
833 if field.is_empty() {
834 Ok(None)
835 } else {
836 Ok(Some(unescape_field(field).map_err(ShelfError::Parse)?))
837 }
838}
839
840fn join_list_field(values: &[String]) -> String {
841 values
842 .iter()
843 .map(|value| escape_field(value))
844 .collect::<Vec<_>>()
845 .join(",")
846}
847
848fn split_list_field(field: &str) -> Result<Vec<String>, ShelfError> {
849 if field.is_empty() {
850 return Ok(Vec::new());
851 }
852 field
853 .split(',')
854 .map(|value| unescape_field(value).map_err(ShelfError::Parse))
855 .collect()
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
860pub enum ShelfError {
861 Io(String),
863 Parse(String),
865}
866
867impl From<std::io::Error> for ShelfError {
868 fn from(value: std::io::Error) -> Self {
869 Self::Io(value.to_string())
870 }
871}
872
873impl Display for ShelfError {
874 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
875 match self {
876 Self::Io(error) => write!(f, "shelf IO failed: {error}"),
877 Self::Parse(error) => write!(f, "shelf parse failed: {error}"),
878 }
879 }
880}
881
882impl std::error::Error for ShelfError {}
883
884#[derive(Debug, Clone, Default, PartialEq, Eq)]
886pub struct OriginState {
887 pub visits: u64,
889 pub last_url: Option<IndexUrl>,
891}
892
893impl OriginState {
894 pub fn record_visit(&mut self, url: IndexUrl) {
896 self.visits = self.visits.saturating_add(1);
897 self.last_url = Some(url);
898 }
899}
900
901#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
903pub enum SessionSidebarMode {
904 #[default]
906 Links,
907 Outline,
909 Forms,
911 Regions,
913 Search,
915 Logs,
917}
918
919impl SessionSidebarMode {
920 #[must_use]
922 pub const fn as_str(self) -> &'static str {
923 match self {
924 Self::Links => "links",
925 Self::Outline => "outline",
926 Self::Forms => "forms",
927 Self::Regions => "regions",
928 Self::Search => "search",
929 Self::Logs => "logs",
930 }
931 }
932
933 pub fn parse(input: &str) -> Option<Self> {
935 match input {
936 "links" => Some(Self::Links),
937 "outline" => Some(Self::Outline),
938 "forms" => Some(Self::Forms),
939 "regions" => Some(Self::Regions),
940 "search" => Some(Self::Search),
941 "logs" => Some(Self::Logs),
942 _ => None,
943 }
944 }
945
946 #[must_use]
948 pub const fn next(self) -> Self {
949 match self {
950 Self::Links => Self::Outline,
951 Self::Outline => Self::Forms,
952 Self::Forms => Self::Regions,
953 Self::Regions => Self::Search,
954 Self::Search => Self::Logs,
955 Self::Logs => Self::Links,
956 }
957 }
958
959 #[must_use]
961 pub const fn previous(self) -> Self {
962 match self {
963 Self::Links => Self::Logs,
964 Self::Logs => Self::Search,
965 Self::Outline => Self::Links,
966 Self::Forms => Self::Outline,
967 Self::Regions => Self::Forms,
968 Self::Search => Self::Regions,
969 }
970 }
971}
972
973impl Display for SessionSidebarMode {
974 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
975 f.write_str(self.as_str())
976 }
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
981pub enum ReaderProfile {
982 #[default]
984 Reader,
985 Docs,
987 Links,
989 Research,
991 Compact,
993 Verbose,
995}
996
997impl ReaderProfile {
998 #[must_use]
1000 pub const fn as_str(self) -> &'static str {
1001 match self {
1002 Self::Reader => "reader",
1003 Self::Docs => "docs",
1004 Self::Links => "links",
1005 Self::Research => "research",
1006 Self::Compact => "compact",
1007 Self::Verbose => "verbose",
1008 }
1009 }
1010
1011 pub fn parse(input: &str) -> Option<Self> {
1013 match input {
1014 "reader" => Some(Self::Reader),
1015 "docs" => Some(Self::Docs),
1016 "links" => Some(Self::Links),
1017 "research" => Some(Self::Research),
1018 "compact" => Some(Self::Compact),
1019 "verbose" => Some(Self::Verbose),
1020 _ => None,
1021 }
1022 }
1023
1024 #[must_use]
1026 pub const fn all() -> [Self; 6] {
1027 [
1028 Self::Reader,
1029 Self::Docs,
1030 Self::Links,
1031 Self::Research,
1032 Self::Compact,
1033 Self::Verbose,
1034 ]
1035 }
1036}
1037
1038impl Display for ReaderProfile {
1039 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1040 f.write_str(self.as_str())
1041 }
1042}
1043
1044#[derive(Debug, Clone, PartialEq, Eq, Default)]
1046pub struct SessionUiState {
1047 pub sidebar_mode: SessionSidebarMode,
1049 pub reader_profile: ReaderProfile,
1051 pub reader_profile_manual: bool,
1053}
1054
1055#[derive(Debug, Clone, PartialEq, Eq)]
1057pub struct SessionState {
1058 pub id: SessionId,
1060 pub history: HistoryStack,
1062 pub bookmarks: BookmarkStore,
1064 pub origins: BTreeMap<Origin, OriginState>,
1066 pub ui: SessionUiState,
1068}
1069
1070impl SessionState {
1071 #[must_use]
1073 pub fn new(id: SessionId) -> Self {
1074 Self {
1075 id,
1076 history: HistoryStack::new(),
1077 bookmarks: BookmarkStore::new(),
1078 origins: BTreeMap::new(),
1079 ui: SessionUiState::default(),
1080 }
1081 }
1082
1083 pub fn visit(&mut self, url: IndexUrl) {
1085 self.visit_entry(HistoryEntry::new(url));
1086 }
1087
1088 pub fn visit_entry(&mut self, entry: HistoryEntry) {
1090 if let Some(origin) = entry.origin() {
1091 self.origins
1092 .entry(origin)
1093 .or_default()
1094 .record_visit(entry.final_url.clone());
1095 }
1096 self.history.visit_entry(entry);
1097 }
1098
1099 pub fn go_back(&mut self) -> Option<&HistoryEntry> {
1101 self.history.go_back()
1102 }
1103
1104 pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
1106 self.history.go_forward()
1107 }
1108
1109 pub fn bookmark_current(&mut self, title: impl Into<String>) -> Option<()> {
1111 let url = self.history.current()?.final_url.clone();
1112 self.bookmarks.add(title, url);
1113 Some(())
1114 }
1115
1116 #[must_use]
1118 pub fn serialize(&self) -> String {
1119 let mut lines = Vec::new();
1120 lines.push("index-session-v1".to_owned());
1121 lines.push(format!("id\t{}", escape_field(self.id.as_str())));
1122 lines.push(format!(
1123 "ui\t{}\t{}\t{}",
1124 self.ui.sidebar_mode.as_str(),
1125 self.ui.reader_profile.as_str(),
1126 if self.ui.reader_profile_manual {
1127 "manual"
1128 } else {
1129 "auto"
1130 }
1131 ));
1132 if let Some(current_index) = self.history.current_index() {
1133 lines.push(format!("history\t{current_index}"));
1134 } else {
1135 lines.push("history\tnone".to_owned());
1136 }
1137 for entry in self.history.entries() {
1138 let mut fields = vec![
1139 "entry".to_owned(),
1140 escape_field(entry.requested_url.as_str()),
1141 escape_field(entry.final_url.as_str()),
1142 ];
1143 fields.extend(entry.redirects.iter().map(|url| escape_field(url.as_str())));
1144 lines.push(fields.join("\t"));
1145 }
1146 for bookmark in self.bookmarks.iter() {
1147 let mut fields = vec!["bookmark".to_owned()];
1148 fields.extend(serialized_bookmark_fields(bookmark));
1149 lines.push(fields.join("\t"));
1150 }
1151 for (origin, state) in &self.origins {
1152 let last_url = state
1153 .last_url
1154 .as_ref()
1155 .map_or_else(String::new, |url| escape_field(url.as_str()));
1156 lines.push(format!(
1157 "origin\t{}\t{}\t{}",
1158 escape_field(origin.as_str()),
1159 state.visits,
1160 last_url
1161 ));
1162 }
1163 lines.join("\n")
1164 }
1165
1166 pub fn deserialize(contents: &str) -> Result<Self, SessionError> {
1168 let mut lines = contents.lines();
1169 if lines.next() != Some("index-session-v1") {
1170 return Err(SessionError::Parse("missing session header".to_owned()));
1171 }
1172
1173 let mut id = None;
1174 let mut current_index = None;
1175 let mut entries = Vec::new();
1176 let mut bookmarks = BookmarkStore::new();
1177 let mut origins = BTreeMap::new();
1178 let mut ui = SessionUiState::default();
1179
1180 for line in lines {
1181 if line.is_empty() {
1182 continue;
1183 }
1184 let fields: Vec<&str> = line.split('\t').collect();
1185 match fields.first().copied() {
1186 Some("id") if fields.len() == 2 => {
1187 id = Some(SessionId::new(
1188 unescape_field(fields[1]).map_err(SessionError::Parse)?,
1189 ));
1190 }
1191 Some("history") if fields.len() == 2 => {
1192 current_index = parse_history_index(fields[1])?;
1193 }
1194 Some("ui") if (2..=4).contains(&fields.len()) => {
1195 let mode = SessionSidebarMode::parse(fields[1])
1196 .ok_or_else(|| SessionError::Parse("invalid sidebar mode".to_owned()))?;
1197 ui.sidebar_mode = mode;
1198 if fields.len() == 3 {
1199 ui.reader_profile = ReaderProfile::parse(fields[2]).ok_or_else(|| {
1200 SessionError::Parse("invalid reader profile".to_owned())
1201 })?;
1202 ui.reader_profile_manual = true;
1203 } else if fields.len() == 4 {
1204 ui.reader_profile = ReaderProfile::parse(fields[2]).ok_or_else(|| {
1205 SessionError::Parse("invalid reader profile".to_owned())
1206 })?;
1207 ui.reader_profile_manual = match fields[3] {
1208 "auto" => false,
1209 "manual" => true,
1210 _ => {
1211 return Err(SessionError::Parse(
1212 "invalid reader profile mode".to_owned(),
1213 ));
1214 }
1215 };
1216 }
1217 }
1218 Some("entry") if fields.len() >= 3 => {
1219 let requested_url = parse_session_url(fields[1])?;
1220 let final_url = parse_session_url(fields[2])?;
1221 let mut redirects = Vec::new();
1222 for field in &fields[3..] {
1223 redirects.push(parse_session_url(field)?);
1224 }
1225 entries.push(HistoryEntry::with_redirects(
1226 requested_url,
1227 final_url,
1228 redirects,
1229 ));
1230 }
1231 Some("bookmark") if fields.len() >= 3 => {
1232 let url = parse_session_url(fields[1])?;
1233 let title = unescape_field(fields[2]).map_err(SessionError::Parse)?;
1234 let note = if fields.len() >= 4 {
1235 Some(unescape_field(fields[3]).map_err(SessionError::Parse)?)
1236 .filter(|note| !note.trim().is_empty())
1237 } else {
1238 None
1239 };
1240 let mut tags = Vec::new();
1241 for field in &fields[4..] {
1242 tags.push(unescape_field(field).map_err(SessionError::Parse)?);
1243 }
1244 bookmarks.add_with_details(title, url, note, tags);
1245 }
1246 Some("origin") if fields.len() == 4 => {
1247 let origin = Origin::from_stored(
1248 unescape_field(fields[1]).map_err(SessionError::Parse)?,
1249 );
1250 let visits = fields[2]
1251 .parse::<u64>()
1252 .map_err(|error| SessionError::Parse(error.to_string()))?;
1253 let last_url = if fields[3].is_empty() {
1254 None
1255 } else {
1256 Some(parse_session_url(fields[3])?)
1257 };
1258 origins.insert(origin, OriginState { visits, last_url });
1259 }
1260 _ => return Err(SessionError::Parse("invalid session record".to_owned())),
1261 }
1262 }
1263
1264 let id = id.ok_or_else(|| SessionError::Parse("missing session id".to_owned()))?;
1265 let history = HistoryStack::from_entries(entries, current_index)?;
1266 Ok(Self {
1267 id,
1268 history,
1269 bookmarks,
1270 origins,
1271 ui,
1272 })
1273 }
1274
1275 pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
1277 fs::write(path, self.serialize()).map_err(SessionError::from)
1278 }
1279
1280 pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
1282 let contents = fs::read_to_string(path).map_err(SessionError::from)?;
1283 Self::deserialize(&contents)
1284 }
1285}
1286
1287#[derive(Debug)]
1289pub enum SessionError {
1290 Io(std::io::Error),
1292 Parse(String),
1294 Url(UrlError),
1296}
1297
1298impl From<std::io::Error> for SessionError {
1299 fn from(value: std::io::Error) -> Self {
1300 Self::Io(value)
1301 }
1302}
1303
1304impl Display for SessionError {
1305 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
1306 match self {
1307 Self::Io(error) => write!(f, "session IO failed: {error}"),
1308 Self::Parse(reason) => write!(f, "session data is invalid: {reason}"),
1309 Self::Url(error) => write!(f, "session URL is invalid: {error}"),
1310 }
1311 }
1312}
1313
1314impl std::error::Error for SessionError {}
1315
1316fn parse_history_index(input: &str) -> Result<Option<usize>, SessionError> {
1317 if input == "none" {
1318 Ok(None)
1319 } else {
1320 input
1321 .parse::<usize>()
1322 .map(Some)
1323 .map_err(|error| SessionError::Parse(error.to_string()))
1324 }
1325}
1326
1327fn parse_session_url(input: &str) -> Result<IndexUrl, SessionError> {
1328 let unescaped = unescape_field(input).map_err(SessionError::Parse)?;
1329 IndexUrl::parse(unescaped).map_err(SessionError::Url)
1330}
1331
1332fn escape_field(input: &str) -> String {
1333 let mut escaped = String::with_capacity(input.len());
1334 for ch in input.chars() {
1335 match ch {
1336 '\\' => escaped.push_str("\\\\"),
1337 '\t' => escaped.push_str("\\t"),
1338 '\n' => escaped.push_str("\\n"),
1339 '\r' => escaped.push_str("\\r"),
1340 _ => escaped.push(ch),
1341 }
1342 }
1343 escaped
1344}
1345
1346fn unescape_field(input: &str) -> Result<String, String> {
1347 let mut unescaped = String::with_capacity(input.len());
1348 let mut chars = input.chars();
1349 while let Some(ch) = chars.next() {
1350 if ch != '\\' {
1351 unescaped.push(ch);
1352 continue;
1353 }
1354
1355 let Some(next) = chars.next() else {
1356 return Err("dangling escape".to_owned());
1357 };
1358 match next {
1359 '\\' => unescaped.push('\\'),
1360 't' => unescaped.push('\t'),
1361 'n' => unescaped.push('\n'),
1362 'r' => unescaped.push('\r'),
1363 other => return Err(format!("unknown escape: {other}")),
1364 }
1365 }
1366 Ok(unescaped)
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371 use std::time::{SystemTime, UNIX_EPOCH};
1372
1373 use super::{
1374 BookmarkStore, HistoryEntry, HistoryStack, KnowledgeShelf, OriginState, ReaderProfile,
1375 ResponseLogEntry, SessionId, SessionSidebarMode, SessionState, ShelfError, ShelfRecord,
1376 };
1377 use crate::{IndexUrl, Origin};
1378
1379 #[test]
1380 fn history_supports_back_and_forward_navigation() -> Result<(), Box<dyn std::error::Error>> {
1381 let first = IndexUrl::parse("https://example.com/one")?;
1382 let second = IndexUrl::parse("https://example.com/two")?;
1383 let third = IndexUrl::parse("https://example.com/three")?;
1384 let mut history = HistoryStack::new();
1385
1386 history.visit(first.clone());
1387 history.visit(second.clone());
1388 history.visit(third);
1389
1390 assert_eq!(
1391 history.go_back().map(|entry| &entry.final_url),
1392 Some(&second)
1393 );
1394 assert_eq!(
1395 history.go_back().map(|entry| &entry.final_url),
1396 Some(&first)
1397 );
1398 assert!(history.go_back().is_none());
1399 assert_eq!(
1400 history.go_forward().map(|entry| &entry.final_url),
1401 Some(&second)
1402 );
1403 Ok(())
1404 }
1405
1406 #[test]
1407 fn visiting_new_url_clears_forward_history() -> Result<(), Box<dyn std::error::Error>> {
1408 let first = IndexUrl::parse("https://example.com/one")?;
1409 let second = IndexUrl::parse("https://example.com/two")?;
1410 let third = IndexUrl::parse("https://example.com/three")?;
1411 let mut history = HistoryStack::new();
1412
1413 history.visit(first);
1414 history.visit(second);
1415 assert!(history.go_back().is_some());
1416 history.visit(third);
1417
1418 assert!(!history.can_go_forward());
1419 Ok(())
1420 }
1421
1422 #[test]
1423 fn response_log_entries_redact_and_bound_server_body() {
1424 let entry = ResponseLogEntry::new(
1425 7,
1426 "POST",
1427 "https://example.com/login?token=secret-token&next=/home",
1428 "https://example.com/home",
1429 Some("text/html"),
1430 "Welcome token=abc123 password=hunter2 visible text",
1431 28,
1432 );
1433
1434 assert_eq!(entry.sequence, 7);
1435 assert!(entry.requested_url.contains("token=[REDACTED]"));
1436 assert!(!entry.requested_url.contains("secret-token"));
1437 assert!(!entry.body_preview.contains("abc123"));
1438 assert!(!entry.body_preview.contains("hunter2"));
1439 assert!(entry.body_preview.chars().count() <= 28);
1440 assert!(entry.truncated);
1441 assert!(entry.title().contains("POST"));
1442 }
1443
1444 #[test]
1445 fn bookmarks_persist_to_disk() -> Result<(), Box<dyn std::error::Error>> {
1446 let path = temp_path("bookmarks");
1447 let mut store = BookmarkStore::new();
1448 let url = IndexUrl::parse("https://example.com/docs")?;
1449 store.add_with_details(
1450 "Docs\tIndex",
1451 url.clone(),
1452 Some("Read before release".to_owned()),
1453 vec!["rust".to_owned(), "docs".to_owned(), "rust".to_owned()],
1454 );
1455
1456 store.save_to_path(&path)?;
1457 let restored = BookmarkStore::load_from_path(&path)?;
1458 std::fs::remove_file(&path)?;
1459
1460 assert!(restored.contains(&url));
1461 assert_eq!(
1462 restored
1463 .iter()
1464 .next()
1465 .map(|bookmark| bookmark.title.as_str()),
1466 Some("Docs\tIndex")
1467 );
1468 let bookmark = restored.iter().next().ok_or("missing bookmark")?;
1469 assert_eq!(bookmark.note.as_deref(), Some("Read before release"));
1470 assert_eq!(bookmark.tags, vec!["docs".to_owned(), "rust".to_owned()]);
1471 Ok(())
1472 }
1473
1474 #[test]
1475 fn bookmark_details_can_be_updated() -> Result<(), Box<dyn std::error::Error>> {
1476 let mut store = BookmarkStore::new();
1477 let url = IndexUrl::parse("https://example.com/docs")?;
1478 store.add("Docs", url.clone());
1479
1480 assert_eq!(
1481 store.update_details(
1482 &url,
1483 Some("Updated".to_owned()),
1484 vec!["research".to_owned(), "docs".to_owned()],
1485 ),
1486 Some(())
1487 );
1488
1489 let bookmark = store.iter().next().ok_or("missing bookmark")?;
1490 assert_eq!(bookmark.note.as_deref(), Some("Updated"));
1491 assert_eq!(
1492 bookmark.tags,
1493 vec!["docs".to_owned(), "research".to_owned()]
1494 );
1495 Ok(())
1496 }
1497
1498 #[test]
1499 fn shelf_ids_are_deterministic_and_records_roundtrip() -> Result<(), Box<dyn std::error::Error>>
1500 {
1501 let path = temp_path("shelf");
1502 let mut record = ShelfRecord::new(
1503 "Index Guide",
1504 Some("https://example.org/guide".to_owned()),
1505 Some("strong-generic".to_owned()),
1506 "12345",
1507 vec!["https://example.org/cite".to_owned()],
1508 "exports/index-guide.md",
1509 "exports/index-guide.json",
1510 );
1511 let same = ShelfRecord::new(
1512 "Index Guide",
1513 Some("https://example.org/guide".to_owned()),
1514 None,
1515 "later",
1516 Vec::new(),
1517 "a.md",
1518 "a.json",
1519 );
1520 assert_eq!(record.id, same.id);
1521
1522 record.add_tag("docs");
1523 record.add_tag("rust");
1524 record.add_tag("docs");
1525 record.set_note("Keep for packaging");
1526 let mut shelf = KnowledgeShelf::new();
1527 shelf.upsert(record.clone());
1528 shelf.save_to_path(&path)?;
1529
1530 let restored = KnowledgeShelf::load_from_path(&path)?;
1531 let restored_record = restored.get(&record.id).ok_or("missing shelf record")?;
1532 assert_eq!(restored_record.title, "Index Guide");
1533 assert_eq!(
1534 restored_record.tags,
1535 vec!["docs".to_owned(), "rust".to_owned()]
1536 );
1537 assert_eq!(restored_record.note.as_deref(), Some("Keep for packaging"));
1538 std::fs::remove_file(&path)?;
1539 Ok(())
1540 }
1541
1542 #[test]
1543 fn missing_shelf_loads_as_empty() -> Result<(), Box<dyn std::error::Error>> {
1544 let path = temp_path("missing-shelf");
1545 let shelf = KnowledgeShelf::load_from_path(&path)?;
1546 assert_eq!(shelf.iter().count(), 0);
1547 Ok(())
1548 }
1549
1550 #[test]
1551 fn shelf_loader_rejects_invalid_records() {
1552 assert_eq!(
1553 KnowledgeShelf::from_serialized("bad").map(|_| ()),
1554 Err(ShelfError::Parse("missing shelf header".to_owned()))
1555 );
1556 assert_eq!(
1557 KnowledgeShelf::from_serialized("index-shelf-v1\nonly-one-field").map(|_| ()),
1558 Err(ShelfError::Parse("invalid shelf record".to_owned()))
1559 );
1560 assert!(
1561 KnowledgeShelf::from_serialized(
1562 "index-shelf-v1\nid\\\ttitle\t\t\t123\t\t\t\tout.md\tout.json"
1563 )
1564 .is_err()
1565 );
1566 assert_eq!(
1567 ShelfError::Parse("bad field".to_owned()).to_string(),
1568 "shelf parse failed: bad field"
1569 );
1570 }
1571
1572 #[test]
1573 fn shelf_search_ranks_metadata_and_markdown_matches() {
1574 let mut rust = ShelfRecord::new(
1575 "Rust Guide",
1576 Some("https://example.org/rust".to_owned()),
1577 Some("strong-generic".to_owned()),
1578 "1",
1579 vec!["https://example.org/citation".to_owned()],
1580 "rust.md",
1581 "rust.json",
1582 );
1583 rust.add_tag("docs");
1584 rust.set_note("ownership and borrowing");
1585 let rust_id = rust.id.clone();
1586
1587 let mut archive = ShelfRecord::new(
1588 "Archive Notes",
1589 Some("https://example.org/archive".to_owned()),
1590 None,
1591 "2",
1592 Vec::new(),
1593 "archive.md",
1594 "archive.json",
1595 );
1596 archive.set_note("release history");
1597
1598 let mut shelf = KnowledgeShelf::new();
1599 shelf.upsert(archive);
1600 shelf.upsert(rust);
1601
1602 let results = shelf.search("ownership", |record| {
1603 (record.id == rust_id).then(|| "# Ownership\nDetailed notes".to_owned())
1604 });
1605
1606 assert_eq!(results.len(), 1);
1607 assert_eq!(results[0].title, "Rust Guide");
1608 assert!(results[0].score > 40);
1609 assert!(results[0].matched_fields.contains(&"note".to_owned()));
1610 assert!(
1611 results[0]
1612 .matched_fields
1613 .contains(&"markdown_headings".to_owned())
1614 );
1615
1616 let empty = shelf.search(" ", |_| None);
1617 assert!(empty.is_empty());
1618 }
1619
1620 #[test]
1621 fn shelf_search_matches_non_english_titles_notes_tags_and_headings() {
1622 let mut spanish = ShelfRecord::new(
1623 "Índice Público",
1624 Some("https://example.org/es".to_owned()),
1625 Some("strong-generic".to_owned()),
1626 "1",
1627 Vec::new(),
1628 "es.md",
1629 "es.json",
1630 );
1631 spanish.add_tag("manuales");
1632 spanish.set_note("Navegación tranquila");
1633 let spanish_id = spanish.id.clone();
1634
1635 let arabic = ShelfRecord::new(
1636 "فهرس المعرفة",
1637 Some("https://example.org/ar".to_owned()),
1638 Some("strong-generic".to_owned()),
1639 "2",
1640 Vec::new(),
1641 "ar.md",
1642 "ar.json",
1643 );
1644 let arabic_id = arabic.id.clone();
1645
1646 let cjk = ShelfRecord::new(
1647 "公開リファレンス",
1648 Some("https://example.org/ja".to_owned()),
1649 Some("strong-generic".to_owned()),
1650 "3",
1651 Vec::new(),
1652 "ja.md",
1653 "ja.json",
1654 );
1655 let cjk_id = cjk.id.clone();
1656
1657 let mut shelf = KnowledgeShelf::new();
1658 shelf.upsert(spanish);
1659 shelf.upsert(arabic);
1660 shelf.upsert(cjk);
1661
1662 let title_results = shelf.search("índice", |_| None);
1663 assert_eq!(
1664 title_results.first().map(|result| result.id.as_str()),
1665 Some(spanish_id.as_str())
1666 );
1667
1668 let note_results = shelf.search("NAVEGACIÓN", |_| None);
1669 assert_eq!(
1670 note_results.first().map(|result| result.id.as_str()),
1671 Some(spanish_id.as_str())
1672 );
1673
1674 let tag_results = shelf.search("MANUALES", |_| None);
1675 assert_eq!(
1676 tag_results.first().map(|result| result.id.as_str()),
1677 Some(spanish_id.as_str())
1678 );
1679
1680 let arabic_results = shelf.search("المعرفة", |_| None);
1681 assert_eq!(
1682 arabic_results.first().map(|result| result.id.as_str()),
1683 Some(arabic_id.as_str())
1684 );
1685
1686 let cjk_results = shelf.search("見出し", |record| {
1687 (record.id == cjk_id).then(|| "# 見出し\n知識ページ".to_owned())
1688 });
1689 assert_eq!(
1690 cjk_results.first().map(|result| result.id.as_str()),
1691 Some(cjk_id.as_str())
1692 );
1693 }
1694
1695 #[test]
1696 fn shelf_search_uses_deterministic_tie_breaks() {
1697 let alpha = ShelfRecord::new("Alpha", None, None, "1", Vec::new(), "a.md", "a.json");
1698 let beta = ShelfRecord::new("Beta", None, None, "2", Vec::new(), "b.md", "b.json");
1699 let mut shelf = KnowledgeShelf::new();
1700 shelf.upsert(beta);
1701 shelf.upsert(alpha);
1702
1703 let results = shelf.search("shared", |_| Some("shared".to_owned()));
1704
1705 assert_eq!(
1706 results
1707 .iter()
1708 .map(|result| result.title.as_str())
1709 .collect::<Vec<_>>(),
1710 vec!["Alpha", "Beta"]
1711 );
1712 }
1713
1714 #[test]
1715 fn session_serialization_roundtrips_history_bookmarks_origins_and_redirects()
1716 -> Result<(), Box<dyn std::error::Error>> {
1717 let requested = IndexUrl::parse("http://example.com/start")?;
1718 let redirect = IndexUrl::parse("https://example.com/start")?;
1719 let final_url = IndexUrl::parse("https://example.com/home")?;
1720 let docs = IndexUrl::parse("https://example.com/docs")?;
1721 let mut session = SessionState::new(SessionId::new("main"));
1722 session.ui.sidebar_mode = SessionSidebarMode::Regions;
1723 session.ui.reader_profile = ReaderProfile::Research;
1724 session.ui.reader_profile_manual = true;
1725
1726 session.visit_entry(HistoryEntry::with_redirects(
1727 requested.clone(),
1728 final_url.clone(),
1729 vec![redirect.clone()],
1730 ));
1731 session.visit(docs.clone());
1732 assert!(session.go_back().is_some());
1733 session.bookmark_current("Home");
1734 session.bookmarks.update_details(
1735 &final_url,
1736 Some("Return later".to_owned()),
1737 vec!["home".to_owned(), "reference".to_owned()],
1738 );
1739
1740 let restored = SessionState::deserialize(&session.serialize())?;
1741
1742 assert_eq!(restored.id.as_str(), "main");
1743 assert_eq!(
1744 restored.history.current().map(|entry| &entry.final_url),
1745 Some(&final_url)
1746 );
1747 assert!(restored.bookmarks.contains(&final_url));
1748 let bookmark = restored
1749 .bookmarks
1750 .iter()
1751 .find(|bookmark| bookmark.url == final_url)
1752 .ok_or("missing bookmark")?;
1753 assert_eq!(bookmark.note.as_deref(), Some("Return later"));
1754 assert_eq!(
1755 bookmark.tags,
1756 vec!["home".to_owned(), "reference".to_owned()]
1757 );
1758 assert_eq!(
1759 restored
1760 .history
1761 .current()
1762 .and_then(|entry| entry.redirects.first()),
1763 Some(&redirect)
1764 );
1765 assert_eq!(
1766 restored
1767 .origins
1768 .get(&Origin::from_stored("https://example.com"))
1769 .map(|state| state.visits),
1770 Some(2)
1771 );
1772 assert_eq!(restored.ui.sidebar_mode, SessionSidebarMode::Regions);
1773 assert_eq!(restored.ui.reader_profile, ReaderProfile::Research);
1774 assert!(restored.ui.reader_profile_manual);
1775 Ok(())
1776 }
1777
1778 #[test]
1779 fn session_sidebar_mode_names_are_stable() {
1780 let modes = [
1781 (SessionSidebarMode::Links, "links"),
1782 (SessionSidebarMode::Outline, "outline"),
1783 (SessionSidebarMode::Forms, "forms"),
1784 (SessionSidebarMode::Regions, "regions"),
1785 (SessionSidebarMode::Search, "search"),
1786 (SessionSidebarMode::Logs, "logs"),
1787 ];
1788
1789 for (mode, name) in modes {
1790 assert_eq!(mode.as_str(), name);
1791 assert_eq!(mode.to_string(), name);
1792 assert_eq!(SessionSidebarMode::parse(name), Some(mode));
1793 }
1794
1795 assert_eq!(
1796 SessionSidebarMode::Links.previous(),
1797 SessionSidebarMode::Logs
1798 );
1799 assert_eq!(SessionSidebarMode::Search.next(), SessionSidebarMode::Logs);
1800 assert_eq!(SessionSidebarMode::Logs.next(), SessionSidebarMode::Links);
1801 assert_eq!(SessionSidebarMode::parse("unknown"), None);
1802 }
1803
1804 #[test]
1805 fn reader_profile_names_are_stable() {
1806 let profiles = [
1807 (ReaderProfile::Reader, "reader"),
1808 (ReaderProfile::Docs, "docs"),
1809 (ReaderProfile::Links, "links"),
1810 (ReaderProfile::Research, "research"),
1811 (ReaderProfile::Compact, "compact"),
1812 (ReaderProfile::Verbose, "verbose"),
1813 ];
1814
1815 assert_eq!(ReaderProfile::all(), profiles.map(|(profile, _)| profile));
1816
1817 for (profile, name) in profiles {
1818 assert_eq!(profile.as_str(), name);
1819 assert_eq!(profile.to_string(), name);
1820 assert_eq!(ReaderProfile::parse(name), Some(profile));
1821 }
1822
1823 assert_eq!(ReaderProfile::parse("unknown"), None);
1824 }
1825
1826 #[test]
1827 fn old_session_ui_lines_default_to_reader_profile() -> Result<(), Box<dyn std::error::Error>> {
1828 let restored =
1829 SessionState::deserialize("index-session-v1\nid\tmain\nui\tregions\nhistory\tnone")?;
1830
1831 assert_eq!(restored.ui.sidebar_mode, SessionSidebarMode::Regions);
1832 assert_eq!(restored.ui.reader_profile, ReaderProfile::Reader);
1833 assert!(!restored.ui.reader_profile_manual);
1834 let restored = SessionState::deserialize(
1835 "index-session-v1\nid\tmain\nui\tregions\tdocs\tmanual\nhistory\tnone",
1836 )?;
1837 assert_eq!(restored.ui.reader_profile, ReaderProfile::Docs);
1838 assert!(restored.ui.reader_profile_manual);
1839 Ok(())
1840 }
1841
1842 #[test]
1843 fn empty_session_roundtrips_and_current_bookmark_is_noop()
1844 -> Result<(), Box<dyn std::error::Error>> {
1845 let mut session = SessionState::new(SessionId::new("empty"));
1846
1847 assert_eq!(session.bookmark_current("Nothing"), None);
1848 assert!(session.go_back().is_none());
1849 assert!(session.go_forward().is_none());
1850
1851 let restored = SessionState::deserialize(&session.serialize())?;
1852 assert_eq!(restored.id.as_str(), "empty");
1853 assert!(restored.history.current().is_none());
1854 Ok(())
1855 }
1856
1857 #[test]
1858 fn session_persists_to_disk() -> Result<(), Box<dyn std::error::Error>> {
1859 let path = temp_path("session");
1860 let mut session = SessionState::new(SessionId::new("disk"));
1861 let url = IndexUrl::parse("https://example.com/session")?;
1862 session.visit(url.clone());
1863
1864 session.save_to_path(&path)?;
1865 let restored = SessionState::load_from_path(&path)?;
1866 std::fs::remove_file(&path)?;
1867
1868 assert_eq!(
1869 restored.history.current().map(|entry| &entry.final_url),
1870 Some(&url)
1871 );
1872 Ok(())
1873 }
1874
1875 #[test]
1876 fn bookmark_remove_returns_removed_entry() -> Result<(), Box<dyn std::error::Error>> {
1877 let url = IndexUrl::parse("https://example.com/remove")?;
1878 let mut store = BookmarkStore::new();
1879 store.add("Remove me", url.clone());
1880
1881 let removed = store.remove(&url);
1882
1883 assert_eq!(
1884 removed.map(|bookmark| bookmark.title),
1885 Some("Remove me".to_owned())
1886 );
1887 assert!(!store.contains(&url));
1888 Ok(())
1889 }
1890
1891 #[test]
1892 fn bookmark_loader_rejects_invalid_records() {
1893 assert!(BookmarkStore::from_serialized("bad").is_err());
1894 assert!(BookmarkStore::from_serialized("index-bookmarks-v1\nonly-one-field").is_err());
1895 assert!(
1896 BookmarkStore::from_serialized("index-bookmarks-v1\nhttps://example.com\\\tTitle")
1897 .is_err()
1898 );
1899 assert!(BookmarkStore::from_serialized("index-bookmarks-v1\nbad-url\tTitle").is_err());
1900 }
1901
1902 #[test]
1903 fn session_loader_rejects_invalid_records() {
1904 assert!(SessionState::deserialize("bad").is_err());
1905 assert!(SessionState::deserialize("index-session-v1\nhistory\t0").is_err());
1906 assert!(SessionState::deserialize("index-session-v1\nid\tmain\nbad\tfield").is_err());
1907 assert!(SessionState::deserialize("index-session-v1\nid\tmain\nhistory\t1").is_err());
1908 assert!(SessionState::deserialize("index-session-v1\nid\tmain\nhistory\tabc").is_err());
1909 assert!(
1910 SessionState::deserialize(
1911 "index-session-v1\nid\tmain\nhistory\tnone\nentry\tbad-url\thttps://example.com",
1912 )
1913 .is_err()
1914 );
1915 assert!(
1916 SessionState::deserialize(
1917 "index-session-v1\nid\tmain\nhistory\tnone\norigin\thttps://example.com\tabc\t",
1918 )
1919 .is_err()
1920 );
1921 }
1922
1923 #[test]
1924 fn per_origin_state_records_visit_count_and_last_url() -> Result<(), Box<dyn std::error::Error>>
1925 {
1926 let first = IndexUrl::parse("https://example.com/one")?;
1927 let second = IndexUrl::parse("https://example.com/two")?;
1928 let mut state = OriginState::default();
1929
1930 state.record_visit(first);
1931 state.record_visit(second.clone());
1932
1933 assert_eq!(state.visits, 2);
1934 assert_eq!(state.last_url, Some(second));
1935 Ok(())
1936 }
1937
1938 #[test]
1939 fn redirect_entries_keep_requested_final_and_hops() -> Result<(), Box<dyn std::error::Error>> {
1940 let requested = IndexUrl::parse("http://example.com")?;
1941 let hop = IndexUrl::parse("https://example.com")?;
1942 let final_url = IndexUrl::parse("https://www.example.com")?;
1943 let entry =
1944 HistoryEntry::with_redirects(requested.clone(), final_url.clone(), vec![hop.clone()]);
1945
1946 assert_eq!(entry.requested_url, requested);
1947 assert_eq!(entry.final_url, final_url);
1948 assert_eq!(entry.redirects, vec![hop]);
1949 Ok(())
1950 }
1951
1952 fn temp_path(name: &str) -> std::path::PathBuf {
1953 let mut path = std::env::temp_dir();
1954 let nanos = SystemTime::now()
1955 .duration_since(UNIX_EPOCH)
1956 .map_or(0, |duration| duration.as_nanos());
1957 path.push(format!("index-{name}-{nanos}.txt"));
1958 path
1959 }
1960}