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;
mod scrap_arg;
#[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,
}
#[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 = "Check that scraps build without writing the site")]
Check,
#[command(
about = "Get the markdown body of a scrap by key",
long_about = "Get the markdown body of a scrap by key.\n\nThe body is the scrap's content: a YAML frontmatter block is left out, and the keys Scraps reads from it are fields of their own (`type`, `description`, `resource`, `verified`, `sources`). Line numbers in `headings` and `code_blocks` count lines of the source file, block included, so they are not offsets into the body."
)]
Get {
#[arg(
value_name = "KEY",
help = "Key of the scrap: its path under the wiki root without .md (e.g. tables/orders)"
)]
key: String,
#[arg(
long,
help = "Context of the scrap, when the argument is a file stem instead of a key"
)]
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 (key,title,ctx,body,headings,code_blocks,images,type,description,verified,sources,resource)."
)]
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 {
#[arg(
value_name = "KEY",
help = "Key of the scrap: its path under the wiki root without .md (e.g. tables/orders)"
)]
key: String,
#[arg(
long,
help = "Context of the scrap, when the argument is a file stem instead of a key"
)]
ctx: Option<String>,
#[arg(long, help = "Output as JSON")]
json: bool,
},
#[command(about = "List inbound wiki-links (backlinks) to a scrap")]
Backlinks {
#[arg(
value_name = "KEY",
help = "Key of the scrap: its path under the wiki root without .md (e.g. tables/orders)"
)]
key: String,
#[arg(
long,
help = "Context of the scrap, when the argument is a file stem instead of a key"
)]
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 = "Type commands")]
Type {
#[command(subcommand)]
type_command: TypeSubCommands,
},
#[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,
},
}
#[derive(Subcommand)]
pub enum TypeSubCommands {
#[command(about = "List types")]
List {
#[arg(long, help = "Output as JSON")]
json: bool,
},
#[command(about = "List scraps of the specified type")]
Scraps {
#[arg(value_name = "TYPE")]
name: String,
#[arg(long, help = "Output as JSON")]
json: bool,
},
}
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 = "unlinkable-key")]
UnlinkableKey,
#[value(name = "stale-by-git")]
StaleByGit,
#[value(name = "unportable-key")]
UnportableKey,
#[value(name = "source-has-page")]
SourceHasPage,
}
#[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::UnlinkableKey => LintRuleName::UnlinkableKey,
CliLintRuleName::StaleByGit => LintRuleName::StaleByGit,
CliLintRuleName::UnportableKey => LintRuleName::UnportableKey,
CliLintRuleName::SourceHasPage => LintRuleName::SourceHasPage,
}
}
}
#[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"]));
}
}