#![allow(clippy::print_stdout, clippy::print_stderr)]
mod format;
use std::process::ExitCode;
use clap::{CommandFactory, Parser, Subcommand};
use nsip::{NsipClient, SearchCriteria};
#[derive(Parser, Debug)]
#[command(name = "nsip")]
#[command(about = "NSIP Search API client for nsipsearch.nsip.org/api", long_about = None)]
#[command(version)]
struct Cli {
#[arg(long, short = 'J', global = true)]
json: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
DateUpdated,
BreedGroups,
Statuses,
TraitRanges {
breed_id: i64,
},
Search {
#[arg(short, long)]
breed_id: Option<i64>,
#[arg(long)]
breed_group_id: Option<i64>,
#[arg(short, long)]
status: Option<String>,
#[arg(short, long)]
gender: Option<String>,
#[arg(long)]
born_after: Option<String>,
#[arg(long)]
born_before: Option<String>,
#[arg(long)]
proven_only: bool,
#[arg(long)]
flock_id: Option<String>,
#[arg(long)]
sort_by: Option<String>,
#[arg(long)]
reverse: bool,
#[arg(short, long, default_value = "0")]
page: u32,
#[arg(long, default_value = "15")]
page_size: u32,
},
Details {
search_string: String,
},
Lineage {
lpn_id: String,
},
Progeny {
lpn_id: String,
#[arg(short, long, default_value = "0")]
page: u32,
#[arg(long, default_value = "10")]
page_size: u32,
},
Profile {
lpn_id: String,
},
Compare {
#[arg(required = true, num_args = 2..=5)]
lpn_ids: Vec<String>,
#[arg(long)]
traits: Option<String>,
},
Completions {
shell: clap_complete::Shell,
},
ManPages {
#[arg(long)]
out_dir: Option<String>,
},
Mcp {
#[arg(long, default_value = "stdio")]
transport: String,
#[arg(long, default_value = "127.0.0.1")]
host: String,
#[arg(long, default_value_t = 8080)]
port: u16,
#[arg(long)]
tools: Option<String>,
#[arg(long)]
auth: bool,
},
}
fn generate_man_pages(out_dir: Option<String>) -> Result<(), nsip::Error> {
let cmd = Cli::command();
let Some(dir) = out_dir else {
let man = clap_mangen::Man::new(cmd);
return man
.render(&mut std::io::stdout())
.map_err(|e| nsip::Error::Parse(format!("man page render error: {e}")));
};
let path = std::path::Path::new(&dir);
std::fs::create_dir_all(path)
.map_err(|e| nsip::Error::Validation(format!("cannot create directory {dir}: {e}")))?;
render_man_page(&cmd, path, "nsip")?;
for sub in cmd.get_subcommands() {
let name = sub.get_name();
let filename = format!("nsip-{name}");
render_man_page(sub, path, &filename)?;
}
println!("Man pages written to {dir}/");
Ok(())
}
fn render_man_page(
cmd: &clap::Command,
dir: &std::path::Path,
name: &str,
) -> Result<(), nsip::Error> {
let man = clap_mangen::Man::new(cmd.clone());
let mut buf: Vec<u8> = Vec::new();
man.render(&mut buf)
.map_err(|e| nsip::Error::Parse(format!("man page render error: {e}")))?;
let filename = format!("{name}.1");
std::fs::write(dir.join(&filename), buf)
.map_err(|e| nsip::Error::Validation(format!("cannot write {filename}: {e}")))
}
fn init_tracing() {
init_tracing_inner();
}
#[cfg(feature = "telemetry")]
fn init_tracing_inner() {
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::util::SubscriberInitExt as _;
let provider = nsip::mcp::telemetry::init_tracer_provider();
let otel_layer = nsip::mcp::telemetry::otel_layer(&provider);
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::from_default_env())
.with(
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.event_format(nsip::mcp::telemetry::OtelJsonFormat::default())
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::default()),
)
.with(otel_layer)
.init();
}
#[cfg(not(feature = "telemetry"))]
fn init_tracing_inner() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.init();
}
#[allow(clippy::too_many_lines)]
async fn run() -> Result<(), nsip::Error> {
let cli = Cli::parse();
let client = NsipClient::new();
match cli.command {
Commands::DateUpdated => {
let updated = client.date_last_updated().await?;
println!(
"{}",
serde_json::to_string_pretty(&updated.data).unwrap_or_default()
);
},
Commands::BreedGroups => {
let groups = client.breed_groups().await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&groups).unwrap_or_default()
);
} else {
print!("{}", format::fmt_breed_groups(&groups));
}
},
Commands::Statuses => {
let statuses = client.statuses().await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&statuses).unwrap_or_default()
);
} else {
println!("Animal Statuses:");
for status in &statuses {
println!(" {status}");
}
}
},
Commands::TraitRanges { breed_id } => {
let ranges = client.trait_ranges(breed_id).await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&ranges).unwrap_or_default()
);
} else {
print!("{}", format::fmt_trait_ranges(&ranges));
}
},
Commands::Search {
breed_id,
breed_group_id,
status,
gender,
born_after,
born_before,
proven_only,
flock_id,
sort_by,
reverse,
page,
page_size,
} => {
let mut criteria = SearchCriteria::new();
if let Some(bid) = breed_id {
criteria = criteria.with_breed_id(bid);
}
if let Some(bgid) = breed_group_id {
criteria = criteria.with_breed_group_id(bgid);
}
if let Some(s) = status {
criteria = criteria.with_status(s);
}
if let Some(g) = gender {
criteria = criteria.with_gender(g);
}
if let Some(date) = born_after {
criteria = criteria.with_born_after(date);
}
if let Some(date) = born_before {
criteria = criteria.with_born_before(date);
}
if proven_only {
criteria = criteria.with_proven_only(true);
}
if let Some(fid) = flock_id {
criteria = criteria.with_flock_id(fid);
}
let sorted_trait = sort_by.as_deref();
let reverse_opt = if reverse { Some(true) } else { None };
let results = client
.search_animals(
page,
page_size,
breed_id,
sorted_trait,
reverse_opt,
Some(&criteria),
)
.await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&results).unwrap_or_default()
);
} else {
print!("{}", format::fmt_search_results(&results));
}
},
Commands::Details { search_string } => {
let details = client.animal_details(&search_string).await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&details).unwrap_or_default()
);
} else {
print!("{}", format::fmt_details(&details));
}
},
Commands::Lineage { lpn_id } => {
let lineage = client.lineage(&lpn_id).await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&lineage).unwrap_or_default()
);
} else {
print!("{}", format::fmt_lineage(&lineage));
}
},
Commands::Progeny {
lpn_id,
page,
page_size,
} => {
let progeny = client.progeny(&lpn_id, page, page_size).await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&progeny).unwrap_or_default()
);
} else {
print!("{}", format::fmt_progeny(&progeny, &lpn_id));
}
},
Commands::Profile { lpn_id } => {
let profile = client.search_by_lpn(&lpn_id).await?;
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&profile).unwrap_or_default()
);
} else {
print!("{}", format::fmt_profile(&profile));
}
},
Commands::Compare { lpn_ids, traits } => {
let mut join_set = tokio::task::JoinSet::new();
for id in lpn_ids {
let c = client.clone();
join_set.spawn(async move { c.animal_details(&id).await });
}
let mut animals = Vec::new();
while let Some(result) = join_set.join_next().await {
let details = result.map_err(|e| nsip::Error::Parse(format!("join error: {e}")))?;
animals.push(details?);
}
let trait_filter: Option<Vec<String>> =
traits.map(|t| t.split(',').map(|s| s.trim().to_string()).collect());
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(&animals).unwrap_or_default()
);
} else {
print!(
"{}",
format::fmt_comparison(&animals, trait_filter.as_deref(),)
);
}
},
Commands::Completions { shell } => {
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "nsip", &mut std::io::stdout());
},
Commands::ManPages { out_dir } => {
generate_man_pages(out_dir)?;
},
Commands::Mcp {
transport,
host,
port,
tools,
auth,
} => {
init_tracing();
let sets = tools.map_or_else(nsip::mcp::tool_sets::EnabledToolSets::all, |csv| {
nsip::mcp::tool_sets::EnabledToolSets::from_csv(&csv)
});
let oauth_state = if auth {
let config =
nsip::mcp::oauth::config::OAuthConfig::try_from_env().ok_or_else(|| {
nsip::Error::Validation(
"--auth requires NSIP_GITHUB_CLIENT_ID, NSIP_GITHUB_CLIENT_SECRET, \
NSIP_AUTH_SECRET, and NSIP_AUTH_BASE_URL environment variables"
.into(),
)
})?;
let store = std::sync::Arc::new(nsip::mcp::oauth::store::InMemoryOAuthStore::new())
as std::sync::Arc<dyn nsip::mcp::oauth::store::OAuthStoreBackend>;
Some(nsip::mcp::oauth::OAuthState::new(config, store))
} else {
None
};
match transport.as_str() {
"stdio" => {
if auth {
eprintln!("warning: --auth is ignored for stdio transport");
}
nsip::mcp::serve_stdio(sets).await?;
},
"http" => nsip::mcp::serve_http(&host, port, sets, oauth_state).await?,
other => {
return Err(nsip::Error::Validation(format!(
"unknown transport: {other}, expected 'stdio' or 'http'"
)));
},
}
},
}
Ok(())
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
},
}
}