Skip to main content

hyalo_cli/commands/
mod.rs

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
27// ---------------------------------------------------------------------------
28// Shared file resolution helpers
29// ---------------------------------------------------------------------------
30
31/// Outcome of resolving the set of files to operate on.
32/// Either a list of `(full_path, rel_path)` pairs or a pre-formed `CommandOutcome`
33/// (user error) when the resolution failed.
34pub enum FilesOrOutcome {
35    Files(Vec<(PathBuf, String)>),
36    Outcome(CommandOutcome),
37}
38
39/// Resolve the set of files to operate on based on `--file` / `--glob` / all files.
40/// Returns a user-error outcome for invalid inputs (e.g. file not found).
41/// A glob that matches no files returns an empty file list with exit 0, not an error.
42pub 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            // Resolve each file, best-effort: collect successes and errors
51            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                // All files failed — return error for the first one (no warning needed)
61                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            // Some succeeded — warn about the ones that didn't
67            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            // Operate on all .md files
98            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            // Clap enforces mutual exclusivity; this branch is unreachable in practice
110            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
122/// Outcome of building a scanned index — either success or a user-facing error.
123pub enum ScannedIndexOutcome {
124    Index(ScannedIndexBuild),
125    Outcome(CommandOutcome),
126}
127
128/// Resolved index — either a borrowed snapshot or an owned scanned build.
129pub(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/// Outcome of [`resolve_index`]: the index is ready, or resolution produced a
144/// user-facing error that should be returned early to the caller.
145pub(crate) enum IndexResolution<'a> {
146    /// The vault index was resolved successfully and is ready to query.
147    Resolved(ResolvedIndex<'a>),
148    /// File/glob resolution failed with a user-facing error; propagate as-is.
149    Outcome(CommandOutcome),
150}
151
152/// Resolve the vault index: use the snapshot if available, otherwise scan from disk.
153///
154/// Returns `Ok(IndexResolution::Resolved(_))` on success.
155/// Returns `Ok(IndexResolution::Outcome(_))` when file resolution produced a user-facing error.
156/// Returns `Err(e)` for unexpected I/O or parse errors.
157#[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
188/// Build a [`ScannedIndex`] from disk, handling file discovery, warnings, and user errors.
189///
190/// When `needs_full_vault` is `true`, all `.md` files in `dir` are scanned regardless of
191/// `files_arg` and `globs`.  Otherwise the normal `collect_files` resolution is used and a
192/// user-error outcome is propagated if resolution fails.
193pub 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        // Validate --file arguments even when doing a full-vault scan.
204        // Without this, missing files silently produce zero results instead
205        // of the expected UserError.
206        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/// Guard that mutation commands require `--file` or `--glob`.
248///
249/// Returns `Some(CommandOutcome::UserError(...))` when neither flag is provided, or `None`
250/// when the caller may proceed.  The `command_name` is used in the error message.
251#[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
274/// Characters that form the start of comparison operators in filter syntax (`>=`, `<=`,
275/// `!=`, `~=`).  When a `--property` key ends with one of these in a mutation command
276/// (`set`, `remove`, `append`), it almost certainly means the user intended
277/// `--where-property` instead.
278const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
279
280/// Reject a `--property` key that looks like a filter expression (ends with a comparison
281/// operator prefix).  Returns `Some(CommandOutcome::UserError(...))` when rejected, or
282/// `None` when the key is fine.
283#[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/// If exactly one file was specified and there is exactly one result, unwrap to a bare
307/// JSON object. Otherwise return the full array.
308#[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/// Convert a `FileResolveError` into a user-facing `CommandOutcome`.
321#[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    // --- reject_filter_in_mutation_property ---
375
376    #[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        // Empty keys are handled elsewhere; the guard should not panic
414        assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
415    }
416
417    // --- iso8601 ---
418
419    #[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}