Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"allow": [
{ "path": "$HOME" },
{ "path": "$HOME/**" },
{ "path": "$HOME/.docsreader" },
{ "path": "$HOME/.docsreader/**" },
{ "path": "$DESKTOP" },
{ "path": "$DESKTOP/**" },
{ "path": "$DOCUMENT" },
Expand Down Expand Up @@ -84,6 +86,8 @@
"allow": [
{ "path": "$HOME" },
{ "path": "$HOME/**" },
{ "path": "$HOME/.docsreader" },
{ "path": "$HOME/.docsreader/**" },
{ "path": "$DESKTOP" },
{ "path": "$DESKTOP/**" },
{ "path": "$DOCUMENT" },
Expand Down
88 changes: 83 additions & 5 deletions src-tauri/core/src/scan.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::fs::File;
use std::io::Read;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

Expand Down Expand Up @@ -51,6 +51,11 @@ pub struct ScanResult {
pub root: String,
pub files: Vec<MarkdownFile>,
pub truncated: bool,
// Files the scan could not include: unreadable entries, permission-denied
// subtrees, symlink loops, and files over MAX_FILE_BYTES. Default keeps
// cached results from older versions deserializable.
#[serde(default)]
pub skipped: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marker: Option<WorkspaceMarker>,
}
Expand Down Expand Up @@ -125,6 +130,12 @@ fn is_skipped_dir(name: &str) -> bool {
SKIP_DIRS.contains(&name)
}

/// True when a directory name collides with an entry the scanner prunes;
/// docs placed under such a folder would never appear in the GUI.
pub fn is_reserved_dir_name(name: &str) -> bool {
SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(name))
}

pub(crate) fn is_markdown(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".md") || lower.ends_with(".markdown") || lower.ends_with(".mdx")
Expand All @@ -149,9 +160,10 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result<ScanRes

let mut entries: Vec<DirEntry> = Vec::new();
let mut truncated = false;
let skipped = AtomicUsize::new(0);

let walker = WalkDir::new(root_path)
.follow_links(false)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 {
Expand All @@ -165,7 +177,16 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result<ScanRes
}
});

