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,
}
impl SummaryStyle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Concise => "concise",
Self::Detailed => "detailed",
Self::Bullets => "bullets",
}
}
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."
}
}
}
}
#[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(ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PagingMode {
#[default]
Auto,
Always,
Never,
}
#[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 = "context-window", global = true)]
pub context_window: Option<usize>,
#[arg(long, global = true)]
pub no_cache: bool,
#[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 = '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,
#[arg(long, global = true, value_enum, default_value = "auto")]
pub paging: PagingMode,
#[arg(long, global = true)]
pub plain: bool,
}
impl Cli {
pub fn effective_paging(&self) -> PagingMode {
self.paging
}
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(hide = true)]
Web {
url: String,
},
#[command(hide = true)]
Book {
title: String,
},
#[command(hide = true)]
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,
Recommend,
Index,
#[command(alias = "tui", alias = "library")]
Browse,
#[command(alias = "chat")]
Ask {
first: Option<String>,
second: Option<String>,
#[arg(short, long)]
interactive: bool,
#[arg(long)]
chats: bool,
#[arg(long)]
resume: Option<i64>,
#[arg(long)]
show_chat: Option<i64>,
#[arg(long)]
delete_chat: Option<i64>,
},
News {
topic: String,
#[arg(short = 't', long, value_enum, default_value = "day")]
timeframe: NewsTimeframe,
#[arg(short, long, default_value_t = 5)]
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>,
},
}
#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum CacheCommand {
Dir,
Size,
Clean {
#[arg(long)]
reset_db: bool,
},
Prune {
#[arg(long)]
older_than: Option<String>,
#[arg(long)]
max_size: Option<String>,
},
}
#[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,
},
File {
path: PathBuf,
},
Stdin,
Cache(CacheCommand),
Config(ConfigSubcommand),
History {
limit: usize,
},
Stats,
Find {
query: &'a str,
},
Models,
Doctor,
Recommend,
Index,
Browse,
Ask {
target: Option<String>,
question: Option<String>,
interactive: bool,
list_chats: bool,
resume: Option<i64>,
show_chat: Option<i64>,
delete_chat: Option<i64>,
},
News {
topic: String,
timeframe: NewsTimeframe,
limit: usize,
},
Completions {
shell: clap_complete::Shell,
},
Upgrade {
check: bool,
force: bool,
version: Option<String>,
},
}
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(),
}),
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.clone())),
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::Recommend => Ok(ResolvedAction::Recommend),
Commands::Index => Ok(ResolvedAction::Index),
Commands::Browse => Ok(ResolvedAction::Browse),
Commands::Ask {
first,
second,
interactive,
chats,
resume,
show_chat,
delete_chat,
} => {
let (target, question) = match (first.as_deref(), second.as_deref()) {
(Some(f), Some(s)) => {
(Some(f.trim().to_string()), Some(s.trim().to_string()))
}
(Some(f), None) => {
if is_probable_question(f) {
(None, Some(f.trim().to_string()))
} else {
(Some(f.trim().to_string()), None)
}
}
(None, None) => (None, None),
(None, Some(s)) => (None, Some(s.trim().to_string())),
};
Ok(ResolvedAction::Ask {
target,
question,
interactive: *interactive || resume.is_some(),
list_chats: *chats,
resume: *resume,
show_chat: *show_chat,
delete_chat: *delete_chat,
})
}
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,
} => Ok(ResolvedAction::Upgrade {
check: *check,
force: *force,
version: version.clone(),
}),
}
} 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://") {
if is_feed_url(trimmed) {
Ok(ResolvedAction::News {
topic: trimmed.to_string(),
timeframe: NewsTimeframe::All,
limit: 5,
})
} else {
Ok(ResolvedAction::Web { url: trimmed })
}
} else {
let candidate_path = std::path::Path::new(trimmed);
if candidate_path.is_file() || is_file_like(trimmed) {
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: 5,
})
} else {
Ok(ResolvedAction::Book { title: trimmed })
}
}
} 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 {
if !m.ids().any(|arg_id| arg_id.as_str() == id) {
return false;
}
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;
}
};
}
macro_rules! apply_opt_field {
($field:ident) => {
if !was_provided(stringify!($field))
&& let Some(val) = profile.and_then(|p| p.$field).or_else(|| config.$field)
{
self.$field = Some(val);
}
};
}
apply_field!(base_url);
apply_field!(model);
apply_field!(api_key);
apply_field!(style);
apply_field!(max_tokens);
apply_field!(no_cache);
apply_field!(quiet);
apply_field!(verbose);
apply_field!(open);
apply_field!(concurrency);
apply_field!(stream);
apply_opt_field!(context_window);
}
}
pub fn is_file_like(s: &str) -> bool {
if s.starts_with("http://") || s.starts_with("https://") {
return false;
}
let path = std::path::Path::new(s);
if path.is_file() {
return true;
}
if s.starts_with("./")
|| s.starts_with("../")
|| s.starts_with('/')
|| s.starts_with('~')
|| s.contains('/')
|| s.contains('\\')
{
return true;
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let lower = ext.to_ascii_lowercase();
matches!(
lower.as_str(),
"pdf" | "md" | "markdown" | "txt" | "text" | "html" | "htm" | "epub" | "json"
)
} else {
false
}
}
pub fn is_feed_url(url: &str) -> bool {
let trimmed = url.trim();
if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") {
return false;
}
let without_fragment = trimmed.split('#').next().unwrap_or(trimmed);
let (path_part, query_part) = match without_fragment.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (without_fragment, None),
};
let lower_path = path_part.trim_end_matches('/').to_ascii_lowercase();
if lower_path.ends_with(".rss")
|| lower_path.ends_with(".atom")
|| lower_path.ends_with("/rss")
|| lower_path.ends_with("/feed")
|| lower_path.ends_with("/atom")
|| lower_path.ends_with("/feed.xml")
|| lower_path.ends_with("/rss.xml")
|| lower_path.ends_with("/atom.xml")
|| lower_path.ends_with("/index.xml")
|| lower_path.ends_with("/feed.json")
|| lower_path.contains("/rss/")
{
return true;
}
if let Some(q) = query_part {
let lower_q = q.to_ascii_lowercase();
if lower_q.contains("format=rss")
|| lower_q.contains("format=atom")
|| lower_q.contains("output=rss")
|| lower_q.contains("feed=rss")
{
return true;
}
}
false
}
pub fn is_news_query(s: &str) -> bool {
let mut words = s.split_whitespace();
let Some(first) = words.next() else {
return false;
};
if first.eq_ignore_ascii_case("news") || first.eq_ignore_ascii_case("headlines") {
return true;
}
let first_is_current_or_latest =
first.eq_ignore_ascii_case("current") || first.eq_ignore_ascii_case("latest");
let mut has_update_or_story = false;
let mut has_more = false;
for word in words {
has_more = true;
if word.eq_ignore_ascii_case("news") || word.eq_ignore_ascii_case("headlines") {
return true;
}
if first_is_current_or_latest
&& (word.eq_ignore_ascii_case("updates") || word.eq_ignore_ascii_case("stories"))
{
has_update_or_story = true;
}
}
first_is_current_or_latest && has_more && has_update_or_story
}
pub fn clean_news_query(s: &str) -> String {
const STOP_WORDS: &[&str] = &[
"current",
"latest",
"today's",
"todays",
"recent",
"news",
"headline",
"headlines",
"about",
"on",
"regarding",
"for",
];
let mut out = String::new();
for word in s.split_whitespace() {
let is_stop = STOP_WORDS
.iter()
.any(|stop| word.eq_ignore_ascii_case(stop));
if !is_stop {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
}
if out.is_empty() {
"top stories".to_string()
} else {
out
}
}
pub fn is_probable_question(s: &str) -> bool {
let trimmed = s.trim();
if trimmed.ends_with('?') {
return true;
}
let Some(first_word) = trimmed.split_whitespace().next() else {
return false;
};
if trimmed.len() <= first_word.len() {
return false;
}
const QUESTION_PREFIXES: &[&str] = &[
"what",
"why",
"how",
"who",
"when",
"where",
"which",
"can",
"could",
"should",
"would",
"is",
"are",
"explain",
"summarize",
"tell",
"describe",
"clarify",
];
QUESTION_PREFIXES
.iter()
.any(|prefix| first_word.eq_ignore_ascii_case(prefix))
}