rustledger_plugin/native/plugins/document_discovery.rs
1//! Auto-discover documents from directories.
2
3use serde::Deserialize;
4
5use crate::types::{
6 DirectiveData, DirectiveWrapper, DocumentData, PluginError, PluginInput, PluginOp, PluginOutput,
7};
8
9use super::super::{NativePlugin, SynthPlugin};
10
11/// Maximum recursion depth for directory scanning to prevent denial-of-service from deeply nested structures.
12const MAX_SCAN_DEPTH: usize = 32;
13
14/// Plugin that auto-discovers document files from configured directories.
15///
16/// Scans directories specified in `option "documents"` for files matching
17/// the pattern: `{Account}/YYYY-MM-DD.description.*`
18///
19/// For example: `documents/Assets/Bank/Checking/2024-01-15.statement.pdf`
20/// generates: `2024-01-15 document Assets:Bank:Checking "documents/Assets/Bank/Checking/2024-01-15.statement.pdf"`
21///
22/// # Configuration
23///
24/// The plugin reads its per-load context (resolved document directories
25/// and the ledger's base directory for relative-path normalization) from
26/// [`PluginInput::config`] as a JSON object:
27///
28/// ```json
29/// {"base_dir": "/path/to/ledger", "directories": ["/abs/path/docs"]}
30/// ```
31///
32/// The loader constructs this config when populating the synth pass; if
33/// `config` is `None` or `directories` is empty, the plugin returns a
34/// no-op (every input directive is kept, nothing synthesized). If `config`
35/// is present but malformed JSON, every input directive is still kept and
36/// a `PluginError::error` is added to the output errors — the plugin
37/// never silently drops directives on bad config. This lets the plugin
38/// sit in the registry as a static instance and be dispatched through
39/// the normal synth-pass machinery.
40///
41/// # Security
42///
43/// - Symlinks are skipped to prevent infinite recursion from symlink cycles
44/// - Maximum recursion depth is enforced to prevent denial-of-service from deeply nested directories
45pub struct DocumentDiscoveryPlugin;
46
47/// Name passed to file-declared / extra-plugin lookups and used by the
48/// loader when emitting the synth-pass config entry. Kept as a constant
49/// so the registry, the loader, and the rustdoc stay in sync.
50pub const DOCUMENT_DISCOVERY_NAME: &str = "document_discovery";
51
52/// JSON config schema parsed from [`PluginInput::config`].
53#[derive(Debug, Deserialize)]
54struct DocumentDiscoveryConfig {
55 base_dir: std::path::PathBuf,
56 directories: Vec<String>,
57}
58
59/// Build the [`PluginInput::config`] JSON string for this plugin.
60///
61/// Centralized here so callers (the loader) don't need to know the
62/// schema — the plugin owns its own config shape.
63#[must_use]
64pub fn document_discovery_config(base_dir: &std::path::Path, directories: &[String]) -> String {
65 serde_json::json!({
66 "base_dir": base_dir,
67 "directories": directories,
68 })
69 .to_string()
70}
71
72impl NativePlugin for DocumentDiscoveryPlugin {
73 fn name(&self) -> &'static str {
74 DOCUMENT_DISCOVERY_NAME
75 }
76
77 fn description(&self) -> &'static str {
78 "Auto-discover documents from directories"
79 }
80
81 fn process(&self, input: PluginInput) -> PluginOutput {
82 use std::path::Path;
83
84 // No config → no-op pass-through. Lets the plugin sit in the
85 // registry unconditionally without doing work when the ledger
86 // hasn't declared `option "documents"`.
87 let Some(config_json) = input.config.as_deref() else {
88 return PluginOutput {
89 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
90 errors: Vec::new(),
91 };
92 };
93
94 let config: DocumentDiscoveryConfig = match serde_json::from_str(config_json) {
95 Ok(c) => c,
96 Err(e) => {
97 return PluginOutput {
98 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
99 errors: vec![PluginError::error(format!(
100 "document_discovery: invalid config JSON: {e}"
101 ))],
102 };
103 }
104 };
105
106 if config.directories.is_empty() {
107 return PluginOutput {
108 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
109 errors: Vec::new(),
110 };
111 }
112
113 let mut new_directives = Vec::new();
114 let mut errors = Vec::new();
115
116 // Collect existing document paths to avoid duplicates.
117 // Normalize paths by resolving relative paths against base_dir, then canonicalizing.
118 let mut existing_docs: std::collections::HashSet<String> = std::collections::HashSet::new();
119 for wrapper in &input.directives {
120 if let DirectiveData::Document(doc) = &wrapper.data {
121 let doc_path = Path::new(&doc.path);
122 let resolved = if doc_path.is_absolute() {
123 doc_path.to_path_buf()
124 } else {
125 config.base_dir.join(doc_path)
126 };
127 let normalized = resolved
128 .canonicalize()
129 .map_or_else(|_| doc.path.clone(), |p| p.to_string_lossy().to_string());
130 existing_docs.insert(normalized);
131 }
132 }
133
134 // Accounts opened in the ledger. This is exactly the set the validator
135 // treats as known (`validate_document` checks `state.accounts`, which
136 // is populated only by `open`), so a document we skip here is precisely
137 // one that would otherwise hard-fail `E1001 AccountNotOpen`.
138 //
139 // Like beancount, we don't synthesize documents for unknown accounts
140 // (so `check` passes, matching `bean-check`). Unlike beancount — which
141 // drops them silently — we surface a *warning* per unknown account, to
142 // catch the common footgun of a stale `documents/` path after an
143 // account rename (a deliberate, more-helpful deviation; #1434). Warnings
144 // don't affect the exit code, so compatibility is preserved.
145 //
146 // Note the asymmetry (intentional — do not "fix" it): an *explicit*
147 // `document` directive to an unopened account still hard-errors via the
148 // validator, matching beancount's `validate_active_accounts`. Only
149 // auto-discovery is lenient, because beancount's discovery never emits
150 // the directive in the first place.
151 let opened: std::collections::HashSet<String> = input
152 .directives
153 .iter()
154 .filter_map(|w| match &w.data {
155 DirectiveData::Open(o) => Some(o.account.clone()),
156 _ => None,
157 })
158 .collect();
159
160 // Files found under directories that map to an unopened account,
161 // grouped by account (BTreeMap → deterministic, one warning per
162 // account instead of one per file).
163 let mut unknown: std::collections::BTreeMap<String, Vec<String>> =
164 std::collections::BTreeMap::new();
165
166 // Scan each directory
167 for dir in &config.directories {
168 let dir_path = Path::new(dir);
169 if !dir_path.exists() {
170 continue;
171 }
172
173 if let Err(e) = scan_documents(
174 dir_path,
175 dir,
176 &existing_docs,
177 &opened,
178 &mut new_directives,
179 &mut unknown,
180 &mut errors,
181 0, // Initial depth
182 ) {
183 errors.push(PluginError::error(format!(
184 "Error scanning documents in {dir}: {e}"
185 )));
186 }
187 }
188
189 // One aggregated warning per unknown account.
190 for (account, paths) in &unknown {
191 errors.push(PluginError::warning(unknown_account_warning(
192 account, paths, &opened,
193 )));
194 }
195
196 // Keep all input directives, then insert discovered documents.
197 let mut ops: Vec<PluginOp> = (0..input.directives.len()).map(PluginOp::Keep).collect();
198 for w in new_directives {
199 ops.push(PluginOp::Insert(w));
200 }
201
202 // Final ordering is the loader's responsibility — it re-sorts
203 // directives after the plugin pass.
204 PluginOutput { ops, errors }
205 }
206}
207
208/// Synthesizes `Document` directives that downstream consumers expect
209/// alongside user-written ones — runs in the synth pass so the early
210/// validator sees them.
211impl SynthPlugin for DocumentDiscoveryPlugin {}
212
213/// Max "did you mean" suggestions to list in a warning.
214const MAX_SUGGESTIONS: usize = 3;
215
216/// Opened accounts that plausibly match `account` — sharing both the root
217/// (first segment) and the leaf (last segment). This finds the sibling-rename
218/// case (`Expenses:Electricity` → `Expenses:Home:Electricity`) without the
219/// false positives of a leaf-only match (e.g. `Income:Refunds:Electricity`).
220fn suggested_accounts(account: &str, opened: &std::collections::HashSet<String>) -> Vec<String> {
221 let root = account.split(':').next();
222 let leaf = account.rsplit(':').next();
223 let mut hits: Vec<String> = opened
224 .iter()
225 .filter(|a| a.split(':').next() == root && a.rsplit(':').next() == leaf)
226 .filter(|a| a.as_str() != account)
227 .cloned()
228 .collect();
229 hits.sort_unstable();
230 hits.truncate(MAX_SUGGESTIONS);
231 hits
232}
233
234/// Build the (aggregated) warning for documents discovered under a directory
235/// that maps to an account that is not open. Names the account, how many files
236/// were skipped (with an example), and suggests likely-intended opened
237/// accounts (the account-rename case).
238fn unknown_account_warning(
239 account: &str,
240 paths: &[String],
241 opened: &std::collections::HashSet<String>,
242) -> String {
243 let example = paths.first().map_or("", String::as_str);
244 let more = paths.len().saturating_sub(1);
245 let files = if more == 0 {
246 example.to_string()
247 } else {
248 format!("{example} (+{more} more)")
249 };
250 let suggestions = suggested_accounts(account, opened);
251 let hint = if suggestions.is_empty() {
252 String::new()
253 } else {
254 format!("; did you mean: {}?", suggestions.join(", "))
255 };
256 format!(
257 "{n} document(s) reference unknown account '{account}' and were skipped \
258 (no such account is open): {files}{hint}",
259 n = paths.len()
260 )
261}
262
263/// Recursively scan a directory for document files.
264///
265/// # Security
266/// - Uses `symlink_metadata` to detect and skip symlinks, preventing infinite loops
267/// - Enforces maximum recursion depth to prevent denial-of-service from deeply nested directories
268#[allow(clippy::only_used_in_recursion)]
269fn scan_documents(
270 path: &std::path::Path,
271 base_dir: &str,
272 existing: &std::collections::HashSet<String>,
273 opened: &std::collections::HashSet<String>,
274 directives: &mut Vec<DirectiveWrapper>,
275 unknown: &mut std::collections::BTreeMap<String, Vec<String>>,
276 errors: &mut Vec<PluginError>,
277 depth: usize,
278) -> std::io::Result<()> {
279 use std::fs;
280
281 // Enforce maximum recursion depth
282 if depth > MAX_SCAN_DEPTH {
283 errors.push(PluginError::warning(format!(
284 "Maximum directory depth ({MAX_SCAN_DEPTH}) exceeded at {}",
285 path.display()
286 )));
287 return Ok(());
288 }
289
290 for entry in fs::read_dir(path)? {
291 let entry = entry?;
292 let entry_path = entry.path();
293
294 // Use symlink_metadata to check file type WITHOUT following symlinks.
295 // This prevents infinite recursion from symlink cycles.
296 let metadata = match fs::symlink_metadata(&entry_path) {
297 Ok(m) => m,
298 Err(_) => continue, // Skip entries we can't stat
299 };
300
301 // Skip symlinks entirely to prevent security issues
302 if metadata.file_type().is_symlink() {
303 continue;
304 }
305
306 if metadata.is_dir() {
307 scan_documents(
308 &entry_path,
309 base_dir,
310 existing,
311 opened,
312 directives,
313 unknown,
314 errors,
315 depth + 1,
316 )?;
317 } else if metadata.is_file() {
318 // Try to parse filename as YYYY-MM-DD.description.ext
319 if let Some(file_name) = entry_path.file_name().and_then(|n| n.to_str())
320 && file_name.len() >= 10
321 && file_name.chars().nth(4) == Some('-')
322 && file_name.chars().nth(7) == Some('-')
323 {
324 let date_str = &file_name[0..10];
325 // Validate date format
326 if date_str.chars().take(4).all(|c| c.is_ascii_digit())
327 && date_str.chars().skip(5).take(2).all(|c| c.is_ascii_digit())
328 && date_str.chars().skip(8).take(2).all(|c| c.is_ascii_digit())
329 {
330 // Extract account from path relative to base_dir
331 if let Ok(rel_path) = entry_path.strip_prefix(base_dir)
332 && let Some(parent) = rel_path.parent()
333 {
334 let account = parent
335 .components()
336 .map(|c| c.as_os_str().to_string_lossy().to_string())
337 .collect::<Vec<_>>()
338 .join(":");
339
340 if !account.is_empty() {
341 let full_path = entry_path.to_string_lossy().to_string();
342
343 // Canonicalize for consistent comparison with existing docs
344 let canonical = entry_path.canonicalize().map_or_else(
345 |_| full_path.clone(),
346 |p| p.to_string_lossy().to_string(),
347 );
348
349 // Skip if already exists (compare canonical paths)
350 if existing.contains(&canonical) {
351 continue;
352 }
353
354 // Only synthesize documents for opened accounts
355 // (see the note in `process`). A file under an
356 // unopened account — e.g. a stale path after an
357 // account rename — is collected for an aggregated
358 // warning and skipped, never synthesized into a
359 // hard `E1001`. (#1434)
360 if !opened.contains(&account) {
361 unknown.entry(account).or_default().push(full_path);
362 continue;
363 }
364
365 directives.push(DirectiveWrapper {
366 directive_type: "document".to_string(),
367 date: date_str.to_string(),
368 filename: None, // Plugin-generated
369 lineno: None,
370 data: DirectiveData::Document(DocumentData {
371 account,
372 path: full_path,
373 tags: vec![],
374 links: vec![],
375 metadata: vec![],
376 }),
377 });
378 }
379 }
380 }
381 }
382 }
383 }
384
385 Ok(())
386}