Skip to main content

index_core/
navigation.rs

1//! Navigation, bookmark, and session state.
2
3use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5use std::fs;
6use std::path::Path;
7
8use crate::{IndexUrl, Origin, UrlError};
9
10/// Stable identifier for a persisted browsing session.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub struct SessionId(String);
13
14impl SessionId {
15    /// Creates a session identifier.
16    #[must_use]
17    pub fn new(input: impl Into<String>) -> Self {
18        Self(input.into())
19    }
20
21    /// Returns the stored session identifier.
22    #[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/// A history entry with redirect information.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HistoryEntry {
37    /// URL the user or caller requested.
38    pub requested_url: IndexUrl,
39    /// Final URL after redirects.
40    pub final_url: IndexUrl,
41    /// Redirect hops observed before the final URL.
42    pub redirects: Vec<IndexUrl>,
43}
44
45impl HistoryEntry {
46    /// Creates a history entry without redirects.
47    #[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    /// Creates a history entry with redirect metadata.
57    #[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    /// Returns the final URL origin.
71    #[must_use]
72    pub fn origin(&self) -> Option<Origin> {
73        self.final_url.origin()
74    }
75}
76
77/// Browser-like back/forward history.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct HistoryStack {
80    back: Vec<HistoryEntry>,
81    current: Option<HistoryEntry>,
82    forward: Vec<HistoryEntry>,
83}
84
85/// Redacted summary of one server response observed during a local session.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ResponseLogEntry {
88    /// Monotonic local sequence number.
89    pub sequence: u64,
90    /// HTTP-like method or semantic source such as `GET` or `POST`.
91    pub method: String,
92    /// URL requested by the user or form.
93    pub requested_url: String,
94    /// Final URL reported by the transport.
95    pub final_url: String,
96    /// MIME type when known.
97    pub mime_type: Option<String>,
98    /// Redacted body preview.
99    pub body_preview: String,
100    /// Whether the body preview was truncated.
101    pub truncated: bool,
102}
103
104impl ResponseLogEntry {
105    /// Creates a redacted, bounded response log entry.
106    #[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    /// Returns a compact title for sidebar display.
133    #[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    /// Creates an empty history stack.
191    #[must_use]
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Visits a URL and clears the forward stack.
197    pub fn visit(&mut self, url: IndexUrl) {
198        self.visit_entry(HistoryEntry::new(url));
199    }
200
201    /// Visits a fully described history entry and clears the forward stack.
202    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    /// Moves one step back and returns the new current entry.
211    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    /// Moves one step forward and returns the new current entry.
221    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    /// Returns the current history entry.
231    #[must_use]
232    pub fn current(&self) -> Option<&HistoryEntry> {
233        self.current.as_ref()
234    }
235
236    /// Returns whether back navigation is available.
237    #[must_use]
238    pub fn can_go_back(&self) -> bool {
239        !self.back.is_empty()
240    }
241
242    /// Returns whether forward navigation is available.
243    #[must_use]
244    pub fn can_go_forward(&self) -> bool {
245        !self.forward.is_empty()
246    }
247
248    /// Returns all entries in display order.
249    #[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    /// Returns the current entry index in `entries`.
259    #[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/// A saved bookmark.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct Bookmark {
302    /// Human-readable title.
303    pub title: String,
304    /// Target URL.
305    pub url: IndexUrl,
306    /// Optional local note for offline research workflows.
307    pub note: Option<String>,
308    /// Stable local tags for grouping bookmarks.
309    pub tags: Vec<String>,
310}
311
312/// Bookmark persistence and lookup.
313#[derive(Debug, Clone, Default, PartialEq, Eq)]
314pub struct BookmarkStore {
315    bookmarks: BTreeMap<String, Bookmark>,
316}
317
318impl BookmarkStore {
319    /// Creates an empty bookmark store.
320    #[must_use]
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Adds or replaces a bookmark.
326    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    /// Adds or replaces a bookmark with local notes and tags.
331    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    /// Updates notes and tags for an existing bookmark.
350    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    /// Removes a bookmark by URL.
363    pub fn remove(&mut self, url: &IndexUrl) -> Option<Bookmark> {
364        self.bookmarks.remove(url.as_str())
365    }
366
367    /// Returns whether the URL is bookmarked.
368    #[must_use]
369    pub fn contains(&self, url: &IndexUrl) -> bool {
370        self.bookmarks.contains_key(url.as_str())
371    }
372
373    /// Iterates bookmarks in stable URL order.
374    pub fn iter(&self) -> impl Iterator<Item = &Bookmark> {
375        self.bookmarks.values()
376    }
377
378    /// Saves bookmarks to disk.
379    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    /// Loads bookmarks from disk.
389    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/// Bookmark persistence errors.
452#[derive(Debug)]
453pub enum BookmarkError {
454    /// Filesystem error.
455    Io(std::io::Error),
456    /// Serialized data was invalid.
457    Parse(String),
458    /// Stored URL was invalid.
459    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/// Durable local knowledge shelf record.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct ShelfRecord {
483    /// Deterministic shelf identifier.
484    pub id: String,
485    /// Saved document title.
486    pub title: String,
487    /// Source URL when known.
488    pub source_url: Option<String>,
489    /// Transform quality category when known.
490    pub quality: Option<String>,
491    /// Saved timestamp as deterministic local text.
492    pub saved_at: String,
493    /// Sorted local tags.
494    pub tags: Vec<String>,
495    /// Optional local note.
496    pub note: Option<String>,
497    /// External citation URLs saved with the record.
498    pub citations: Vec<String>,
499    /// Markdown export path.
500    pub markdown_path: String,
501    /// JSON export path.
502    pub json_path: String,
503}
504
505impl ShelfRecord {
506    /// Creates a shelf record with a deterministic id.
507    #[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    /// Adds a tag, preserving sorted uniqueness.
534    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    /// Replaces the local note.
544    pub fn set_note(&mut self, note: impl Into<String>) {
545        self.note = Some(note.into());
546    }
547}
548
549/// Search result for a local knowledge shelf query.
550#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct ShelfSearchResult {
552    /// Matching shelf record id.
553    pub id: String,
554    /// Matching shelf record title.
555    pub title: String,
556    /// Optional source URL.
557    pub source_url: Option<String>,
558    /// Deterministic rank score.
559    pub score: u16,
560    /// Fields that matched the query.
561    pub matched_fields: Vec<String>,
562}
563
564/// Local knowledge shelf store.
565#[derive(Debug, Clone, PartialEq, Eq, Default)]
566pub struct KnowledgeShelf {
567    records: BTreeMap<String, ShelfRecord>,
568}
569
570impl KnowledgeShelf {
571    /// Creates an empty shelf.
572    #[must_use]
573    pub fn new() -> Self {
574        Self::default()
575    }
576
577    /// Inserts or replaces a shelf record.
578    pub fn upsert(&mut self, record: ShelfRecord) {
579        self.records.insert(record.id.clone(), record);
580    }
581
582    /// Returns a shelf record.
583    #[must_use]
584    pub fn get(&self, id: &str) -> Option<&ShelfRecord> {
585        self.records.get(id)
586    }
587
588    /// Returns a mutable shelf record.
589    pub fn get_mut(&mut self, id: &str) -> Option<&mut ShelfRecord> {
590        self.records.get_mut(id)
591    }
592
593    /// Iterates records in deterministic id order.
594    pub fn iter(&self) -> impl Iterator<Item = &ShelfRecord> {
595        self.records.values()
596    }
597
598    /// Searches shelf metadata and caller-provided Markdown exports.
599    ///
600    /// The `markdown_for` callback lets hosts load exported Markdown from local
601    /// storage without making `index-core` perform filesystem IO.
602    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    /// Saves the shelf to disk.
635    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    /// Loads a shelf from disk. Missing shelves are treated as empty shelves.
644    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/// Knowledge shelf persistence errors.
859#[derive(Debug, Clone, PartialEq, Eq)]
860pub enum ShelfError {
861    /// Filesystem failure.
862    Io(String),
863    /// Parse failure.
864    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/// Per-origin browsing state.
885#[derive(Debug, Clone, Default, PartialEq, Eq)]
886pub struct OriginState {
887    /// Number of visits recorded for this origin.
888    pub visits: u64,
889    /// Last final URL observed for this origin.
890    pub last_url: Option<IndexUrl>,
891}
892
893impl OriginState {
894    /// Records a visit for this origin.
895    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/// Persisted sidebar mode preference for a browsing session.
902#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
903pub enum SessionSidebarMode {
904    /// Sidebar lists page links.
905    #[default]
906    Links,
907    /// Sidebar lists headings and page regions.
908    Outline,
909    /// Sidebar lists forms and actions.
910    Forms,
911    /// Sidebar lists semantic page regions.
912    Regions,
913    /// Sidebar lists the latest search results.
914    Search,
915    /// Sidebar lists local redacted response logs.
916    Logs,
917}
918
919impl SessionSidebarMode {
920    /// Returns the stable serialized mode name.
921    #[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    /// Parses a persisted sidebar mode.
934    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    /// Returns the next sidebar mode.
947    #[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    /// Returns the previous sidebar mode.
960    #[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/// Persisted reader profile preference for terminal presentation.
980#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
981pub enum ReaderProfile {
982    /// Balanced reading defaults.
983    #[default]
984    Reader,
985    /// Documentation-oriented emphasis for headings, tables, and code.
986    Docs,
987    /// Link-forward emphasis for navigation-heavy pages.
988    Links,
989    /// Research emphasis for quotes, references, and diagnostics.
990    Research,
991    /// Dense presentation for small terminals.
992    Compact,
993    /// More explicit visual cues for structure and status.
994    Verbose,
995}
996
997impl ReaderProfile {
998    /// Returns the stable serialized profile name.
999    #[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    /// Parses a persisted reader profile.
1012    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    /// Returns all stable profile variants in display order.
1025    #[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/// Persisted terminal UI state for a browsing session.
1045#[derive(Debug, Clone, PartialEq, Eq, Default)]
1046pub struct SessionUiState {
1047    /// Last selected sidebar mode.
1048    pub sidebar_mode: SessionSidebarMode,
1049    /// Last selected reader profile.
1050    pub reader_profile: ReaderProfile,
1051    /// Whether the profile was selected explicitly by the user.
1052    pub reader_profile_manual: bool,
1053}
1054
1055/// Complete persisted browsing session state.
1056#[derive(Debug, Clone, PartialEq, Eq)]
1057pub struct SessionState {
1058    /// Session identifier.
1059    pub id: SessionId,
1060    /// Back/forward navigation state.
1061    pub history: HistoryStack,
1062    /// Saved bookmarks.
1063    pub bookmarks: BookmarkStore,
1064    /// Per-origin state.
1065    pub origins: BTreeMap<Origin, OriginState>,
1066    /// Persisted terminal UI preferences.
1067    pub ui: SessionUiState,
1068}
1069
1070impl SessionState {
1071    /// Creates an empty session.
1072    #[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    /// Visits a URL in this session.
1084    pub fn visit(&mut self, url: IndexUrl) {
1085        self.visit_entry(HistoryEntry::new(url));
1086    }
1087
1088    /// Visits a history entry in this session.
1089    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    /// Moves one step back.
1100    pub fn go_back(&mut self) -> Option<&HistoryEntry> {
1101        self.history.go_back()
1102    }
1103
1104    /// Moves one step forward.
1105    pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
1106        self.history.go_forward()
1107    }
1108
1109    /// Bookmarks the current page when one is loaded.
1110    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    /// Serializes the session to a deterministic text representation.
1117    #[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    /// Deserializes a saved session.
1167    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    /// Saves the session to disk.
1276    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    /// Loads a session from disk.
1281    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/// Session persistence errors.
1288#[derive(Debug)]
1289pub enum SessionError {
1290    /// Filesystem error.
1291    Io(std::io::Error),
1292    /// Serialized data was invalid.
1293    Parse(String),
1294    /// Stored URL was invalid.
1295    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}