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/// Resolve the vault index: use the snapshot if available, otherwise scan from disk.
144///
145/// Returns `Ok(Ok(ResolvedIndex))` on success.
146/// Returns `Ok(Err(CommandOutcome))` when file resolution produced a user-facing error.
147/// Returns `Err(e)` for unexpected I/O or parse errors.
148#[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
177/// Build a [`ScannedIndex`] from disk, handling file discovery, warnings, and user errors.
178///
179/// When `needs_full_vault` is `true`, all `.md` files in `dir` are scanned regardless of
180/// `files_arg` and `globs`.  Otherwise the normal `collect_files` resolution is used and a
181/// user-error outcome is propagated if resolution fails.
182pub 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        // Validate --file arguments even when doing a full-vault scan.
193        // Without this, missing files silently produce zero results instead
194        // of the expected UserError.
195        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/// Guard that mutation commands require `--file` or `--glob`.
237///
238/// Returns `Some(CommandOutcome::UserError(...))` when neither flag is provided, or `None`
239/// when the caller may proceed.  The `command_name` is used in the error message.
240#[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
263/// Characters that form the start of comparison operators in filter syntax (`>=`, `<=`,
264/// `!=`, `~=`).  When a `--property` key ends with one of these in a mutation command
265/// (`set`, `remove`, `append`), it almost certainly means the user intended
266/// `--where-property` instead.
267const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
268
269/// Reject a `--property` key that looks like a filter expression (ends with a comparison
270/// operator prefix).  Returns `Some(CommandOutcome::UserError(...))` when rejected, or
271/// `None` when the key is fine.
272#[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/// If exactly one file was specified and there is exactly one result, unwrap to a bare
296/// JSON object. Otherwise return the full array.
297#[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/// Convert a `FileResolveError` into a user-facing `CommandOutcome`.
310#[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    // --- reject_filter_in_mutation_property ---
364
365    #[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        // Empty keys are handled elsewhere; the guard should not panic
403        assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
404    }
405
406    // --- iso8601 ---
407
408    #[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}