scraps 3.0.0

Scraps is a portable CLI knowledge hub for managing interconnected Markdown documentation with Wiki-link notation.
use clap::{Parser, Subcommand, ValueEnum};
use clap_verbosity_flag::{Verbosity, WarnLevel};
use std::path::PathBuf;

use crate::usecase::lint::rule::LintRuleName;
use crate::usecase::todo::usecase::StatusFilter;
use scraps_libs::search::engine::SearchLogic;

pub mod cmd;
mod config;
mod display;
mod json;
pub mod path_resolver;
mod progress;

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Cli {
    #[arg(
        short = 'C',
        long = "directory",
        global = true,
        env = "SCRAPS_DIRECTORY",
        value_name = "DIR",
        help = "Run as if started in <DIR> (instead of the current directory)"
    )]
    pub directory: Option<PathBuf>,

    #[command(subcommand)]
    pub command: SubCommands,
}

/// `--git` predates git-derived metadata being read by default. It is still
/// accepted, so workflows written for it keep working; only `--no-git` turns
/// the lookup off.
#[derive(clap::Args)]
pub struct GitFlags {
    #[arg(
        long,
        overrides_with = "git",
        help = "Skip git-derived metadata (commited_ts): the updated view then has no dates to sort by"
    )]
    no_git: bool,

    #[arg(long, hide = true, overrides_with = "no_git")]
    git: bool,
}

impl GitFlags {
    pub fn enabled(&self) -> bool {
        self.git || !self.no_git
    }
}

#[derive(Subcommand)]
pub enum SubCommands {
    #[command(about = "Build scraps")]
    Build {
        #[command(flatten)]
        verbose: Verbosity<WarnLevel>,

        #[command(flatten)]
        git: GitFlags,
    },

    #[command(about = "Get the markdown body of a scrap by title")]
    Get {
        title: String,

        #[arg(long, help = "Disambiguate title across contexts")]
        ctx: Option<String>,

        #[arg(
            long,
            value_name = "HEADING",
            help = "Restrict the output to the section under the given heading text"
        )]
        heading: Option<String>,

        #[arg(
            long,
            value_name = "FIELDS",
            num_args = 0..=1,
            default_missing_value = "",
            help = "Output as JSON. Optionally accepts a comma-separated field projection (title,ctx,body,headings,code_blocks)."
        )]
        json: Option<String>,
    },

    #[command(about = "Write .scraps.toml to the project directory")]
    Init,

    #[command(about = "Lint scraps for wiki-link quality issues")]
    Lint {
        #[arg(
            short = 'r',
            long = "rule",
            help = "Run only the specified lint rule(s)"
        )]
        rules: Vec<CliLintRuleName>,
    },

    #[command(about = "List outbound wiki-links from a scrap")]
    Links {
        title: String,

        #[arg(long, help = "Disambiguate title across contexts")]
        ctx: Option<String>,

        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "List inbound wiki-links (backlinks) to a scrap")]
    Backlinks {
        title: String,

        #[arg(long, help = "Disambiguate title across contexts")]
        ctx: Option<String>,

        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "Search scraps by query (fuzzy)")]
    Search {
        query: String,

        #[arg(long, default_value_t = 100, help = "Maximum number of results")]
        num: usize,

        #[arg(
            long,
            value_enum,
            default_value_t = CliSearchLogic::Or,
            help = "Search logic for multi-keyword queries"
        )]
        logic: CliSearchLogic,

        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "Serve the site with build scraps")]
    Serve {
        #[command(flatten)]
        git: GitFlags,
    },

    #[command(about = "Tag commands")]
    Tag {
        #[command(subcommand)]
        tag_command: TagSubCommands,
    },

    #[command(about = "Aggregate YAML frontmatter across the wiki")]
    Frontmatter {
        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "Aggregate markdown task list items across the wiki")]
    Todo {
        #[arg(
            long,
            value_enum,
            default_value_t = CliTodoStatus::Open,
            help = "Filter task items by status"
        )]
        status: CliTodoStatus,

        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "MCP server commands")]
    Mcp {
        #[command(subcommand)]
        mcp_command: McpSubCommands,
    },
}

#[derive(Subcommand)]
pub enum TagSubCommands {
    #[command(about = "List tags")]
    List {
        #[arg(long, help = "Output as JSON")]
        json: bool,
    },

    #[command(about = "List scraps that reference the specified tag")]
    Backlinks {
        tag: String,

        #[arg(long, help = "Output as JSON")]
        json: bool,
    },
}

