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;
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
26// ---------------------------------------------------------------------------
27// Shared file resolution helpers
28// ---------------------------------------------------------------------------
29
30/// Outcome of resolving the set of files to operate on.
31/// Either a list of `(full_path, rel_path)` pairs or a pre-formed `CommandOutcome`
32/// (user error) when the resolution failed.
33pub enum FilesOrOutcome {
34    Files(Vec<(PathBuf, String)>),
35    Outcome(CommandOutcome),
36}
37
38/// Resolve the set of files to operate on based on `--file` / `--glob` / all files.
39/// Returns a user-error outcome for invalid inputs (e.g. file not found).
40/// A glob that matches no files returns an empty file list with exit 0, not an error.
41pub 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            // Resolve each file, best-effort: collect successes and errors
50            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                // All files failed — return error for the first one (no warning needed)
60                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            // Some succeeded — warn about the ones that didn't
66            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            // Operate on all .md files
97            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            // Clap enforces mutual exclusivity; this branch is unreachable in practice
109            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
121/// Outcome of building a scanned index — either success or a user-facing error.
122pub enum ScannedIndexOutcome {
123    Index(ScannedIndexBuild),
124    Outcome(CommandOutcome),
125}
126
127/// Resolved index — either a borrowed snapshot or an owned scanned build.
128pub(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/// Resolve the vault index: use the snapshot if available, otherwise scan from disk.
143///
144/// Returns `Ok(Ok(ResolvedIndex))` on success.
145/// Returns `Ok(Err(CommandOutcome))` when file resolution produced a user-facing error.
146/// Returns `Err(e)` for unexpected I/O or parse errors.
147#[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
176/// Build a [`ScannedIndex`] from disk, handling file discovery, warnings, and user errors.
177///
178/// When `needs_full_vault` is `true`, all `.md` files in `dir` are scanned regardless of
179/// `files_arg` and `globs`.  Otherwise the normal `collect_files` resolution is used and a
180/// user-error outcome is propagated if resolution fails.
181pub 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        // Validate --file arguments even when doing a full-vault scan.
192        // Without this, missing files silently produce zero results instead
193        // of the expected UserError.
194        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/// Guard that mutation commands require `--file` or `--glob`.
236///
237/// Returns `Some(CommandOutcome::UserError(...))` when neither flag is provided, or `None`
238/// when the caller may proceed.  The `command_name` is used in the error message.
239#[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
262/// Characters that form the start of comparison operators in filter syntax (`>=`, `<=`,
263/// `!=`, `~=`).  When a `--property` key ends with one of these in a mutation command
264/// (`set`, `remove`, `append`), it almost certainly means the user intended
265/// `--where-property` instead.
266const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
267
268/// Reject a `--property` key that looks like a filter expression (ends with a comparison
269/// operator prefix).  Returns `Some(CommandOutcome::UserError(...))` when rejected, or
270/// `None` when the key is fine.
271#[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/// If exactly one file was specified and there is exactly one result, unwrap to a bare
295/// JSON object. Otherwise return the full array.
296#[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/// Convert a `FileResolveError` into a user-facing `CommandOutcome`.
309#[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    // --- reject_filter_in_mutation_property ---
363
364    #[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        // Empty keys are handled elsewhere; the guard should not panic
402        assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
403    }
404
405    // --- iso8601 ---
406
407    #[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}