Skip to main content

hyalo_cli/
run.rs

1use std::process;
2
3use clap::{CommandFactory, FromArgMatches};
4
5use crate::cli::args::{Cli, Commands, FindFilters, IndexFlags};
6use crate::cli::help::{filter_examples, filter_long_help};
7use crate::commands::init as init_commands;
8use crate::dispatch::{CommandContext, dispatch};
9use crate::error::AppError;
10use crate::hints::{CommonHintFlags, HintContext, HintSource};
11use crate::output::{CommandOutcome, Format};
12use crate::output_pipeline::{COUNT_UNSUPPORTED_ERROR, OutputPipeline};
13use hyalo_core::index::SnapshotIndex;
14
15/// Extract the effective index path from whichever subcommand is active.
16///
17/// Walks the command tree and retrieves `IndexFlags` from the matching arm,
18/// then delegates to `IndexFlags::effective_index_path`.
19/// Relative `--index-file` paths are resolved against the current working directory.
20/// Returns `None` for commands that do not carry `IndexFlags`.
21fn effective_index_path_for(
22    cmd: &Commands,
23    vault_dir: &std::path::Path,
24) -> Option<std::path::PathBuf> {
25    use crate::cli::args::{LinksAction, PropertiesAction, TagsAction, TaskAction};
26
27    let flags: Option<&IndexFlags> = match cmd {
28        Commands::Find { index_flags, .. }
29        | Commands::Summary { index_flags, .. }
30        | Commands::Backlinks { index_flags, .. }
31        | Commands::Set { index_flags, .. }
32        | Commands::Remove { index_flags, .. }
33        | Commands::Append { index_flags, .. }
34        | Commands::Mv { index_flags, .. }
35        | Commands::Read { index_flags, .. }
36        | Commands::Lint { index_flags, .. } => Some(index_flags),
37        Commands::Tags { action } => match action {
38            Some(
39                TagsAction::Summary { index_flags, .. } | TagsAction::Rename { index_flags, .. },
40            ) => Some(index_flags),
41            None => None,
42        },
43        Commands::Properties { action } => match action {
44            Some(
45                PropertiesAction::Summary { index_flags, .. }
46                | PropertiesAction::Rename { index_flags, .. },
47            ) => Some(index_flags),
48            None => None,
49        },
50        Commands::Links { action } => match action {
51            LinksAction::Fix { index_flags, .. } | LinksAction::Auto { index_flags, .. } => {
52                Some(index_flags)
53            }
54        },
55        Commands::Task { action } => match action {
56            TaskAction::Read { index_flags, .. }
57            | TaskAction::Toggle { index_flags, .. }
58            | TaskAction::Set { index_flags, .. } => Some(index_flags),
59        },
60        Commands::CreateIndex { .. }
61        | Commands::DropIndex { .. }
62        | Commands::Init { .. }
63        | Commands::Deinit
64        | Commands::Completion { .. }
65        | Commands::Views { .. }
66        | Commands::Types { .. } => None,
67    };
68
69    let raw = flags?.effective_index_path(vault_dir)?;
70    // Relative --index-file paths are resolved against CWD.
71    // Bare --index already returns an absolute-or-relative-to-vault path from
72    // effective_index_path(), so only resolve when the path is still relative
73    // and it came from --index-file (not bare --index).
74    let resolved = if raw.is_relative() && flags.and_then(|f| f.index_file.as_ref()).is_some() {
75        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
76        cwd.join(&raw)
77    } else {
78        raw
79    };
80    Some(resolved)
81}
82
83/// Derive the task selector string for hint context.
84fn task_selector(line: &[usize], section: Option<&String>, all: bool) -> Option<String> {
85    if all {
86        Some("all".to_owned())
87    } else if let Some(s) = section {
88        Some(format!("section:{s}"))
89    } else if line.len() > 1 {
90        Some("lines".to_owned())
91    } else {
92        None
93    }
94}
95
96#[allow(clippy::too_many_lines)]
97pub fn run() {
98    match run_inner() {
99        Ok(()) => {
100            crate::warn::flush_summary();
101        }
102        Err(e) => {
103            crate::warn::flush_summary();
104            let code = match e {
105                AppError::User(msg) => {
106                    if !msg.is_empty() {
107                        eprintln!("{msg}");
108                    }
109                    1
110                }
111                AppError::Internal(err) => {
112                    let s = err.to_string();
113                    if !s.is_empty() {
114                        eprintln!("Error: {err}");
115                    }
116                    2
117                }
118                AppError::Clap(err) => {
119                    let code = err.exit_code();
120                    let _ = err.print();
121                    code
122                }
123                AppError::Exit(code) => code,
124            };
125            process::exit(code);
126        }
127    }
128}
129
130#[allow(clippy::too_many_lines)]
131fn run_inner() -> Result<(), AppError> {
132    // Pre-scan for --quiet / -q so config-loading warnings are also suppressed.
133    let early_quiet = std::env::args().any(|a| a == "--quiet" || a == "-q");
134    crate::warn::init(early_quiet);
135
136    // Load per-project config from .hyalo.toml in CWD before parsing args.
137    // This lets us hide flags that already have config-provided defaults,
138    // keeping `--help` output focused on what the user actually needs to set.
139    let config = crate::config::load_config();
140
141    // Build the clap Command and hide global flags that are already covered by
142    // the project config.  `mut_arg` is scoped to the root command, but because
143    // both `--dir` and `--format` are declared `global = true`, hiding them on
144    // the root is sufficient for --help at every level.
145    let hide_dir = config
146        .dir
147        .components()
148        .ne(std::path::Path::new(".").components());
149    let hide_format = config.format != "json";
150
151    let mut cmd = Cli::command();
152    if hide_dir {
153        cmd = cmd.mut_arg("dir", |a| a.hide(true));
154    }
155    if hide_format {
156        cmd = cmd.mut_arg("format", |a| a.hide(true));
157    }
158
159    // Apply runtime-filtered help text so that examples and cookbook entries
160    // that reference config-defaulted flags are stripped from help output.
161    // `after_help` is shown by `-h`; `after_long_help` is shown by `--help`.
162    cmd = cmd
163        .after_help(filter_examples(hide_dir, hide_format))
164        .after_long_help(filter_long_help(hide_dir, hide_format));
165
166    // Global args (--format, --jq, etc.) are only defined on the root Command
167    // in clap derive — they aren't propagated to subcommands until parse time.
168    // We can't use mut_subcommand to hide them from `init --help` because
169    // they don't exist on the subcommand Command node yet.  This is a known
170    // clap limitation with `global = true` derive args.
171    let raw_args: Vec<String> = std::env::args().collect();
172    let matches = match cmd.try_get_matches_from(raw_args.iter().map(String::as_str)) {
173        Ok(m) => m,
174        Err(e) => {
175            // Intercept `--filter` before falling through to clap's built-in
176            // suggestion, which picks `--file` (closest by Levenshtein distance).
177            // Users almost always mean `--property` here.
178            if e.kind() == clap::error::ErrorKind::UnknownArgument
179                && crate::suggest::unknown_arg_is(&e, "--filter")
180            {
181                eprintln!(
182                    "error: unexpected argument '--filter' found\n\n\
183                     tip: did you mean '--property'?\n\n\
184                     Example: hyalo find --property status=planned\n"
185                );
186                return Err(AppError::Exit(2));
187            }
188
189            // Intercept `--tag` / `-t` on the `append` subcommand. Tags are
190            // scalar list items, so there is nothing to "append" in the
191            // property-level sense — `hyalo set --tag T` is the right tool.
192            // Surface that hint instead of clap's generic unknown-arg error.
193            //
194            // Gate the hint on the *resolved* top-level subcommand rather
195            // than a substring scan, so unrelated commands whose args happen
196            // to include `append` (e.g. `hyalo find append`) don't get the
197            // `hyalo append`-specific message.
198            if e.kind() == clap::error::ErrorKind::UnknownArgument
199                && crate::suggest::top_level_subcommand(&raw_args, &Cli::command())
200                    == Some("append")
201                && (crate::suggest::unknown_arg_is(&e, "--tag")
202                    || crate::suggest::unknown_arg_is(&e, "-t"))
203            {
204                eprintln!(
205                    "error: `hyalo append` does not accept --tag (tags are scalar list items, not appendable)\n\n\
206                     hint: use `hyalo set <file> --tag <tag>` to add a tag\n"
207                );
208                return Err(AppError::Exit(2));
209            }
210
211            // Only attempt subcommand suggestions when clap couldn't recognise a
212            // flag or subcommand — this avoids misleading tips for other error kinds.
213            if matches!(
214                e.kind(),
215                clap::error::ErrorKind::InvalidSubcommand | clap::error::ErrorKind::UnknownArgument
216            ) && let Some(suggestion) =
217                crate::suggest::suggest_subcommand_correction(&raw_args, &Cli::command())
218            {
219                eprintln!("{e}\n  tip: did you mean:\n\n    {suggestion}\n");
220                return Err(AppError::Exit(2));
221            }
222
223            // Suggest --version / --help when the user types a close misspelling
224            // as a bare subcommand (e.g. `hyalo versio`, `hyalo hep`).
225            // BUT: scope this to top-level subcommands only — don't fire when the
226            // parent context is already a known subcommand like `properties`.
227            if e.kind() == clap::error::ErrorKind::InvalidSubcommand {
228                use clap::error::{ContextKind, ContextValue};
229                let parent_is_properties = raw_args
230                    .iter()
231                    .any(|a| a == "properties" || a == "property");
232                if let Some(invalid) = e.context().find_map(|(k, v)| {
233                    if k == ContextKind::InvalidSubcommand {
234                        if let ContextValue::String(s) = v {
235                            Some(s.as_str())
236                        } else {
237                            None
238                        }
239                    } else {
240                        None
241                    }
242                }) {
243                    // Special hint for `hyalo properties <something>` typos.
244                    if parent_is_properties {
245                        eprintln!(
246                            "{e}\n  hint: 'properties' has subcommands; try 'hyalo properties summary' or 'hyalo properties rename'\n"
247                        );
248                        return Err(AppError::Exit(2));
249                    }
250                    for (target, suggestion) in [("version", "--version"), ("help", "--help")] {
251                        if strsim::damerau_levenshtein(invalid, target) <= 2 {
252                            eprintln!("{e}\n  tip: did you mean `hyalo {suggestion}`?\n");
253                            return Err(AppError::Exit(2));
254                        }
255                    }
256                }
257            }
258
259            return Err(AppError::Clap(e));
260        }
261    };
262    let mut cli = match Cli::from_arg_matches(&matches) {
263        Ok(c) => c,
264        Err(e) => return Err(AppError::Clap(e)),
265    };
266
267    // Re-apply quiet flag from the fully-parsed CLI (the early pre-scan
268    // covers the common case but this ensures correctness after full parsing).
269    crate::warn::init(cli.quiet);
270
271    // `init` operates on CWD directly and needs no config or format resolution.
272    // Dispatch it before the rest of the setup.
273    // The global --dir flag is used as the dir value for .hyalo.toml.
274    // Reject --count early — init is not a list command.
275    if cli.count
276        && matches!(
277            cli.command,
278            Commands::Init { .. } | Commands::Deinit | Commands::Completion { .. }
279        )
280    {
281        eprintln!("{COUNT_UNSUPPORTED_ERROR}");
282        return Err(AppError::Exit(2));
283    }
284    if let Commands::Init { claude } = cli.command {
285        let init_dir = cli.dir.as_deref().and_then(|p| p.to_str());
286        match init_commands::run_init(init_dir, claude) {
287            Ok(CommandOutcome::Success { output, .. } | CommandOutcome::RawOutput(output)) => {
288                println!("{output}");
289                return Ok(());
290            }
291            Ok(CommandOutcome::UserError(output)) => return Err(AppError::User(output)),
292            Err(e) => return Err(AppError::Internal(e)),
293        }
294    }
295    if let Commands::Deinit = cli.command {
296        match init_commands::run_deinit() {
297            Ok(CommandOutcome::Success { output, .. } | CommandOutcome::RawOutput(output)) => {
298                println!("{output}");
299                return Ok(());
300            }
301            Ok(CommandOutcome::UserError(output)) => return Err(AppError::User(output)),
302            Err(e) => return Err(AppError::Internal(e)),
303        }
304    }
305    if let Commands::Completion { shell } = cli.command {
306        let mut cmd = Cli::command();
307        clap_complete::generate(shell, &mut cmd, "hyalo", &mut std::io::stdout());
308        return Ok(());
309    }
310    // Merge: CLI args override config, config overrides hardcoded defaults.
311    // Track whether --dir was explicitly passed (not from config) so hints
312    // can omit it when the user relies on .hyalo.toml.
313    let dir_from_cli = cli.dir.is_some();
314    let format_from_cli = cli.format.is_some();
315    let hints_from_cli = cli.hints;
316    // Determine the effective vault directory and the config to use:
317    //
318    // - When --dir is explicitly provided on the CLI, validate first, then
319    //   reload .hyalo.toml from the target directory so its schema, format,
320    //   hints, site_prefix, and search config apply — not the caller's CWD
321    //   config.
322    // - Otherwise, keep the CWD config (already loaded) and use its dir.
323    let (dir, config) = if let Some(cli_dir) = cli.dir {
324        // Validate before loading config to avoid misleading file-read warnings.
325        if !cli_dir.exists() {
326            return Err(AppError::User(format!(
327                "Error: --dir path '{}' does not exist.",
328                cli_dir.display()
329            )));
330        }
331        if cli_dir.is_file() {
332            return Err(AppError::User(format!(
333                "Error: --dir path '{}' is a file, not a directory. Use --file to target a single file.",
334                cli_dir.display()
335            )));
336        }
337        let target_config = crate::config::load_config_from(&cli_dir);
338        (cli_dir, target_config)
339    } else {
340        let vault_dir = config.dir.clone();
341        (vault_dir, config)
342    };
343    // The directory where .hyalo.toml lives. Views/types are stored there.
344    let config_dir = config.config_dir.clone();
345
346    // Validate that the resolved dir exists and is a directory (for the
347    // non-CLI case where dir comes from .hyalo.toml).
348    if !dir.exists() {
349        return Err(AppError::User(format!(
350            "Error: --dir path '{}' does not exist.",
351            dir.display()
352        )));
353    }
354    if dir.is_file() {
355        return Err(AppError::User(format!(
356            "Error: --dir path '{}' is a file, not a directory. Use --file to target a single file.",
357            dir.display()
358        )));
359    }
360
361    // Derive site_prefix with tri-state precedence:
362    //
363    //   1. CLI --site-prefix flag  (present → use it; empty string = explicit disable)
364    //   2. `site_prefix` in .hyalo.toml  (same: empty string = explicit disable)
365    //   3. Auto-derive from canonicalized dir's last path component
366    //      (only runs when neither 1 nor 2 is present)
367    //
368    // Empty strings in (1) and (2) short-circuit the chain and result in
369    // site_prefix = None, suppressing all absolute-link resolution.
370    let site_prefix_owned: Option<String> = if cli.site_prefix.is_some() {
371        // Explicit CLI flag wins — empty string intentionally disables prefix.
372        cli.site_prefix.filter(|s| !s.is_empty())
373    } else if config.site_prefix.is_some() {
374        // Config file override — empty string intentionally disables prefix.
375        config.site_prefix.filter(|s| !s.is_empty())
376    } else {
377        // Auto-derive from the last component of the resolved dir.
378        match std::fs::canonicalize(&dir) {
379            Ok(canonical) => canonical
380                .file_name()
381                .and_then(|n| n.to_str())
382                .map(std::borrow::ToOwned::to_owned),
383            Err(_) => {
384                // canonicalize can still fail on valid directories (e.g. broken
385                // symlink chains on some platforms). Fall back to the raw path
386                // component rather than losing the prefix entirely.
387                dir.file_name()
388                    .and_then(|n| n.to_str())
389                    .filter(|s| *s != ".")
390                    .map(std::borrow::ToOwned::to_owned)
391            }
392        }
393    };
394    let site_prefix = site_prefix_owned.as_deref();
395    // CLI --format is already validated by Clap; fall back to config (String) with runtime parse.
396    let format = if let Some(f) = cli.format {
397        f
398    } else if let Some(fmt) = Format::from_str_opt(&config.format) {
399        fmt
400    } else {
401        eprintln!(
402            "Invalid output format '{}' in .hyalo.toml; supported formats are: json, text",
403            config.format
404        );
405        return Err(AppError::Exit(2));
406    };
407    let hints_flag = if cli.hints {
408        true
409    } else if cli.no_hints {
410        false
411    } else {
412        config.hints
413    };
414
415    // Resolve --view: load the named view from .hyalo.toml and merge CLI overrides.
416    if let Commands::Find {
417        view: Some(ref view_name),
418        ref mut filters,
419        ..
420    } = cli.command
421    {
422        let views = crate::commands::views::load_views(&config_dir);
423        match views.get(view_name) {
424            Some(base) => {
425                let overlay = std::mem::take(filters);
426                *filters = base.clone();
427                filters.merge_from(&overlay);
428            }
429            None => {
430                return Err(AppError::User(format!(
431                    "Error: unknown view '{view_name}'\n\n  tip: run 'hyalo views list' to see available views"
432                )));
433            }
434        }
435    }
436
437    // If the CLI didn't supply a pattern but the view did, propagate it.
438    // Skip when --regexp is active — BM25 pattern and regex are mutually exclusive
439    // (clap enforces this for CLI args, but a view's pattern bypasses clap).
440    if let Commands::Find {
441        ref mut pattern,
442        ref filters,
443        ..
444    } = cli.command
445        && pattern.is_none()
446        && filters.regexp.is_none()
447        && let Some(ref view_pattern) = filters.pattern
448    {
449        *pattern = Some(view_pattern.clone());
450    }
451
452    // --jq operates on JSON, so it conflicts with an explicit --format text.
453    let jq_filter = cli.jq.as_deref();
454
455    // `read` defaults to text output (unlike other commands which default to json).
456    // Skip the override when --jq is active (jq needs JSON).
457    let format = if !format_from_cli
458        && jq_filter.is_none()
459        && matches!(cli.command, Commands::Read { .. })
460    {
461        Format::Text
462    } else {
463        format
464    };
465    // --count replaces the entire output pipeline, so check its conflicts first.
466    if cli.count && jq_filter.is_some() {
467        eprintln!("Error: --count cannot be combined with --jq");
468        eprintln!(
469            "  --count prints the bare total; --jq applies a custom filter — use one or the other"
470        );
471        return Err(AppError::Exit(2));
472    }
473    if jq_filter.is_some() && format != Format::Json {
474        eprintln!("Error: --jq cannot be combined with --format {format}");
475        eprintln!("  --jq always operates on JSON output; drop --format or use --format json");
476        return Err(AppError::Exit(2));
477    }
478    // Always force JSON internally so the output pipeline can wrap results in the
479    // envelope.  The user-requested format is applied by the pipeline afterwards.
480    let effective_format = Format::Json;
481
482    // Build hint context before the command dispatch.
483    // Only include CLI-explicit flags in hints — config values are inherited
484    // automatically when the user runs the hint command from the same CWD.
485    let hint_ctx = if hints_flag && jq_filter.is_none() {
486        // Capture the three global flags that every HintContext arm needs.
487        // Computed once here so each arm can call HintContext::from_common
488        // instead of repeating the same three field assignments.
489        let common = CommonHintFlags {
490            dir: if dir_from_cli {
491                dir.to_str()
492                    .map(std::borrow::ToOwned::to_owned)
493                    .filter(|s| s != ".")
494            } else {
495                None
496            },
497            format: if format_from_cli {
498                Some(format.to_string())
499            } else {
500                None
501            },
502            hints: hints_from_cli,
503        };
504
505        match &cli.command {
506            Commands::Summary { glob, .. } => {
507                let mut ctx = HintContext::from_common(HintSource::Summary, &common);
508                ctx.glob.clone_from(glob);
509                Some(ctx)
510            }
511            Commands::Properties {
512                action: Some(crate::cli::args::PropertiesAction::Summary { glob, limit, .. }),
513            } => {
514                let mut ctx = HintContext::from_common(HintSource::PropertiesSummary, &common);
515                ctx.glob.clone_from(glob);
516                ctx.has_limit = limit.is_some();
517                Some(ctx)
518            }
519            Commands::Tags {
520                action: Some(crate::cli::args::TagsAction::Summary { glob, limit, .. }),
521            } => {
522                let mut ctx = HintContext::from_common(HintSource::TagsSummary, &common);
523                ctx.glob.clone_from(glob);
524                ctx.has_limit = limit.is_some();
525                Some(ctx)
526            }
527            Commands::Tags { action: None } => {
528                // Bare `hyalo tags` defaults to summary with no glob.
529                Some(HintContext::from_common(HintSource::TagsSummary, &common))
530            }
531            Commands::Find {
532                pattern,
533                file_positional,
534                view,
535                filters:
536                    FindFilters {
537                        glob,
538                        regexp,
539                        properties,
540                        tag,
541                        task,
542                        file,
543                        fields,
544                        sort,
545                        limit,
546                        sections,
547                        ..
548                    },
549                ..
550            } => {
551                // Merge positional files for hint context (view merging happens later)
552                let file = if file_positional.is_empty() {
553                    file
554                } else {
555                    file_positional
556                };
557                let mut ctx = HintContext::from_common(HintSource::Find, &common);
558                ctx.glob.clone_from(glob);
559                ctx.fields.clone_from(fields);
560                ctx.sort.clone_from(sort);
561                ctx.has_limit = limit.is_some();
562                ctx.has_body_search = pattern.is_some();
563                ctx.body_pattern.clone_from(pattern);
564                ctx.has_regex_search = regexp.is_some();
565                ctx.property_filters.clone_from(properties);
566                ctx.tag_filters.clone_from(tag);
567                ctx.task_filter.clone_from(task);
568                ctx.file_targets.clone_from(file);
569                ctx.section_filters.clone_from(sections);
570                ctx.view_name.clone_from(view);
571                Some(ctx)
572            }
573            Commands::Set {
574                file_positional,
575                file,
576                glob,
577                dry_run,
578                ..
579            } => {
580                let mut ctx = HintContext::from_common(HintSource::Set, &common);
581                ctx.glob.clone_from(glob);
582                let src = if file_positional.is_empty() {
583                    file
584                } else {
585                    file_positional
586                };
587                ctx.file_targets.clone_from(src);
588                ctx.dry_run = *dry_run;
589                Some(ctx)
590            }
591            Commands::Remove {
592                file_positional,
593                file,
594                glob,
595                dry_run,
596                ..
597            } => {
598                let mut ctx = HintContext::from_common(HintSource::Remove, &common);
599                ctx.glob.clone_from(glob);
600                let src = if file_positional.is_empty() {
601                    file
602                } else {
603                    file_positional
604                };
605                ctx.file_targets.clone_from(src);
606                ctx.dry_run = *dry_run;
607                Some(ctx)
608            }
609            Commands::Append {
610                file_positional,
611                file,
612                glob,
613                dry_run,
614                ..
615            } => {
616                let mut ctx = HintContext::from_common(HintSource::Append, &common);
617                ctx.glob.clone_from(glob);
618                let src = if file_positional.is_empty() {
619                    file
620                } else {
621                    file_positional
622                };
623                ctx.file_targets.clone_from(src);
624                ctx.dry_run = *dry_run;
625                Some(ctx)
626            }
627            Commands::Read {
628                file_positional,
629                file,
630                ..
631            } => {
632                let mut ctx = HintContext::from_common(HintSource::Read, &common);
633                if let Some(f) = file_positional.as_ref().or(file.as_ref()) {
634                    ctx.file_targets = vec![f.clone()];
635                }
636                Some(ctx)
637            }
638            Commands::Backlinks {
639                file_positional,
640                file,
641                limit,
642                ..
643            } => {
644                let mut ctx = HintContext::from_common(HintSource::Backlinks, &common);
645                if let Some(f) = file_positional.as_ref().or(file.as_ref()) {
646                    ctx.file_targets = vec![f.clone()];
647                }
648                ctx.has_limit = limit.is_some();
649                Some(ctx)
650            }
651            Commands::Mv {
652                file_positional,
653                file,
654                dry_run,
655                ..
656            } => {
657                let mut ctx = HintContext::from_common(HintSource::Mv, &common);
658                if let Some(f) = file_positional.as_ref().or(file.as_ref()) {
659                    ctx.file_targets = vec![f.clone()];
660                }
661                ctx.dry_run = *dry_run;
662                Some(ctx)
663            }
664            Commands::Task { action } => {
665                let (source, file_pos, file_flag, selector) = match action {
666                    crate::cli::args::TaskAction::Toggle {
667                        file_positional,
668                        file,
669                        line,
670                        section,
671                        all,
672                        dry_run: _,
673                        ..
674                    } => (
675                        HintSource::TaskToggle,
676                        file_positional,
677                        file,
678                        task_selector(line, section.as_ref(), *all),
679                    ),
680                    crate::cli::args::TaskAction::Set {
681                        file_positional,
682                        file,
683                        line,
684                        section,
685                        all,
686                        ..
687                    } => (
688                        HintSource::TaskSetStatus,
689                        file_positional,
690                        file,
691                        task_selector(line, section.as_ref(), *all),
692                    ),
693                    crate::cli::args::TaskAction::Read {
694                        file_positional,
695                        file,
696                        line,
697                        section,
698                        all,
699                        ..
700                    } => (
701                        HintSource::TaskRead,
702                        file_positional,
703                        file,
704                        task_selector(line, section.as_ref(), *all),
705                    ),
706                };
707                let mut ctx = HintContext::from_common(source, &common);
708                if let Some(f) = file_pos.as_ref().or(file_flag.as_ref()) {
709                    ctx.file_targets = vec![f.clone()];
710                }
711                ctx.task_selector = selector;
712                Some(ctx)
713            }
714            Commands::Links { action } => match action {
715                crate::cli::args::LinksAction::Fix { apply, glob, .. } => {
716                    let mut ctx = HintContext::from_common(HintSource::LinksFix, &common);
717                    ctx.glob.clone_from(glob);
718                    ctx.dry_run = !apply;
719                    Some(ctx)
720                }
721                crate::cli::args::LinksAction::Auto {
722                    apply,
723                    glob,
724                    file,
725                    min_length,
726                    exclude_title,
727                    ..
728                } => {
729                    let mut ctx = HintContext::from_common(HintSource::LinksAuto, &common);
730                    ctx.glob.clone_from(glob);
731                    ctx.dry_run = !apply;
732                    ctx.auto_link_file.clone_from(file);
733                    ctx.auto_link_min_length = Some(*min_length);
734                    ctx.auto_link_exclude_titles.clone_from(exclude_title);
735                    Some(ctx)
736                }
737            },
738            Commands::CreateIndex { output, .. } => {
739                let mut ctx = HintContext::from_common(HintSource::CreateIndex, &common);
740                ctx.index_path = output.as_ref().map(|p| p.to_string_lossy().into_owned());
741                Some(ctx)
742            }
743            Commands::DropIndex { .. } => {
744                Some(HintContext::from_common(HintSource::DropIndex, &common))
745            }
746            Commands::Lint {
747                file_positional,
748                file,
749                glob,
750                r#type: _,
751                fix: _,
752                dry_run,
753                limit,
754                ..
755            } => {
756                let mut ctx = HintContext::from_common(HintSource::Lint, &common);
757                ctx.glob.clone_from(glob);
758                ctx.dry_run = *dry_run;
759                ctx.has_limit = limit.is_some();
760                let mut targets: Vec<String> = file.clone();
761                if let Some(pos) = file_positional {
762                    targets.insert(0, pos.clone());
763                }
764                ctx.file_targets = targets;
765                Some(ctx)
766            }
767            Commands::Types { action } => {
768                use crate::cli::args::TypesAction;
769                let subcommand = match action {
770                    Some(TypesAction::List) | None => Some("list".to_owned()),
771                    Some(TypesAction::Show { .. }) => Some("show".to_owned()),
772                    Some(TypesAction::Remove { .. }) => Some("remove".to_owned()),
773                    Some(TypesAction::Set { .. }) => Some("set".to_owned()),
774                };
775                Some(HintContext::from_common(
776                    HintSource::Types { subcommand },
777                    &common,
778                ))
779            }
780            Commands::Properties { .. }
781            | Commands::Tags { .. }
782            | Commands::Init { .. }
783            | Commands::Deinit
784            | Commands::Completion { .. }
785            | Commands::Views { .. } => None,
786        }
787    } else {
788        None
789    };
790
791    // Extract the effective index path from the subcommand's IndexFlags.
792    // --index-file PATH wins; bare --index resolves to vault_dir/.hyalo-index.
793    // Relative --index-file paths are resolved against CWD (caller convention).
794    let index_path_buf: Option<std::path::PathBuf> = effective_index_path_for(&cli.command, &dir);
795
796    let mut snapshot_index: Option<SnapshotIndex> = if let Some(ref p) = index_path_buf {
797        match SnapshotIndex::load(p) {
798            Ok(Some(idx)) => {
799                // Warn when the snapshot was built for a different vault or
800                // site-prefix — the index data may not match the current run.
801                let canonical_dir = std::fs::canonicalize(&dir).unwrap_or_else(|_| dir.clone());
802                let vault_dir_str = canonical_dir.to_string_lossy();
803                if idx.validate(&vault_dir_str, site_prefix) {
804                    Some(idx)
805                } else {
806                    let (hdr_vault, hdr_prefix, _, _) = idx.header_info();
807                    crate::warn::warn(format!(
808                        "index was built for vault '{hdr_vault}' (prefix {hdr_prefix:?}) but current \
809                         vault is '{vault_dir_str}' (prefix {site_prefix:?}); falling back to disk scan",
810                    ));
811                    None
812                }
813            }
814            Ok(None) => None, // incompatible schema — already warned; fall back to disk scan
815            Err(e) => {
816                crate::warn::warn(format!(
817                    "failed to load index: {e}; falling back to disk scan"
818                ));
819                None
820            }
821        }
822    } else {
823        None
824    };
825
826    let config_language_owned = config.search_language.clone();
827    let config_default_limit = config.default_limit;
828    let schema = config.schema;
829    let frontmatter_link_props_owned = config.frontmatter_link_props;
830    let validate_on_write = config.validate_on_write;
831    let lint_ignore = config.lint_ignore;
832    let case_insensitive_mode = config.case_insensitive_mode;
833
834    // Propagate the configured frontmatter-link property list into the loaded
835    // snapshot so that per-file refreshes (`rescan_entry` / `rename_entry`) use
836    // the same list as the initial index build.
837    if let Some(idx) = snapshot_index.as_mut() {
838        idx.set_frontmatter_link_props(frontmatter_link_props_owned.clone());
839    }
840    let mut ctx = CommandContext {
841        dir: &dir,
842        config_dir: &config_dir,
843        site_prefix,
844        effective_format,
845        user_format: format,
846        snapshot_index: &mut snapshot_index,
847        index_path: index_path_buf.as_deref(),
848        config_language: config_language_owned.as_deref(),
849        frontmatter_link_props: frontmatter_link_props_owned.as_deref(),
850        schema: &schema,
851        validate_on_write,
852        lint_ignore: &lint_ignore,
853        case_insensitive_mode,
854        exit_code_override: None,
855        config_default_limit,
856        programmatic_output: jq_filter.is_some() || cli.count,
857    };
858    let result = dispatch(cli.command, &mut ctx);
859    let exit_code_override = ctx.exit_code_override;
860
861    let pipeline = OutputPipeline {
862        user_format: format,
863        jq_filter,
864        hint_ctx: hint_ctx.as_ref(),
865        count: cli.count,
866    };
867    let code = pipeline.finalize(result);
868    // Commands like `lint` may override the exit code even on success output.
869    let final_code = exit_code_override.unwrap_or(code);
870    if final_code == 0 {
871        Ok(())
872    } else {
873        Err(AppError::Exit(final_code))
874    }
875}