/// Loopback IPv4 rather than `localhost`, which resolves to `::1` first on some
/// systems and would leave IPv4 clients unable to connect.
const DEFAULT_MCP_HTTP_ADDR: &str = "127.0.0.1:1113";

#[derive(Subcommand)]
pub enum McpSubCommands {
    #[command(about = "Start MCP server with stdio transport, or Streamable HTTP with --http")]
    Serve {
        #[arg(
            long,
            value_name = "ADDR",
            num_args = 0..=1,
            default_missing_value = DEFAULT_MCP_HTTP_ADDR,
            help = "Serve over Streamable HTTP at this address instead of stdio; the MCP endpoint is <addr>/mcp"
        )]
        http: Option<String>,

        #[arg(
            long = "allowed-host",
            value_name = "HOST",
            requires = "http",
            help = "Accept this Host header in addition to the loopback default, for serving behind a reverse proxy or tunnel; repeatable"
        )]
        allowed_host: Vec<String>,
    },
}

#[derive(Clone, Debug, ValueEnum)]
pub enum CliLintRuleName {
    #[value(name = "dead-end")]
    DeadEnd,
    #[value(name = "lonely")]
    Lonely,
    #[value(name = "self-link")]
    SelfLink,
    #[value(name = "overlinking")]
    Overlinking,
    #[value(name = "broken-link")]
    BrokenLink,
    #[value(name = "broken-heading-ref")]
    BrokenHeadingRef,
    #[value(name = "stale-by-git")]
    StaleByGit,
}

#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum CliSearchLogic {
    #[value(name = "and")]
    And,
    #[value(name = "or")]
    Or,
}

#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum CliTodoStatus {
    #[value(name = "open")]
    Open,
    #[value(name = "done")]
    Done,
    #[value(name = "deferred")]
    Deferred,
    #[value(name = "all")]
    All,
}

impl From<CliTodoStatus> for StatusFilter {
    fn from(cli: CliTodoStatus) -> Self {
        match cli {
            CliTodoStatus::Open => StatusFilter::Open,
            CliTodoStatus::Done => StatusFilter::Done,
            CliTodoStatus::Deferred => StatusFilter::Deferred,
            CliTodoStatus::All => StatusFilter::All,
        }
    }
}

impl From<CliSearchLogic> for SearchLogic {
    fn from(cli: CliSearchLogic) -> Self {
        match cli {
            CliSearchLogic::And => SearchLogic::And,
            CliSearchLogic::Or => SearchLogic::Or,
        }
    }
}

impl From<CliLintRuleName> for LintRuleName {
    fn from(cli: CliLintRuleName) -> Self {
        match cli {
            CliLintRuleName::DeadEnd => LintRuleName::DeadEnd,
            CliLintRuleName::Lonely => LintRuleName::Lonely,
            CliLintRuleName::SelfLink => LintRuleName::SelfLink,
            CliLintRuleName::Overlinking => LintRuleName::Overlinking,
            CliLintRuleName::BrokenLink => LintRuleName::BrokenLink,
            CliLintRuleName::BrokenHeadingRef => LintRuleName::BrokenHeadingRef,
            CliLintRuleName::StaleByGit => LintRuleName::StaleByGit,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn build_reads_git(args: &[&str]) -> bool {
        match Cli::try_parse_from(args).unwrap().command {
            SubCommands::Build { git, .. } => git.enabled(),
            _ => panic!("not a build"),
        }
    }

    fn serve_reads_git(args: &[&str]) -> bool {
        match Cli::try_parse_from(args).unwrap().command {
            SubCommands::Serve { git } => git.enabled(),
            _ => panic!("not a serve"),
        }
    }

    #[test]
    fn it_reads_git_unless_told_not_to() {
        assert!(build_reads_git(&["scraps", "build"]));
        assert!(!build_reads_git(&["scraps", "build", "--no-git"]));
        assert!(serve_reads_git(&["scraps", "serve"]));
        assert!(!serve_reads_git(&["scraps", "serve", "--no-git"]));
    }

    #[test]
    fn it_still_takes_the_git_flag_written_before_the_default() {
        assert!(build_reads_git(&["scraps", "build", "--git"]));
        assert!(!build_reads_git(&["scraps", "build", "--git", "--no-git"]));
        assert!(build_reads_git(&["scraps", "build", "--no-git", "--git"]));
    }
}