use clap::{Parser, Subcommand, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SummaryStyle {
Concise,
Detailed,
Bullets,
Executive,
Tldr,
Critique,
Eli5,
Flashcards,
}
impl SummaryStyle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Concise => "concise",
Self::Detailed => "detailed",
Self::Bullets => "bullets",
Self::Executive => "executive",
Self::Tldr => "tldr",
Self::Critique => "critique",
Self::Eli5 => "eli5",
Self::Flashcards => "flashcards",
}
}
pub fn prompt_guidance(&self) -> &'static str {
match self {
Self::Concise => "Provide a concise, high-level summary capturing the core message in 2-3 brief paragraphs.",
Self::Detailed => "Provide a detailed, structured summary covering all major sections, themes, arguments, and conclusions.",
Self::Bullets => "Provide a structured summary formatted as clear, concise bullet points highlighting key insights.",
Self::Executive => "Provide an executive summary format: Executive Overview, Core Findings/Themes, Strategic Insights, and Takeaways.",
Self::Tldr => "Provide an ultra-compact TL;DR: exactly one bold sentence summarizing the core thesis, followed by exactly 3 short bullet points of essential takeaways.",
Self::Critique => "Provide a rigorous critical evaluation: 1. Core Thesis & Claims, 2. Supporting Evidence & Methodology, 3. Hidden Assumptions or Blindspots, 4. Counterarguments & Alternative Perspectives.",
Self::Eli5 => "Explain this in plain, simple English suitable for a beginner or non-expert (ELI5). Use intuitive real-world metaphors, define or avoid technical jargon, and keep the tone accessible and clear.",
Self::Flashcards => "Extract the key concepts as high-yield Q&A study flashcards. Format each card as: **Q: [Clear, focused question]** followed by **A: [Concise, definitive answer]**.",
}
}
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum NewsTimeframe {
#[default]
Day,
Week,
Month,
All,
}
impl NewsTimeframe {
pub fn as_google_query_suffix(&self) -> &'static str {
match self {
Self::Day => "when:1d",
Self::Week => "when:7d",
Self::Month => "when:30d",
Self::All => "",
}
}
pub fn display_label(&self) -> &'static str {
match self {
Self::Day => "Today / Past 24 Hours",
Self::Week => "Past Week",
Self::Month => "Past Month",
Self::All => "All Time",
}
}
}
#[derive(Parser, Debug)]
#[command(
name = "srz",
version,
about = "Modular CLI to summarize webpages and books using local OpenAI-compliant LLMs",
long_about = "A fast, modular CLI that extracts and summarizes webpages and books. \
Supports local OpenAI-compatible backends like Ollama, LM Studio, vLLM, and llama.cpp. \
For books, searches the web by default for grounded synopses, with automatic fallback to LLM memory."
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(value_name = "TARGET")]
pub target: Option<String>,
#[arg(
long,
global = true,
env = "OPENAI_BASE_URL",
default_value = "http://localhost:9931/v1"
)]
pub base_url: String,
#[arg(
long,
global = true,
env = "OPENAI_MODEL",
default_value = "ggml-org/gemma-4-E4B-it-GGUF:Q8_0"
)]
pub model: String,
#[arg(long, global = true, env = "OPENAI_API_KEY", default_value = "")]
pub api_key: String,
#[arg(long, global = true, value_enum, default_value = "concise")]
pub style: SummaryStyle,
#[arg(long, global = true, default_value_t = 8192)]
pub max_tokens: u32,
#[arg(long, global = true)]
pub no_search: bool,
#[arg(long, global = true)]
pub no_cache: bool,
#[arg(long, global = true)]
pub author: Option<String>,
#[arg(long, global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub profile: Option<String>,
#[arg(short, long, global = true)]
pub output: Option<PathBuf>,
#[arg(short, long, global = true)]
pub quiet: bool,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub json: bool,
#[arg(short = 'y', long, global = true)]
pub yes: bool,
#[arg(short = 'b', long, global = true)]
pub open: bool,
#[arg(long, global = true, default_value_t = 1)]
pub concurrency: usize,
#[arg(long, global = true)]
pub stream: bool,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Web {
url: String,
},
Book {
title: String,
},
File {
path: PathBuf,
},
Cache {
#[command(subcommand)]
action: CacheCommand,
},
Config {
#[command(subcommand)]
action: ConfigSubcommand,
},
History {
#[arg(short, long, default_value_t = 10)]
limit: usize,
},
Stats,
Find {
query: String,
},
Models,
Doctor,
Index,
News {
topic: String,
#[arg(short = 't', long, value_enum, default_value = "day")]
timeframe: NewsTimeframe,
#[arg(short, long, default_value_t = 4)]
limit: usize,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Upgrade {
#[arg(short, long)]
check: bool,
#[arg(short, long)]
force: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
dry_run: bool,
},
}
#[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheCommand {
Dir,
Size,
Clean,
}
#[derive(Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSubcommand {
Path,
Show,
Init,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ResolvedAction<'a> {
Web {
url: &'a str,
},
Book {
title: &'a str,
author: Option<&'a str>,
no_search: bool,
},
File {
path: PathBuf,
},
Stdin,
Cache(CacheCommand),
Config(ConfigSubcommand),
History {
limit: usize,
},
Stats,
Find {
query: &'a str,
},
Models,
Doctor,
Index,
News {
topic: String,
timeframe: NewsTimeframe,
limit: usize,
},
Completions {
shell: clap_complete::Shell,
},
Upgrade {
check: bool,
force: bool,
version: Option<String>,
dry_run: bool,
},
}
impl Cli {
pub fn stream_mode<'a>(&self, header_title: &'a str) -> crate::llm::StreamMode<'a> {
if self.stream && self.output.is_none() && !self.json {
crate::llm::StreamMode::Stdout { header_title }
} else {
crate::llm::StreamMode::Buffered
}
}
pub fn resolve_action(&self) -> Result<ResolvedAction<'_>, &'static str> {
if let Some(cmd) = &self.command {
match cmd {
Commands::Web { url } => Ok(ResolvedAction::Web { url: url.trim() }),
Commands::Book { title } => Ok(ResolvedAction::Book {
title: title.trim(),
author: self.author.as_deref(),
no_search: self.no_search,
}),
Commands::File { path } => {
if path.as_os_str() == "-" {
Ok(ResolvedAction::Stdin)
} else {
Ok(ResolvedAction::File {
path: path.clone(),
})
}
}
Commands::Cache { action } => Ok(ResolvedAction::Cache(*action)),
Commands::Config { action } => Ok(ResolvedAction::Config(*action)),
Commands::History { limit } => Ok(ResolvedAction::History { limit: *limit }),
Commands::Stats => Ok(ResolvedAction::Stats),
Commands::Find { query } => Ok(ResolvedAction::Find {
query: query.trim(),
}),
Commands::Models => Ok(ResolvedAction::Models),
Commands::Doctor => Ok(ResolvedAction::Doctor),
Commands::Index => Ok(ResolvedAction::Index),
Commands::News {
topic,
timeframe,
limit,
} => Ok(ResolvedAction::News {
topic: topic.trim().to_string(),
timeframe: *timeframe,
limit: *limit,
}),
Commands::Completions { shell } => Ok(ResolvedAction::Completions { shell: *shell }),
Commands::Upgrade {
check,
force,
version,
dry_run,
} => Ok(ResolvedAction::Upgrade {
check: *check,
force: *force,
version: version.clone(),
dry_run: *dry_run,
}),
}
} else if let Some(target) = &self.target {
let trimmed = target.trim();
if trimmed == "-" {
Ok(ResolvedAction::Stdin)
} else if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
Ok(ResolvedAction::Web { url: trimmed })
} else {
let candidate_path = std::path::Path::new(trimmed);
if candidate_path.is_file() {
Ok(ResolvedAction::File {
path: candidate_path.to_path_buf(),
})
} else if is_news_query(trimmed) {
let clean_topic = clean_news_query(trimmed);
Ok(ResolvedAction::News {
topic: clean_topic,
timeframe: NewsTimeframe::Day,
limit: 4,
})
} else {
Ok(ResolvedAction::Book {
title: trimmed,
author: self.author.as_deref(),
no_search: self.no_search,
})
}
}
} else {
Err("Please provide a URL, local file, book title, or '-' for stdin. Run `srz --help` for details.")
}
}
pub fn apply_config(
&mut self,
config: &crate::config::Config,
matches: Option<&clap::ArgMatches>,
) {
let profile = self
.profile
.as_deref()
.and_then(|p| config.profiles.get(p));
let was_provided = |id: &str| -> bool {
if let Some(m) = matches {
m.value_source(id) == Some(clap::parser::ValueSource::CommandLine)
|| m.value_source(id) == Some(clap::parser::ValueSource::EnvVariable)
} else {
false
}
};
macro_rules! apply_field {
($field:ident) => {
if !was_provided(stringify!($field))
&& let Some(val) = profile
.and_then(|p| p.$field.clone())
.or_else(|| config.$field.clone())
{
self.$field = val;
}
};
}
apply_field!(base_url);
apply_field!(model);
apply_field!(api_key);
apply_field!(style);
apply_field!(max_tokens);
apply_field!(no_search);
apply_field!(no_cache);
apply_field!(quiet);
apply_field!(verbose);
apply_field!(open);
apply_field!(concurrency);
apply_field!(stream);
}
}
pub fn is_news_query(s: &str) -> bool {
let lower = s.to_ascii_lowercase();
let words: Vec<&str> = lower.split_whitespace().collect();
if words.is_empty() {
return false;
}
words.contains(&"news")
|| words.contains(&"headlines")
|| ((words[0] == "current" || words[0] == "latest")
&& words.len() > 1
&& (words.contains(&"updates") || words.contains(&"stories")))
}
pub fn clean_news_query(s: &str) -> String {
let words: Vec<&str> = s.split_whitespace().collect();
if words.is_empty() {
return "top stories".to_string();
}
let stop_words = [
"current", "latest", "today's", "todays", "recent", "news", "headline", "headlines",
"about", "on", "regarding", "for",
];
let filtered: Vec<&str> = words
.iter()
.copied()
.filter(|w| {
let lower = w.to_ascii_lowercase();
!stop_words.contains(&lower.as_str())
})
.collect();
if filtered.is_empty() {
"top stories".to_string()
} else {
filtered.join(" ")
}
}