obsidian-rs-cli 0.6.0

A CLI for interacting with Obsidian vaults
use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

#[derive(Parser)]
#[command(name = "obsidian-rs", about = "Query and navigate Obsidian vaults")]
pub struct Cli {
    /// Path to the vault directory. Defaults to the nearest parent directory containing
    /// '.obsidian/', or the current directory if none is found.
    #[arg(
        long,
        short = 'v',
        global = true,
        env = "OBSIDIAN_VAULT",
        help_heading = "Global options"
    )]
    pub vault: Option<PathBuf>,
    /// Force color output even when not writing to a TTY
    #[arg(long, global = true, help_heading = "Global options")]
    pub color: bool,
    /// Disable color output
    #[arg(long, global = true, help_heading = "Global options")]
    pub no_color: bool,
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand)]
pub enum Command {
    /// Search for notes in the vault.
    ///
    /// Filters are applied in two stages:
    ///
    /// 1. First 'AND' filters are applied to narrow down the candidate set of notes.
    ///    If any 'AND' filter is specified, only notes that match all 'AND' filters will be included
    ///    in the candidate set. If no 'AND' filters are specified, all notes in the vault will be
    ///    included in the candidate set.
    ///
    /// 2. Then 'OR' filters are applied to the candidate set. If any 'OR' filter is specified, only notes
    ///    that match at least one 'OR' filter will be included in the final results. If no 'OR' filters
    ///    are specified, all notes in the candidate set will be included in the final results.
    Search(Box<SearchArgs>),
    /// Work with individual notes
    Note(NoteArgs),
    /// Work with tags across the vault
    Tags(TagsArgs),
    /// Check vault health: report duplicate IDs/aliases, broken links, and stranded notes
    Check(CheckArgs),
}

#[derive(clap::Args)]
pub struct CheckArgs {
    /// Ignore notes matching this glob pattern (matched against vault-relative path, repeatable)
    #[arg(long, short = 'i')]
    pub ignore: Vec<String>,
}