for entry in walker.filter_map(|e| e.ok()) {
for entry in walker {
let entry = match entry {
Ok(e) => e,
// Permission-denied subtrees and symlink loops arrive as error
// entries; count them so the GUI can say files were left out.
Err(_) => {
skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
};
if entry.file_type().is_dir() {
dirs_visited.fetch_add(1, Ordering::Relaxed);
maybe_emit_walk_progress(
Expand All @@ -192,6 +213,7 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result<ScanRes

if let Ok(meta) = entry.metadata() {
if meta.len() > MAX_FILE_BYTES {
skipped.fetch_add(1, Ordering::Relaxed);
continue;
}
}
Expand All @@ -209,15 +231,21 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result<ScanRes
let mut files: Vec<MarkdownFile> = entries
.par_iter()
.filter_map(|entry| {
let metadata = entry.metadata().ok()?;
let Ok(metadata) = entry.metadata() else {
skipped.fetch_add(1, Ordering::Relaxed);
return None;
};
let size = metadata.len();
let modified = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());

let content = read_partial(entry.path()).ok()?;
let Ok(content) = read_partial(entry.path()) else {
skipped.fetch_add(1, Ordering::Relaxed);
return None;
};
let (title, tags) = parse_meta(&content);

let rel_path = entry
Expand Down Expand Up @@ -274,6 +302,7 @@ pub fn run_scan(progress: &dyn ScanProgressSink, path: String) -> Result<ScanRes
root: root_path.to_string_lossy().to_string(),
files,
truncated,
skipped: skipped.load(Ordering::Relaxed),
marker,
})
}
Expand Down Expand Up @@ -388,4 +417,53 @@ mod tests {
assert!(target.links.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}

#[cfg(unix)]
#[test]
fn scan_includes_symlinked_markdown_files() {
let dir = test_dir("scan_symlink");
let outside = test_dir("scan_symlink_target");
std::fs::write(outside.join("real.md"), "# Linked Doc\n").unwrap();
std::os::unix::fs::symlink(outside.join("real.md"), dir.join("linked.md")).unwrap();

let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap();
let linked = result
.files
.iter()
.find(|f| f.rel_path == "linked.md")
.expect("symlinked markdown appears in results");
assert_eq!(linked.title.as_deref(), Some("Linked Doc"));
assert_eq!(result.skipped, 0);
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&outside);
}

#[cfg(unix)]
#[test]
fn scan_counts_symlink_loop_as_skipped() {
let dir = test_dir("scan_loop");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::os::unix::fs::symlink(&dir, dir.join("sub/loop")).unwrap();
std::fs::write(dir.join("a.md"), "# A\n").unwrap();

let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap();
assert_eq!(result.files.len(), 1);
assert_eq!(result.files[0].rel_path, "a.md");
assert_eq!(result.skipped, 1, "loop error entry is counted");
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn scan_counts_oversize_files_as_skipped() {
let dir = test_dir("scan_oversize");
std::fs::write(dir.join("ok.md"), "# Ok\n").unwrap();
std::fs::write(dir.join("big.md"), vec![b'x'; MAX_FILE_BYTES as usize + 1]).unwrap();

let result = run_scan(&NoopProgressSink, dir.to_string_lossy().to_string()).unwrap();
assert_eq!(result.files.len(), 1);
assert_eq!(result.files[0].rel_path, "ok.md");
assert_eq!(result.skipped, 1);
assert!(!result.truncated);
let _ = std::fs::remove_dir_all(&dir);
}
}
46 changes: 46 additions & 0 deletions src-tauri/core/src/write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ fn validate_phase(phase: &str) -> Result<(), CoreError> {
),
);
}
// A phase folder named like a pruned directory (build, dist, ...) would
// make its docs invisible to the GUI while writes still report success.
if crate::scan::is_reserved_dir_name(phase) {
return Err(CoreError::new(
ErrorCode::InvalidInput,
format!("phase {phase:?} is a reserved name"),
)
.with_recovery(format!(
"choose a different phase name, e.g. \"{phase}-phase\""
)));
}
Ok(())
}

Expand Down Expand Up @@ -563,6 +574,41 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}

#[tokio::test]
async fn rejects_reserved_phase_names() {
let root = test_dir("reserved_phase");
for phase in ["build", "dist", "target", "venv"] {
let err = write_doc_core(
&root,
&NewDoc {
phase: Some(phase),
..NewDoc::new("Doc", "x", DocStatus::Research)
},
)
.await
.unwrap_err();
assert_eq!(err.code, ErrorCode::InvalidInput, "phase {phase:?}");
assert!(
err.message.contains("reserved"),
"message says why: {}",
err.message
);
assert!(err.recovery.is_some(), "suggests picking another name");
}

let ok = write_doc_core(
&root,
&NewDoc {
phase: Some("discovery"),
..NewDoc::new("Doc", "x", DocStatus::Research)
},
)
.await
.unwrap();
assert_eq!(ok.rel_path, "research/discovery/doc.md");
let _ = std::fs::remove_dir_all(&root);
}

