use crate::domain::action::BookmarkAction;
use crate::domain::bookmark::Bookmark;
use crate::domain::embedding::Embedder;
use crate::domain::error::{DomainError, DomainResult};
use crate::domain::repositories::repository::BookmarkRepository;
use crate::domain::repositories::vector_repository::VectorRepository;
use crate::util::helper::calc_content_hash;
use crate::util::path::{abspath, is_file_path};
use markdown::{to_html_with_options, Options};
use regex::Regex;
use std::fs::{self, File};
use std::io::Write;
use std::path::Path;
use std::sync::Arc;
use tracing::{debug, error, info, instrument};
#[derive(Debug, Clone)]
struct TocEntry {
level: u8,
title: String,
id: String,
}
#[derive(Debug)]
pub struct MarkdownAction {
repository: Option<Arc<dyn BookmarkRepository>>,
vector_repository: Option<Arc<dyn VectorRepository>>,
embedder: Arc<dyn Embedder>,
}
impl MarkdownAction {
#[allow(dead_code)]
pub fn new(embedder: Arc<dyn Embedder>) -> Self {
Self {
repository: None,
vector_repository: None,
embedder,
}
}
pub fn new_with_repository(
repository: Arc<dyn BookmarkRepository>,
vector_repository: Arc<dyn VectorRepository>,
embedder: Arc<dyn Embedder>,
) -> Self {
Self {
repository: Some(repository),
vector_repository: Some(vector_repository),
embedder,
}
}
fn read_markdown_from_file(&self, path_str: &str) -> DomainResult<String> {
debug!("Attempting to read from path: {}", path_str);
let resolved_path = match abspath(path_str) {
Some(path) => {
debug!("Path resolved with abspath: {}", path);
path
}
None => {
debug!("abspath failed, trying as relative path");
let path = Path::new(path_str);
if path.exists() {
path.to_string_lossy().to_string()
} else {
return Err(DomainError::Other(format!(
"File not found: {}. Neither absolute nor relative path exists.",
path_str
)));
}
}
};
debug!("Reading from resolved path: {}", resolved_path);
fs::read_to_string(&resolved_path).map_err(|e| {
DomainError::Other(format!(
"Failed to read markdown file '{}': {}",
resolved_path, e
))
})
}
fn can_update_embedding(&self, bookmark: &Bookmark) -> bool {
if self.repository.is_none() {
return false;
}
if !bookmark.embeddable {
return false;
}
self.embedder.dimensions() > 0
}
fn update_embedding(&self, bookmark: &Bookmark) -> DomainResult<()> {
if !self.can_update_embedding(bookmark) {
debug!("Embedding update skipped: not allowed or not possible");
return Ok(());
}
let repository = self.repository.as_ref().unwrap();
if let Some(id) = bookmark.id {
let mut updated_bookmark = repository
.get_by_id(id)?
.ok_or_else(|| DomainError::BookmarkNotFound(id.to_string()))?;
let content = updated_bookmark.get_content_for_embedding();
let content_hash = calc_content_hash(&content);
if updated_bookmark.content_hash.as_ref() != Some(&content_hash) {
debug!("Content changed, updating embedding for bookmark ID {}", id);
if let Some(embedding) = self.embedder.embed_document(&content)? {
if let Some(vec_repo) = &self.vector_repository {
vec_repo.upsert_embedding(id, &embedding)?;
}
}
updated_bookmark.embedding = None;
updated_bookmark.content_hash = Some(content_hash);
repository.update(&updated_bookmark)?;
info!("Successfully updated content hash for bookmark ID {}", id);
} else {
debug!(
"Content unchanged, not updating embedding for bookmark ID {}",
id
);
}
}
Ok(())
}
fn extract_and_process_headers(&self, html_content: &str) -> (String, Vec<TocEntry>) {
let mut processed_html = html_content.to_string();
let mut toc_entries: Vec<TocEntry> = Vec::new();
let mut header_counts = std::collections::HashMap::new();
let header_regex = Regex::new(r"<(h[123])(?:\s+[^>]*)?>(.*?)</h[123]>").unwrap();
let id_regex = Regex::new(r#"\s+id\s*=\s*["']([^"']+)["']"#).unwrap();
let matches: Vec<_> = header_regex.find_iter(html_content).collect();
for m in matches.iter() {
let full_match = m.as_str();
if let Some(header_cap) = header_regex.captures(full_match) {
let level = match &header_cap[1] {
"h1" => 1,
"h2" => 2,
"h3" => 3,
_ => continue,
};
let content = &header_cap[2];
let existing_id = id_regex.captures(full_match).map(|c| c[1].to_string());
let header_id = if let Some(ref id) = existing_id {
id.clone()
} else {
let base_id = self.generate_header_id(content);
let count = header_counts.entry(base_id.clone()).or_insert(0);
*count += 1;
if *count > 1 {
format!("{}-{}", base_id, *count - 1)
} else {
base_id
}
};
toc_entries.push(TocEntry {
level,
title: self.clean_html_content(content),
id: header_id.clone(),
});
if existing_id.is_none() {
let new_header = format!(
"<{} id=\"{}\">{}</{}>",
&header_cap[1], header_id, content, &header_cap[1]
);
processed_html = processed_html.replace(full_match, &new_header);
}
}
}
(processed_html, toc_entries)
}
fn generate_header_id(&self, content: &str) -> String {
let clean_content = self.clean_html_content(content);
clean_content
.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() {
c
} else if c.is_whitespace() || c == '-' || c == '_' {
'-'
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<&str>>()
.join("-")
}
fn clean_html_content(&self, content: &str) -> String {
let tag_regex = Regex::new(r"<[^>]*>").unwrap();
tag_regex.replace_all(content, "").trim().to_string()
}
fn get_source_file_path(&self, bookmark: &Bookmark) -> Option<std::path::PathBuf> {
let content_or_path = &bookmark.url;
if is_file_path(content_or_path) {
if let Some(resolved) = abspath(content_or_path) {
return Some(std::path::PathBuf::from(resolved));
}
let path = std::path::Path::new(content_or_path);
if path.exists() {
if let Ok(canonical) = path.canonicalize() {
return Some(canonical);
}
}
}
None
}
fn extract_resource_paths(&self, html: &str) -> Vec<(String, String)> {
let mut resources = Vec::new();
let img_regex = Regex::new(r#"<img\s+[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap();
let link_regex = Regex::new(r#"<link\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap();
let script_regex =
Regex::new(r#"<script\s+[^>]*src\s*=\s*["']([^"']+)["'][^>]*>"#).unwrap();
for cap in img_regex.captures_iter(html) {
let full_match = cap[0].to_string();
let path = cap[1].to_string();
if !path.starts_with("http://")
&& !path.starts_with("https://")
&& !path.starts_with("data:")
&& !path.starts_with("//")
{
resources.push((full_match, path));
}
}
for cap in link_regex.captures_iter(html) {
let full_match = cap[0].to_string();
let path = cap[1].to_string();
if !path.starts_with("http://")
&& !path.starts_with("https://")
&& !path.starts_with("data:")
&& !path.starts_with("//")
{
resources.push((full_match, path));
}
}
for cap in script_regex.captures_iter(html) {
let full_match = cap[0].to_string();
let path = cap[1].to_string();
if !path.starts_with("http://")
&& !path.starts_with("https://")
&& !path.starts_with("data:")
&& !path.starts_with("//")
{
resources.push((full_match, path));
}
}
resources
}
fn resolve_resource_path(
&self,
relative_path: &str,
source_md_path: &Path,
) -> Option<std::path::PathBuf> {
let source_dir = source_md_path.parent()?;
let resource_path = if relative_path.starts_with('/') {
std::path::PathBuf::from(relative_path)
} else {
source_dir.join(relative_path)
};
if let Ok(canonical_path) = resource_path.canonicalize() {
if canonical_path.exists() {
debug!(
"Resolved resource path '{}' to '{:?}'",
relative_path, canonical_path
);
return Some(canonical_path);
} else {
debug!(
"Resource path '{}' resolved but file does not exist: {:?}",
relative_path, canonical_path
);
}
} else {
debug!("Failed to canonicalize resource path: {}", relative_path);
}
None
}
fn copy_resources_to_temp(
&self,
html: &str,
source_md_path: &Path,
temp_dir: &Path,
) -> DomainResult<()> {
let resources = self.extract_resource_paths(html);
if resources.is_empty() {
debug!("No local resources found in HTML");
return Ok(());
}
debug!("Found {} local resources to copy", resources.len());
for (_full_match, relative_path) in resources {
if let Some(source_file) = self.resolve_resource_path(&relative_path, source_md_path) {
let dest_path = temp_dir.join(&relative_path);
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
DomainError::Other(format!(
"Failed to create directory {:?}: {}",
parent, e
))
})?;
}
fs::copy(&source_file, &dest_path).map_err(|e| {
DomainError::Other(format!(
"Failed to copy resource from {:?} to {:?}: {}",
source_file, dest_path, e
))
})?;
info!(
"Copied resource: {} from {:?} to {:?}",
relative_path, source_file, dest_path
);
} else {
debug!(
"Could not resolve resource path, skipping: {}",
relative_path
);
}
}
Ok(())
}
fn generate_toc_html(&self, toc_entries: &[TocEntry]) -> String {
if toc_entries.is_empty() {
return String::new();
}
let mut toc_html = String::new();
toc_html.push_str(
r#"<nav class="toc-sidebar" id="toc-sidebar">
<div class="toc-header">
<h3>Table of Contents</h3>
<button class="toc-toggle" id="toc-toggle">✕</button>
</div>
<ul class="toc-list">
"#,
);
for entry in toc_entries {
let indent_class = match entry.level {
1 => "toc-h1",
2 => "toc-h2",
3 => "toc-h3",
_ => "toc-h1",
};
toc_html.push_str(&format!(
" <li class=\"toc-item {}\"><a href=\"#{}\" class=\"toc-link\">{}</a></li>\n",
indent_class, entry.id, entry.title
));
}
toc_html.push_str(
r#" </ul>
</nav>
<button class="toc-mobile-toggle" id="toc-mobile-toggle">📋</button>"#,
);
toc_html
}
}
impl BookmarkAction for MarkdownAction {
#[instrument(skip(self, bookmark), level = "debug")]
fn execute(&self, bookmark: &Bookmark) -> DomainResult<()> {
let content_or_path = &bookmark.url;
let markdown_content = if is_file_path(content_or_path) {
debug!("Treating content as a file path: {}", content_or_path);
self.read_markdown_from_file(content_or_path)?
} else {
debug!("Treating content as direct markdown");
content_or_path.to_string()
};
let rendered_markdown = markdown_content.clone();
if let Err(e) = self.update_embedding(bookmark) {
error!("Failed to update embedding: {}", e);
}
debug!("Rendering markdown content to HTML");
let options = Options::gfm();
let html_content = to_html_with_options(&rendered_markdown, &options)
.map_err(|e| DomainError::Other(format!("Failed to render markdown: {}", e)))?;
let (processed_html, toc_entries) = self.extract_and_process_headers(&html_content);
let toc_html = self.generate_toc_html(&toc_entries);
let full_html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>{}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
:root {{
--background-color: #ffffff;
--text-color: #333333;
--code-background: #f5f5f5;
--blockquote-border: #ddd;
--blockquote-color: #666;
--link-color: #0366d6;
--table-border: #ddd;
--table-header-bg: #f2f2f2;
--table-row-alt-bg: #f9f9f9;
--base-font-size: 16px;
--code-font-size: 0.99;
}}
@media (prefers-color-scheme: dark) {{
:root {{
--background-color: #1e1e1e;
--text-color: #e0e0e0;
--code-background: #2d2d2d;
--blockquote-border: #555;
--blockquote-color: #aaa;
--link-color: #58a6ff;
--table-border: #444;
--table-header-bg: #2d2d2d;
--table-row-alt-bg: #262626;
}}
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--background-color);
margin: 0;
padding: 0;
font-size: var(--base-font-size);
}}
.container {{
display: flex;
min-height: 100vh;
}}
.main-content {{
flex: 1;
max-width: 900px;
margin: 0 auto;
padding: 20px;
transition: margin-left 0.3s ease;
}}
.main-content.toc-visible {{
margin-left: 320px;
}}
/* TOC Sidebar Styling */
.toc-sidebar {{
position: fixed;
left: 0;
top: 0;
width: 300px;
height: 100vh;
background-color: var(--background-color);
border-right: 1px solid var(--table-border);
overflow-y: auto;
padding: 20px;
box-sizing: border-box;
z-index: 1000;
transition: transform 0.3s ease;
}}
.toc-sidebar.hidden {{
transform: translateX(-100%);
}}
.toc-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid var(--table-border);
}}
.toc-header h3 {{
margin: 0;
font-size: 1.1em;
font-weight: 600;
}}
.toc-toggle {{
background: none;
border: none;
font-size: 1.2em;
cursor: pointer;
color: var(--text-color);
padding: 5px;
border-radius: 3px;
}}
.toc-toggle:hover {{
background-color: var(--code-background);
}}
.toc-list {{
list-style: none;
padding: 0;
margin: 0;
}}
.toc-item {{
margin: 0;
padding: 0;
}}
.toc-link {{
display: block;
padding: 6px 0;
color: var(--text-color);
text-decoration: none;
border-radius: 3px;
transition: all 0.2s ease;
font-size: 0.9em;
line-height: 1.4;
}}
.toc-link:hover {{
background-color: var(--code-background);
padding-left: 8px;
}}
.toc-link.active {{
color: var(--link-color);
background-color: var(--code-background);
font-weight: 500;
}}
.toc-h1 .toc-link {{
font-weight: 600;
font-size: 0.95em;
}}
.toc-h2 .toc-link {{
padding-left: 16px;
font-size: 0.88em;
}}
.toc-h3 .toc-link {{
padding-left: 32px;
font-size: 0.85em;
color: var(--blockquote-color);
}}
.toc-mobile-toggle {{
display: none;
position: fixed;
top: 20px;
left: 20px;
background-color: var(--link-color);
color: white;
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
font-size: 1.2em;
cursor: pointer;
z-index: 1001;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}}
/* Mobile responsive */
@media (max-width: 1024px) {{
.main-content.toc-visible {{
margin-left: 0;
padding: 20px;
}}
.toc-sidebar {{
transform: translateX(-100%);
}}
.toc-sidebar.mobile-visible {{
transform: translateX(0);
}}
.toc-mobile-toggle {{
display: block;
}}
}}
@media (max-width: 768px) {{
.main-content {{
padding: 15px;
}}
.toc-sidebar {{
width: 280px;
}}
}}
h1, h2, h3, h4, h5, h6 {{
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}}
h1 {{ font-size: 2em; border-bottom: 1px solid var(--table-border); padding-bottom: 0.3em; }}
h2 {{ font-size: 1.5em; border-bottom: 1px solid var(--table-border); padding-bottom: 0.3em; }}
a {{ color: var(--link-color); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
/* Enhanced pre/code styling with syntax highlighting support */
pre {{
background-color: var(--code-background);
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
font-size: var(--code-font-size);
line-height: 1.45;
}}
code {{
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background-color: var(--code-background);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: var(--code-font-size);
}}
/* Inline code should be slightly larger for better readability */
p code, li code, td code {{
font-size: calc(var(--code-font-size) * 1.05);
}}
pre code {{
padding: 0;
background-color: transparent;
white-space: pre;
word-break: normal;
overflow-wrap: normal;
}}
/* Syntax highlighting classes */
.hljs-keyword {{ color: #cf222e; }}
.hljs-built_in {{ color: #e36209; }}
.hljs-type {{ color: #953800; }}
.hljs-literal {{ color: #0550ae; }}
.hljs-number {{ color: #0550ae; }}
.hljs-string {{ color: #0a3069; }}
.hljs-comment {{ color: #6e7781; }}
.hljs-doctag {{ color: #0550ae; }}
.hljs-meta {{ color: #8250df; }}
.hljs-function {{ color: #8250df; }}
@media (prefers-color-scheme: dark) {{
.hljs-keyword {{ color: #ff7b72; }}
.hljs-built_in {{ color: #ffa657; }}
.hljs-type {{ color: #ff7b72; }}
.hljs-literal {{ color: #79c0ff; }}
.hljs-number {{ color: #79c0ff; }}
.hljs-string {{ color: #a5d6ff; }}
.hljs-comment {{ color: #8b949e; }}
.hljs-doctag {{ color: #79c0ff; }}
.hljs-meta {{ color: #d2a8ff; }}
.hljs-function {{ color: #d2a8ff; }}
}}
/* Enhanced blockquote styling */
blockquote {{
margin: 0;
padding-left: 16px;
padding-right: 16px;
padding-bottom: 1px;
padding-top: 1px;
background: rgba(0, 0, 0, 0.05);
border-left: 4px solid var(--blockquote-border);
color: var(--blockquote-color);
margin-bottom: 16px;
}}
@media (prefers-color-scheme: dark) {{
blockquote {{
background: rgba(255, 255, 255, 0.05);
}}
}}
/* Enhanced image styling */
img {{
max-width: 100%;
box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin: 10px 0;
border-radius: 4px;
}}
/* Enhanced table styling with explicit font size control */
table {{
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
overflow: auto;
font-size: 1em; /* Match body font size */
}}
th, td {{
border: 1px solid var(--table-border);
padding: 8px 13px;
text-align: left;
font-size: 1em; /* Consistent font size */
vertical-align: top;
}}
th {{
background-color: var(--table-header-bg);
font-weight: 600;
}}
tr {{
font-size: 1em; /* Ensure rows maintain consistent font size */
}}
tr:nth-child(even) {{
background-color: var(--table-row-alt-bg);
}}
/* Lists styling */
ul, ol {{
padding-left: 2em;
margin-top: 0;
margin-bottom: 16px;
}}
li {{
margin-top: 0.25em;
}}
/* Task lists */
ul.contains-task-list {{
list-style-type: none;
padding-left: 1em;
}}
.task-list-item {{
position: relative;
padding-left: 1.5em;
}}
.task-list-item input {{
position: absolute;
left: 0;
top: 0.25em;
}}
/* Smooth scrolling for anchor links */
html {{
scroll-behavior: smooth;
}}
/* Ensure headers have some top margin for anchor positioning */
h1[id], h2[id], h3[id] {{
scroll-margin-top: 20px;
}}
</style>
<!-- MathJax for LaTeX rendering -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.7/MathJax.js?config=TeX-MML-AM_CHTML"></script>
<script type="text/x-mathjax-config">
MathJax.Hub.Config({{
tex2jax: {{
inlineMath: [['$','$'], ['\\(','\\)']],
displayMath: [['$$','$$'], ['\\[','\\]']],
processEscapes: true
}}
}});
</script>
<!-- Highlight.js for code syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/rust.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/java.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/cpp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/languages/sql.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', (event) => {{
document.querySelectorAll('pre code').forEach((block) => {{
hljs.highlightBlock(block);
}});
// Add checkbox functionality for task lists
document.querySelectorAll('.task-list-item input[type="checkbox"]').forEach(checkbox => {{
checkbox.disabled = false;
checkbox.addEventListener('change', function() {{
this.parentElement.classList.toggle('completed');
}});
}});
// TOC functionality
const tocSidebar = document.getElementById('toc-sidebar');
const tocToggle = document.getElementById('toc-toggle');
const tocMobileToggle = document.getElementById('toc-mobile-toggle');
const mainContent = document.querySelector('.main-content');
const tocLinks = document.querySelectorAll('.toc-link');
// If TOC exists, add toc-visible class to main content
if (tocSidebar) {{
mainContent.classList.add('toc-visible');
}}
// Toggle TOC visibility
function toggleToc() {{
if (window.innerWidth <= 1024) {{
tocSidebar.classList.toggle('mobile-visible');
}} else {{
tocSidebar.classList.toggle('hidden');
mainContent.classList.toggle('toc-visible');
}}
}}
// Desktop toggle
if (tocToggle) {{
tocToggle.addEventListener('click', toggleToc);
}}
// Mobile toggle
if (tocMobileToggle) {{
tocMobileToggle.addEventListener('click', toggleToc);
}}
// Close mobile TOC when clicking on a link
tocLinks.forEach(link => {{
link.addEventListener('click', () => {{
if (window.innerWidth <= 1024) {{
tocSidebar.classList.remove('mobile-visible');
}}
}});
}});
// Active section highlighting
function updateActiveSection() {{
const headers = document.querySelectorAll('h1[id], h2[id], h3[id]');
const scrollPosition = window.scrollY + 100;
let activeId = null;
for (const header of headers) {{
if (header.offsetTop <= scrollPosition) {{
activeId = header.id;
}} else {{
break;
}}
}}
// Update active link
tocLinks.forEach(link => {{
link.classList.remove('active');
if (activeId && link.getAttribute('href') === '#' + activeId) {{
link.classList.add('active');
}}
}});
}}
// Update active section on scroll
window.addEventListener('scroll', updateActiveSection);
updateActiveSection(); // Initial call
// Handle window resize
window.addEventListener('resize', () => {{
if (window.innerWidth > 1024 && tocSidebar) {{
tocSidebar.classList.remove('mobile-visible');
if (!tocSidebar.classList.contains('hidden')) {{
mainContent.classList.add('toc-visible');
}}
}} else {{
tocSidebar.classList.remove('hidden');
mainContent.classList.remove('toc-visible');
}}
}});
}});
</script>
</head>
<body>
{}
<div class="container">
<main class="main-content">
{}
</main>
</div>
</body>
</html>"#,
bookmark.title, toc_html, processed_html
);
let temp_dir = tempfile::Builder::new()
.prefix("bkmr-markdown-")
.tempdir()
.map_err(|e| {
DomainError::Other(format!("Failed to create temporary directory: {}", e))
})?;
if let Some(source_path) = self.get_source_file_path(bookmark) {
debug!("Copying resources for file: {:?}", source_path);
self.copy_resources_to_temp(&full_html, &source_path, temp_dir.path())?;
} else {
debug!("Content is not from a file, skipping resource copying");
}
let safe_title = bookmark
.title
.replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "_");
let file_name = format!("{}.html", safe_title);
let file_path = temp_dir.path().join(file_name);
let mut file = File::create(&file_path)
.map_err(|e| DomainError::Other(format!("Failed to create HTML file: {}", e)))?;
file.write_all(full_html.as_bytes())
.map_err(|e| DomainError::Other(format!("Failed to write HTML to file: {}", e)))?;
debug!("Opening HTML file in browser: {:?}", file_path);
open::that(&file_path)
.map_err(|e| DomainError::Other(format!("Failed to open HTML in browser: {}", e)))?;
std::mem::forget(temp_dir);
Ok(())
}
fn description(&self) -> &'static str {
"Render markdown and open in browser"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::tag::Tag;
use crate::util::testing::{init_test_env, EnvGuard};
use std::collections::HashSet;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn given_markdown_file_when_read_then_returns_content() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let mut temp_file = NamedTempFile::new().unwrap();
let test_content = "# Test Markdown\n\nThis is a test.";
write!(temp_file, "{}", test_content).unwrap();
let result = action.read_markdown_from_file(temp_file.path().to_str().unwrap());
assert!(result.is_ok());
assert_eq!(result.unwrap(), test_content);
let result = action.read_markdown_from_file("/this/file/does/not/exist.md");
assert!(result.is_err());
}
#[test]
fn given_action_when_check_embedding_update_then_returns_eligibility() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder_no_repo = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action_no_repo = MarkdownAction::new(embedder_no_repo);
let repository = Arc::new(crate::util::testing::setup_test_db());
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let vector_repository = Arc::new(crate::infrastructure::repositories::null_vector_repository::NullVectorRepository);
let action_with_repo = MarkdownAction::new_with_repository(repository, vector_repository, embedder);
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let embeddable_bookmark = Bookmark {
id: Some(1),
url: "# Test".to_string(),
title: "Test Document".to_string(),
description: "A test document".to_string(),
tags: tags.clone(),
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let non_embeddable_bookmark = Bookmark {
id: Some(2),
url: "# Test".to_string(),
title: "Test Document".to_string(),
description: "A test document".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: false,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
assert!(
!action_no_repo.can_update_embedding(&embeddable_bookmark),
"Should return false when no repository is available"
);
assert!(
!action_with_repo.can_update_embedding(&non_embeddable_bookmark),
"Should return false when bookmark is not embeddable"
);
}
#[test]
#[ignore = "This test opens a browser which might not be available in CI"]
fn given_markdown_content_when_execute_then_renders_html() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let markdown = "# Test Markdown\n\nThis is a **test** with math: $$E = mc^2$$";
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: markdown.to_string(),
title: "Test Markdown Document".to_string(),
description: "A test markdown document".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.execute(&bookmark);
if result.is_err() {
if let DomainError::Other(msg) = &result.unwrap_err() {
if !msg.contains("Failed to open HTML in browser") {
panic!("Test failed with unexpected error: {}", msg);
}
}
}
}
#[test]
#[ignore = "This test opens a browser which might not be available in CI"]
fn given_markdown_with_table_when_execute_then_renders_with_table_styles() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let markdown = "# Test Table\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Cell 1 | Cell 2 | Cell 3 |\n| Cell 4 | Cell 5 | Cell 6 |";
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: markdown.to_string(),
title: "Test Table Document".to_string(),
description: "A test markdown document with tables".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.execute(&bookmark);
if result.is_err() {
if let DomainError::Other(msg) = &result.unwrap_err() {
if !msg.contains("Failed to open HTML in browser") {
panic!("Test failed with unexpected error: {}", msg);
}
}
}
}
#[test]
#[ignore = "This test opens a browser which might not be available in CI"]
fn given_markdown_with_code_when_execute_then_renders_with_highlighting() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let markdown = "# Code Highlighting\n\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```\n\n```python\ndef hello():\n print(\"Hello, world!\")\n```";
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: markdown.to_string(),
title: "Code Highlighting Document".to_string(),
description: "A test markdown document with code blocks".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.execute(&bookmark);
if result.is_err() {
if let DomainError::Other(msg) = &result.unwrap_err() {
if !msg.contains("Failed to open HTML in browser") {
panic!("Test failed with unexpected error: {}", msg);
}
}
}
}
#[test]
fn given_markdown_headers_when_extract_and_process_then_generates_ids() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = r#"<h1>Main Title</h1>
<p>Some content</p>
<h2>Section 1</h2>
<p>More content</p>
<h3>Subsection 1.1</h3>
<p>Even more content</p>"#;
let (processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 3);
assert_eq!(toc_entries[0].level, 1);
assert_eq!(toc_entries[0].title, "Main Title");
assert_eq!(toc_entries[0].id, "main-title");
assert_eq!(toc_entries[1].level, 2);
assert_eq!(toc_entries[1].title, "Section 1");
assert_eq!(toc_entries[1].id, "section-1");
assert_eq!(toc_entries[2].level, 3);
assert_eq!(toc_entries[2].title, "Subsection 1.1");
assert_eq!(toc_entries[2].id, "subsection-1-1");
assert!(processed_html.contains("<h1 id=\"main-title\">Main Title</h1>"));
assert!(processed_html.contains("<h2 id=\"section-1\">Section 1</h2>"));
assert!(processed_html.contains("<h3 id=\"subsection-1-1\">Subsection 1.1</h3>"));
}
#[test]
fn given_headers_with_existing_ids_when_extract_then_preserves_ids() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = r#"<h1 id="existing-id">Title with ID</h1>
<h2>Title without ID</h2>"#;
let (processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 2);
assert_eq!(toc_entries[0].id, "existing-id");
assert!(processed_html.contains("<h1 id=\"existing-id\">Title with ID</h1>"));
assert_eq!(toc_entries[1].id, "title-without-id");
assert!(processed_html.contains("<h2 id=\"title-without-id\">Title without ID</h2>"));
}
#[test]
fn given_duplicate_header_titles_when_extract_then_creates_unique_ids() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = r#"<h1>Introduction</h1>
<h2>Introduction</h2>
<h3>Introduction</h3>"#;
let (processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 3);
assert_eq!(toc_entries[0].id, "introduction");
assert_eq!(toc_entries[1].id, "introduction-1");
assert_eq!(toc_entries[2].id, "introduction-2");
assert!(processed_html.contains("<h1 id=\"introduction\">Introduction</h1>"));
assert!(processed_html.contains("<h2 id=\"introduction-1\">Introduction</h2>"));
assert!(processed_html.contains("<h3 id=\"introduction-2\">Introduction</h3>"));
}
#[test]
fn given_headers_with_html_content_when_extract_then_cleans_html() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = r#"<h1>Title with <strong>bold</strong> and <em>italic</em></h1>
<h2>Code: <code>function()</code></h2>"#;
let (_processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 2);
assert_eq!(toc_entries[0].title, "Title with bold and italic");
assert_eq!(toc_entries[1].title, "Code: function()");
assert_eq!(toc_entries[0].id, "title-with-bold-and-italic");
assert_eq!(toc_entries[1].id, "code-function");
}
#[test]
fn given_empty_content_when_extract_headers_then_returns_empty() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = "<p>No headers here</p>";
let (processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 0);
assert_eq!(processed_html, html_content); }
#[test]
fn given_h4_and_higher_headers_when_extract_then_ignores_them() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html_content = r#"<h1>H1 Title</h1>
<h2>H2 Title</h2>
<h3>H3 Title</h3>
<h4>H4 Title</h4>
<h5>H5 Title</h5>
<h6>H6 Title</h6>"#;
let (processed_html, toc_entries) = action.extract_and_process_headers(html_content);
assert_eq!(toc_entries.len(), 3);
assert_eq!(toc_entries[0].level, 1);
assert_eq!(toc_entries[1].level, 2);
assert_eq!(toc_entries[2].level, 3);
assert!(processed_html.contains("<h4>H4 Title</h4>"));
assert!(processed_html.contains("<h5>H5 Title</h5>"));
assert!(processed_html.contains("<h6>H6 Title</h6>"));
}
#[test]
fn given_header_text_when_generate_id_then_creates_valid_id() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
assert_eq!(action.generate_header_id("Simple Title"), "simple-title");
assert_eq!(
action.generate_header_id("Title with Special! @#$% Characters"),
"title-with-special-characters"
);
assert_eq!(action.generate_header_id("Section 1.2.3"), "section-1-2-3");
assert_eq!(
action.generate_header_id(" Multiple Spaces and--Hyphens "),
"multiple-spaces-and-hyphens"
);
assert_eq!(
action.generate_header_id("Title with <strong>HTML</strong> tags"),
"title-with-html-tags"
);
}
#[test]
fn given_html_content_when_clean_then_removes_tags() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
assert_eq!(
action.clean_html_content("<strong>Bold</strong> text"),
"Bold text"
);
assert_eq!(
action.clean_html_content("<em>Italic</em> and <code>code</code>"),
"Italic and code"
);
assert_eq!(
action.clean_html_content("<a href='#'>Link</a> text"),
"Link text"
);
assert_eq!(
action.clean_html_content("<div><span>Nested</span> content</div>"),
"Nested content"
);
assert_eq!(action.clean_html_content("Plain text"), "Plain text");
assert_eq!(
action.clean_html_content("Text with <br/> break"),
"Text with break"
);
}
#[test]
fn given_empty_headers_when_generate_toc_then_returns_empty_html() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let toc_entries = vec![];
let toc_html = action.generate_toc_html(&toc_entries);
assert_eq!(toc_html, "");
}
#[test]
fn given_header_entries_when_generate_toc_then_creates_html_list() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let toc_entries = vec![
TocEntry {
level: 1,
title: "Main Title".to_string(),
id: "main-title".to_string(),
},
TocEntry {
level: 2,
title: "Section 1".to_string(),
id: "section-1".to_string(),
},
TocEntry {
level: 3,
title: "Subsection 1.1".to_string(),
id: "subsection-1-1".to_string(),
},
];
let toc_html = action.generate_toc_html(&toc_entries);
assert!(toc_html.contains("<nav class=\"toc-sidebar\" id=\"toc-sidebar\">"));
assert!(toc_html.contains("Table of Contents"));
assert!(toc_html.contains("<ul class=\"toc-list\">"));
assert!(toc_html.contains("<li class=\"toc-item toc-h1\"><a href=\"#main-title\" class=\"toc-link\">Main Title</a></li>"));
assert!(toc_html.contains("<li class=\"toc-item toc-h2\"><a href=\"#section-1\" class=\"toc-link\">Section 1</a></li>"));
assert!(toc_html.contains("<li class=\"toc-item toc-h3\"><a href=\"#subsection-1-1\" class=\"toc-link\">Subsection 1.1</a></li>"));
assert!(toc_html
.contains("<button class=\"toc-mobile-toggle\" id=\"toc-mobile-toggle\">📋</button>"));
}
#[test]
fn given_special_characters_in_titles_when_generate_toc_then_escapes_html() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let toc_entries = vec![TocEntry {
level: 1,
title: "Title with & < > \" characters".to_string(),
id: "title-with-characters".to_string(),
}];
let toc_html = action.generate_toc_html(&toc_entries);
assert!(toc_html.contains("Title with & < > \" characters"));
assert!(toc_html.contains("href=\"#title-with-characters\""));
}
#[test]
fn given_file_path_bookmark_when_get_source_then_returns_path() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "# Test").unwrap();
let temp_path = temp_file.path().to_str().unwrap().to_string();
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: temp_path,
title: "Test".to_string(),
description: "Test".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.get_source_file_path(&bookmark);
assert!(result.is_some());
assert!(result.unwrap().exists());
}
#[test]
fn given_markdown_content_bookmark_when_get_source_then_returns_none() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: "# Direct Markdown Content".to_string(),
title: "Test".to_string(),
description: "Test".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.get_source_file_path(&bookmark);
assert!(result.is_none());
}
#[test]
fn given_nonexistent_file_path_bookmark_when_get_source_then_returns_none() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let mut tags = HashSet::new();
tags.insert(Tag::new("_md_").unwrap());
let bookmark = Bookmark {
id: Some(1),
url: "/nonexistent/file.md".to_string(),
title: "Test".to_string(),
description: "Test".to_string(),
tags,
access_count: 0,
created_at: Some(chrono::Utc::now()),
updated_at: chrono::Utc::now(),
embedding: None,
content_hash: None,
embeddable: true,
file_path: None,
file_mtime: None,
file_hash: None,
opener: None,
accessed_at: None,
};
let result = action.get_source_file_path(&bookmark);
assert!(result.is_none());
}
#[test]
fn given_html_with_images_when_extract_resources_then_returns_paths() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html = r#"
<p>Some content</p>
<img src="./images/photo.png" alt="Photo">
<img src="./assets/logo.jpg" alt="Logo">
"#;
let resources = action.extract_resource_paths(html);
assert_eq!(resources.len(), 2);
assert_eq!(resources[0].1, "./images/photo.png");
assert_eq!(resources[1].1, "./assets/logo.jpg");
}
#[test]
fn given_html_with_mixed_urls_when_extract_resources_then_filters_correctly() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html = r#"
<img src="./local.png" alt="Local">
<img src="https://example.com/remote.png" alt="Remote">
<img src="http://example.com/http.png" alt="HTTP">
<img src="data:image/png;base64,abc123" alt="Data URI">
<img src="//cdn.example.com/cdn.png" alt="Protocol Relative">
"#;
let resources = action.extract_resource_paths(html);
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].1, "./local.png");
}
#[test]
fn given_html_with_link_and_script_tags_when_extract_resources_then_includes_all() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html = r#"
<link rel="stylesheet" href="./styles.css">
<script src="./scripts/app.js"></script>
<img src="./images/icon.png">
"#;
let resources = action.extract_resource_paths(html);
assert_eq!(resources.len(), 3);
let paths: Vec<&str> = resources.iter().map(|(_, path)| path.as_str()).collect();
assert!(paths.contains(&"./images/icon.png"));
assert!(paths.contains(&"./styles.css"));
assert!(paths.contains(&"./scripts/app.js"));
}
#[test]
fn given_empty_html_when_extract_resources_then_returns_empty_vec() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html = "<p>No resources here</p>";
let resources = action.extract_resource_paths(html);
assert_eq!(resources.len(), 0);
}
#[test]
fn given_html_with_quoted_paths_when_extract_resources_then_handles_both_quotes() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let html = r#"
<img src="./single.png" alt="Single">
<img src='./double.png' alt="Double">
"#;
let resources = action.extract_resource_paths(html);
assert_eq!(resources.len(), 2);
assert_eq!(resources[0].1, "./single.png");
assert_eq!(resources[1].1, "./double.png");
}
#[test]
fn given_relative_path_when_resolve_resource_then_returns_absolute() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let temp_dir = tempfile::tempdir().unwrap();
let md_path = temp_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let images_dir = temp_dir.path().join("images");
fs::create_dir(&images_dir).unwrap();
let image_path = images_dir.join("photo.png");
fs::write(&image_path, "fake image data").unwrap();
let result = action.resolve_resource_path("./images/photo.png", &md_path);
assert!(result.is_some());
assert!(result.unwrap().exists());
}
#[test]
fn given_parent_relative_path_when_resolve_resource_then_returns_absolute() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let temp_dir = tempfile::tempdir().unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir(&docs_dir).unwrap();
let md_path = docs_dir.join("test.md");
fs::write(&md_path, "# Test").unwrap();
let assets_dir = temp_dir.path().join("assets");
fs::create_dir(&assets_dir).unwrap();
let logo_path = assets_dir.join("logo.png");
fs::write(&logo_path, "fake logo data").unwrap();
let result = action.resolve_resource_path("../assets/logo.png", &md_path);
assert!(result.is_some());
assert!(result.unwrap().exists());
}
#[test]
fn given_absolute_path_when_resolve_resource_then_returns_path() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let mut temp_file = NamedTempFile::new().unwrap();
write!(temp_file, "image data").unwrap();
let absolute_path = temp_file.path().to_str().unwrap();
let md_path = std::path::PathBuf::from("/tmp/test.md");
let result = action.resolve_resource_path(absolute_path, &md_path);
assert!(result.is_some());
}
#[test]
fn given_nonexistent_path_when_resolve_resource_then_returns_none() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let temp_dir = tempfile::tempdir().unwrap();
let md_path = temp_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let result = action.resolve_resource_path("./nonexistent.png", &md_path);
assert!(result.is_none());
}
#[test]
fn given_path_with_dots_when_resolve_resource_then_canonicalizes() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let temp_dir = tempfile::tempdir().unwrap();
let docs_dir = temp_dir.path().join("docs");
fs::create_dir(&docs_dir).unwrap();
let notes_dir = docs_dir.join("notes");
fs::create_dir(¬es_dir).unwrap();
let md_path = notes_dir.join("test.md");
fs::write(&md_path, "# Test").unwrap();
let images_dir = docs_dir.join("images");
fs::create_dir(&images_dir).unwrap();
let photo_path = images_dir.join("photo.png");
fs::write(&photo_path, "fake photo data").unwrap();
let result = action.resolve_resource_path("./../images/./photo.png", &md_path);
assert!(result.is_some());
let resolved = result.unwrap();
assert!(resolved.exists());
assert!(!resolved.to_string_lossy().contains("/./"));
assert!(!resolved.to_string_lossy().contains("/../"));
}
#[test]
fn given_single_image_when_copy_resources_then_copies_correctly() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let source_dir = tempfile::tempdir().unwrap();
let md_path = source_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let images_dir = source_dir.path().join("images");
fs::create_dir(&images_dir).unwrap();
let image_path = images_dir.join("photo.png");
fs::write(&image_path, "test image data").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let html = r#"<img src="./images/photo.png" alt="Photo">"#;
let result = action.copy_resources_to_temp(html, &md_path, temp_dir.path());
assert!(result.is_ok());
let copied_file = temp_dir.path().join("images/photo.png");
assert!(copied_file.exists());
let content = fs::read_to_string(copied_file).unwrap();
assert_eq!(content, "test image data");
}
#[test]
fn given_multiple_resources_when_copy_resources_then_copies_all() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let source_dir = tempfile::tempdir().unwrap();
let md_path = source_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let images_dir = source_dir.path().join("images");
fs::create_dir(&images_dir).unwrap();
fs::write(images_dir.join("photo.png"), "photo data").unwrap();
let styles_dir = source_dir.path().join("styles");
fs::create_dir(&styles_dir).unwrap();
fs::write(styles_dir.join("main.css"), "css data").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let html = r#"
<link rel="stylesheet" href="./styles/main.css">
<img src="./images/photo.png" alt="Photo">
"#;
let result = action.copy_resources_to_temp(html, &md_path, temp_dir.path());
assert!(result.is_ok());
assert!(temp_dir.path().join("styles/main.css").exists());
assert!(temp_dir.path().join("images/photo.png").exists());
}
#[test]
fn given_nested_directories_when_copy_resources_then_maintains_structure() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let source_dir = tempfile::tempdir().unwrap();
let md_path = source_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let assets_dir = source_dir.path().join("assets");
fs::create_dir(&assets_dir).unwrap();
let images_dir = assets_dir.join("images");
fs::create_dir(&images_dir).unwrap();
let icons_dir = images_dir.join("icons");
fs::create_dir(&icons_dir).unwrap();
fs::write(icons_dir.join("logo.svg"), "svg data").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let html = r#"<img src="./assets/images/icons/logo.svg" alt="Logo">"#;
let result = action.copy_resources_to_temp(html, &md_path, temp_dir.path());
assert!(result.is_ok());
let copied_file = temp_dir.path().join("assets/images/icons/logo.svg");
assert!(copied_file.exists());
let content = fs::read_to_string(copied_file).unwrap();
assert_eq!(content, "svg data");
}
#[test]
fn given_nonexistent_resource_when_copy_resources_then_continues() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let source_dir = tempfile::tempdir().unwrap();
let md_path = source_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let images_dir = source_dir.path().join("images");
fs::create_dir(&images_dir).unwrap();
fs::write(images_dir.join("existing.png"), "exists").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let html = r#"
<img src="./images/existing.png" alt="Exists">
<img src="./images/nonexistent.png" alt="Missing">
"#;
let result = action.copy_resources_to_temp(html, &md_path, temp_dir.path());
assert!(result.is_ok());
assert!(temp_dir.path().join("images/existing.png").exists());
assert!(!temp_dir.path().join("images/nonexistent.png").exists());
}
#[test]
fn given_empty_html_when_copy_resources_then_returns_ok() {
let _ = init_test_env();
let _guard = EnvGuard::new();
let embedder = Arc::new(crate::infrastructure::embeddings::DummyEmbedding);
let action = MarkdownAction::new(embedder);
let source_dir = tempfile::tempdir().unwrap();
let md_path = source_dir.path().join("test.md");
fs::write(&md_path, "# Test").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let html = "<p>No resources</p>";
let result = action.copy_resources_to_temp(html, &md_path, temp_dir.path());
assert!(result.is_ok());
}
}