#[derive(clap::Args)]
pub struct SearchArgs {
    /// Only include notes whose path matches one of these glob patterns (matched against vault-relative path, repeatable)
    #[arg(long, help_heading = "Path filters")]
    pub glob: Vec<String>,
    /// Same as --glob but with global OR semantics
    #[arg(long, help_heading = "Path filters")]
    pub or_glob: Vec<String>,
    /// Filter by exact note ID match (AND semantics)
    #[arg(long, help_heading = "Metadata filters")]
    pub id: Option<String>,
    /// Filter by exact note ID match, case-sensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub or_id: Vec<String>,
    /// Filter by tag, case-insensitive by default (AND semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub tag: Vec<String>,
    /// Filter by tag, case-insensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub or_tag: Vec<String>,
    /// Filter by title substring, smart case-sensitive by default (AND semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub title_contains: Vec<String>,
    /// Filter by title substring, smart case-sensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub or_title_contains: Vec<String>,
    /// Filter by exact alias, smart case-sensitive by default (AND semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub alias: Vec<String>,
    /// Filter by exact alias, smart case-sensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub or_alias: Vec<String>,
    /// Filter by alias substring, smart case-sensitive by default (AND semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub alias_contains: Vec<String>,
    /// Filter by alias substring, smart case-sensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Metadata filters")]
    pub or_alias_contains: Vec<String>,
    /// Filter by content substring, smart case-sensitive by default (AND semantics, repeatable)
    #[arg(long, help_heading = "Content filters")]
    pub content_contains: Vec<String>,
    /// Filter by content substring, smart case-sensitive by default (OR semantics, repeatable)
    #[arg(long, help_heading = "Content filters")]
    pub or_content_contains: Vec<String>,
    /// Filter by content pattern, smart case-sensitive by default (AND semantics, repeatable).
    /// See https://docs.rs/regex/latest/regex/#syntax.
    #[arg(long, help_heading = "Content filters")]
    pub content_matches: Vec<String>,
    /// Filter by content pattern, smart case-sensitive by default (OR semantics, repeatable).
    /// See https://docs.rs/regex/latest/regex/#syntax.
    #[arg(long, help_heading = "Content filters")]
    pub or_content_matches: Vec<String>,
    /// Execute the search case sensitive. By default, title, alias, and content filters are
    /// smart case-sensitive, while ID filters are case-sensitive, and tag filters are case-insensitive.
    /// This flag overrides -i/--ignore-case and -S/--smart-case.
    #[arg(long, short = 's', help_heading = "Filter behavior")]
    pub case_sensitive: bool,
    /// Execute the search case insensitive. This flag overrides -S/--smart-case.
    #[arg(long, short = 'i', help_heading = "Filter behavior")]
    pub ignore_case: bool,
    /// Search case insensitively for patterns that are all lowercase, otherwise search case
    /// sensitively.
    #[arg(long, short = 'S', help_heading = "Filter behavior")]
    pub smart_case: bool,
    /// Include inline tags in the search, as opposed to just frontmatter tags
    #[arg(long, help_heading = "Filter behavior")]
    pub inline_tags: bool,
    /// Sort order for results
    #[arg(long, help_heading = "Output options")]
    pub sort: Option<SortOrder>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct BacklinksArgs {
    /// Path to the note (resolved relative to current directory)
    pub note: PathBuf,
    /// Sort order for results
    #[arg(long, short = 's', help_heading = "Output options")]
    pub sort: Option<SortOrder>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct RenameArgs {
    /// Path to the note to rename (resolved relative to current directory)
    pub note: PathBuf,
    /// New path for the note (resolved relative to current directory, .md added if omitted)
    pub new_path: PathBuf,
    /// Preview what would change without modifying any files
    #[arg(long)]
    pub dry_run: bool,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct MergeArgs {
    /// One or more source notes followed by the destination note.
    /// All paths are resolved relative to the current directory.
    /// The last path is the destination; all preceding paths are sources.
    /// Sources are merged into the destination (which is created if it doesn't exist) and deleted.
    #[arg(name = "PATH", required = true, num_args = 2..)]
    pub paths: Vec<PathBuf>,
    /// Preview what would change without modifying any files
    #[arg(long)]
    pub dry_run: bool,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct NoteArgs {
    #[command(subcommand)]
    pub subcommand: NoteCommand,
}

#[derive(Subcommand)]
pub enum NoteCommand {
    /// Resolve a note from a path, ID, or alias
    Resolve(ResolveArgs),
    /// List all notes
    List(ListArgs),
    /// Search for notes (alias for 'obsidian-rs search')
    Search(Box<SearchArgs>),
    /// Read contents/frontmatter of a note
    Read(ReadArgs),
    /// Write a new note
    Write(WriteArgs),
    /// Find notes that link to a given note
    Backlinks(BacklinksArgs),
    /// Merge two or more notes into a single destination note
    Merge(MergeArgs),
    /// Extract a section or span from a note into a new note
    Extract(ExtractArgs),
    /// Patch the body of a note by replacing one exact string with another
    Patch(PatchArgs),
    /// Rename a note and update all backlinks
    Rename(RenameArgs),
    /// Update frontmatter metadata fields of a note
    Update(UpdateArgs),
}

#[derive(clap::Args)]
pub struct ResolveArgs {
    /// Path, ID, or alias of the note to resolve
    pub note: String,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct ListArgs {
    /// Sort order for results
    #[arg(long, short = 's', help_heading = "Output options")]
    pub sort: Option<SortOrder>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct ReadArgs {
    /// Path to the note to read (resolved relative to current directory)
    pub note: PathBuf,
    /// Include frontmatter in the output
    #[arg(long, help_heading = "Output options")]
    pub frontmatter: bool,
    /// Exclude content from the output (--frontmatter is assumed if this is set)
    #[arg(long, help_heading = "Output options")]
    pub no_content: bool,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct WriteArgs {
    /// Path to the note to write (resolved relative to the vault root or current directory, .md added if omitted)
    pub note: PathBuf,
    /// Content to write to the note. If omitted, content is read from stdin.
    pub content: Option<String>,
    /// A title for the note if one can't be inferred from the content
    #[arg(long, short = 't')]
    pub title: Option<String>,
    /// Add tag(s) to frontmatter (repeatable)
    #[arg(long)]
    pub tag: Vec<String>,
    /// Add alias(es) to frontmatter (repeatable)
    #[arg(long, short = 'a')]
    pub alias: Vec<String>,
    /// Force overwrite any existing note
    #[arg(long)]
    pub force: bool,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct ExtractArgs {
    /// Path, ID, or alias of the source note
    pub note: String,
    /// Path for the new note (resolved relative to the vault root or current directory, .md added if omitted)
    pub new_path: PathBuf,
    /// Extract a named section (supports nested heading paths like Parent#Child)
    #[arg(long)]
    pub section: Option<String>,
    /// 1-indexed start line for span extraction
    #[arg(long)]
    pub start_line: Option<usize>,
    /// 0-indexed start column for span extraction
    #[arg(long)]
    pub start_col: Option<usize>,
    /// 1-indexed end line for span extraction
    #[arg(long)]
    pub end_line: Option<usize>,
    /// 0-indexed end column for span extraction (exclusive)
    #[arg(long)]
    pub end_col: Option<usize>,
    /// Override the new note's ID
    #[arg(long)]
    pub new_id: Option<String>,
    /// Text to replace the extracted section/span with in the source note
    #[arg(long)]
    pub replace_with: Option<String>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct PatchArgs {
    /// Path to the note (resolved relative to current directory)
    pub note: PathBuf,
    /// The exact string to find (must appear exactly once in the note body)
    #[arg(long)]
    pub old_string: String,
    /// The string to replace it with
    #[arg(long)]
    pub new_string: String,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct UpdateArgs {
    /// Path to the note (resolved relative to vault root or current directory).
    /// If omitted, note paths are read from stdin (one per line).
    pub note: Option<PathBuf>,
    /// Add tag(s) to frontmatter (repeatable)
    #[arg(long, short = 't')]
    pub add_tag: Vec<String>,
    /// Remove tag(s) from frontmatter (repeatable)
    #[arg(long)]
    pub rm_tag: Vec<String>,
    /// Add alias(es) to frontmatter (repeatable)
    #[arg(long, short = 'a')]
    pub add_alias: Vec<String>,
    /// Set a field in the frontmatter to a value (repeatable, --set key=value). The value is
    /// parsed as YAML, so it can be a string (with or without quotes), number, boolean, list, map, or null.
    /// If the field already exits, it will be overwritten. To remove a field, set it to null (e.g. --set myfield=null).
    #[arg(long)]
    pub set: Vec<String>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(Clone, ValueEnum)]
pub enum OutputFormat {
    Plain,
    Json,
}

#[derive(Clone, ValueEnum)]
pub enum SortOrder {
    PathAsc,
    PathDesc,
    ModifiedAsc,
    ModifiedDesc,
    CreatedAsc,
    CreatedDesc,
}

// Get SortOrder from the corresponding type in obsidian-core.
impl From<SortOrder> for obsidian_core::SortOrder {
    fn from(sort_order: SortOrder) -> Self {
        match sort_order {
            SortOrder::PathAsc => obsidian_core::SortOrder::PathAsc,
            SortOrder::PathDesc => obsidian_core::SortOrder::PathDesc,
            SortOrder::ModifiedAsc => obsidian_core::SortOrder::ModifiedAsc,
            SortOrder::ModifiedDesc => obsidian_core::SortOrder::ModifiedDesc,
            SortOrder::CreatedAsc => obsidian_core::SortOrder::CreatedAsc,
            SortOrder::CreatedDesc => obsidian_core::SortOrder::CreatedDesc,
        }
    }
}

#[derive(clap::Args)]
pub struct TagsArgs {
    #[command(subcommand)]
    pub subcommand: TagsCommand,
}

#[derive(Subcommand)]
pub enum TagsCommand {
    /// Find all occurrences of the given tags across the vault
    Search(TagsSearchArgs),
    /// List all tags used across the vault
    List(TagsListArgs),
}

#[derive(clap::Args)]
pub struct TagsSearchArgs {
    /// Tags to search for (OR semantics — occurrences of any given tag are shown)
    #[arg(required = true)]
    pub tags: Vec<String>,
    /// Sort order for results
    #[arg(long, short = 's', help_heading = "Output options")]
    pub sort: Option<SortOrder>,
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}

#[derive(clap::Args)]
pub struct TagsListArgs {
    /// Output format
    #[arg(long, short = 'f', default_value = "plain", help_heading = "Output options")]
    pub format: OutputFormat,
}