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