use calcit::cli_args::{LibsCommand, LibsSubcommand};
use colored::Colorize;
use serde::Deserialize;
use super::markdown_read::{RenderMarkdownOptions, render_markdown_sections};
#[derive(Debug, Clone, Deserialize)]
pub struct LibraryEntry {
#[serde(rename = ":package-name")]
pub package_name: String,
#[serde(rename = ":repository")]
pub repository: String,
#[serde(rename = ":category", default)]
pub category: EdnSet,
#[serde(rename = ":description", default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EdnSet {
#[serde(rename = "__edn_set", default)]
pub items: Vec<EdnTag>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct EdnTag {
#[serde(rename = "__edn_tag")]
pub tag: String,
}
impl EdnSet {
pub fn to_strings(&self) -> Vec<String> {
self.items.iter().map(|t| t.tag.clone()).collect()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LibraryRegistry {
#[serde(rename = ":description")]
pub description: String,
#[serde(rename = ":libraries")]
pub libraries: Vec<LibraryEntry>,
}
struct ReadmeHeader<'a> {
package: &'a str,
source: Option<&'a str>,
path: Option<&'a str>,
repository: Option<&'a str>,
description: Option<&'a str>,
file: Option<&'a str>,
}
pub fn handle_libs_command(cmd: &LibsCommand) -> Result<(), String> {
match &cmd.subcommand {
None => handle_list_libs(),
Some(LibsSubcommand::Readme(opts)) => handle_readme(
&opts.package,
opts.file.as_deref(),
&opts.headings,
!opts.no_subheadings,
opts.full,
opts.with_lines,
),
Some(LibsSubcommand::Search(opts)) => handle_search(&opts.keyword),
Some(LibsSubcommand::ScanMd(opts)) => handle_scan_md(&opts.module),
}
}
fn fetch_registry() -> Result<LibraryRegistry, String> {
let url = "https://libs.calcit-lang.org/base.cirru";
let response = ureq::get(url)
.call()
.map_err(|e| format!("Failed to connect to library registry: {e}"))?;
let text = response
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read response text: {e}"))?;
let edn = cirru_edn::parse(&text).map_err(|e| format!("Failed to parse Cirru EDN: {e}"))?;
let json_value = serde_json::to_value(&edn).map_err(|e| format!("Failed to convert EDN to JSON: {e}"))?;
serde_json::from_value(json_value).map_err(|e| format!("Failed to parse library registry: {e}"))
}
fn handle_list_libs() -> Result<(), String> {
println!("{}", "Fetching Calcit libraries from registry...".dimmed());
let registry = fetch_registry()?;
println!("\n{}", "Available Calcit Libraries:".bold());
println!("{}", "=".repeat(60).dimmed());
println!("{}", registry.description.dimmed());
println!();
for lib in ®istry.libraries {
println!("{}", lib.package_name.cyan().bold());
println!(" {}: {}", "repo".dimmed(), lib.repository);
let categories = lib.category.to_strings();
if !categories.is_empty() {
println!(" {}: {}", "category".dimmed(), categories.join(", "));
}
if let Some(desc) = &lib.description {
println!(" {}: {}", "desc".dimmed(), desc);
}
println!();
}
println!("{}", format!("Total: {} libraries", registry.libraries.len()).dimmed());
println!("\n{}", "Use 'cr libs readme <package>' to view library README.".dimmed());
println!("{}", "Use 'cr libs search <keyword>' to search libraries.".dimmed());
println!("{}", "Use 'caps' command to install libraries.".dimmed());
Ok(())
}
fn print_readme_header(header: ReadmeHeader<'_>) {
println!("\n{} {}", "Package:".bold(), header.package.cyan().bold());
if let Some(source) = header.source {
println!("{} {}", "Source:".bold(), source.green());
}
if let Some(path) = header.path {
println!("{} {}", "Path:".bold(), path.dimmed());
}
if let Some(repository) = header.repository {
println!("{} {}", "Repository:".bold(), repository);
}
if let Some(description) = header.description {
println!("{} {}", "Description:".bold(), description);
}
if let Some(file) = header.file {
println!("{} {}", "File:".bold(), file);
}
println!();
}
fn handle_readme(
package: &str,
file: Option<&str>,
heading_queries: &[String],
include_subheadings: bool,
full: bool,
with_lines: bool,
) -> Result<(), String> {
let file_name = file.unwrap_or("README.md");
println!("{}", format!("Looking for {file_name} in '{package}'...").dimmed());
let home_dir = std::env::var("HOME").map_err(|_| "Failed to get HOME directory".to_string())?;
let local_path = format!("{home_dir}/.config/calcit/modules/{package}/{file_name}");
let render_readme = |content: &str| -> Result<(), String> {
let no_match_error = format!("No heading matched in {file_name}. Use 'cr libs readme {package} -f {file_name}' to list headings.");
render_markdown_sections(
content,
heading_queries,
RenderMarkdownOptions {
include_subheadings,
full,
with_lines,
command_prefix: "cr libs readme <package>",
with_file_option: true,
no_headings_message: "No Markdown headings found, printing full file.",
print_full_when_no_headings: true,
no_match_error: &no_match_error,
},
)
};
if let Ok(content) = std::fs::read_to_string(&local_path) {
print_readme_header(ReadmeHeader {
package,
source: Some("Local"),
path: Some(&local_path),
repository: None,
description: None,
file: None,
});
return render_readme(&content);
}
println!("{}", "Not found locally, fetching from GitHub...".to_string().dimmed());
let registry = fetch_registry()?;
let lib = registry
.libraries
.iter()
.find(|l| l.package_name == package)
.ok_or_else(|| format!("Package '{package}' not found in registry"))?;
let base_url = github_to_raw_base(&lib.repository)?;
let agent = ureq::Agent::config_builder().user_agent("calcit-cli").build().new_agent();
let content =
fetch_file_content(&agent, &base_url, "main", file_name).or_else(|_| fetch_file_content(&agent, &base_url, "master", file_name))?;
print_readme_header(ReadmeHeader {
package: &lib.package_name,
source: None,
path: None,
repository: Some(&lib.repository),
description: lib.description.as_deref(),
file: Some(file_name),
});
render_readme(&content)
}
fn github_to_raw_base(repo_url: &str) -> Result<String, String> {
if !repo_url.starts_with("https://github.com/") {
return Err(format!("Unsupported repository URL format: {repo_url}"));
}
let path = repo_url.trim_start_matches("https://github.com/").trim_end_matches('/');
Ok(format!("https://raw.githubusercontent.com/{path}"))
}
fn fetch_file_content(agent: &ureq::Agent, base_url: &str, branch: &str, file_name: &str) -> Result<String, String> {
let url = format!("{base_url}/{branch}/{file_name}");
let response = agent.get(&url).call().map_err(|e| format!("Failed to fetch file: {e}"))?;
response
.into_body()
.read_to_string()
.map_err(|e| format!("Failed to read file: {e}"))
}
fn handle_search(keyword: &str) -> Result<(), String> {
println!("{}", format!("Searching for '{keyword}'...").dimmed());
let registry = fetch_registry()?;
let keyword_lower = keyword.to_lowercase();
let results: Vec<&LibraryEntry> = registry
.libraries
.iter()
.filter(|lib| {
lib.package_name.to_lowercase().contains(&keyword_lower)
|| lib.description.as_ref().is_some_and(|d| d.to_lowercase().contains(&keyword_lower))
|| lib.category.to_strings().iter().any(|c| c.to_lowercase().contains(&keyword_lower))
})
.collect();
if results.is_empty() {
println!("\n{}", format!("No libraries found matching '{keyword}'").yellow());
return Ok(());
}
println!("\n{}", format!("Found {} libraries matching '{}':", results.len(), keyword).bold());
println!("{}", "=".repeat(60).dimmed());
for lib in results {
println!("{}", lib.package_name.cyan().bold());
println!(" {}: {}", "repo".dimmed(), lib.repository);
let categories = lib.category.to_strings();
if !categories.is_empty() {
println!(" {}: {}", "category".dimmed(), categories.join(", "));
}
if let Some(desc) = &lib.description {
println!(" {}: {}", "desc".dimmed(), desc);
}
println!();
}
println!("{}", "Use 'cr libs readme <package>' to view library README.".dimmed());
Ok(())
}
fn handle_scan_md(module: &str) -> Result<(), String> {
let home_dir = std::env::var("HOME").map_err(|_| "Failed to get HOME directory".to_string())?;
let module_path = format!("{home_dir}/.config/calcit/modules/{module}");
if !std::path::Path::new(&module_path).exists() {
return Err(format!("Module directory not found: {module_path}"));
}
println!("{}", format!("Scanning markdown files in '{module}'...").cyan().bold());
println!("{}: {}", "Path".dimmed(), module_path);
println!();
let mut md_files = Vec::new();
scan_directory(&module_path, &module_path, &mut md_files)?;
if md_files.is_empty() {
println!("{}", "No markdown files found.".yellow());
return Ok(());
}
md_files.sort();
println!("{}", format!("Found {} markdown files:", md_files.len()).bold());
println!("{}", "=".repeat(60).dimmed());
for file in &md_files {
println!(" {}", file.cyan());
}
println!();
println!(
"{}",
format!("Use 'cr libs readme {module} -f <file>' to read a specific file").dimmed()
);
Ok(())
}
fn scan_directory(base_path: &str, current_path: &str, results: &mut Vec<String>) -> Result<(), String> {
let entries = std::fs::read_dir(current_path).map_err(|e| format!("Failed to read directory {current_path}: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "md" {
let relative_path = path
.strip_prefix(base_path)
.map_err(|e| format!("Failed to get relative path: {e}"))?
.to_string_lossy()
.to_string();
results.push(relative_path);
}
}
} else if path.is_dir() {
if let Some(dir_name) = path.file_name() {
if !dir_name.to_string_lossy().starts_with('.') {
scan_directory(base_path, &path.to_string_lossy(), results)?;
}
}
}
}
Ok(())
}