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 mod lint;
10pub(crate) mod mutation;
11pub mod mv;
12pub mod properties;
13pub mod read;
14pub mod remove;
15pub mod section_scanner;
16pub mod set;
17pub mod summary;
18pub mod tags;
19pub mod tasks;
20pub mod types;
21pub mod views;
22
23use crate::output::{CommandOutcome, Format};
24use anyhow::Result;
25use hyalo_core::discovery::{self, FileResolveError};
26use hyalo_core::index::{ScanOptions, ScannedIndex, ScannedIndexBuild, SnapshotIndex, VaultIndex};
27use std::path::{Path, PathBuf};
28
29// ---------------------------------------------------------------------------
30// Shared file resolution helpers
31// ---------------------------------------------------------------------------
32
33/// Outcome of resolving the set of files to operate on.
34/// Either a list of `(full_path, rel_path)` pairs or a pre-formed `CommandOutcome`
35/// (user error) when the resolution failed.
36pub enum FilesOrOutcome {
37    Files(Vec<(PathBuf, String)>),
38    Outcome(CommandOutcome),
39}
40
41/// Resolve the set of files to operate on based on `--file` / `--glob` / all files.
42/// Returns a user-error outcome for invalid inputs (e.g. file not found).
43/// A glob that matches no files returns an empty file list with exit 0, not an error.
44pub fn collect_files(
45    dir: &Path,
46    files: &[String],
47    globs: &[String],
48    format: Format,
49) -> Result<FilesOrOutcome> {
50    match (files.is_empty(), globs.is_empty()) {
51        (false, true) => {
52            // Resolve each file, best-effort: collect successes and errors
53            let mut resolved = Vec::new();
54            let mut errors = Vec::new();
55            for f in files {
56                match discovery::resolve_file(dir, f) {
57                    Ok(r) => resolved.push(r),
58                    Err(e) => errors.push((f.clone(), e)),
59                }
60            }
61            if resolved.is_empty() {
62                // All files failed — return error for the first one (no warning needed)
63                let (_, first_err) = errors.into_iter().next().expect("at least one error");
64                return Ok(FilesOrOutcome::Outcome(resolve_error_to_outcome(
65                    first_err, format,
66                )));
67            }
68            // Some succeeded — warn about the ones that didn't
69            for (path, err) in &errors {
70                let msg = match err {
71                    FileResolveError::NotFound { .. } => format!("file not found: {path}"),
72                    FileResolveError::NotFoundSuggestion { suggestion, .. } => {
73                        format!("file not found: {path} (did you mean {suggestion}?)")
74                    }
75                    FileResolveError::MissingExtension { hint, .. } => {
76                        format!("file not found: {path} (did you mean {hint}?)")
77                    }
78                    FileResolveError::IsDirectory { hint, .. } => {
79                        format!("path is a directory, not a file: {path} (try {hint})")
80                    }
81                    FileResolveError::OutsideVault { .. } => {
82                        format!("file resolves outside vault boundary: {path}")
83                    }
84                    FileResolveError::InvalidPath { reason, .. } => {
85                        format!("invalid path ({reason}): {path}")
86                    }
87                };
88                crate::warn::warn(&msg);
89            }
90            Ok(FilesOrOutcome::Files(resolved))
91        }
92        (true, false) => {
93            let all = discovery::discover_files(dir)?;
94            let matched = discovery::match_globs(dir, &all, globs)?;
95            crate::warn::warn_glob_dir_overlap(dir, globs, matched.len());
96            Ok(FilesOrOutcome::Files(matched))
97        }
98        (true, true) => {
99            // Operate on all .md files
100            let all = discovery::discover_files(dir)?;
101            let with_rel: Vec<(PathBuf, String)> = all
102                .into_iter()
103                .map(|p| {
104                    let rel = discovery::relative_path(dir, &p);
105                    (p, rel)
106                })
107                .collect();
108            Ok(FilesOrOutcome::Files(with_rel))
109        }
110        (false, false) => {
111            // Clap enforces mutual exclusivity; this branch is unreachable in practice
112            let out = crate::output::format_error(
113                format,
114                "--file and --glob are mutually exclusive",
115                None,
116                None,
117                None,
118            );
119            Ok(FilesOrOutcome::Outcome(CommandOutcome::UserError(out)))
120        }
121    }
122}
123
124/// Outcome of building a scanned index — either success or a user-facing error.
125pub enum ScannedIndexOutcome {
126    Index(ScannedIndexBuild),
127    Outcome(CommandOutcome),
128}
129
130/// Resolved index — either a borrowed snapshot or an owned scanned build.
131pub(crate) enum ResolvedIndex<'a> {
132    Snapshot(&'a SnapshotIndex),
133    Scanned(ScannedIndexBuild),
134}
135
136impl ResolvedIndex<'_> {
137    pub(crate) fn as_index(&self) -> &dyn VaultIndex {
138        match self {
139            ResolvedIndex::Snapshot(idx) => *idx,
140            ResolvedIndex::Scanned(build) => &build.index,
141        }
142    }
143}
144
145/// Outcome of [`resolve_index`]: the index is ready, or resolution produced a
146/// user-facing error that should be returned early to the caller.
147pub(crate) enum IndexResolution<'a> {
148    /// The vault index was resolved successfully and is ready to query.
149    Resolved(ResolvedIndex<'a>),
150    /// File/glob resolution failed with a user-facing error; propagate as-is.
151    Outcome(CommandOutcome),
152}
153
154/// Resolve the vault index: use the snapshot if available, otherwise scan from disk.
155///
156/// Returns `Ok(IndexResolution::Resolved(_))` on success.
157/// Returns `Ok(IndexResolution::Outcome(_))` when file resolution produced a user-facing error.
158/// Returns `Err(e)` for unexpected I/O or parse errors.
159#[allow(clippy::too_many_arguments)]
160pub(crate) fn resolve_index<'a>(
161    snapshot: Option<&'a SnapshotIndex>,
162    dir: &Path,
163    files: &[String],
164    globs: &[String],
165    format: Format,
166    site_prefix: Option<&str>,
167    needs_full_vault: bool,
168    options: &ScanOptions<'_>,
169) -> Result<IndexResolution<'a>> {
170    if let Some(idx) = snapshot {
171        return Ok(IndexResolution::Resolved(ResolvedIndex::Snapshot(idx)));
172    }
173    let outcome = build_scanned_index(
174        dir,
175        files,
176        globs,
177        format,
178        site_prefix,
179        needs_full_vault,
180        options,
181    )?;
182    match outcome {
183        ScannedIndexOutcome::Index(build) => {
184            Ok(IndexResolution::Resolved(ResolvedIndex::Scanned(build)))
185        }
186        ScannedIndexOutcome::Outcome(o) => Ok(IndexResolution::Outcome(o)),
187    }
188}
189
190/// Build a [`ScannedIndex`] from disk, handling file discovery, warnings, and user errors.
191///
192/// When `needs_full_vault` is `true`, all `.md` files in `dir` are scanned regardless of
193/// `files_arg` and `globs`.  Otherwise the normal `collect_files` resolution is used and a
194/// user-error outcome is propagated if resolution fails.
195pub fn build_scanned_index(
196    dir: &Path,
197    files_arg: &[String],
198    globs: &[String],
199    format: Format,
200    site_prefix: Option<&str>,
201    needs_full_vault: bool,
202    options: &ScanOptions<'_>,
203) -> Result<ScannedIndexOutcome> {
204    let files: Vec<(PathBuf, String)> = if needs_full_vault {
205        // Validate --file arguments even when doing a full-vault scan.
206        // Without this, missing files silently produce zero results instead
207        // of the expected UserError.
208        if !files_arg.is_empty() {
209            let mut resolved = Vec::new();
210            let mut first_err = None;
211            for f in files_arg {
212                match discovery::resolve_file(dir, f) {
213                    Ok(r) => resolved.push(r),
214                    Err(e) if first_err.is_none() => first_err = Some(e),
215                    Err(_) => {}
216                }
217            }
218            if resolved.is_empty()
219                && let Some(e) = first_err
220            {
221                return Ok(ScannedIndexOutcome::Outcome(resolve_error_to_outcome(
222                    e, format,
223                )));
224            }
225        }
226        discovery::discover_files(dir)?
227            .into_iter()
228            .map(|p| {
229                let rel = discovery::relative_path(dir, &p);
230                (p, rel)
231            })
232            .collect()
233    } else {
234        match collect_files(dir, files_arg, globs, format)? {
235            FilesOrOutcome::Outcome(o) => return Ok(ScannedIndexOutcome::Outcome(o)),
236            FilesOrOutcome::Files(f) => f,
237        }
238    };
239
240    let build = ScannedIndex::build(&files, site_prefix, options)?;
241
242    for w in &build.warnings {
243        crate::warn::warn(format!("skipping {}: {}", w.rel_path, w.message));
244    }
245
246    Ok(ScannedIndexOutcome::Index(build))
247}
248
249/// Guard that mutation commands require `--file` or `--glob`.
250///
251/// Returns `Some(CommandOutcome::UserError(...))` when neither flag is provided, or `None`
252/// when the caller may proceed.  The `command_name` is used in the error message.
253#[must_use]
254pub fn require_file_or_glob(
255    files: &[String],
256    globs: &[String],
257    command_name: &str,
258    format: Format,
259) -> Option<CommandOutcome> {
260    if files.is_empty() && globs.is_empty() {
261        let out = crate::output::format_error(
262            format,
263            &format!("{command_name} requires --file or --glob"),
264            None,
265            Some(
266                "use --file <path> to target a single file or --glob <pattern> to target multiple files",
267            ),
268            None,
269        );
270        Some(CommandOutcome::UserError(out))
271    } else {
272        None
273    }
274}
275
276/// Characters that form the start of comparison operators in filter syntax (`>=`, `<=`,
277/// `!=`, `~=`).  When a `--property` key ends with one of these in a mutation command
278/// (`set`, `remove`, `append`), it almost certainly means the user intended
279/// `--where-property` instead.
280const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
281
282/// Reject a `--property` key that looks like a filter expression (ends with a comparison
283/// operator prefix).  Returns `Some(CommandOutcome::UserError(...))` when rejected, or
284/// `None` when the key is fine.
285#[must_use]
286pub fn reject_filter_in_mutation_property(key: &str, format: Format) -> Option<CommandOutcome> {
287    let trimmed = key.trim_end();
288    let ch = trimmed.chars().last()?;
289    if !FILTER_OP_SUFFIXES.contains(&ch) {
290        return None;
291    }
292    let out = crate::output::format_error(
293        format,
294        &format!(
295            "invalid property name '{trimmed}': ends with '{ch}' which looks like a filter \
296             operator (e.g. >=, <=, !=, ~=)"
297        ),
298        None,
299        Some(
300            "--property in mutation commands is for mutation, not filtering — \
301             use --where-property to filter which files are mutated",
302        ),
303        None,
304    );
305    Some(CommandOutcome::UserError(out))
306}
307
308/// If exactly one file was specified and there is exactly one result, unwrap to a bare
309/// JSON object. Otherwise return the full array.
310#[must_use]
311pub fn unwrap_single_file_result(
312    files: &[String],
313    mut results: Vec<serde_json::Value>,
314) -> serde_json::Value {
315    if files.len() == 1 && results.len() == 1 {
316        results.pop().unwrap_or_default()
317    } else {
318        serde_json::json!(results)
319    }
320}
321
322/// Convert a `FileResolveError` into a user-facing `CommandOutcome`.
323#[must_use]
324pub fn resolve_error_to_outcome(err: FileResolveError, format: Format) -> CommandOutcome {
325    match err {
326        FileResolveError::MissingExtension { path, hint } => {
327            CommandOutcome::UserError(crate::output::format_error(
328                format,
329                "file not found",
330                Some(&path),
331                Some(&format!("did you mean {hint}?")),
332                None,
333            ))
334        }
335        FileResolveError::NotFound { path } => CommandOutcome::UserError(
336            crate::output::format_error(format, "file not found", Some(&path), None, None),
337        ),
338        FileResolveError::NotFoundSuggestion { path, suggestion } => {
339            CommandOutcome::UserError(crate::output::format_error(
340                format,
341                "file not found",
342                Some(&path),
343                Some(&format!("did you mean {suggestion}?")),
344                None,
345            ))
346        }
347        FileResolveError::IsDirectory { path, hint } => {
348            CommandOutcome::UserError(crate::output::format_error(
349                format,
350                "path is a directory, not a file",
351                Some(&path),
352                Some(&hint),
353                None,
354            ))
355        }
356        FileResolveError::OutsideVault { path } => {
357            CommandOutcome::UserError(crate::output::format_error(
358                format,
359                "file resolves outside vault boundary",
360                Some(&path),
361                None,
362                None,
363            ))
364        }
365        FileResolveError::InvalidPath { path, reason } => CommandOutcome::UserError(
366            crate::output::format_error(format, "invalid path", Some(&path), Some(reason), None),
367        ),
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use hyalo_core::index::format_iso8601;
375
376    // --- reject_filter_in_mutation_property ---
377
378    #[test]
379    fn reject_filter_gt() {
380        assert!(reject_filter_in_mutation_property("priority>", Format::Json).is_some());
381    }
382
383    #[test]
384    fn reject_filter_lt() {
385        assert!(reject_filter_in_mutation_property("priority<", Format::Json).is_some());
386    }
387
388    #[test]
389    fn reject_filter_bang() {
390        assert!(reject_filter_in_mutation_property("status!", Format::Json).is_some());
391    }
392
393    #[test]
394    fn reject_filter_tilde() {
395        assert!(reject_filter_in_mutation_property("name~", Format::Json).is_some());
396    }
397
398    #[test]
399    fn accept_plain_key() {
400        assert!(reject_filter_in_mutation_property("status", Format::Json).is_none());
401    }
402
403    #[test]
404    fn accept_hyphenated_key() {
405        assert!(reject_filter_in_mutation_property("my-key", Format::Json).is_none());
406    }
407
408    #[test]
409    fn accept_underscored_key() {
410        assert!(reject_filter_in_mutation_property("key_name", Format::Json).is_none());
411    }
412
413    #[test]
414    fn accept_empty_key() {
415        // Empty keys are handled elsewhere; the guard should not panic
416        assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
417    }
418
419    // --- iso8601 ---
420
421    #[test]
422    fn iso8601_epoch() {
423        assert_eq!(format_iso8601(0), "1970-01-01T00:00:00Z");
424    }
425
426    #[test]
427    fn iso8601_known_date() {
428        assert_eq!(format_iso8601(1_705_314_600), "2024-01-15T10:30:00Z");
429    }
430}