1#![allow(clippy::missing_errors_doc)]
2pub mod append;
3pub mod backlinks;
4pub mod create_index;
5pub mod drop_index;
6pub mod find;
7pub mod init;
8pub mod links;
9pub mod lint;
10pub(crate) mod mutation;
11pub mod mv;
12pub mod properties;
13pub mod read;
14pub mod remove;
15pub mod section_scanner;
16pub mod set;
17pub mod summary;
18pub mod tags;
19pub mod tasks;
20pub mod types;
21pub mod views;
22
23use crate::output::{CommandOutcome, Format};
24use anyhow::Result;
25use hyalo_core::discovery::{self, FileResolveError};
26use hyalo_core::index::{ScanOptions, ScannedIndex, ScannedIndexBuild, SnapshotIndex, VaultIndex};
27use std::path::{Path, PathBuf};
28
29pub enum FilesOrOutcome {
37 Files(Vec<(PathBuf, String)>),
38 Outcome(CommandOutcome),
39}
40
41pub fn collect_files(
45 dir: &Path,
46 files: &[String],
47 globs: &[String],
48 format: Format,
49) -> Result<FilesOrOutcome> {
50 match (files.is_empty(), globs.is_empty()) {
51 (false, true) => {
52 let mut resolved = Vec::new();
54 let mut errors = Vec::new();
55 for f in files {
56 match discovery::resolve_file(dir, f) {
57 Ok(r) => resolved.push(r),
58 Err(e) => errors.push((f.clone(), e)),
59 }
60 }
61 if resolved.is_empty() {
62 let (_, first_err) = errors.into_iter().next().expect("at least one error");
64 return Ok(FilesOrOutcome::Outcome(resolve_error_to_outcome(
65 first_err, format,
66 )));
67 }
68 for (path, err) in &errors {
70 let msg = match err {
71 FileResolveError::NotFound { .. } => format!("file not found: {path}"),
72 FileResolveError::NotFoundSuggestion { suggestion, .. } => {
73 format!("file not found: {path} (did you mean {suggestion}?)")
74 }
75 FileResolveError::MissingExtension { hint, .. } => {
76 format!("file not found: {path} (did you mean {hint}?)")
77 }
78 FileResolveError::IsDirectory { hint, .. } => {
79 format!("path is a directory, not a file: {path} (try {hint})")
80 }
81 FileResolveError::OutsideVault { .. } => {
82 format!("file resolves outside vault boundary: {path}")
83 }
84 FileResolveError::InvalidPath { reason, .. } => {
85 format!("invalid path ({reason}): {path}")
86 }
87 };
88 crate::warn::warn(&msg);
89 }
90 Ok(FilesOrOutcome::Files(resolved))
91 }
92 (true, false) => {
93 let all = discovery::discover_files(dir)?;
94 let matched = discovery::match_globs(dir, &all, globs)?;
95 crate::warn::warn_glob_dir_overlap(dir, globs, matched.len());
96 Ok(FilesOrOutcome::Files(matched))
97 }
98 (true, true) => {
99 let all = discovery::discover_files(dir)?;
101 let with_rel: Vec<(PathBuf, String)> = all
102 .into_iter()
103 .map(|p| {
104 let rel = discovery::relative_path(dir, &p);
105 (p, rel)
106 })
107 .collect();
108 Ok(FilesOrOutcome::Files(with_rel))
109 }
110 (false, false) => {
111 let out = crate::output::format_error(
113 format,
114 "--file and --glob are mutually exclusive",
115 None,
116 None,
117 None,
118 );
119 Ok(FilesOrOutcome::Outcome(CommandOutcome::UserError(out)))
120 }
121 }
122}
123
124pub enum ScannedIndexOutcome {
126 Index(ScannedIndexBuild),
127 Outcome(CommandOutcome),
128}
129
130pub(crate) enum ResolvedIndex<'a> {
132 Snapshot(&'a SnapshotIndex),
133 Scanned(ScannedIndexBuild),
134}
135
136impl ResolvedIndex<'_> {
137 pub(crate) fn as_index(&self) -> &dyn VaultIndex {
138 match self {
139 ResolvedIndex::Snapshot(idx) => *idx,
140 ResolvedIndex::Scanned(build) => &build.index,
141 }
142 }
143}
144
145pub(crate) enum IndexResolution<'a> {
148 Resolved(ResolvedIndex<'a>),
150 Outcome(CommandOutcome),
152}
153
154#[allow(clippy::too_many_arguments)]
160pub(crate) fn resolve_index<'a>(
161 snapshot: Option<&'a SnapshotIndex>,
162 dir: &Path,
163 files: &[String],
164 globs: &[String],
165 format: Format,
166 site_prefix: Option<&str>,
167 needs_full_vault: bool,
168 options: &ScanOptions<'_>,
169) -> Result<IndexResolution<'a>> {
170 if let Some(idx) = snapshot {
171 return Ok(IndexResolution::Resolved(ResolvedIndex::Snapshot(idx)));
172 }
173 let outcome = build_scanned_index(
174 dir,
175 files,
176 globs,
177 format,
178 site_prefix,
179 needs_full_vault,
180 options,
181 )?;
182 match outcome {
183 ScannedIndexOutcome::Index(build) => {
184 Ok(IndexResolution::Resolved(ResolvedIndex::Scanned(build)))
185 }
186 ScannedIndexOutcome::Outcome(o) => Ok(IndexResolution::Outcome(o)),
187 }
188}
189
190pub fn build_scanned_index(
196 dir: &Path,
197 files_arg: &[String],
198 globs: &[String],
199 format: Format,
200 site_prefix: Option<&str>,
201 needs_full_vault: bool,
202 options: &ScanOptions<'_>,
203) -> Result<ScannedIndexOutcome> {
204 let files: Vec<(PathBuf, String)> = if needs_full_vault {
205 if !files_arg.is_empty() {
209 let mut resolved = Vec::new();
210 let mut first_err = None;
211 for f in files_arg {
212 match discovery::resolve_file(dir, f) {
213 Ok(r) => resolved.push(r),
214 Err(e) if first_err.is_none() => first_err = Some(e),
215 Err(_) => {}
216 }
217 }
218 if resolved.is_empty()
219 && let Some(e) = first_err
220 {
221 return Ok(ScannedIndexOutcome::Outcome(resolve_error_to_outcome(
222 e, format,
223 )));
224 }
225 }
226 discovery::discover_files(dir)?
227 .into_iter()
228 .map(|p| {
229 let rel = discovery::relative_path(dir, &p);
230 (p, rel)
231 })
232 .collect()
233 } else {
234 match collect_files(dir, files_arg, globs, format)? {
235 FilesOrOutcome::Outcome(o) => return Ok(ScannedIndexOutcome::Outcome(o)),
236 FilesOrOutcome::Files(f) => f,
237 }
238 };
239
240 let build = ScannedIndex::build(&files, site_prefix, options)?;
241
242 for w in &build.warnings {
243 crate::warn::warn(format!("skipping {}: {}", w.rel_path, w.message));
244 }
245
246 Ok(ScannedIndexOutcome::Index(build))
247}
248
249#[must_use]
254pub fn require_file_or_glob(
255 files: &[String],
256 globs: &[String],
257 command_name: &str,
258 format: Format,
259) -> Option<CommandOutcome> {
260 if files.is_empty() && globs.is_empty() {
261 let out = crate::output::format_error(
262 format,
263 &format!("{command_name} requires --file or --glob"),
264 None,
265 Some(
266 "use --file <path> to target a single file or --glob <pattern> to target multiple files",
267 ),
268 None,
269 );
270 Some(CommandOutcome::UserError(out))
271 } else {
272 None
273 }
274}
275
276const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
281
282#[must_use]
286pub fn reject_filter_in_mutation_property(key: &str, format: Format) -> Option<CommandOutcome> {
287 let trimmed = key.trim_end();
288 let ch = trimmed.chars().last()?;
289 if !FILTER_OP_SUFFIXES.contains(&ch) {
290 return None;
291 }
292 let out = crate::output::format_error(
293 format,
294 &format!(
295 "invalid property name '{trimmed}': ends with '{ch}' which looks like a filter \
296 operator (e.g. >=, <=, !=, ~=)"
297 ),
298 None,
299 Some(
300 "--property in mutation commands is for mutation, not filtering — \
301 use --where-property to filter which files are mutated",
302 ),
303 None,
304 );
305 Some(CommandOutcome::UserError(out))
306}
307
308#[must_use]
311pub fn unwrap_single_file_result(
312 files: &[String],
313 mut results: Vec<serde_json::Value>,
314) -> serde_json::Value {
315 if files.len() == 1 && results.len() == 1 {
316 results.pop().unwrap_or_default()
317 } else {
318 serde_json::json!(results)
319 }
320}
321
322#[must_use]
324pub fn resolve_error_to_outcome(err: FileResolveError, format: Format) -> CommandOutcome {
325 match err {
326 FileResolveError::MissingExtension { path, hint } => {
327 CommandOutcome::UserError(crate::output::format_error(
328 format,
329 "file not found",
330 Some(&path),
331 Some(&format!("did you mean {hint}?")),
332 None,
333 ))
334 }
335 FileResolveError::NotFound { path } => CommandOutcome::UserError(
336 crate::output::format_error(format, "file not found", Some(&path), None, None),
337 ),
338 FileResolveError::NotFoundSuggestion { path, suggestion } => {
339 CommandOutcome::UserError(crate::output::format_error(
340 format,
341 "file not found",
342 Some(&path),
343 Some(&format!("did you mean {suggestion}?")),
344 None,
345 ))
346 }
347 FileResolveError::IsDirectory { path, hint } => {
348 CommandOutcome::UserError(crate::output::format_error(
349 format,
350 "path is a directory, not a file",
351 Some(&path),
352 Some(&hint),
353 None,
354 ))
355 }
356 FileResolveError::OutsideVault { path } => {
357 CommandOutcome::UserError(crate::output::format_error(
358 format,
359 "file resolves outside vault boundary",
360 Some(&path),
361 None,
362 None,
363 ))
364 }
365 FileResolveError::InvalidPath { path, reason } => CommandOutcome::UserError(
366 crate::output::format_error(format, "invalid path", Some(&path), Some(reason), None),
367 ),
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use hyalo_core::index::format_iso8601;
375
376 #[test]
379 fn reject_filter_gt() {
380 assert!(reject_filter_in_mutation_property("priority>", Format::Json).is_some());
381 }
382
383 #[test]
384 fn reject_filter_lt() {
385 assert!(reject_filter_in_mutation_property("priority<", Format::Json).is_some());
386 }
387
388 #[test]
389 fn reject_filter_bang() {
390 assert!(reject_filter_in_mutation_property("status!", Format::Json).is_some());
391 }
392
393 #[test]
394 fn reject_filter_tilde() {
395 assert!(reject_filter_in_mutation_property("name~", Format::Json).is_some());
396 }
397
398 #[test]
399 fn accept_plain_key() {
400 assert!(reject_filter_in_mutation_property("status", Format::Json).is_none());
401 }
402
403 #[test]
404 fn accept_hyphenated_key() {
405 assert!(reject_filter_in_mutation_property("my-key", Format::Json).is_none());
406 }
407
408 #[test]
409 fn accept_underscored_key() {
410 assert!(reject_filter_in_mutation_property("key_name", Format::Json).is_none());
411 }
412
413 #[test]
414 fn accept_empty_key() {
415 assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
417 }
418
419 #[test]
422 fn iso8601_epoch() {
423 assert_eq!(format_iso8601(0), "1970-01-01T00:00:00Z");
424 }
425
426 #[test]
427 fn iso8601_known_date() {
428 assert_eq!(format_iso8601(1_705_314_600), "2024-01-15T10:30:00Z");
429 }
430}