#![allow(clippy::missing_errors_doc)]
pub mod append;
pub mod backlinks;
pub mod create_index;
pub mod drop_index;
pub mod find;
pub mod init;
pub mod links;
pub(crate) mod mutation;
pub mod mv;
pub mod properties;
pub mod read;
pub mod remove;
pub mod section_scanner;
pub mod set;
pub mod summary;
pub mod tags;
pub mod tasks;
pub mod views;
use crate::output::{CommandOutcome, Format};
use anyhow::Result;
use hyalo_core::discovery::{self, FileResolveError};
use hyalo_core::index::{ScanOptions, ScannedIndex, ScannedIndexBuild, SnapshotIndex, VaultIndex};
use std::path::{Path, PathBuf};
pub enum FilesOrOutcome {
Files(Vec<(PathBuf, String)>),
Outcome(CommandOutcome),
}
pub fn collect_files(
dir: &Path,
files: &[String],
globs: &[String],
format: Format,
) -> Result<FilesOrOutcome> {
match (files.is_empty(), globs.is_empty()) {
(false, true) => {
let mut resolved = Vec::new();
let mut errors = Vec::new();
for f in files {
match discovery::resolve_file(dir, f) {
Ok(r) => resolved.push(r),
Err(e) => errors.push((f.clone(), e)),
}
}
if resolved.is_empty() {
let (_, first_err) = errors.into_iter().next().expect("at least one error");
return Ok(FilesOrOutcome::Outcome(resolve_error_to_outcome(
first_err, format,
)));
}
for (path, err) in &errors {
let msg = match err {
FileResolveError::NotFound { .. } => format!("file not found: {path}"),
FileResolveError::NotFoundSuggestion { suggestion, .. } => {
format!("file not found: {path} (did you mean {suggestion}?)")
}
FileResolveError::MissingExtension { hint, .. } => {
format!("file not found: {path} (did you mean {hint}?)")
}
FileResolveError::IsDirectory { hint, .. } => {
format!("path is a directory, not a file: {path} (try {hint})")
}
FileResolveError::OutsideVault { .. } => {
format!("file resolves outside vault boundary: {path}")
}
FileResolveError::InvalidPath { reason, .. } => {
format!("invalid path ({reason}): {path}")
}
};
crate::warn::warn(&msg);
}
Ok(FilesOrOutcome::Files(resolved))
}
(true, false) => {
let all = discovery::discover_files(dir)?;
let matched = discovery::match_globs(dir, &all, globs)?;
crate::warn::warn_glob_dir_overlap(dir, globs, matched.len());
Ok(FilesOrOutcome::Files(matched))
}
(true, true) => {
let all = discovery::discover_files(dir)?;
let with_rel: Vec<(PathBuf, String)> = all
.into_iter()
.map(|p| {
let rel = discovery::relative_path(dir, &p);
(p, rel)
})
.collect();
Ok(FilesOrOutcome::Files(with_rel))
}
(false, false) => {
let out = crate::output::format_error(
format,
"--file and --glob are mutually exclusive",
None,
None,
None,
);
Ok(FilesOrOutcome::Outcome(CommandOutcome::UserError(out)))
}
}
}
pub enum ScannedIndexOutcome {
Index(ScannedIndexBuild),
Outcome(CommandOutcome),
}
pub(crate) enum ResolvedIndex<'a> {
Snapshot(&'a SnapshotIndex),
Scanned(ScannedIndexBuild),
}
impl ResolvedIndex<'_> {
pub(crate) fn as_index(&self) -> &dyn VaultIndex {
match self {
ResolvedIndex::Snapshot(idx) => *idx,
ResolvedIndex::Scanned(build) => &build.index,
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn resolve_index<'a>(
snapshot: Option<&'a SnapshotIndex>,
dir: &Path,
files: &[String],
globs: &[String],
format: Format,
site_prefix: Option<&str>,
needs_full_vault: bool,
options: ScanOptions,
) -> Result<Result<ResolvedIndex<'a>, CommandOutcome>> {
if let Some(idx) = snapshot {
return Ok(Ok(ResolvedIndex::Snapshot(idx)));
}
let outcome = build_scanned_index(
dir,
files,
globs,
format,
site_prefix,
needs_full_vault,
&options,
)?;
match outcome {
ScannedIndexOutcome::Index(build) => Ok(Ok(ResolvedIndex::Scanned(build))),
ScannedIndexOutcome::Outcome(o) => Ok(Err(o)),
}
}
pub fn build_scanned_index(
dir: &Path,
files_arg: &[String],
globs: &[String],
format: Format,
site_prefix: Option<&str>,
needs_full_vault: bool,
options: &ScanOptions,
) -> Result<ScannedIndexOutcome> {
let files: Vec<(PathBuf, String)> = if needs_full_vault {
if !files_arg.is_empty() {
let mut resolved = Vec::new();
let mut first_err = None;
for f in files_arg {
match discovery::resolve_file(dir, f) {
Ok(r) => resolved.push(r),
Err(e) if first_err.is_none() => first_err = Some(e),
Err(_) => {}
}
}
if resolved.is_empty()
&& let Some(e) = first_err
{
return Ok(ScannedIndexOutcome::Outcome(resolve_error_to_outcome(
e, format,
)));
}
}
discovery::discover_files(dir)?
.into_iter()
.map(|p| {
let rel = discovery::relative_path(dir, &p);
(p, rel)
})
.collect()
} else {
match collect_files(dir, files_arg, globs, format)? {
FilesOrOutcome::Outcome(o) => return Ok(ScannedIndexOutcome::Outcome(o)),
FilesOrOutcome::Files(f) => f,
}
};
let build = ScannedIndex::build(&files, site_prefix, options)?;
for w in &build.warnings {
crate::warn::warn(format!("skipping {}: {}", w.rel_path, w.message));
}
Ok(ScannedIndexOutcome::Index(build))
}
#[must_use]
pub fn require_file_or_glob(
files: &[String],
globs: &[String],
command_name: &str,
format: Format,
) -> Option<CommandOutcome> {
if files.is_empty() && globs.is_empty() {
let out = crate::output::format_error(
format,
&format!("{command_name} requires --file or --glob"),
None,
Some(
"use --file <path> to target a single file or --glob <pattern> to target multiple files",
),
None,
);
Some(CommandOutcome::UserError(out))
} else {
None
}
}
const FILTER_OP_SUFFIXES: &[char] = &['<', '>', '!', '~'];
#[must_use]
pub fn reject_filter_in_mutation_property(key: &str, format: Format) -> Option<CommandOutcome> {
let trimmed = key.trim_end();
let ch = trimmed.chars().last()?;
if !FILTER_OP_SUFFIXES.contains(&ch) {
return None;
}
let out = crate::output::format_error(
format,
&format!(
"invalid property name '{trimmed}': ends with '{ch}' which looks like a filter \
operator (e.g. >=, <=, !=, ~=)"
),
None,
Some(
"--property in mutation commands is for mutation, not filtering — \
use --where-property to filter which files are mutated",
),
None,
);
Some(CommandOutcome::UserError(out))
}
#[must_use]
pub fn unwrap_single_file_result(
files: &[String],
mut results: Vec<serde_json::Value>,
) -> serde_json::Value {
if files.len() == 1 && results.len() == 1 {
results.pop().unwrap_or_default()
} else {
serde_json::json!(results)
}
}
#[must_use]
pub fn resolve_error_to_outcome(err: FileResolveError, format: Format) -> CommandOutcome {
match err {
FileResolveError::MissingExtension { path, hint } => {
CommandOutcome::UserError(crate::output::format_error(
format,
"file not found",
Some(&path),
Some(&format!("did you mean {hint}?")),
None,
))
}
FileResolveError::NotFound { path } => CommandOutcome::UserError(
crate::output::format_error(format, "file not found", Some(&path), None, None),
),
FileResolveError::NotFoundSuggestion { path, suggestion } => {
CommandOutcome::UserError(crate::output::format_error(
format,
"file not found",
Some(&path),
Some(&format!("did you mean {suggestion}?")),
None,
))
}
FileResolveError::IsDirectory { path, hint } => {
CommandOutcome::UserError(crate::output::format_error(
format,
"path is a directory, not a file",
Some(&path),
Some(&hint),
None,
))
}
FileResolveError::OutsideVault { path } => {
CommandOutcome::UserError(crate::output::format_error(
format,
"file resolves outside vault boundary",
Some(&path),
None,
None,
))
}
FileResolveError::InvalidPath { path, reason } => CommandOutcome::UserError(
crate::output::format_error(format, "invalid path", Some(&path), Some(reason), None),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use hyalo_core::index::format_iso8601;
#[test]
fn reject_filter_gt() {
assert!(reject_filter_in_mutation_property("priority>", Format::Json).is_some());
}
#[test]
fn reject_filter_lt() {
assert!(reject_filter_in_mutation_property("priority<", Format::Json).is_some());
}
#[test]
fn reject_filter_bang() {
assert!(reject_filter_in_mutation_property("status!", Format::Json).is_some());
}
#[test]
fn reject_filter_tilde() {
assert!(reject_filter_in_mutation_property("name~", Format::Json).is_some());
}
#[test]
fn accept_plain_key() {
assert!(reject_filter_in_mutation_property("status", Format::Json).is_none());
}
#[test]
fn accept_hyphenated_key() {
assert!(reject_filter_in_mutation_property("my-key", Format::Json).is_none());
}
#[test]
fn accept_underscored_key() {
assert!(reject_filter_in_mutation_property("key_name", Format::Json).is_none());
}
#[test]
fn accept_empty_key() {
assert!(reject_filter_in_mutation_property("", Format::Json).is_none());
}
#[test]
fn iso8601_epoch() {
assert_eq!(format_iso8601(0), "1970-01-01T00:00:00Z");
}
#[test]
fn iso8601_known_date() {
assert_eq!(format_iso8601(1_705_314_600), "2024-01-15T10:30:00Z");
}
}