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
143#[allow(clippy::too_many_arguments)]
149pub(crate) fn resolve_index<'a>(
150 snapshot: Option<&'a SnapshotIndex>,
151 dir: &Path,
152 files: &[String],
153 globs: &[String],
154 format: Format,
155 site_prefix: Option<&str>,
156 needs_full_vault: bool,
157 options: ScanOptions,
158) -> Result<Result<ResolvedIndex<'a>, CommandOutcome>> {
159 if let Some(idx) = snapshot {
160 return Ok(Ok(ResolvedIndex::Snapshot(idx)));
161 }
162 let outcome = build_scanned_index(
163 dir,
164 files,
165 globs,
166 format,
167 site_prefix,
168 needs_full_vault,
169 &options,
170 )?;
171 match outcome {
172 ScannedIndexOutcome::Index(build) => Ok(Ok(ResolvedIndex::Scanned(build))),
173 ScannedIndexOutcome::Outcome(o) => Ok(Err(o)),
174 }
175}
176
177pub fn build_scanned_index(
183 dir: &Path,
184 files_arg: &[String],
185 globs: &[String],
186 format: Format,
187 site_prefix: Option<&str>,
188 needs_full_vault: bool,
189 options: &ScanOptions,
190) -> Result<ScannedIndexOutcome> {
191 let files: Vec<(PathBuf, String)> = if needs_full_vault {
192 if !files_arg.is_empty() {
196 let mut resolved = Vec::new();
197 let mut first_err = None;
198 for f in files_arg {
199 match discovery::resolve_file(dir, f) {
200 Ok(r) => resolved.push(r),
201 Err(e) if first_err.is_none() => first_err = Some(e),
202 Err(_) => {}
203 }
204 }
205 if resolved.is_empty()
206 && let Some(e) = first_err
207 {
208 return Ok(ScannedIndexOutcome::Outcome(resolve_error_to_outcome(
209 e, format,
210 )));
211 }
212 }
213 discovery::discover_files(dir)?
214 .into_iter()
215 .map(|p| {
216 let rel = discovery::relative_path(dir, &p);
217 (p, rel)
218 })
219 .collect()
220 } else {
221 match collect_files(dir, files_arg, globs, format)? {
222 FilesOrOutcome::Outcome(o) => return Ok(ScannedIndexOutcome::Outcome(o)),
223 FilesOrOutcome::Files(f) => f,
224 }
225 };
226
227 let build = ScannedIndex::build(&files, site_prefix, options)?;
228
229 for w in &build.warnings {
230 crate::warn::warn(format!("skipping {}: {}", w.rel_path, w.message));
231 }
232
233 Ok(ScannedIndexOutcome::Index(build))
234}
235
236#[must_use]
241pub fn require_file_or_glob(
242 files: &[String],
243 globs: &[String],
244 command_name: &str,
245 format: Format,
246) -> Option<CommandOutcome> {
247 if files.is_empty() && globs.is_empty() {
248 let out = crate::output::format_error(
249 format,
250 &format!("{command_name} requires --file or --glob"),
251 None,
252 Some(
253 "use --file <path> to target a single file or --glob <pattern> to target multiple files",
254 ),
255 None,
256 );
257 Some(CommandOutcome::UserError(out))
258 } else {
259 None
260 }
261}
262
263const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
268
269#[must_use]
273pub fn reject_filter_in_mutation_property(key: &str, format: Format) -> Option<CommandOutcome> {
274 let trimmed = key.trim_end();
275 let ch = trimmed.chars().last()?;
276 if !FILTER_OP_SUFFIXES.contains(&ch) {
277 return None;
278 }
279 let out = crate::output::format_error(
280 format,
281 &format!(
282 "invalid property name '{trimmed}': ends with '{ch}' which looks like a filter \
283 operator (e.g. >=, <=, !=, ~=)"
284 ),
285 None,
286 Some(
287 "--property in mutation commands is for mutation, not filtering — \
288 use --where-property to filter which files are mutated",
289 ),
290 None,
291 );
292 Some(CommandOutcome::UserError(out))
293}
294
295#[must_use]
298pub fn unwrap_single_file_result(
299 files: &[String],
300 mut results: Vec<serde_json::Value>,
301) -> serde_json::Value {
302 if files.len() == 1 && results.len() == 1 {
303 results.pop().unwrap_or_default()
304 } else {
305 serde_json::json!(results)
306 }
307}
308
309#[must_use]
311pub fn resolve_error_to_outcome(err: FileResolveError, format: Format) -> CommandOutcome {
312 match err {
313 FileResolveError::MissingExtension { path, hint } => {
314 CommandOutcome::UserError(crate::output::format_error(
315 format,
316 "file not found",
317 Some(&path),
318 Some(&format!("did you mean {hint}?")),
319 None,
320 ))
321 }
322 FileResolveError::NotFound { path } => CommandOutcome::UserError(
323 crate::output::format_error(format, "file not found", Some(&path), None, None),
324 ),
325 FileResolveError::NotFoundSuggestion { path, suggestion } => {
326 CommandOutcome::UserError(crate::output::format_error(
327 format,
328 "file not found",
329 Some(&path),
330 Some(&format!("did you mean {suggestion}?")),
331 None,
332 ))
333 }
334 FileResolveError::IsDirectory { path, hint } => {
335 CommandOutcome::UserError(crate::output::format_error(
336 format,
337 "path is a directory, not a file",
338 Some(&path),
339 Some(&hint),
340 None,
341 ))
342 }
343 FileResolveError::OutsideVault { path } => {
344 CommandOutcome::UserError(crate::output::format_error(
345 format,
346 "file resolves outside vault boundary",
347 Some(&path),
348 None,
349 None,
350 ))
351 }
352 FileResolveError::InvalidPath { path, reason } => CommandOutcome::UserError(
353 crate::output::format_error(format, "invalid path", Some(&path), Some(reason), None),
354 ),
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use hyalo_core::index::format_iso8601;
362
363 #[test]
366 fn reject_filter_gt() {
367 assert!(reject_filter_in_mutation_property("priority>", Format::Json).is_some());
368 }
369
370 #[test]
371 fn reject_filter_lt() {
372 assert!(reject_filter_in_mutation_property("priority<", Format::Json).is_some());
373 }
374
375 #[test]
376 fn reject_filter_bang() {
377 assert!(reject_filter_in_mutation_property("status!", Format::Json).is_some());
378 }
379
380 #[test]
381 fn reject_filter_tilde() {
382 assert!(reject_filter_in_mutation_property("name~", Format::Json).is_some());
383 }
384
385 #[test]
386 fn accept_plain_key() {
387 assert!(reject_filter_in_mutation_property("status", Format::Json).is_none());
388 }
389
390 #[test]
391 fn accept_hyphenated_key() {
392 assert!(reject_filter_in_mutation_property("my-key", Format::Json).is_none());
393 }
394
395 #[test]
396 fn accept_underscored_key() {
397 assert!(reject_filter_in_mutation_property("key_name", Format::Json).is_none());
398 }
399
400 #[test]
401 fn accept_empty_key() {
402 assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
404 }
405
406 #[test]
409 fn iso8601_epoch() {
410 assert_eq!(format_iso8601(0), "1970-01-01T00:00:00Z");
411 }
412
413 #[test]
414 fn iso8601_known_date() {
415 assert_eq!(format_iso8601(1_705_314_600), "2024-01-15T10:30:00Z");
416 }
417}