use std::collections::BTreeMap;
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::Path;
use crate::{IndexUrl, Origin, UrlError};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SessionId(String);
impl SessionId {
#[must_use]
pub fn new(input: impl Into<String>) -> Self {
Self(input.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Display for SessionId {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryEntry {
pub requested_url: IndexUrl,
pub final_url: IndexUrl,
pub redirects: Vec<IndexUrl>,
}
impl HistoryEntry {
#[must_use]
pub fn new(url: IndexUrl) -> Self {
Self {
requested_url: url.clone(),
final_url: url,
redirects: Vec::new(),
}
}
#[must_use]
pub fn with_redirects(
requested_url: IndexUrl,
final_url: IndexUrl,
redirects: Vec<IndexUrl>,
) -> Self {
Self {
requested_url,
final_url,
redirects,
}
}
#[must_use]
pub fn origin(&self) -> Option<Origin> {
self.final_url.origin()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HistoryStack {
back: Vec<HistoryEntry>,
current: Option<HistoryEntry>,
forward: Vec<HistoryEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResponseLogEntry {
pub sequence: u64,
pub method: String,
pub requested_url: String,
pub final_url: String,
pub mime_type: Option<String>,
pub body_preview: String,
pub truncated: bool,
}
impl ResponseLogEntry {
#[must_use]
pub fn new(
sequence: u64,
method: impl Into<String>,
requested_url: impl AsRef<str>,
final_url: impl AsRef<str>,
mime_type: Option<&str>,
body: &str,
preview_limit: usize,
) -> Self {
let requested_url = redact_log_text(requested_url.as_ref());
let final_url = redact_log_text(final_url.as_ref());
let mime_type = mime_type.map(redact_log_text);
let redacted_body = redact_log_text(body);
let (body_preview, truncated) = bounded_preview(&redacted_body, preview_limit);
Self {
sequence,
method: method.into(),
requested_url,
final_url,
mime_type,
body_preview,
truncated,
}
}
#[must_use]
pub fn title(&self) -> String {
format!("#{} {} {}", self.sequence, self.method, self.final_url)
}
}
fn bounded_preview(input: &str, limit: usize) -> (String, bool) {
if limit == 0 {
return (String::new(), !input.is_empty());
}
let mut output = String::new();
for (count, ch) in input.chars().enumerate() {
if count >= limit {
return (output, true);
}
output.push(ch);
}
(output, false)
}
fn redact_log_text(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
output.push(ch);
if is_sensitive_prefix(&output) {
while matches!(chars.peek(), Some(' ' | '=' | ':' | '"' | '\'')) {
if let Some(separator) = chars.next() {
output.push(separator);
}
}
while matches!(
chars.peek(),
Some(candidate)
if !matches!(candidate, '&' | ';' | '\n' | '\r' | '\t' | ' ' | '"' | '\'')
) {
let _ = chars.next();
}
output.push_str("[REDACTED]");
}
}
output
}
fn is_sensitive_prefix(input: &str) -> bool {
let lower = input.to_ascii_lowercase();
lower.ends_with("authorization")
|| lower.ends_with("cookie")
|| lower.ends_with("set-cookie")
|| lower.ends_with("token")
|| lower.ends_with("password")
|| lower.ends_with("secret")
|| lower.ends_with("api_key")
|| lower.ends_with("apikey")
}
impl HistoryStack {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn visit(&mut self, url: IndexUrl) {
self.visit_entry(HistoryEntry::new(url));
}
pub fn visit_entry(&mut self, entry: HistoryEntry) {
if let Some(current) = self.current.take() {
self.back.push(current);
}
self.current = Some(entry);
self.forward.clear();
}
pub fn go_back(&mut self) -> Option<&HistoryEntry> {
let previous = self.back.pop()?;
if let Some(current) = self.current.take() {
self.forward.push(current);
}
self.current = Some(previous);
self.current.as_ref()
}
pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
let next = self.forward.pop()?;
if let Some(current) = self.current.take() {
self.back.push(current);
}
self.current = Some(next);
self.current.as_ref()
}
#[must_use]
pub fn current(&self) -> Option<&HistoryEntry> {
self.current.as_ref()
}
#[must_use]
pub fn can_go_back(&self) -> bool {
!self.back.is_empty()
}
#[must_use]
pub fn can_go_forward(&self) -> bool {
!self.forward.is_empty()
}
#[must_use]
pub fn entries(&self) -> Vec<&HistoryEntry> {
self.back
.iter()
.chain(self.current.iter())
.chain(self.forward.iter().rev())
.collect()
}
#[must_use]
pub fn current_index(&self) -> Option<usize> {
self.current.as_ref().map(|_current| self.back.len())
}
fn from_entries(
entries: Vec<HistoryEntry>,
current_index: Option<usize>,
) -> Result<Self, SessionError> {
let Some(current_index) = current_index else {
return Ok(Self::new());
};
if current_index >= entries.len() {
return Err(SessionError::Parse(
"history current index is out of range".to_owned(),
));
}
let mut back = Vec::new();
let mut current = None;
let mut forward = Vec::new();
for (index, entry) in entries.into_iter().enumerate() {
if index < current_index {
back.push(entry);
} else if index == current_index {
current = Some(entry);
} else {
forward.push(entry);
}
}
forward.reverse();
Ok(Self {
back,
current,
forward,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bookmark {
pub title: String,
pub url: IndexUrl,
pub note: Option<String>,
pub tags: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BookmarkStore {
bookmarks: BTreeMap<String, Bookmark>,
}
impl BookmarkStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, title: impl Into<String>, url: IndexUrl) {
self.add_with_details(title, url, None, Vec::<String>::new());
}
pub fn add_with_details(
&mut self,
title: impl Into<String>,
url: IndexUrl,
note: Option<String>,
tags: Vec<String>,
) {
self.bookmarks.insert(
url.as_str().to_owned(),
Bookmark {
title: title.into(),
url,
note: note.filter(|note| !note.trim().is_empty()),
tags: normalized_tags(tags),
},
);
}
pub fn update_details(
&mut self,
url: &IndexUrl,
note: Option<String>,
tags: Vec<String>,
) -> Option<()> {
let bookmark = self.bookmarks.get_mut(url.as_str())?;
bookmark.note = note.filter(|note| !note.trim().is_empty());
bookmark.tags = normalized_tags(tags);
Some(())
}
pub fn remove(&mut self, url: &IndexUrl) -> Option<Bookmark> {
self.bookmarks.remove(url.as_str())
}
#[must_use]
pub fn contains(&self, url: &IndexUrl) -> bool {
self.bookmarks.contains_key(url.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = &Bookmark> {
self.bookmarks.values()
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), BookmarkError> {
let mut lines = Vec::new();
lines.push("index-bookmarks-v1".to_owned());
for bookmark in self.iter() {
lines.push(serialized_bookmark_fields(bookmark).join("\t"));
}
fs::write(path, lines.join("\n")).map_err(BookmarkError::from)
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, BookmarkError> {
let contents = fs::read_to_string(path).map_err(BookmarkError::from)?;
Self::from_serialized(&contents)
}
fn from_serialized(contents: &str) -> Result<Self, BookmarkError> {
let mut lines = contents.lines();
if lines.next() != Some("index-bookmarks-v1") {
return Err(BookmarkError::Parse("missing bookmark header".to_owned()));
}
let mut store = Self::new();
for line in lines {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 2 {
return Err(BookmarkError::Parse("invalid bookmark record".to_owned()));
}
let url = IndexUrl::parse(unescape_field(fields[0]).map_err(BookmarkError::Parse)?)
.map_err(BookmarkError::Url)?;
let title = unescape_field(fields[1]).map_err(BookmarkError::Parse)?;
let note = if fields.len() >= 3 {
Some(unescape_field(fields[2]).map_err(BookmarkError::Parse)?)
.filter(|note| !note.trim().is_empty())
} else {
None
};
let mut tags = Vec::new();
for field in &fields[3..] {
tags.push(unescape_field(field).map_err(BookmarkError::Parse)?);
}
store.add_with_details(title, url, note, tags);
}
Ok(store)
}
}
fn serialized_bookmark_fields(bookmark: &Bookmark) -> Vec<String> {
let mut fields = vec![
escape_field(bookmark.url.as_str()),
escape_field(&bookmark.title),
];
if bookmark.note.is_some() || !bookmark.tags.is_empty() {
fields.push(escape_field(bookmark.note.as_deref().unwrap_or_default()));
fields.extend(bookmark.tags.iter().map(|tag| escape_field(tag)));
}
fields
}
fn normalized_tags(tags: Vec<String>) -> Vec<String> {
let mut tags = tags
.into_iter()
.map(|tag| tag.trim().to_owned())
.filter(|tag| !tag.is_empty())
.collect::<Vec<_>>();
tags.sort();
tags.dedup();
tags
}
#[derive(Debug)]
pub enum BookmarkError {
Io(std::io::Error),
Parse(String),
Url(UrlError),
}
impl From<std::io::Error> for BookmarkError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl Display for BookmarkError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "bookmark IO failed: {error}"),
Self::Parse(reason) => write!(f, "bookmark data is invalid: {reason}"),
Self::Url(error) => write!(f, "bookmark URL is invalid: {error}"),
}
}
}
impl std::error::Error for BookmarkError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShelfRecord {
pub id: String,
pub title: String,
pub source_url: Option<String>,
pub quality: Option<String>,
pub saved_at: String,
pub tags: Vec<String>,
pub note: Option<String>,
pub citations: Vec<String>,
pub markdown_path: String,
pub json_path: String,
}
impl ShelfRecord {
#[must_use]
pub fn new(
title: impl Into<String>,
source_url: Option<String>,
quality: Option<String>,
saved_at: impl Into<String>,
citations: Vec<String>,
markdown_path: impl Into<String>,
json_path: impl Into<String>,
) -> Self {
let title = title.into();
let id = shelf_id(&title, source_url.as_deref());
Self {
id,
title,
source_url,
quality,
saved_at: saved_at.into(),
tags: Vec::new(),
note: None,
citations,
markdown_path: markdown_path.into(),
json_path: json_path.into(),
}
}
pub fn add_tag(&mut self, tag: impl Into<String>) {
let tag = tag.into();
if tag.trim().is_empty() || self.tags.iter().any(|existing| existing == &tag) {
return;
}
self.tags.push(tag);
self.tags.sort();
}
pub fn set_note(&mut self, note: impl Into<String>) {
self.note = Some(note.into());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShelfSearchResult {
pub id: String,
pub title: String,
pub source_url: Option<String>,
pub score: u16,
pub matched_fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct KnowledgeShelf {
records: BTreeMap<String, ShelfRecord>,
}
impl KnowledgeShelf {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn upsert(&mut self, record: ShelfRecord) {
self.records.insert(record.id.clone(), record);
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&ShelfRecord> {
self.records.get(id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut ShelfRecord> {
self.records.get_mut(id)
}
pub fn iter(&self) -> impl Iterator<Item = &ShelfRecord> {
self.records.values()
}
pub fn search<F>(&self, query: &str, mut markdown_for: F) -> Vec<ShelfSearchResult>
where
F: FnMut(&ShelfRecord) -> Option<String>,
{
let normalized_query = normalize_search_text(query);
if normalized_query.is_empty() {
return Vec::new();
}
let tokens = normalized_query
.split_whitespace()
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let mut results = Vec::new();
for record in self.iter() {
let markdown = markdown_for(record).unwrap_or_default();
if let Some(result) = score_shelf_record(record, &normalized_query, &tokens, &markdown)
{
results.push(result);
}
}
results.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.title.cmp(&right.title))
.then_with(|| left.id.cmp(&right.id))
});
results
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), ShelfError> {
let mut lines = vec!["index-shelf-v1".to_owned()];
for record in self.iter() {
lines.push(serialized_shelf_record(record).join("\t"));
}
fs::write(path, lines.join("\n")).map_err(ShelfError::from)
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ShelfError> {
match fs::read_to_string(path) {
Ok(contents) => Self::from_serialized(&contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::new()),
Err(error) => Err(ShelfError::Io(error.to_string())),
}
}
fn from_serialized(contents: &str) -> Result<Self, ShelfError> {
let mut lines = contents.lines();
if lines.next() != Some("index-shelf-v1") {
return Err(ShelfError::Parse("missing shelf header".to_owned()));
}
let mut shelf = Self::new();
for line in lines {
if line.is_empty() {
continue;
}
let fields = line.split('\t').collect::<Vec<_>>();
if fields.len() != 10 {
return Err(ShelfError::Parse("invalid shelf record".to_owned()));
}
let mut record = ShelfRecord {
id: unescape_field(fields[0]).map_err(ShelfError::Parse)?,
title: unescape_field(fields[1]).map_err(ShelfError::Parse)?,
source_url: optional_unescape_field(fields[2])?,
quality: optional_unescape_field(fields[3])?,
saved_at: unescape_field(fields[4]).map_err(ShelfError::Parse)?,
tags: split_list_field(fields[5])?,
note: optional_unescape_field(fields[6])?,
citations: split_list_field(fields[7])?,
markdown_path: unescape_field(fields[8]).map_err(ShelfError::Parse)?,
json_path: unescape_field(fields[9]).map_err(ShelfError::Parse)?,
};
record.tags.sort();
record.tags.dedup();
shelf.upsert(record);
}
Ok(shelf)
}
}
fn score_shelf_record(
record: &ShelfRecord,
query: &str,
tokens: &[String],
markdown: &str,
) -> Option<ShelfSearchResult> {
let mut score = 0;
let mut matched_fields = Vec::new();
score += score_field(
"title",
&record.title,
query,
tokens,
60,
&mut matched_fields,
);
if let Some(source_url) = &record.source_url {
score += score_field(
"source_url",
source_url,
query,
tokens,
20,
&mut matched_fields,
);
}
if !record.tags.is_empty() {
score += score_field(
"tags",
&record.tags.join(" "),
query,
tokens,
50,
&mut matched_fields,
);
}
if let Some(note) = &record.note {
score += score_field("note", note, query, tokens, 40, &mut matched_fields);
}
if !record.citations.is_empty() {
score += score_field(
"citations",
&record.citations.join(" "),
query,
tokens,
30,
&mut matched_fields,
);
}
score += score_field(
"markdown_headings",
&markdown_headings(markdown),
query,
tokens,
45,
&mut matched_fields,
);
score += score_field("markdown", markdown, query, tokens, 10, &mut matched_fields);
(score > 0).then(|| ShelfSearchResult {
id: record.id.clone(),
title: record.title.clone(),
source_url: record.source_url.clone(),
score,
matched_fields,
})
}
fn score_field(
field: &str,
value: &str,
query: &str,
tokens: &[String],
weight: u16,
matched_fields: &mut Vec<String>,
) -> u16 {
if field_matches(value, query, tokens) {
matched_fields.push(field.to_owned());
weight
} else {
0
}
}
fn field_matches(value: &str, query: &str, tokens: &[String]) -> bool {
let value = normalize_search_text(value);
!value.is_empty() && (value.contains(query) || tokens.iter().all(|token| value.contains(token)))
}
fn markdown_headings(markdown: &str) -> String {
markdown
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
trimmed
.strip_prefix('#')
.map(|heading| heading.trim_start_matches('#').trim())
})
.filter(|heading| !heading.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
fn normalize_search_text(input: &str) -> String {
input
.split_whitespace()
.flat_map(|part| part.chars().flat_map(char::to_lowercase).chain([' ']))
.collect::<String>()
.trim_end()
.to_owned()
}
fn shelf_id(title: &str, source_url: Option<&str>) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in source_url.unwrap_or("").bytes().chain(title.bytes()) {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("shelf-{hash:016x}")
}
fn serialized_shelf_record(record: &ShelfRecord) -> Vec<String> {
vec![
escape_field(&record.id),
escape_field(&record.title),
record
.source_url
.as_ref()
.map_or_else(String::new, |value| escape_field(value)),
record
.quality
.as_ref()
.map_or_else(String::new, |value| escape_field(value)),
escape_field(&record.saved_at),
join_list_field(&record.tags),
record
.note
.as_ref()
.map_or_else(String::new, |value| escape_field(value)),
join_list_field(&record.citations),
escape_field(&record.markdown_path),
escape_field(&record.json_path),
]
}
fn optional_unescape_field(field: &str) -> Result<Option<String>, ShelfError> {
if field.is_empty() {
Ok(None)
} else {
Ok(Some(unescape_field(field).map_err(ShelfError::Parse)?))
}
}
fn join_list_field(values: &[String]) -> String {
values
.iter()
.map(|value| escape_field(value))
.collect::<Vec<_>>()
.join(",")
}
fn split_list_field(field: &str) -> Result<Vec<String>, ShelfError> {
if field.is_empty() {
return Ok(Vec::new());
}
field
.split(',')
.map(|value| unescape_field(value).map_err(ShelfError::Parse))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShelfError {
Io(String),
Parse(String),
}
impl From<std::io::Error> for ShelfError {
fn from(value: std::io::Error) -> Self {
Self::Io(value.to_string())
}
}
impl Display for ShelfError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "shelf IO failed: {error}"),
Self::Parse(error) => write!(f, "shelf parse failed: {error}"),
}
}
}
impl std::error::Error for ShelfError {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OriginState {
pub visits: u64,
pub last_url: Option<IndexUrl>,
}
impl OriginState {
pub fn record_visit(&mut self, url: IndexUrl) {
self.visits = self.visits.saturating_add(1);
self.last_url = Some(url);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionSidebarMode {
#[default]
Links,
Outline,
Forms,
Regions,
Search,
Logs,
}
impl SessionSidebarMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Links => "links",
Self::Outline => "outline",
Self::Forms => "forms",
Self::Regions => "regions",
Self::Search => "search",
Self::Logs => "logs",
}
}
pub fn parse(input: &str) -> Option<Self> {
match input {
"links" => Some(Self::Links),
"outline" => Some(Self::Outline),
"forms" => Some(Self::Forms),
"regions" => Some(Self::Regions),
"search" => Some(Self::Search),
"logs" => Some(Self::Logs),
_ => None,
}
}
#[must_use]
pub const fn next(self) -> Self {
match self {
Self::Links => Self::Outline,
Self::Outline => Self::Forms,
Self::Forms => Self::Regions,
Self::Regions => Self::Search,
Self::Search => Self::Logs,
Self::Logs => Self::Links,
}
}
#[must_use]
pub const fn previous(self) -> Self {
match self {
Self::Links => Self::Logs,
Self::Logs => Self::Search,
Self::Outline => Self::Links,
Self::Forms => Self::Outline,
Self::Regions => Self::Forms,
Self::Search => Self::Regions,
}
}
}
impl Display for SessionSidebarMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReaderProfile {
#[default]
Reader,
Docs,
Links,
Research,
Compact,
Verbose,
}
impl ReaderProfile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Reader => "reader",
Self::Docs => "docs",
Self::Links => "links",
Self::Research => "research",
Self::Compact => "compact",
Self::Verbose => "verbose",
}
}
pub fn parse(input: &str) -> Option<Self> {
match input {
"reader" => Some(Self::Reader),
"docs" => Some(Self::Docs),
"links" => Some(Self::Links),
"research" => Some(Self::Research),
"compact" => Some(Self::Compact),
"verbose" => Some(Self::Verbose),
_ => None,
}
}
#[must_use]
pub const fn all() -> [Self; 6] {
[
Self::Reader,
Self::Docs,
Self::Links,
Self::Research,
Self::Compact,
Self::Verbose,
]
}
}
impl Display for ReaderProfile {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SessionUiState {
pub sidebar_mode: SessionSidebarMode,
pub reader_profile: ReaderProfile,
pub reader_profile_manual: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionState {
pub id: SessionId,
pub history: HistoryStack,
pub bookmarks: BookmarkStore,
pub origins: BTreeMap<Origin, OriginState>,
pub ui: SessionUiState,
}
impl SessionState {
#[must_use]
pub fn new(id: SessionId) -> Self {
Self {
id,
history: HistoryStack::new(),
bookmarks: BookmarkStore::new(),
origins: BTreeMap::new(),
ui: SessionUiState::default(),
}
}
pub fn visit(&mut self, url: IndexUrl) {
self.visit_entry(HistoryEntry::new(url));
}
pub fn visit_entry(&mut self, entry: HistoryEntry) {
if let Some(origin) = entry.origin() {
self.origins
.entry(origin)
.or_default()
.record_visit(entry.final_url.clone());
}
self.history.visit_entry(entry);
}
pub fn go_back(&mut self) -> Option<&HistoryEntry> {
self.history.go_back()
}
pub fn go_forward(&mut self) -> Option<&HistoryEntry> {
self.history.go_forward()
}
pub fn bookmark_current(&mut self, title: impl Into<String>) -> Option<()> {
let url = self.history.current()?.final_url.clone();
self.bookmarks.add(title, url);
Some(())
}
#[must_use]
pub fn serialize(&self) -> String {
let mut lines = Vec::new();
lines.push("index-session-v1".to_owned());
lines.push(format!("id\t{}", escape_field(self.id.as_str())));
lines.push(format!(
"ui\t{}\t{}\t{}",
self.ui.sidebar_mode.as_str(),
self.ui.reader_profile.as_str(),
if self.ui.reader_profile_manual {
"manual"
} else {
"auto"
}
));
if let Some(current_index) = self.history.current_index() {
lines.push(format!("history\t{current_index}"));
} else {
lines.push("history\tnone".to_owned());
}
for entry in self.history.entries() {
let mut fields = vec![
"entry".to_owned(),
escape_field(entry.requested_url.as_str()),
escape_field(entry.final_url.as_str()),
];
fields.extend(entry.redirects.iter().map(|url| escape_field(url.as_str())));
lines.push(fields.join("\t"));
}
for bookmark in self.bookmarks.iter() {
let mut fields = vec!["bookmark".to_owned()];
fields.extend(serialized_bookmark_fields(bookmark));
lines.push(fields.join("\t"));
}
for (origin, state) in &self.origins {
let last_url = state
.last_url
.as_ref()
.map_or_else(String::new, |url| escape_field(url.as_str()));
lines.push(format!(
"origin\t{}\t{}\t{}",
escape_field(origin.as_str()),
state.visits,
last_url
));
}
lines.join("\n")
}
pub fn deserialize(contents: &str) -> Result<Self, SessionError> {
let mut lines = contents.lines();
if lines.next() != Some("index-session-v1") {
return Err(SessionError::Parse("missing session header".to_owned()));
}
let mut id = None;
let mut current_index = None;
let mut entries = Vec::new();
let mut bookmarks = BookmarkStore::new();
let mut origins = BTreeMap::new();
let mut ui = SessionUiState::default();
for line in lines {
if line.is_empty() {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
match fields.first().copied() {
Some("id") if fields.len() == 2 => {
id = Some(SessionId::new(
unescape_field(fields[1]).map_err(SessionError::Parse)?,
));
}
Some("history") if fields.len() == 2 => {
current_index = parse_history_index(fields[1])?;
}
Some("ui") if (2..=4).contains(&fields.len()) => {
let mode = SessionSidebarMode::parse(fields[1])
.ok_or_else(|| SessionError::Parse("invalid sidebar mode".to_owned()))?;
ui.sidebar_mode = mode;
if fields.len() == 3 {
ui.reader_profile = ReaderProfile::parse(fields[2]).ok_or_else(|| {
SessionError::Parse("invalid reader profile".to_owned())
})?;
ui.reader_profile_manual = true;
} else if fields.len() == 4 {
ui.reader_profile = ReaderProfile::parse(fields[2]).ok_or_else(|| {
SessionError::Parse("invalid reader profile".to_owned())
})?;
ui.reader_profile_manual = match fields[3] {
"auto" => false,
"manual" => true,
_ => {
return Err(SessionError::Parse(
"invalid reader profile mode".to_owned(),
));
}
};
}
}
Some("entry") if fields.len() >= 3 => {
let requested_url = parse_session_url(fields[1])?;
let final_url = parse_session_url(fields[2])?;
let mut redirects = Vec::new();
for field in &fields[3..] {
redirects.push(parse_session_url(field)?);
}
entries.push(HistoryEntry::with_redirects(
requested_url,
final_url,
redirects,
));
}
Some("bookmark") if fields.len() >= 3 => {
let url = parse_session_url(fields[1])?;
let title = unescape_field(fields[2]).map_err(SessionError::Parse)?;
let note = if fields.len() >= 4 {
Some(unescape_field(fields[3]).map_err(SessionError::Parse)?)
.filter(|note| !note.trim().is_empty())
} else {
None
};
let mut tags = Vec::new();
for field in &fields[4..] {
tags.push(unescape_field(field).map_err(SessionError::Parse)?);
}
bookmarks.add_with_details(title, url, note, tags);
}
Some("origin") if fields.len() == 4 => {
let origin = Origin::from_stored(
unescape_field(fields[1]).map_err(SessionError::Parse)?,
);
let visits = fields[2]
.parse::<u64>()
.map_err(|error| SessionError::Parse(error.to_string()))?;
let last_url = if fields[3].is_empty() {
None
} else {
Some(parse_session_url(fields[3])?)
};
origins.insert(origin, OriginState { visits, last_url });
}
_ => return Err(SessionError::Parse("invalid session record".to_owned())),
}
}
let id = id.ok_or_else(|| SessionError::Parse("missing session id".to_owned()))?;
let history = HistoryStack::from_entries(entries, current_index)?;
Ok(Self {
id,
history,
bookmarks,
origins,
ui,
})
}
pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
fs::write(path, self.serialize()).map_err(SessionError::from)
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
let contents = fs::read_to_string(path).map_err(SessionError::from)?;
Self::deserialize(&contents)
}
}
#[derive(Debug)]
pub enum SessionError {
Io(std::io::Error),
Parse(String),
Url(UrlError),
}
impl From<std::io::Error> for SessionError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl Display for SessionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "session IO failed: {error}"),
Self::Parse(reason) => write!(f, "session data is invalid: {reason}"),
Self::Url(error) => write!(f, "session URL is invalid: {error}"),
}
}
}
impl std::error::Error for SessionError {}
fn parse_history_index(input: &str) -> Result<Option<usize>, SessionError> {
if input == "none" {
Ok(None)
} else {
input
.parse::<usize>()
.map(Some)
.map_err(|error| SessionError::Parse(error.to_string()))
}
}
fn parse_session_url(input: &str) -> Result<IndexUrl, SessionError> {
let unescaped = unescape_field(input).map_err(SessionError::Parse)?;
IndexUrl::parse(unescaped).map_err(SessionError::Url)
}
fn escape_field(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'\t' => escaped.push_str("\\t"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
_ => escaped.push(ch),
}
}
escaped
}
fn unescape_field(input: &str) -> Result<String, String> {
let mut unescaped = String::with_capacity(input.len());
let mut chars = input.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
unescaped.push(ch);
continue;
}
let Some(next) = chars.next() else {
return Err("dangling escape".to_owned());
};
match next {
'\\' => unescaped.push('\\'),
't' => unescaped.push('\t'),
'n' => unescaped.push('\n'),
'r' => unescaped.push('\r'),
other => return Err(format!("unknown escape: {other}")),
}
}
Ok(unescaped)
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
BookmarkStore, HistoryEntry, HistoryStack, KnowledgeShelf, OriginState, ReaderProfile,
ResponseLogEntry, SessionId, SessionSidebarMode, SessionState, ShelfError, ShelfRecord,
};
use crate::{IndexUrl, Origin};
#[test]
fn history_supports_back_and_forward_navigation() -> Result<(), Box<dyn std::error::Error>> {
let first = IndexUrl::parse("https://example.com/one")?;
let second = IndexUrl::parse("https://example.com/two")?;
let third = IndexUrl::parse("https://example.com/three")?;
let mut history = HistoryStack::new();
history.visit(first.clone());
history.visit(second.clone());
history.visit(third);
assert_eq!(
history.go_back().map(|entry| &entry.final_url),
Some(&second)
);
assert_eq!(
history.go_back().map(|entry| &entry.final_url),
Some(&first)
);
assert!(history.go_back().is_none());
assert_eq!(
history.go_forward().map(|entry| &entry.final_url),
Some(&second)
);
Ok(())
}
#[test]
fn visiting_new_url_clears_forward_history() -> Result<(), Box<dyn std::error::Error>> {
let first = IndexUrl::parse("https://example.com/one")?;
let second = IndexUrl::parse("https://example.com/two")?;
let third = IndexUrl::parse("https://example.com/three")?;
let mut history = HistoryStack::new();
history.visit(first);
history.visit(second);
assert!(history.go_back().is_some());
history.visit(third);
assert!(!history.can_go_forward());
Ok(())
}
#[test]
fn response_log_entries_redact_and_bound_server_body() {
let entry = ResponseLogEntry::new(
7,
"POST",
"https://example.com/login?token=secret-token&next=/home",
"https://example.com/home",
Some("text/html"),
"Welcome token=abc123 password=hunter2 visible text",
28,
);
assert_eq!(entry.sequence, 7);
assert!(entry.requested_url.contains("token=[REDACTED]"));
assert!(!entry.requested_url.contains("secret-token"));
assert!(!entry.body_preview.contains("abc123"));
assert!(!entry.body_preview.contains("hunter2"));
assert!(entry.body_preview.chars().count() <= 28);
assert!(entry.truncated);
assert!(entry.title().contains("POST"));
}
#[test]
fn bookmarks_persist_to_disk() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path("bookmarks");
let mut store = BookmarkStore::new();
let url = IndexUrl::parse("https://example.com/docs")?;
store.add_with_details(
"Docs\tIndex",
url.clone(),
Some("Read before release".to_owned()),
vec!["rust".to_owned(), "docs".to_owned(), "rust".to_owned()],
);
store.save_to_path(&path)?;
let restored = BookmarkStore::load_from_path(&path)?;
std::fs::remove_file(&path)?;
assert!(restored.contains(&url));
assert_eq!(
restored
.iter()
.next()
.map(|bookmark| bookmark.title.as_str()),
Some("Docs\tIndex")
);
let bookmark = restored.iter().next().ok_or("missing bookmark")?;
assert_eq!(bookmark.note.as_deref(), Some("Read before release"));
assert_eq!(bookmark.tags, vec!["docs".to_owned(), "rust".to_owned()]);
Ok(())
}
#[test]
fn bookmark_details_can_be_updated() -> Result<(), Box<dyn std::error::Error>> {
let mut store = BookmarkStore::new();
let url = IndexUrl::parse("https://example.com/docs")?;
store.add("Docs", url.clone());
assert_eq!(
store.update_details(
&url,
Some("Updated".to_owned()),
vec!["research".to_owned(), "docs".to_owned()],
),
Some(())
);
let bookmark = store.iter().next().ok_or("missing bookmark")?;
assert_eq!(bookmark.note.as_deref(), Some("Updated"));
assert_eq!(
bookmark.tags,
vec!["docs".to_owned(), "research".to_owned()]
);
Ok(())
}
#[test]
fn shelf_ids_are_deterministic_and_records_roundtrip() -> Result<(), Box<dyn std::error::Error>>
{
let path = temp_path("shelf");
let mut record = ShelfRecord::new(
"Index Guide",
Some("https://example.org/guide".to_owned()),
Some("strong-generic".to_owned()),
"12345",
vec!["https://example.org/cite".to_owned()],
"exports/index-guide.md",
"exports/index-guide.json",
);
let same = ShelfRecord::new(
"Index Guide",
Some("https://example.org/guide".to_owned()),
None,
"later",
Vec::new(),
"a.md",
"a.json",
);
assert_eq!(record.id, same.id);
record.add_tag("docs");
record.add_tag("rust");
record.add_tag("docs");
record.set_note("Keep for packaging");
let mut shelf = KnowledgeShelf::new();
shelf.upsert(record.clone());
shelf.save_to_path(&path)?;
let restored = KnowledgeShelf::load_from_path(&path)?;
let restored_record = restored.get(&record.id).ok_or("missing shelf record")?;
assert_eq!(restored_record.title, "Index Guide");
assert_eq!(
restored_record.tags,
vec!["docs".to_owned(), "rust".to_owned()]
);
assert_eq!(restored_record.note.as_deref(), Some("Keep for packaging"));
std::fs::remove_file(&path)?;
Ok(())
}
#[test]
fn missing_shelf_loads_as_empty() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path("missing-shelf");
let shelf = KnowledgeShelf::load_from_path(&path)?;
assert_eq!(shelf.iter().count(), 0);
Ok(())
}
#[test]
fn shelf_loader_rejects_invalid_records() {
assert_eq!(
KnowledgeShelf::from_serialized("bad").map(|_| ()),
Err(ShelfError::Parse("missing shelf header".to_owned()))
);
assert_eq!(
KnowledgeShelf::from_serialized("index-shelf-v1\nonly-one-field").map(|_| ()),
Err(ShelfError::Parse("invalid shelf record".to_owned()))
);
assert!(
KnowledgeShelf::from_serialized(
"index-shelf-v1\nid\\\ttitle\t\t\t123\t\t\t\tout.md\tout.json"
)
.is_err()
);
assert_eq!(
ShelfError::Parse("bad field".to_owned()).to_string(),
"shelf parse failed: bad field"
);
}
#[test]
fn shelf_search_ranks_metadata_and_markdown_matches() {
let mut rust = ShelfRecord::new(
"Rust Guide",
Some("https://example.org/rust".to_owned()),
Some("strong-generic".to_owned()),
"1",
vec!["https://example.org/citation".to_owned()],
"rust.md",
"rust.json",
);
rust.add_tag("docs");
rust.set_note("ownership and borrowing");
let rust_id = rust.id.clone();
let mut archive = ShelfRecord::new(
"Archive Notes",
Some("https://example.org/archive".to_owned()),
None,
"2",
Vec::new(),
"archive.md",
"archive.json",
);
archive.set_note("release history");
let mut shelf = KnowledgeShelf::new();
shelf.upsert(archive);
shelf.upsert(rust);
let results = shelf.search("ownership", |record| {
(record.id == rust_id).then(|| "# Ownership\nDetailed notes".to_owned())
});
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Rust Guide");
assert!(results[0].score > 40);
assert!(results[0].matched_fields.contains(&"note".to_owned()));
assert!(
results[0]
.matched_fields
.contains(&"markdown_headings".to_owned())
);
let empty = shelf.search(" ", |_| None);
assert!(empty.is_empty());
}
#[test]
fn shelf_search_matches_non_english_titles_notes_tags_and_headings() {
let mut spanish = ShelfRecord::new(
"Índice Público",
Some("https://example.org/es".to_owned()),
Some("strong-generic".to_owned()),
"1",
Vec::new(),
"es.md",
"es.json",
);
spanish.add_tag("manuales");
spanish.set_note("Navegación tranquila");
let spanish_id = spanish.id.clone();
let arabic = ShelfRecord::new(
"فهرس المعرفة",
Some("https://example.org/ar".to_owned()),
Some("strong-generic".to_owned()),
"2",
Vec::new(),
"ar.md",
"ar.json",
);
let arabic_id = arabic.id.clone();
let cjk = ShelfRecord::new(
"公開リファレンス",
Some("https://example.org/ja".to_owned()),
Some("strong-generic".to_owned()),
"3",
Vec::new(),
"ja.md",
"ja.json",
);
let cjk_id = cjk.id.clone();
let mut shelf = KnowledgeShelf::new();
shelf.upsert(spanish);
shelf.upsert(arabic);
shelf.upsert(cjk);
let title_results = shelf.search("índice", |_| None);
assert_eq!(
title_results.first().map(|result| result.id.as_str()),
Some(spanish_id.as_str())
);
let note_results = shelf.search("NAVEGACIÓN", |_| None);
assert_eq!(
note_results.first().map(|result| result.id.as_str()),
Some(spanish_id.as_str())
);
let tag_results = shelf.search("MANUALES", |_| None);
assert_eq!(
tag_results.first().map(|result| result.id.as_str()),
Some(spanish_id.as_str())
);
let arabic_results = shelf.search("المعرفة", |_| None);
assert_eq!(
arabic_results.first().map(|result| result.id.as_str()),
Some(arabic_id.as_str())
);
let cjk_results = shelf.search("見出し", |record| {
(record.id == cjk_id).then(|| "# 見出し\n知識ページ".to_owned())
});
assert_eq!(
cjk_results.first().map(|result| result.id.as_str()),
Some(cjk_id.as_str())
);
}
#[test]
fn shelf_search_uses_deterministic_tie_breaks() {
let alpha = ShelfRecord::new("Alpha", None, None, "1", Vec::new(), "a.md", "a.json");
let beta = ShelfRecord::new("Beta", None, None, "2", Vec::new(), "b.md", "b.json");
let mut shelf = KnowledgeShelf::new();
shelf.upsert(beta);
shelf.upsert(alpha);
let results = shelf.search("shared", |_| Some("shared".to_owned()));
assert_eq!(
results
.iter()
.map(|result| result.title.as_str())
.collect::<Vec<_>>(),
vec!["Alpha", "Beta"]
);
}
#[test]
fn session_serialization_roundtrips_history_bookmarks_origins_and_redirects()
-> Result<(), Box<dyn std::error::Error>> {
let requested = IndexUrl::parse("http://example.com/start")?;
let redirect = IndexUrl::parse("https://example.com/start")?;
let final_url = IndexUrl::parse("https://example.com/home")?;
let docs = IndexUrl::parse("https://example.com/docs")?;
let mut session = SessionState::new(SessionId::new("main"));
session.ui.sidebar_mode = SessionSidebarMode::Regions;
session.ui.reader_profile = ReaderProfile::Research;
session.ui.reader_profile_manual = true;
session.visit_entry(HistoryEntry::with_redirects(
requested.clone(),
final_url.clone(),
vec![redirect.clone()],
));
session.visit(docs.clone());
assert!(session.go_back().is_some());
session.bookmark_current("Home");
session.bookmarks.update_details(
&final_url,
Some("Return later".to_owned()),
vec!["home".to_owned(), "reference".to_owned()],
);
let restored = SessionState::deserialize(&session.serialize())?;
assert_eq!(restored.id.as_str(), "main");
assert_eq!(
restored.history.current().map(|entry| &entry.final_url),
Some(&final_url)
);
assert!(restored.bookmarks.contains(&final_url));
let bookmark = restored
.bookmarks
.iter()
.find(|bookmark| bookmark.url == final_url)
.ok_or("missing bookmark")?;
assert_eq!(bookmark.note.as_deref(), Some("Return later"));
assert_eq!(
bookmark.tags,
vec!["home".to_owned(), "reference".to_owned()]
);
assert_eq!(
restored
.history
.current()
.and_then(|entry| entry.redirects.first()),
Some(&redirect)
);
assert_eq!(
restored
.origins
.get(&Origin::from_stored("https://example.com"))
.map(|state| state.visits),
Some(2)
);
assert_eq!(restored.ui.sidebar_mode, SessionSidebarMode::Regions);
assert_eq!(restored.ui.reader_profile, ReaderProfile::Research);
assert!(restored.ui.reader_profile_manual);
Ok(())
}
#[test]
fn session_sidebar_mode_names_are_stable() {
let modes = [
(SessionSidebarMode::Links, "links"),
(SessionSidebarMode::Outline, "outline"),
(SessionSidebarMode::Forms, "forms"),
(SessionSidebarMode::Regions, "regions"),
(SessionSidebarMode::Search, "search"),
(SessionSidebarMode::Logs, "logs"),
];
for (mode, name) in modes {
assert_eq!(mode.as_str(), name);
assert_eq!(mode.to_string(), name);
assert_eq!(SessionSidebarMode::parse(name), Some(mode));
}
assert_eq!(
SessionSidebarMode::Links.previous(),
SessionSidebarMode::Logs
);
assert_eq!(SessionSidebarMode::Search.next(), SessionSidebarMode::Logs);
assert_eq!(SessionSidebarMode::Logs.next(), SessionSidebarMode::Links);
assert_eq!(SessionSidebarMode::parse("unknown"), None);
}
#[test]
fn reader_profile_names_are_stable() {
let profiles = [
(ReaderProfile::Reader, "reader"),
(ReaderProfile::Docs, "docs"),
(ReaderProfile::Links, "links"),
(ReaderProfile::Research, "research"),
(ReaderProfile::Compact, "compact"),
(ReaderProfile::Verbose, "verbose"),
];
assert_eq!(ReaderProfile::all(), profiles.map(|(profile, _)| profile));
for (profile, name) in profiles {
assert_eq!(profile.as_str(), name);
assert_eq!(profile.to_string(), name);
assert_eq!(ReaderProfile::parse(name), Some(profile));
}
assert_eq!(ReaderProfile::parse("unknown"), None);
}
#[test]
fn old_session_ui_lines_default_to_reader_profile() -> Result<(), Box<dyn std::error::Error>> {
let restored =
SessionState::deserialize("index-session-v1\nid\tmain\nui\tregions\nhistory\tnone")?;
assert_eq!(restored.ui.sidebar_mode, SessionSidebarMode::Regions);
assert_eq!(restored.ui.reader_profile, ReaderProfile::Reader);
assert!(!restored.ui.reader_profile_manual);
let restored = SessionState::deserialize(
"index-session-v1\nid\tmain\nui\tregions\tdocs\tmanual\nhistory\tnone",
)?;
assert_eq!(restored.ui.reader_profile, ReaderProfile::Docs);
assert!(restored.ui.reader_profile_manual);
Ok(())
}
#[test]
fn empty_session_roundtrips_and_current_bookmark_is_noop()
-> Result<(), Box<dyn std::error::Error>> {
let mut session = SessionState::new(SessionId::new("empty"));
assert_eq!(session.bookmark_current("Nothing"), None);
assert!(session.go_back().is_none());
assert!(session.go_forward().is_none());
let restored = SessionState::deserialize(&session.serialize())?;
assert_eq!(restored.id.as_str(), "empty");
assert!(restored.history.current().is_none());
Ok(())
}
#[test]
fn session_persists_to_disk() -> Result<(), Box<dyn std::error::Error>> {
let path = temp_path("session");
let mut session = SessionState::new(SessionId::new("disk"));
let url = IndexUrl::parse("https://example.com/session")?;
session.visit(url.clone());
session.save_to_path(&path)?;
let restored = SessionState::load_from_path(&path)?;
std::fs::remove_file(&path)?;
assert_eq!(
restored.history.current().map(|entry| &entry.final_url),
Some(&url)
);
Ok(())
}
#[test]
fn bookmark_remove_returns_removed_entry() -> Result<(), Box<dyn std::error::Error>> {
let url = IndexUrl::parse("https://example.com/remove")?;
let mut store = BookmarkStore::new();
store.add("Remove me", url.clone());
let removed = store.remove(&url);
assert_eq!(
removed.map(|bookmark| bookmark.title),
Some("Remove me".to_owned())
);
assert!(!store.contains(&url));
Ok(())
}
#[test]
fn bookmark_loader_rejects_invalid_records() {
assert!(BookmarkStore::from_serialized("bad").is_err());
assert!(BookmarkStore::from_serialized("index-bookmarks-v1\nonly-one-field").is_err());
assert!(
BookmarkStore::from_serialized("index-bookmarks-v1\nhttps://example.com\\\tTitle")
.is_err()
);
assert!(BookmarkStore::from_serialized("index-bookmarks-v1\nbad-url\tTitle").is_err());
}
#[test]
fn session_loader_rejects_invalid_records() {
assert!(SessionState::deserialize("bad").is_err());
assert!(SessionState::deserialize("index-session-v1\nhistory\t0").is_err());
assert!(SessionState::deserialize("index-session-v1\nid\tmain\nbad\tfield").is_err());
assert!(SessionState::deserialize("index-session-v1\nid\tmain\nhistory\t1").is_err());
assert!(SessionState::deserialize("index-session-v1\nid\tmain\nhistory\tabc").is_err());
assert!(
SessionState::deserialize(
"index-session-v1\nid\tmain\nhistory\tnone\nentry\tbad-url\thttps://example.com",
)
.is_err()
);
assert!(
SessionState::deserialize(
"index-session-v1\nid\tmain\nhistory\tnone\norigin\thttps://example.com\tabc\t",
)
.is_err()
);
}
#[test]
fn per_origin_state_records_visit_count_and_last_url() -> Result<(), Box<dyn std::error::Error>>
{
let first = IndexUrl::parse("https://example.com/one")?;
let second = IndexUrl::parse("https://example.com/two")?;
let mut state = OriginState::default();
state.record_visit(first);
state.record_visit(second.clone());
assert_eq!(state.visits, 2);
assert_eq!(state.last_url, Some(second));
Ok(())
}
#[test]
fn redirect_entries_keep_requested_final_and_hops() -> Result<(), Box<dyn std::error::Error>> {
let requested = IndexUrl::parse("http://example.com")?;
let hop = IndexUrl::parse("https://example.com")?;
let final_url = IndexUrl::parse("https://www.example.com")?;
let entry =
HistoryEntry::with_redirects(requested.clone(), final_url.clone(), vec![hop.clone()]);
assert_eq!(entry.requested_url, requested);
assert_eq!(entry.final_url, final_url);
assert_eq!(entry.redirects, vec![hop]);
Ok(())
}
fn temp_path(name: &str) -> std::path::PathBuf {
let mut path = std::env::temp_dir();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
path.push(format!("index-{name}-{nanos}.txt"));
path
}
}