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