#[tokio::test]
async fn move_unknown_slug_is_doc_not_found() {
let root = test_dir("nomove");
Expand Down
6 changes: 6 additions & 0 deletions src/components/explorer/ExplorerSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,19 @@ function ExplorerFooter({
}
const total = activeScan.result.files.length;
const visible = total - hiddenCount;
const skipped = activeScan.result.skipped ?? 0;
return (
<span className="flex items-center gap-2 px-2">
<span>
{visible} files
{activeScan.result.truncated && " (50k cap)"}
{matchCount !== visible && ` · ${matchCount} match`}
</span>
{skipped > 0 && (
<span title="Files that could not be read or were too large to include">
{skipped} skipped
</span>
)}
{hiddenCount > 0 && (
<button
type="button"
Expand Down
122 changes: 121 additions & 1 deletion src/hooks/useLibrary.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { renderHook, waitFor } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";

import { watch, type WatchEvent } from "@tauri-apps/plugin-fs";
import { scanDirectory } from "@/lib/scan";
import type { RegistryWorkspace } from "@/lib/workspaces";

let registry: RegistryWorkspace[] = [];
Expand Down Expand Up @@ -145,3 +147,121 @@ describe("useLibrary registry sync", () => {
expect(dismissed).not.toContain("/manual/only");
});
});

// Past DEBOUNCE_MS in useLibrary.ts, so a scheduled rescan fires.
const PAST_DEBOUNCE_MS = 700;
const REGISTRY_DIR = "/home/u/.docsreader";
const WATCH_OK = async () => () => {};

async function workspaceWatchCallback(root: string) {
await waitFor(() =>
expect(
vi.mocked(watch).mock.calls.some(([watched]) => watched === root)
).toBe(true)
);
const call = vi.mocked(watch).mock.calls.find(([watched]) => watched === root);
if (!call) throw new Error(`no watch attached for ${root}`);
return call[1];
}

function fireWatchEvent(
callback: (event: WatchEvent) => void,
type: WatchEvent["type"],
paths: string[]
) {
callback({ type, paths, attrs: {} });
}

describe("useLibrary stale-while-revalidate rescans", () => {
beforeEach(() => {
registry = [];
storedRoots = [];
dismissed = [];
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it("rescans the initial root after startup hydration", async () => {
storedRoots = ["/manual/folder"];
await mount();
await waitFor(() =>
expect(scanDirectory).toHaveBeenCalledWith(
"/manual/folder",
expect.any(Function)
)
);
});

it("rescans a root when it is selected", async () => {
storedRoots = ["/first", "/second"];
const hook = await mount();
vi.mocked(scanDirectory).mockClear();
await hook.result.current.selectRoot("/second");
await waitFor(() =>
expect(scanDirectory).toHaveBeenCalledWith("/second", expect.any(Function))
);
});

it("schedules a rescan when a file is modified in place", async () => {
storedRoots = ["/manual/folder"];
await mount();
const callback = await workspaceWatchCallback("/manual/folder");
vi.mocked(scanDirectory).mockClear();
vi.useFakeTimers();
fireWatchEvent(callback, { modify: { kind: "data", mode: "content" } }, [
"/manual/folder/doc.md",
]);
await vi.advanceTimersByTimeAsync(PAST_DEBOUNCE_MS);
expect(scanDirectory).toHaveBeenCalledWith(
"/manual/folder",
expect.any(Function)
);
});

it("schedules a rescan for an overflow sentinel event", async () => {
storedRoots = ["/manual/folder"];
await mount();
const callback = await workspaceWatchCallback("/manual/folder");
vi.mocked(scanDirectory).mockClear();
vi.useFakeTimers();
fireWatchEvent(callback, "other", []);
await vi.advanceTimersByTimeAsync(PAST_DEBOUNCE_MS);
expect(scanDirectory).toHaveBeenCalledWith(
"/manual/folder",
expect.any(Function)
);
});
});

describe("useLibrary watch resilience", () => {
beforeEach(() => {
registry = [];
storedRoots = [];
dismissed = [];
vi.clearAllMocks();
});

it("retries a failing watch and warns after the final attempt", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.mocked(watch).mockRejectedValue(new Error("forbidden path"));
try {
await mount();
await waitFor(() => expect(warn).toHaveBeenCalled(), { timeout: 4000 });
const registryAttempts = vi
.mocked(watch)
.mock.calls.filter(([watched]) => watched === REGISTRY_DIR);
expect(registryAttempts).toHaveLength(3);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("registry watch failed"),
REGISTRY_DIR,
expect.any(Error)
);
} finally {
warn.mockRestore();
vi.mocked(watch).mockImplementation(WATCH_OK);
}
});
});
Loading
Loading