use std::path::{Path, PathBuf};
use clap::{Args, Parser, Subcommand};
use crate::output::Format;
#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if requires &bool
pub(crate) fn is_false(v: &bool) -> bool {
!v
}
pub(crate) fn parse_limit(s: &str) -> Result<usize, String> {
s.parse()
.map_err(|_| format!("'{s}' is not a valid number"))
}
/// Value parser for `--threshold`: accepts a `f64` in `[0.0, 1.0]`.
pub(crate) fn parse_threshold(s: &str) -> Result<f64, String> {
let v: f64 = s
.parse()
.map_err(|_| format!("'{s}' is not a valid floating-point number"))?;
if (0.0..=1.0).contains(&v) {
Ok(v)
} else {
Err(format!(
"threshold must be between 0.0 and 1.0 (inclusive), got {v}"
))
}
}
/// Index flags, flattened into subcommands that can consume a snapshot index.
#[derive(Args, Debug, Default, Clone)]
pub(crate) struct IndexFlags {
/// Use the snapshot index at `.hyalo-index` in the vault directory.
///
/// Read-only commands (find, summary, tags summary, properties summary,
/// backlinks) skip the disk scan entirely when the index is present.
///
/// Mutation commands (set, remove, append, task, mv, tags rename,
/// properties rename, links fix) still read/write individual files on disk
/// but also patch the index entry in-place after each mutation — keeping
/// the index current for subsequent queries.
///
/// If the index file is incompatible (e.g. after a hyalo upgrade) hyalo
/// falls back to a full disk scan automatically.
#[arg(long)]
pub index: bool,
/// Use the snapshot index at PATH instead of the default `.hyalo-index`.
///
/// Implies `--index`. Relative paths are resolved against the current
/// working directory (not the vault dir). Absolute paths are used as-is.
///
/// Read-only commands skip the disk scan entirely. Mutation commands
/// patch the index in-place after each write — see `--index` for details.
///
/// If the index file is incompatible hyalo falls back to a disk scan.
#[arg(long, value_name = "PATH")]
pub index_file: Option<PathBuf>,
}
impl IndexFlags {
/// Return the effective index path given the vault directory.
///
/// - `--index-file PATH` wins; relative paths are returned as-is
/// (caller resolves against CWD).
/// - Bare `--index` returns `vault_dir/.hyalo-index` (relative to vault,
/// not CWD; caller should not CWD-resolve this).
/// - Neither flag → `None`.
pub(crate) fn effective_index_path(&self, vault_dir: &Path) -> Option<PathBuf> {
if let Some(ref p) = self.index_file {
Some(p.clone())
} else if self.index {
Some(vault_dir.join(".hyalo-index"))
} else {
None
}
}
}
/// Resolve a file argument that can be passed as positional or --file flag.
/// Returns an error if neither is provided.
pub(crate) fn resolve_single_file(
positional: Option<String>,
flag: Option<String>,
) -> anyhow::Result<String> {
match (positional, flag) {
(Some(f), None) | (None, Some(f)) => Ok(f),
(None, None) => anyhow::bail!("required argument missing: provide <FILE> or --file <FILE>"),
// conflicts_with prevents this at parse time; defensive fallback.
(Some(_), Some(_)) => anyhow::bail!("cannot specify both <FILE> and --file"),
}
}
#[derive(Parser)]
#[command(
name = "hyalo",
version,
about = "Query, filter, and mutate YAML frontmatter across markdown file collections",
long_about = "Hyalo — query, filter, and mutate YAML frontmatter across markdown file collections.\n\n\
Compatible with Obsidian vaults, Zettelkasten systems, and any directory of .md files \
with YAML frontmatter. Also resolves [[wikilinks]] and manages task checkboxes.\n\n\
SCOPE: Hyalo operates on a directory of .md files. It can query and mutate frontmatter \
properties, tags, tasks, and links.\n\n\
PATH RESOLUTION: All file and --glob paths are relative to --dir (defaults to \".\"). \
If a file path starts with the --dir prefix, it is stripped automatically \
(e.g. --file docs/note.md resolves to note.md when --dir is docs). \
Globs use standard syntax: '**/*.md' matches recursively, 'notes/*.md' matches one level.\n\n\
OUTPUT: Returns JSON by default (--format json). All JSON is wrapped in a consistent envelope:\n\
\u{00a0} {\"results\": <payload>, \"total\": N, \"hints\": [...]}\n\
total is present for list commands (find, tags, properties, backlinks). \
hints is always present (empty [] when --no-hints). \
--jq operates on the full envelope, e.g. --jq '.results[].file' or --jq '.total'.\n\
--count prints just the total as a bare integer (shortcut for --jq '.total').\n\
Use --format text for human-readable output. \
Successful output goes to stdout; errors go to stderr with exit code 1 (user error) or 2 (internal error).\n\n\
ABSOLUTE LINKS: Links like `/docs/page.md` are resolved by stripping a site prefix. \
By default the prefix is auto-derived from --dir's last path component (e.g. --dir ../my-site/docs → prefix \"docs\"). \
Override with --site-prefix <PREFIX>, or --site-prefix \"\" to disable. Also settable in .hyalo.toml.\n\n\
CONFIG: Place a .hyalo.toml in the working directory to set defaults:\n\
\u{00a0} dir = \"vault/\" # default --dir\n\
\u{00a0} format = \"text\" # default --format (CLI default is json)\n\
\u{00a0} hints = false # disable hints (CLI default is on)\n\
\u{00a0} site_prefix = \"docs\" # override auto-derived site prefix for absolute links\n\
CLI flags always take precedence.\n\n\
See COMMAND REFERENCE below for full syntax of each command."
)]
pub(crate) struct Cli {
/// Root directory for resolving all file and --glob paths.
/// Default: "." (Override via .hyalo.toml)
#[arg(short, long, global = true)]
pub dir: Option<PathBuf>,
/// Output format: "json" or "text".
/// Default: "json" (Override via .hyalo.toml)
#[arg(long, global = true)]
pub format: Option<Format>,
/// Apply a jq filter expression to the JSON output of any command.
/// Operates on the full JSON envelope: {"results": ..., "total": N, "hints": [...]}.
/// The filtered result is printed as plain text. Incompatible with --format text.
/// Example: --jq '.results[].file' or --jq '.results | map(.properties.status) | unique'.
/// Note: recursive filters (e.g. 'recurse', '..') on large inputs may run indefinitely
#[arg(long, global = true, value_name = "FILTER")]
pub jq: Option<String>,
/// Print only the total count as a bare integer for list commands
/// (find, tags summary, properties summary, backlinks).
/// Shortcut for --jq '.total'. Incompatible with --jq.
#[arg(long, global = true)]
pub count: bool,
/// Force hints on (already the default).
/// Text mode: '-> hyalo ... # description' lines — concrete, copy-pasteable commands with descriptions.
/// JSON mode: populates the "hints" array in the envelope (always present, empty when suppressed).
/// Suppressed when --jq is active.
#[arg(long, global = true)]
pub hints: bool,
/// Disable drill-down command hints (enabled by default).
/// Override via .hyalo.toml: hints = false
/// When both --hints and --no-hints are present, --hints takes precedence.
#[arg(long, global = true)]
pub no_hints: bool,
/// Site prefix for resolving root-absolute links like `/docs/page.md`.
///
/// When a markdown file contains a link like `/docs/guides/setup.md`, hyalo strips the
/// leading `/<prefix>/` to get the vault-relative path `guides/setup.md`. This is how
/// documentation sites (GitHub Pages, VuePress, Docusaurus) map URL paths to file paths.
///
/// By default, hyalo auto-derives the prefix from --dir's last path component:
/// --dir ../vscode-docs/docs → prefix = "docs"
/// --dir /home/me/wiki → prefix = "wiki"
/// --dir . → prefix = name of the current directory
///
/// Use --site-prefix to override when the directory name doesn't match the URL prefix,
/// or pass --site-prefix "" to disable absolute-link resolution entirely.
///
/// Also settable via `site_prefix = "docs"` in .hyalo.toml.
/// Precedence: --site-prefix flag > .hyalo.toml > auto-derived from --dir.
#[arg(long, global = true, value_name = "PREFIX")]
pub site_prefix: Option<String>,
/// Suppress all warnings printed to stderr.
///
/// Useful in scripts or CI pipelines where warning noise is undesirable.
/// Identical warnings are always deduplicated regardless of this flag;
/// use `--quiet` to suppress them entirely.
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
#[command(subcommand)]
pub command: Commands,
}
/// All filter arguments for `hyalo find`, extracted so they can be serialized as views.
#[derive(Debug, Clone, Default, clap::Args, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub(crate) struct FindFilters {
/// BM25 search pattern (stored in views, not a CLI arg on find — find uses a positional arg instead)
#[arg(skip)]
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
/// Regex body text search (case-insensitive by default; use (?-i) to override). Mutually exclusive with PATTERN
#[arg(long, short = 'e', value_name = "REGEX")]
#[serde(skip_serializing_if = "Option::is_none")]
pub regexp: Option<String>,
/// Property filter: K=V (eq), K!=V (neq), K>=V, K<=V, K>V, K<V, K (exists), !K (absent), K~=pat or K~=/pat/i (regex). Repeatable (AND)
#[arg(short, long = "property", value_name = "FILTER")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub properties: Vec<String>,
/// Tag filter: exact or prefix match (e.g. 'project' matches 'project/backend' but not 'projects'). Repeatable (AND)
#[arg(short, long, value_name = "TAG")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tag: Vec<String>,
/// Task presence filter: 'todo', 'done', 'any', or a single status character
#[arg(long, value_name = "STATUS")]
#[serde(skip_serializing_if = "Option::is_none")]
pub task: Option<String>,
/// Section heading filter: case-insensitive substring match (e.g. 'Tasks' matches 'Tasks [4/4]');
/// prefix '##' to pin heading level; use '/regex/' for regex (e.g. '/DEC-03[12]/'). Repeatable (OR)
#[arg(short, long = "section", value_name = "HEADING")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sections: Vec<String>,
/// Target file(s) (repeatable). Mutually exclusive with --glob
#[arg(short, long, conflicts_with = "glob")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub file: Vec<String>,
/// Glob pattern(s) to select files, relative to --dir (repeatable); prefix '!' to negate (e.g. '!**/draft-*')
#[arg(short, long, conflicts_with = "file")]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub glob: Vec<String>,
/// Comma-separated list of optional fields to include: all, properties, properties-typed, tags, sections (alias: outline), tasks, links, backlinks, title (default: properties, tags, sections, links — excludes tasks, properties-typed, backlinks, and title). Use 'all' to include every field. 'file' and 'modified' are always included. 'properties' is a {key: value} map; 'properties-typed' is a [{name, type, value}] array; 'backlinks' requires scanning all files; 'title' is the frontmatter title property or first H1 heading (null if neither found). Note: in JSON output, `properties-typed` is serialized as `properties_typed` (underscore)
#[arg(long, value_name = "FIELDS", use_value_delimiter = true)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
/// Sort order: 'file' (default), 'modified', 'backlinks_count', 'links_count', 'title', 'date', or 'property:<KEY>' for any frontmatter property
#[arg(long)]
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<String>,
/// Reverse the sort order (ascending becomes descending and vice versa)
#[arg(long)]
#[serde(skip_serializing_if = "is_false")]
pub reverse: bool,
/// Maximum number of results to return (0 = unlimited).
/// Default cap is bypassed when --jq or --count is used
#[arg(short = 'n', long, value_parser = parse_limit)]
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
/// Only return files with at least one unresolved link (auto-includes links field)
#[arg(long)]
#[serde(skip_serializing_if = "is_false")]
pub broken_links: bool,
/// Only return orphan files: no inbound links and no outbound links (auto-includes backlinks field)
#[arg(long)]
#[serde(skip_serializing_if = "is_false")]
pub orphan: bool,
/// Only return dead-end files: have inbound links but no outbound links (auto-includes links field)
#[arg(long)]
#[serde(skip_serializing_if = "is_false")]
pub dead_end: bool,
/// Filter by title: case-insensitive substring match against the displayed title
/// (frontmatter 'title' property or first H1 heading). Use /regex/ for regex
/// (e.g. '/^The/' or '/^The/i').
#[arg(long)]
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Stemmer language for BM25 body search (also --stemmer). Selects Snowball stemmer for BM25
/// tokenization — NOT markdown code-block language.
/// Default: english. Accepts full names (english, german, …) or ISO 639-1 codes (en, de, …).
/// Supported: arabic (ar), danish (da), dutch (nl), english (en), finnish (fi), french (fr),
/// german (de), greek (el), hungarian (hu), italian (it), norwegian (no, nb, nn),
/// portuguese (pt), romanian (ro), russian (ru), spanish (es), swedish (sv), tamil (ta),
/// turkish (tr)
#[arg(long, alias = "stemmer", value_name = "LANG")]
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
}
impl FindFilters {
/// Merge CLI overrides onto a view's filters.
/// - Vec fields: CLI extends the view
/// - Option fields: CLI overrides if Some
/// - Bool fields: OR (CLI can turn on, not off)
pub(crate) fn merge_from(&mut self, overlay: &Self) {
if overlay.pattern.is_some() {
self.pattern.clone_from(&overlay.pattern);
}
if overlay.regexp.is_some() {
self.regexp.clone_from(&overlay.regexp);
}
self.properties.extend(overlay.properties.iter().cloned());
self.tag.extend(overlay.tag.iter().cloned());
if overlay.task.is_some() {
self.task.clone_from(&overlay.task);
}
self.sections.extend(overlay.sections.iter().cloned());
// file and glob are mutually exclusive (clap enforces this at parse time).
// If the overlay provides either, it replaces the base to avoid an invalid
// combination where both file and glob are non-empty.
if !overlay.file.is_empty() {
self.file.extend(overlay.file.iter().cloned());
self.glob.clear();
} else if !overlay.glob.is_empty() {
self.glob.extend(overlay.glob.iter().cloned());
self.file.clear();
}
self.fields.extend(overlay.fields.iter().cloned());
if overlay.sort.is_some() {
self.sort.clone_from(&overlay.sort);
}
self.reverse = self.reverse || overlay.reverse;
if overlay.limit.is_some() {
self.limit = overlay.limit;
}
self.broken_links = self.broken_links || overlay.broken_links;
self.orphan = self.orphan || overlay.orphan;
self.dead_end = self.dead_end || overlay.dead_end;
if overlay.title.is_some() {
self.title.clone_from(&overlay.title);
}
if overlay.language.is_some() {
self.language.clone_from(&overlay.language);
}
}
}
#[derive(Subcommand)]
pub(crate) enum Commands {
/// Search and filter markdown files — returns file objects with metadata, structure, tasks, and links
#[command(long_about = "Search and filter markdown files.\n\n\
Returns a JSON envelope: {\"results\": [...], \"total\": N, \"hints\": [...]}.\n\
Each item in results contains the file path, modified time, \
and optionally: frontmatter properties, tags, document sections, tasks, and links.\n\n\
SEARCH MODES:\n\
- PATTERN (positional): BM25 ranked full-text search with stemming. Results are sorted by \
relevance score (highest first) unless --sort is specified. Each result includes a numeric \
'score' field in the output. Stemming normalises words to their root: 'running' matches \
documents containing 'run', 'runner', 'running', etc.\n\
- --regexp/-e REGEX: regex body text search (case-insensitive by default; unranked; \
results include per-line 'matches' instead of 'score'). Mutually exclusive with PATTERN.\n\n\
QUERY SYNTAX (for PATTERN):\n\
- Multiple words: implicit AND — all terms required (e.g. 'rust programming' returns \
only documents containing both words)\n\
- OR keyword: explicit OR — either term matches (e.g. 'rust OR golang' returns docs with \
either word, ranked by combined BM25 score). Case-insensitive ('or' also works). When OR \
is present, all non-negated terms become OR alternatives.\n\
- \"quoted phrase\": exact consecutive match after stemming (e.g. '\"javascript promises\"' \
matches only documents with that exact phrase)\n\
- -term: exclude documents containing this term (e.g. 'rust -javascript' finds Rust docs \
that don't mention javascript; stemming applies, so '-running' also excludes 'run')\n\
- AND keyword: accepted but optional (implicit between terms)\n\
- Combine freely: 'rust -java', 'rust OR golang', '\"error handling\" -panic'\n\n\
LANGUAGE: The --language flag (or [search] language in .hyalo.toml, or frontmatter \
'language' property per file) selects the Snowball stemmer for tokenization. Default: english. \
Accepts full names or ISO 639-1 codes (e.g. 'en' for english, 'de' for german). \
Supported: arabic (ar), danish (da), dutch (nl), english (en), finnish (fi), french (fr), \
german (de), greek (el), hungarian (hu), italian (it), norwegian (no, nb, nn), portuguese (pt), \
romanian (ro), russian (ru), spanish (es), swedish (sv), tamil (ta), turkish (tr). \
Language precedence: frontmatter > --language > config > english.\n\n\
FILTERS: All filters are AND'd together.\n\
- --property K=V: frontmatter property filter (supports =, !=, >, >=, <, <=, bare K for existence, !K for absence, K~=pattern or K~=/pattern/i for regex)\n\
- --tag T: tag filter (exact or prefix via '/': 'project' matches 'project/backend' but NOT 'projects' — no substring or fuzzy matching)\n\
- --task STATUS: task presence filter ('todo', 'done', 'any', or a single status char)\n\
- --section HEADING: section scope filter (exclude files without a matching section; within \
matching files, restrict tasks and content matches to the section scope; case-insensitive \
substring (contains) match by default, e.g. 'Tasks' matches 'Tasks [4/4]'; use leading '#' \
to pin heading level, e.g. '## Tasks'; use '/regex/' for regex matching). Repeatable (OR). \
Nested subsections are included.\n\n\
FIELDS: Use --fields to limit which fields appear (default: all). \
Properties are a {key: value} map; use --fields properties-typed for [{name, type, value}] array.\n\
JQ: --jq operates on the full envelope. Examples: --jq '.results[].file', --jq '.total'.\n\
VIEWS: --view <name> loads a saved filter set from .hyalo.toml. Additional CLI flags \
merge on top: list filters (--property, --tag, --section, --glob) extend the view's \
lists; scalar filters (--regexp, --sort, --limit, --title, --task, --language) override; bool \
flags (--broken-links, --orphan, --dead-end, --reverse) OR. Example: hyalo find --view drafts --limit 5\n\
COMMON MISTAKES:\n\
- Property regex uses ~= (tilde-equals), NOT =~ (Perl-style). Wrong: 'title=~/pat/', right: 'title~=/pat/'.\n\
- --title searches the displayed title (frontmatter or H1); --property title~= only searches frontmatter.\n\
- --tag uses prefix matching: 'project' matches 'project/backend' but NOT 'projects'.\n\
POSITIONAL ARGUMENTS: The first positional argument is always PATTERN (body text search), not a file path. \
Subsequent positional arguments are treated as FILE targets. \
To filter by file without a body search, use --file instead of a positional argument.\n\
SIDE EFFECTS: None (read-only).")]
Find {
/// BM25 ranked body text search with stemming (e.g. "running" matches "run", "ran"); results sorted by relevance
#[arg(value_name = "PATTERN", conflicts_with = "regexp")]
pattern: Option<String>,
/// Target file(s) as positional args — alternative to --file (repeatable after PATTERN)
#[arg(value_name = "FILE", conflicts_with_all = ["glob", "file"])]
file_positional: Vec<String>,
/// Use a saved view (named filter set from .hyalo.toml). Additional CLI filters
/// are merged on top: list filters (--property, --tag, --section, --glob) extend
/// the view; scalar filters (--sort, --limit, --regexp, --title, --task) override it
#[arg(long, value_name = "NAME")]
view: Option<String>,
#[command(flatten)]
filters: FindFilters,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Read file body content, optionally filtered by section or line range (read-only)
#[command(
alias = "show",
long_about = "Read the body content of a markdown file.\n\n\
Returns the raw text after the YAML frontmatter block. Use --section to extract a \
specific section by heading (case-insensitive substring match; use leading '#' to \
pin heading level, e.g. '## Tasks'; use '/regex/' for regex matching; nested subsections are included), \
--lines to slice a line range, and --frontmatter to include the YAML frontmatter.\n\n\
OUTPUT: Defaults to plain text (unlike all other commands which default to JSON). \
Pass --format json to get {\"results\": {\"file\": \"...\", \"content\": \"...\"}, \"hints\": [...]}.\n\
SIDE EFFECTS: None (read-only)."
)]
Read {
/// Target file (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// Target file (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// Extract section(s) by substring match (e.g. 'Tasks' matches 'Tasks [4/4]');
/// prefix '##' to pin heading level; use '/regex/' for regex. Nested subsections included
#[arg(short, long, value_name = "HEADING")]
section: Option<String>,
/// Slice output by line range: 5:10, 5:, :10, or 5 (1-based, inclusive, relative to body content)
#[arg(short, long)]
lines: Option<String>,
/// Include the YAML frontmatter in output
#[arg(long)]
frontmatter: bool,
// TODO: Read doesn't use the snapshot index yet; consider removing
// IndexFlags or wiring it in for file resolution.
#[command(flatten)]
index_flags: IndexFlags,
},
/// Property operations: summary or bulk rename
#[command(long_about = "Property operations across matched files.\n\n\
Subcommands:\n\
- summary: Unique property names, types, and file counts (read-only).\n\
- rename: Rename a property key across files (mutates files).")]
Properties {
#[command(subcommand)]
action: Option<PropertiesAction>,
},
/// Tag operations: summary or bulk rename
#[command(long_about = "Tag operations across matched files.\n\n\
Subcommands:\n\
- summary: Unique tags with file counts (read-only).\n\
- rename: Rename a tag across files (mutates files).")]
Tags {
#[command(subcommand)]
action: Option<TagsAction>,
},
/// Read, toggle, or set status on task checkboxes (single, bulk, or by section)
#[command(long_about = "Read, toggle, or set status on task checkboxes.\n\n\
Subcommands:\n\
- read: Show task details for one or more tasks.\n\
- toggle: Flip completion state ([ ] <-> [x], custom -> [x]).\n\
- set: Set an arbitrary single-character status.\n\n\
INPUT: FILE (positional or --file) and one of: --line (repeatable/comma-separated), --section <heading>, or --all.\n\
SCOPE: Single file only.\n\
SIDE EFFECTS: 'toggle' and 'set' modify the file on disk. 'read' is read-only.")]
Task {
#[command(subcommand)]
action: TaskAction,
},
/// Show a compact vault summary: file counts, property/tag/status counts, tasks, links, orphans, dead-ends (read-only)
#[command(
long_about = "Show a compact vault summary (~20-30 lines regardless of vault size).\n\n\
OUTPUT: A 'VaultSummary' object with file counts (total + top-level directories), \
property summary (unique names/types/counts), tag summary (unique tags/counts), \
status grouping (value + count, no file lists), \
task counts (total/done), link health (total/broken count), \
orphan count, dead-end count, and recently modified files.\n\
Drill down with: hyalo find --orphan, --dead-end, --broken-links, --property status=X.\n\
SCOPE: Scans all .md files under --dir unless narrowed with --glob.\n\
SIDE EFFECTS: None (read-only).\n\
USE WHEN: You need a quick overview of a vault's metadata landscape."
)]
Summary {
/// Glob pattern(s) to filter which files to include, relative to --dir (repeatable); prefix '!' to negate (e.g. '!**/draft-*')
#[arg(short, long)]
glob: Vec<String>,
/// Number of recent files to show (default: 10)
#[arg(short = 'n', long, default_value = "10")]
recent: usize,
/// Limit directory listing depth (0 = root only; stats are always full)
#[arg(long)]
depth: Option<usize>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// List all files that link to a given file (read-only)
#[command(
long_about = "List all files that link to a given file (reverse link lookup).\n\n\
Builds an in-memory link graph by scanning all .md files in the vault, \
then returns every file that contains a [[wikilink]] or [markdown](link) \
pointing to the target file.\n\n\
OUTPUT: JSON object with file, backlinks array (source, line, target, label), and total count.\n\
SIDE EFFECTS: None (read-only)."
)]
Backlinks {
/// Target file to find backlinks for (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// Target file to find backlinks for (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// Maximum number of backlinks to return (0 = unlimited).
/// Default cap is bypassed when --jq or --count is used
#[arg(short = 'n', long, value_parser = parse_limit)]
limit: Option<usize>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Move/rename a file and update all inbound and outbound links
#[command(
long_about = "Move or rename a markdown file and update all links across the vault.\n\n\
Builds an in-memory link graph, then:\n\
1. Moves the file on disk.\n\
2. Rewrites all [[wikilinks]] and [markdown](links) in other files that pointed to the old path.\n\
3. Rewrites relative markdown links inside the moved file whose targets changed due to the new directory context.\n\n\
Use --dry-run to preview changes without writing.\n\n\
OUTPUT: JSON object with from, to, updated_files (with per-file replacements), and totals.\n\
SIDE EFFECTS: Moves the file and modifies files containing links (unless --dry-run)."
)]
Mv {
/// Source file to move (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// Source file to move (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// Destination path (relative to --dir, must end with .md)
#[arg(long)]
to: String,
/// Preview changes without modifying any files
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Set (create or overwrite) frontmatter properties and/or add tags across file(s)
#[command(
long_about = "Set (create or overwrite) frontmatter properties and/or add tags across file(s).\n\n\
INPUT: One or more --property K=V arguments and/or --tag T arguments, with FILE (positional or --file) or --glob.\n\
BEHAVIOR:\n\
- --property K=V: creates or overwrites the property. Type is auto-inferred from V \
(number, bool, text). Use K=[a,b,c] to create a YAML list; values are comma-split and trimmed. \
A file is skipped if the stored value is already identical.\n\
GUARD: --property accepts only plain K=V assignments. Filter syntax (>=, <=, !=, ~=) \
is rejected — use --where-property for filtering.\n\
- --tag T: idempotent tag add. Creates the 'tags' list if absent. Skips files that already have the tag.\n\
OUTPUT: A single result object if one mutation was requested; an array if multiple.\n\
Each result: {\"property\": K, \"value\": V, \"modified\": [...], \"skipped\": [...], \"total\": N}\n\
or: {\"tag\": T, \"modified\": [...], \"skipped\": [...], \"total\": N}\n\
FILTERS (optional, narrow which files are mutated):\n\
- --where-property FILTER: only mutate files whose frontmatter matches (same syntax as find --property: \
K=V, K!=V, K>=V, K<=V, K>V, K<V, or K for existence). Quote filters containing > or < to prevent \
shell redirection (e.g. --where-property 'priority>=3'). If the property is a list, matches if any \
element matches. Repeatable (AND).\n\
- --where-tag T: only mutate files with this tag (nested matching: 'project' matches 'project/backend'). \
Repeatable (AND).\n\
SIDE EFFECTS: Modifies matched files on disk (unless --dry-run is passed).\n\
USE WHEN: You need to create or overwrite frontmatter properties or add tags, \
possibly across many files at once."
)]
Set {
/// Target file(s) as positional argument(s) — alternative to --file
#[arg(value_name = "FILE", conflicts_with_all = ["glob", "file"])]
file_positional: Vec<String>,
/// Property to set: K=V (type inferred from V). Repeatable
#[arg(short, long = "property", value_name = "K=V")]
properties: Vec<String>,
/// Tag to add (idempotent). Repeatable
#[arg(short, long, value_name = "TAG")]
tag: Vec<String>,
/// Target file(s) (repeatable). Mutually exclusive with --glob
#[arg(short, long, conflicts_with = "glob")]
file: Vec<String>,
/// Glob pattern(s) for multiple files, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long, conflicts_with = "file")]
glob: Vec<String>,
/// Filter: only mutate files whose frontmatter property matches (repeatable, AND). Same syntax as find --property
#[arg(long = "where-property", value_name = "FILTER")]
where_properties: Vec<String>,
/// Filter: only mutate files with this tag (repeatable, AND). Same syntax as find --tag
#[arg(long = "where-tag", value_name = "TAG")]
where_tags: Vec<String>,
/// Preview changes without modifying any files
#[arg(long)]
dry_run: bool,
/// Validate new values against the schema from .hyalo.toml; reject writes that would
/// create lint errors. Implied by `validate_on_write = true` in [schema] config.
#[arg(long, alias = "strict")]
validate: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Remove frontmatter properties and/or tags from file(s)
#[command(
long_about = "Remove frontmatter properties and/or tags from file(s).\n\n\
INPUT: One or more --property K or K=V arguments and/or --tag T arguments, with FILE (positional or --file) or --glob.\n\
BEHAVIOR:\n\
- --property K: removes the entire key from frontmatter. Skips files where it is absent.\n\
- --property K=V: if the property is a list, removes V from the list; if it is a scalar \
that matches V (case-insensitive), removes the key entirely; otherwise skips the file.\n\
GUARD: --property accepts only plain K or K=V arguments. Filter syntax (>=, <=, !=, ~=) \
is rejected — use --where-property for filtering.\n\
- --tag T: removes the tag from the 'tags' list. Skips files where the tag is not present.\n\
OUTPUT: A single result object if one mutation was requested; an array if multiple.\n\
Each result: {\"property\": K, [\"value\": V,] \"modified\": [...], \"skipped\": [...], \"total\": N}\n\
or: {\"tag\": T, \"modified\": [...], \"skipped\": [...], \"total\": N}\n\
FILTERS (optional, narrow which files are mutated):\n\
- --where-property FILTER: only mutate files whose frontmatter matches (same syntax as find --property: \
K=V, K!=V, K>=V, K<=V, K>V, K<V, or K for existence). Quote filters containing > or < to prevent \
shell redirection (e.g. --where-property 'priority>=3'). If the property is a list, matches if any \
element matches. Repeatable (AND).\n\
- --where-tag T: only mutate files with this tag (nested matching: 'project' matches 'project/backend'). \
Repeatable (AND).\n\
SIDE EFFECTS: Modifies matched files on disk (unless --dry-run is passed).\n\
USE WHEN: You need to delete properties or remove tags from one or more files."
)]
Remove {
/// Target file(s) as positional argument(s) — alternative to --file
#[arg(value_name = "FILE", conflicts_with_all = ["glob", "file"])]
file_positional: Vec<String>,
/// Property to remove: K (removes key) or K=V (removes value from list/scalar). Repeatable
#[arg(short, long = "property", value_name = "K or K=V")]
properties: Vec<String>,
/// Tag to remove. Repeatable
#[arg(short, long, value_name = "TAG")]
tag: Vec<String>,
/// Target file(s) (repeatable). Mutually exclusive with --glob
#[arg(short, long, conflicts_with = "glob")]
file: Vec<String>,
/// Glob pattern(s) for multiple files, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long, conflicts_with = "file")]
glob: Vec<String>,
/// Filter: only mutate files whose frontmatter property matches (repeatable, AND). Same syntax as find --property
#[arg(long = "where-property", value_name = "FILTER")]
where_properties: Vec<String>,
/// Filter: only mutate files with this tag (repeatable, AND). Same syntax as find --tag
#[arg(long = "where-tag", value_name = "TAG")]
where_tags: Vec<String>,
/// Preview changes without modifying any files
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Initialize hyalo configuration and optional tool integrations
#[command(
long_about = "Create .hyalo.toml and optionally set up Claude Code integration.\n\n\
Without flags, creates a .hyalo.toml config file.\n\
With --claude, also installs the hyalo skill for Claude Code.\n\n\
Use the global --dir flag to specify the markdown directory to record in .hyalo.toml."
)]
Init {
/// Set up Claude Code integration (skill + CLAUDE.md hint)
#[arg(long)]
claude: bool,
},
/// Remove hyalo configuration and Claude Code integration artifacts
#[command(
long_about = "Remove .hyalo.toml and all Claude Code integration artifacts created by `init`.\n\n\
Removes skills, rules, and the managed section from .claude/CLAUDE.md.\n\
Safe to run when artifacts are already absent (idempotent)."
)]
Deinit,
/// Build a snapshot index for faster repeated read-only queries
#[command(
name = "create-index",
long_about = "Scan the vault and write a binary snapshot index to disk.\n\n\
The index captures a point-in-time snapshot of all vault metadata.\n\
Delete it after use via `hyalo drop-index`.\n\n\
The index file can be passed to any supported command via `--index-file <PATH>`.\n\
Read-only commands skip the disk scan entirely. Mutation commands\n\
(set, remove, append, task, mv, tags rename, properties rename) still\n\
read/write files on disk but also patch the index in-place after each\n\
mutation — keeping it current for subsequent queries. This is safe as\n\
long as no external tool modifies vault files while the index is active.\n\n\
OUTPUT: JSON object with `path`, `files_indexed`, and `warnings`.\n\
SIDE EFFECTS: Writes a binary file (default: .hyalo-index in --dir)."
)]
CreateIndex {
/// Output path for the index file (default: .hyalo-index in --dir)
#[arg(short, long)]
output: Option<PathBuf>,
/// Allow writing the index file outside the vault directory
#[arg(long)]
allow_outside_vault: bool,
},
/// Delete a snapshot index file created with create-index
#[command(
name = "drop-index",
long_about = "Delete a snapshot index file.\n\n\
Drop the index when your session is complete. The index should\n\
not outlive its session.\n\n\
If --path is omitted, deletes .hyalo-index in --dir.\n\n\
OUTPUT: JSON object with `deleted` path.\n\
SIDE EFFECTS: Deletes the index file from disk."
)]
DropIndex {
/// Path to the index file to delete (default: .hyalo-index in --dir)
#[arg(short, long)]
path: Option<PathBuf>,
/// Allow deleting an index file outside the vault directory
#[arg(long)]
allow_outside_vault: bool,
},
/// Append values to list properties in file(s) frontmatter, promoting scalars to lists
#[command(
long_about = "Append values to list properties in file(s) frontmatter.\n\n\
INPUT: One or more --property K=V arguments, with FILE (positional or --file) or --glob.\n\
Note: --tag is not available on append (tags are atomic, not lists). Use 'hyalo set --tag T' to add tags.\n\
BEHAVIOR:\n\
- Property absent or null: creates it as a single-element list [V].\n\
- Property is a list: appends V if not already present (case-insensitive duplicate check).\n\
- Property is a scalar (string, number, bool): promotes to [existing, V].\n\
- Property is a mapping: returns an error.\n\
GUARD: --property accepts only plain K=V assignments. Filter syntax (>=, <=, !=, ~=) \
is rejected — use --where-property for filtering.\n\
OUTPUT: A single result object if one mutation was requested; an array if multiple.\n\
Each result: {\"property\": K, \"value\": V, \"modified\": [...], \"skipped\": [...], \"total\": N}\n\
FILTERS (optional, narrow which files are mutated):\n\
- --where-property FILTER: only mutate files whose frontmatter matches (same syntax as find --property: \
K=V, K!=V, K>=V, K<=V, K>V, K<V, or K for existence). Quote filters containing > or < to prevent \
shell redirection (e.g. --where-property 'priority>=3'). If the property is a list, matches if any \
element matches. Repeatable (AND).\n\
- --where-tag T: only mutate files with this tag (nested matching: 'project' matches 'project/backend'). \
Repeatable (AND).\n\
SIDE EFFECTS: Modifies matched files on disk (unless --dry-run is passed).\n\
USE WHEN: You need to append items to list-type properties such as 'aliases' or 'authors' \
without overwriting the existing list."
)]
Append {
/// Target file(s) as positional argument(s) — alternative to --file
#[arg(value_name = "FILE", conflicts_with_all = ["glob", "file"])]
file_positional: Vec<String>,
/// Property to append to: K=V. Repeatable
#[arg(short, long = "property", value_name = "K=V", required = true)]
properties: Vec<String>,
/// Target file(s) (repeatable). Mutually exclusive with --glob
#[arg(short, long, conflicts_with = "glob")]
file: Vec<String>,
/// Glob pattern(s) for multiple files, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long, conflicts_with = "file")]
glob: Vec<String>,
/// Filter: only mutate files whose frontmatter property matches (repeatable, AND). Same syntax as find --property
#[arg(long = "where-property", value_name = "FILTER")]
where_properties: Vec<String>,
/// Filter: only mutate files with this tag (repeatable, AND). Same syntax as find --tag
#[arg(long = "where-tag", value_name = "TAG")]
where_tags: Vec<String>,
/// Preview changes without modifying any files
#[arg(long)]
dry_run: bool,
/// Validate new values against the schema from .hyalo.toml; reject writes that would
/// create lint errors. Implied by `validate_on_write = true` in [schema] config.
#[arg(long, alias = "strict")]
validate: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Manage saved views (named find filter sets stored in .hyalo.toml)
#[command(
long_about = "Manage saved views — named find queries stored in .hyalo.toml.\n\n\
Views let you save frequently used filter combinations under a name\n\
and recall them with `hyalo find --view <name>`. CLI flags passed alongside\n\
--view are merged on top — list filters extend, scalars override.\n\n\
Calling `hyalo views` without a subcommand defaults to `hyalo views list`.\n\n\
Subcommands:\n\
- list: Show all saved views and their filters (default).\n\
- set: Create or update a view.\n\
- remove: Delete a view.\n\n\
SIDE EFFECTS: 'set' and 'remove' modify .hyalo.toml. 'list' is read-only."
)]
Views {
#[command(subcommand)]
action: Option<ViewsAction>,
},
/// Detect and repair broken links across the vault
#[command(
long_about = "Detect and repair broken wikilinks and markdown links.\n\n\
Scans the vault for links that cannot be resolved to an existing file, \
then uses fuzzy matching (case-insensitive, extension mismatch, shortest-path, \
Jaro-Winkler) to find the best candidate replacement.\n\n\
Default behaviour is a dry run — no files are modified. Pass --apply to write fixes.\n\n\
OUTPUT: JSON object with broken/fixable/unfixable counts, per-fix details \
(source, line, old_target, new_target, strategy, confidence), and \
the list of links that could not be matched.\n\
SIDE EFFECTS: None unless --apply is passed.\n\n\
TIP: For read-only auditing, use 'hyalo summary' (link health overview)\n\
or 'hyalo find --broken-links' (list files with unresolved links)."
)]
Links {
#[command(subcommand)]
action: LinksAction,
},
/// Validate frontmatter properties against the `.hyalo.toml` schema (optional auto-fix)
#[command(
long_about = "Validate frontmatter properties against the schema defined in `.hyalo.toml`.\n\n\
Reads the `[schema.default]` and `[schema.types.*]` sections from `.hyalo.toml` to\n\
determine which properties are required, what types they should be, and which enum\n\
values are allowed. Reports violations at two severity levels:\n\n\
- error: schema violation (missing required property, wrong type, invalid enum value,\n\
pattern mismatch)\n\
- warn: soft issue (no 'type' property, no 'tags', property not declared in schema)\n\n\
When no `[schema]` section exists in `.hyalo.toml`, lint exits 0 with zero violations.\n\n\
INPUT: Optional FILE (positional or --file) or --glob to narrow scope.\n\
Without any file arguments, the entire vault is linted.\n\n\
OUTPUT: Text by default (per-file violations + summary line). Use --format json for a\n\
JSON payload (wrapped by the standard CLI envelope under `results`):\n\
{\"files\": [{\"file\": \"...\", \"violations\": [...]}], \"total\": N, \"fixes\": [...]}.\n\n\
AUTO-FIX: With --fix, `hyalo lint` attempts to repair fixable violations in place:\n\
\u{00a0} - Insert missing properties that have defaults in the schema ($today expands\n\
\u{00a0} to the current date).\n\
\u{00a0} - Correct close enum typos (Levenshtein distance <= 2) to the nearest value.\n\
\u{00a0} - Normalize date formats (e.g. 2026-4-9 -> 2026-04-09).\n\
\u{00a0} - Infer a missing `type` property when the file path matches a\n\
\u{00a0} [schema.types.*].filename-template.\n\
Missing required properties without defaults are reported, never fabricated.\n\
Use --dry-run to preview fixes without writing any files.\n\n\
EXIT CODES: 0 = clean (after fixes), 1 = errors remain, 2 = internal error.\n\n\
EXAMPLES:\n\
\u{00a0} hyalo lint\n\
\u{00a0} hyalo lint iterations/iteration-101-bm25.md\n\
\u{00a0} hyalo lint --glob \"iterations/*.md\"\n\
\u{00a0} hyalo lint --format json\n\
\u{00a0} hyalo lint --limit 10\n\
\u{00a0} hyalo lint --fix\n\
\u{00a0} hyalo lint --fix --dry-run\n\n\
SIDE EFFECTS: None without --fix. With --fix (and without --dry-run), mutated files\n\
are rewritten atomically, preserving body bytes and frontmatter key order.\n\n\
TIP: Run `hyalo summary` to see a one-line lint count across the whole vault."
)]
Lint {
/// Target file (relative to --dir) — positional form
#[arg(value_name = "FILE", conflicts_with_all = ["file", "glob", "type"])]
file_positional: Option<String>,
/// Target file(s) (repeatable). Mutually exclusive with --glob
#[arg(short, long, conflicts_with_all = ["glob", "type"])]
file: Vec<String>,
/// Glob pattern(s) to select files, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long, conflicts_with_all = ["file", "type"])]
glob: Vec<String>,
/// Restrict linting to files matching the named type's filename template.
/// Equivalent to --glob <template-as-glob>. Mutually exclusive with --file and --glob.
#[arg(long = "type", conflicts_with_all = ["file", "glob", "file_positional"])]
r#type: Option<String>,
/// Auto-remediate fixable violations (defaults, enum typos, date format, type inference)
#[arg(long)]
fix: bool,
/// With --fix, preview changes without writing files
#[arg(long, requires = "fix")]
dry_run: bool,
/// Maximum number of files to include in output.
/// Default cap is bypassed when --jq or --count is used
#[arg(short = 'n', long, value_parser = parse_limit)]
limit: Option<usize>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Manage document-type schemas in `.hyalo.toml`
#[command(
long_about = "Manage document-type schemas stored in `.hyalo.toml`.\n\n Type schemas define required properties, default values, property constraints,\n and filename templates for each document type.\n\n Calling `hyalo types` without a subcommand defaults to `hyalo types list`.\n\n Subcommands:\n - list: Show all defined types and their required fields (default).\n - show: Show the full schema for a single type.\n - remove: Delete a type entry.\n - set: Create or update a type schema (upsert). Auto-creates the type if it doesn't exist.\n\n TOML editing preserves comments and formatting.\n\n SIDE EFFECTS: remove/set modify .hyalo.toml. list and show are read-only."
)]
Types {
#[command(subcommand)]
action: Option<TypesAction>,
},
/// Generate shell completions for the given shell
#[command(
display_order = 900,
long_about = "Generate shell completion scripts.\n\n\
Prints a completion script for the specified shell to stdout.\n\
Source or install the output in your shell's completion directory.\n\n\
EXAMPLES:\n\
\u{00a0} bash: hyalo completion bash > ~/.local/share/bash-completion/completions/hyalo\n\
\u{00a0} zsh: hyalo completion zsh > ~/.local/share/zsh/site-functions/_hyalo\n\
\u{00a0} fish: hyalo completion fish > ~/.config/fish/completions/hyalo.fish\n\
\u{00a0} elvish: hyalo completion elvish > ~/.config/elvish/lib/completions/hyalo.elv\n\
\u{00a0} powershell: hyalo completion powershell > _hyalo.ps1\n\n\
SIDE EFFECTS: None (prints to stdout)."
)]
Completion {
/// Target shell
#[arg(value_enum)]
shell: clap_complete::Shell,
},
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)] // Set variant holds FindFilters by design; boxing would complicate dispatch
pub(crate) enum ViewsAction {
/// List all saved views
#[command(
long_about = "Show all saved views and their filter configurations.\n\n\
OUTPUT: JSON envelope with results (array of view objects) and total count.\n\
SIDE EFFECTS: None (read-only)."
)]
List,
/// Create or update a saved view
#[command(long_about = "Save a combination of find filters under a name.\n\n\
The view is stored in .hyalo.toml and can be recalled with `hyalo find --view <name>`.\n\
You can combine --view with additional CLI filters to extend or override the saved set.\n\
Overwrites if the view already exists.\n\n\
SIDE EFFECTS: Modifies .hyalo.toml.")]
Set {
/// View name (first positional arg)
#[arg(value_name = "NAME")]
name: String,
/// Optional BM25 search pattern to save with the view (second positional arg).
/// Example: `hyalo views set my-view "search terms" --tag foo`
#[arg(value_name = "PATTERN")]
pattern: Option<String>,
#[command(flatten)]
filters: FindFilters,
},
/// Delete a saved view
#[command(long_about = "Remove a saved view from .hyalo.toml.\n\n\
SIDE EFFECTS: Modifies .hyalo.toml.")]
Remove {
/// View name to delete
#[arg(value_name = "NAME")]
name: String,
},
}
#[derive(Subcommand)]
pub(crate) enum TypesAction {
/// List all defined types and their required fields (default)
#[command(
long_about = "List all type schemas defined in `.hyalo.toml`.\n\n OUTPUT: JSON envelope with results array and total count.\n SIDE EFFECTS: None (read-only)."
)]
List,
/// Show the full schema for a single type
#[command(
long_about = "Display the full merged schema for a named type.\n\n OUTPUT: JSON object with type name, required fields, defaults,\n filename template, and property constraints.\n SIDE EFFECTS: None (read-only)."
)]
Show {
/// Type name to display
#[arg(value_name = "TYPE")]
type_name: String,
},
/// Remove a type entry from `.hyalo.toml`
#[command(
long_about = "Remove a `[schema.types.<name>]` section from `.hyalo.toml`.\n\n Fails with a user error if the type does not exist.\n\n OUTPUT: JSON result with action and type name.\n SIDE EFFECTS: Modifies .hyalo.toml."
)]
Remove {
/// Type name to remove
#[arg(value_name = "TYPE")]
type_name: String,
},
/// Create or update a type schema's required fields, defaults, or property constraints
#[command(
long_about = "Create or update a type schema in `.hyalo.toml`. If the type doesn't exist, it is created automatically.\n\n When creating the first type (i.e. the [schema] section is new), `validate_on_write = true` is set automatically so that `set`/`append` enforce schema constraints by default.\n\n All mutation flags are optional and combinable in a single invocation.\n\n FLAGS:\n - --required <fields>: comma-separated required property names to add (repeatable).\n - --default key=value: set a default; auto-applied to files missing the property.\n - --property-type key=type: set a type constraint (string/date/number/boolean/list/enum).\n - --property-values key=val1,val2,...: set enum values; implies type=enum.\n - --filename-template <template>: set the filename template for this type.\n - --dry-run: preview changes without writing anything.\n\n OUTPUT: JSON result with action, dry_run, defaults_applied, constraint_violations.\n SIDE EFFECTS: Modifies .hyalo.toml and may write to vault files (unless --dry-run)."
)]
Set {
/// Type name to update
#[arg(value_name = "TYPE")]
type_name: String,
/// Comma-separated list of required property names to add (repeatable)
#[arg(long, value_name = "FIELDS")]
required: Vec<String>,
/// Set a default value: key=value (repeatable)
#[arg(long, value_name = "KEY=VALUE")]
default: Vec<String>,
/// Set the property type constraint: key=type (repeatable)
#[arg(long, value_name = "KEY=TYPE")]
property_type: Vec<String>,
/// Set enum values for a property: key=val1,val2,... (repeatable)
#[arg(long, value_name = "KEY=VALUES")]
property_values: Vec<String>,
/// Set the filename template for new files of this type
#[arg(long, value_name = "TEMPLATE")]
filename_template: Option<String>,
/// Preview changes without writing any files
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand)]
pub(crate) enum LinksAction {
/// Auto-repair broken links using fuzzy matching
#[command(long_about = "Find broken links and propose (or apply) fixes.\n\n\
Matching strategies (in priority order):\n\
1. Case-insensitive exact match\n\
2. Extension mismatch (.md present/absent)\n\
3. Unique stem match anywhere in the vault (shortest-path)\n\
4. Jaro-Winkler fuzzy match above --threshold\n\n\
Use --apply to write fixes to disk. Without --apply, only a dry-run report is printed.\n\n\
Case-mismatch detection: when case-insensitive resolution is active (controlled by\n\
`[links] case_insensitive` in .hyalo.toml — \"auto\", \"true\", or \"false\"), broken links\n\
that differ only in casing from an on-disk file are reported as case_mismatches and\n\
rewritten to the canonical casing when --apply is used. On macOS and Windows,\n\
\"auto\" (the default) enables this automatically.")]
Fix {
/// Preview changes without modifying files (default when --apply is omitted)
#[arg(long)]
dry_run: bool,
/// Apply fixes to files on disk
#[arg(long, conflicts_with = "dry_run")]
apply: bool,
/// Minimum similarity threshold for fuzzy matching (0.0–1.0)
#[arg(long, default_value = "0.8", value_parser = parse_threshold)]
threshold: f64,
/// Glob pattern(s) to filter which files to check, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long)]
glob: Vec<String>,
/// Ignore broken links whose target contains any of these substrings (repeatable).
/// Useful for skipping Hugo template links, external paths, etc.
#[arg(long)]
ignore_target: Vec<String>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Auto-link unlinked mentions of known page titles
#[command(
long_about = "Scan body text for unlinked mentions of known page titles and convert them to [[wikilinks]].\n\n\
Title sources (in priority order):\n\
1. Filename stems (without .md)\n\
2. Frontmatter `title` property\n\
3. Frontmatter `aliases` property (list of alternate names)\n\n\
Exclusion zones: frontmatter, fenced code blocks, inline code,\n\
existing [[wikilinks]] and [markdown](links), headings, comment fences (%%), self-links.\n\n\
Filtering options:\n\
--first-only Only emit the first mention of each target per source file\n\
--exclude-title Exclude specific titles (repeatable, case-insensitive)\n\
--exclude-target-glob Exclude target pages by vault-relative path glob (repeatable)\n\n\
Without --apply, prints a dry-run report. Pass --apply to write changes.\n\n\
COMMON MISTAKES:\n\
- --exclude-target-glob filters by file path, --exclude-title filters by title text. \
Use --exclude-target-glob for directories (e.g. 'templates/*'), --exclude-title for words.\n\
- Ambiguous titles (same title from 2+ files) are automatically skipped. Use --exclude-title \
to suppress specific titles, or rename one of the source files.\n\
- Short titles match too aggressively. Use --min-length (default 3) to skip common short words.\n\
- Without --first-only, every mention is linked. This can over-link — use --first-only for prose."
)]
Auto {
/// Preview changes without modifying files (default when --apply is omitted)
#[arg(long)]
dry_run: bool,
/// Apply changes to files on disk
#[arg(long, conflicts_with = "dry_run")]
apply: bool,
/// Minimum title length to consider (skip short common words)
#[arg(long, default_value = "3")]
min_length: usize,
/// Titles to exclude from matching (repeatable, case-insensitive)
#[arg(long)]
exclude_title: Vec<String>,
/// Only emit the first match of each target title per source file
#[arg(long)]
first_only: bool,
/// Exclude target pages whose vault-relative path matches a glob pattern (repeatable)
#[arg(long)]
exclude_target_glob: Vec<String>,
/// Restrict to a single file (vault-relative path)
#[arg(long, conflicts_with = "glob")]
file: Option<String>,
/// Glob pattern(s) to filter which files to scan (repeatable); prefix '!' to negate
#[arg(short, long, conflicts_with = "file")]
glob: Vec<String>,
#[command(flatten)]
index_flags: IndexFlags,
},
}
#[derive(Subcommand)]
pub(crate) enum TaskAction {
/// Show task details for one or more tasks (read-only)
#[command(long_about = "Show task details for one or more tasks.\n\n\
INPUT: FILE (positional or --file) and one of: --line (repeatable), --section <heading>, or --all.\n\
OUTPUT: wrapped in {\"results\": <task>, ...} envelope; single object for one task, array for multiple.\n\
SIDE EFFECTS: None (read-only).\n\
USE WHEN: You need to inspect task status before toggling or updating.\n\n\
EXAMPLES:\n \
hyalo task read note.md --line 5\n \
hyalo task read note.md --line 5,7,9\n \
hyalo task read note.md --section Tasks\n \
hyalo task read note.md --all\n \
hyalo task read --file note.md --line 5")]
Read {
/// File containing the task(s) (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// File containing the task(s) (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// 1-based line number(s). Comma-separated or repeatable: --line 5,7,9 or --line 5 --line 7
#[arg(short, long, value_delimiter = ',', action = clap::ArgAction::Append, conflicts_with_all = ["section", "all"])]
line: Vec<usize>,
/// Select all tasks under a heading (case-insensitive substring, ##-pinned, or /regex/)
#[arg(long, conflicts_with_all = ["line", "all"])]
section: Option<String>,
/// Select all tasks in the file
#[arg(long, conflicts_with_all = ["line", "section"])]
all: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Toggle task completion: [ ] -> [x], [x]/[X] -> [ ], custom -> [x]
#[command(
long_about = "Toggle task completion: [ ] -> [x], [x]/[X] -> [ ], custom -> [x].\n\n\
INPUT: FILE (positional or --file) and one of: --line (repeatable), --section <heading>, or --all.\n\
OUTPUT: wrapped in {\"results\": <task>, ...} envelope; single object for one task, array for multiple.\n\
SIDE EFFECTS: Modifies the file on disk (rewrites the checkbox character).\n\
USE WHEN: You need to mark tasks as done or re-open completed tasks.\n\n\
EXAMPLES:\n \
hyalo task toggle note.md --line 5\n \
hyalo task toggle note.md --line 5,7,9\n \
hyalo task toggle note.md --section Tasks\n \
hyalo task toggle note.md --all\n \
hyalo task toggle --file note.md --line 5"
)]
Toggle {
/// File containing the task(s) (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// File containing the task(s) (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// 1-based line number(s). Comma-separated or repeatable: --line 5,7,9 or --line 5 --line 7
#[arg(short, long, value_delimiter = ',', action = clap::ArgAction::Append, conflicts_with_all = ["section", "all"])]
line: Vec<usize>,
/// Select all tasks under a heading (case-insensitive substring, ##-pinned, or /regex/)
#[arg(long, conflicts_with_all = ["line", "all"])]
section: Option<String>,
/// Select all tasks in the file
#[arg(long, conflicts_with_all = ["line", "section"])]
all: bool,
/// Preview the toggle result without writing the file
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Set a custom single-character status on one or more tasks
#[command(
name = "set",
alias = "set-status",
long_about = "Set a custom single-character status on one or more task checkboxes.\n\n\
INPUT: FILE (positional or --file), --status (single char), and one of: --line (repeatable), --section <heading>, or --all.\n\
OUTPUT: wrapped in {\"results\": <task>, ...} envelope; single object for one task, array for multiple.\n\
SIDE EFFECTS: Modifies the file on disk unless --dry-run is passed.\n\
USE WHEN: You need to set a non-standard status like '?' (question), '-' (cancelled), or '!' (important).\n\n\
EXAMPLES:\n \
hyalo task set note.md --line 5 --status '?'\n \
hyalo task set note.md --line 5,7 --status '-'\n \
hyalo task set note.md --section Tasks --status '-'\n \
hyalo task set note.md --all --status x\n \
hyalo task set note.md --line 5 --status '?' --dry-run"
)]
Set {
/// File containing the task(s) (relative to --dir) — positional form
#[arg(value_name = "FILE")]
file_positional: Option<String>,
/// File containing the task(s) (relative to --dir) — flag form
#[arg(short, long, value_name = "FILE", conflicts_with = "file_positional")]
file: Option<String>,
/// 1-based line number(s). Comma-separated or repeatable: --line 5,7,9 or --line 5 --line 7
#[arg(short, long, value_delimiter = ',', action = clap::ArgAction::Append, conflicts_with_all = ["section", "all"])]
line: Vec<usize>,
/// Select all tasks under a heading (case-insensitive substring, ##-pinned, or /regex/)
#[arg(long, conflicts_with_all = ["line", "all"])]
section: Option<String>,
/// Select all tasks in the file
#[arg(long, conflicts_with_all = ["line", "section"])]
all: bool,
/// Single character to set as the task status (e.g. '?', '-', '!')
#[arg(short, long)]
status: String,
/// Preview which tasks would be changed without modifying the file
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
}
#[derive(Subcommand)]
pub(crate) enum PropertiesAction {
/// Show unique property names with types and file counts (read-only)
#[command(
long_about = "Aggregate summary of frontmatter properties across matched files.\n\n\
OUTPUT: List of unique property names, their inferred type, and how many files contain them.\n\
SCOPE: Scans all .md files under --dir unless narrowed with --glob.\n\
SIDE EFFECTS: None (read-only).\n\
USE WHEN: You need to discover what properties exist or audit frontmatter across a vault."
)]
Summary {
/// Glob pattern(s) to select files (repeatable); prefix '!' to negate
#[arg(short, long)]
glob: Vec<String>,
/// Maximum number of results to return (0 = unlimited).
/// Default cap is bypassed when --jq or --count is used
#[arg(short = 'n', long, value_parser = parse_limit)]
limit: Option<usize>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Rename a property key across all matched files
#[command(
long_about = "Rename a frontmatter property key across matched files.\n\n\
Preserves the value and type. Skips files where the target key already exists (conflict).\n\
SIDE EFFECTS: Modifies matched files on disk."
)]
Rename {
/// Property key to rename from
#[arg(long)]
from: String,
/// Property key to rename to
#[arg(long)]
to: String,
/// Glob pattern(s) to scope which files to scan (repeatable); prefix '!' to negate
#[arg(short, long)]
glob: Vec<String>,
/// Preview changes without writing to disk
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
}
#[derive(Subcommand)]
pub(crate) enum TagsAction {
/// Show unique tags with file counts (read-only)
#[command(long_about = "Aggregate summary of tags across matched files.\n\n\
OUTPUT: Each unique tag and how many files contain it. Tags are compared case-insensitively.\n\
SCOPE: Scans all .md files under --dir unless narrowed with --glob.\n\
SIDE EFFECTS: None (read-only).\n\
USE WHEN: You need to see which tags exist, find popular/orphan tags, or audit tag taxonomy.")]
Summary {
/// Glob pattern(s) to filter which files to scan, relative to --dir (repeatable); prefix '!' to negate
#[arg(short, long)]
glob: Vec<String>,
/// Maximum number of results to return (0 = unlimited).
/// Default cap is bypassed when --jq or --count is used
#[arg(short = 'n', long, value_parser = parse_limit)]
limit: Option<usize>,
#[command(flatten)]
index_flags: IndexFlags,
},
/// Rename a tag across all matched files
#[command(long_about = "Rename a tag across all matched files.\n\n\
Atomic per-file: if the new tag already exists on a file, only the old tag is removed.\n\
SIDE EFFECTS: Modifies matched files on disk.")]
Rename {
/// Tag to rename from
#[arg(long)]
from: String,
/// Tag to rename to
#[arg(long)]
to: String,
/// Glob pattern(s) to scope which files to scan (repeatable); prefix '!' to negate
#[arg(short, long)]
glob: Vec<String>,
/// Preview changes without writing to disk
#[arg(long)]
dry_run: bool,
#[command(flatten)]
index_flags: IndexFlags,
},
}