mod git;
use argh::{self, FromArgs};
use cirru_edn::Edn;
use colored::*;
use git::*;
use semver::Version;
use std::{
collections::HashMap,
fs,
io::Write,
path::{Path, PathBuf},
sync::Arc,
thread,
};
#[derive(Debug, Clone, PartialEq, Eq)]
struct PackageDeps {
calcit_version: Option<String>,
dependencies: HashMap<Arc<str>, Arc<str>>,
}
impl TryFrom<Edn> for PackageDeps {
type Error = String;
fn try_from(value: Edn) -> Result<Self, Self::Error> {
let deps_info = value.view_map()?;
#[allow(clippy::mutable_key_type)]
let dict = deps_info.get_or_nil("dependencies").view_map()?.0;
let mut deps: HashMap<Arc<str>, Arc<str>> = HashMap::new();
for (k, v) in &dict {
match (k, v) {
(Edn::Str(k), Edn::Str(v)) => {
deps.insert(k.to_owned(), v.to_owned());
}
_ => {
return Err(format!("invalid dependency: {k} {v}"));
}
}
}
let expected_version: Option<String> = match deps_info.get_or_nil("calcit-version") {
Edn::Str(s) => Some((*s).to_owned()),
Edn::Nil => None,
v => return Err(format!("invalid calcit-version: {v}")),
};
Ok(PackageDeps {
calcit_version: expected_version,
dependencies: deps,
})
}
}
pub fn main() -> Result<(), String> {
let cli_args: TopLevelCaps = argh::from_env();
if let Some(SubCommand::Download(dep_names)) = &cli_args.subcommand {
if dep_names.packages.is_empty() {
eprintln!("Error: no packages to download!");
std::process::exit(1);
}
let dict: HashMap<Arc<str>, Arc<str>> = dep_names
.packages
.iter()
.map(|s| {
let (org_and_folder, version) = s.split_once('@').ok_or("invalid name")?;
Ok((org_and_folder.to_owned().into(), version.to_owned().into()))
})
.collect::<Result<_, String>>()?;
download_deps(dict, cli_args)?;
return Ok(());
}
if Path::new(&cli_args.input).exists() {
let content = fs::read_to_string(&cli_args.input).map_err(|e| e.to_string())?;
let parsed = cirru_edn::parse(&content).map_err(|e| {
eprintln!("\nFailed to parse '{}':", cli_args.input);
eprintln!("{e}");
format!("Failed to parse '{}'", cli_args.input)
})?;
let deps: PackageDeps = parsed.try_into()?;
if let Some(version) = &deps.calcit_version
&& version != CALCIT_VERSION
{
eprintln!("[Warn] calcit version mismatch, deps.cirru expected {version}, running {CALCIT_VERSION}");
}
match &cli_args.subcommand {
Some(SubCommand::Outdated(opts)) => {
let updated = outdated_tags(deps, &cli_args.input, opts.yes)?;
if updated {
println!("\nDownloading updated dependencies...");
let content = fs::read_to_string(&cli_args.input).map_err(|e| e.to_string())?;
let parsed = cirru_edn::parse(&content).map_err(|e| {
eprintln!("\nFailed to parse '{}':", cli_args.input);
eprintln!("{e}");
format!("Failed to parse '{}'", cli_args.input)
})?;
let updated_deps: PackageDeps = parsed.try_into()?;
download_deps(updated_deps.dependencies, cli_args)?;
}
}
Some(SubCommand::Add(opts)) => {
if opts.packages.is_empty() {
return Err("no packages to add".to_string());
}
let mut updated_deps = deps;
for raw in &opts.packages {
let org_and_folder = normalize_package_name(raw)?;
updated_deps
.dependencies
.insert(org_and_folder.into(), opts.version.to_owned().into());
}
write_deps_file(&cli_args.input, &updated_deps)?;
println!("updated {}", cli_args.input.green());
download_deps(updated_deps.dependencies, cli_args)?;
}
Some(SubCommand::Remove(opts)) => {
if opts.packages.is_empty() {
return Err("no packages to remove".to_string());
}
let mut updated_deps = deps;
for raw in &opts.packages {
let org_and_folder = normalize_package_name(raw)?;
updated_deps.dependencies.remove(org_and_folder.as_str());
}
write_deps_file(&cli_args.input, &updated_deps)?;
println!("updated {}", cli_args.input.green());
download_deps(updated_deps.dependencies, cli_args)?;
}
Some(SubCommand::Download(dep_names)) => {
unreachable!("already handled: {:?}", dep_names);
}
None => {
download_deps(deps.dependencies, cli_args)?;
}
}
Ok(())
} else if Path::new("package.cirru").exists() {
eprintln!("{}", "Error: 'package.cirru' is deprecated!".red().bold());
eprintln!("Please rename it to 'deps.cirru':");
eprintln!(" {}", "mv package.cirru deps.cirru".yellow());
std::process::exit(1);
} else {
eprintln!("Error: no {} found!", cli_args.input);
std::process::exit(1);
}
}
fn download_deps(deps: HashMap<Arc<str>, Arc<str>>, options: TopLevelCaps) -> Result<(), String> {
let clone_target = if options.local_debug {
println!("{}", " [DEBUG] local debug mode, cloning to test-modules/".yellow());
".config/calcit/test-modules"
} else {
".config/calcit/modules"
};
let modules_dir = dirs::home_dir().ok_or("no config dir")?.join(clone_target);
if !modules_dir.exists() {
fs::create_dir_all(&modules_dir).map_err(|e| e.to_string())?;
dim_println(format!("created dir: {modules_dir:?}"));
}
let mut children = vec![];
for (org_and_folder, version) in deps {
let org_and_folder = org_and_folder.clone();
let options = options.to_owned();
let modules_dir = modules_dir.clone();
let options2 = options.clone();
let ret = thread::spawn(move || {
let ret = handle_path(modules_dir, version, &options2, org_and_folder);
if let Err(e) = ret {
err_println(format!("{e}\n"));
}
});
children.push(ret);
}
for child in children {
child.join().unwrap();
}
Ok(())
}
fn handle_path(modules_dir: PathBuf, version: Arc<str>, options: &TopLevelCaps, org_and_folder: Arc<str>) -> Result<(), String> {
let (_org, folder) = org_and_folder.split_once('/').ok_or("invalid name")?;
let folder_path = modules_dir.join(folder);
let build_file = folder_path.join("build.sh");
let git_repo = GitRepo { dir: folder_path.clone() };
if folder_path.exists() {
let current_head = git_repo.current_head()?;
if current_head.get_name() == *version {
dim_println(format!("√ found {} of {}", gray(&version), gray(folder)));
if let GitHead::Branch(branch) = current_head
&& options.pull_branch
{
dim_println(format!("↺ pulling {} at version {}", gray(&org_and_folder), gray(&version)));
git_repo.pull(&branch)?;
dim_println(format!("pulled {} at {}", gray(folder), gray(&version)));
if build_file.exists() {
let build_msg = call_build_script(&folder_path)?;
dim_println(format!("ran build script for {}", gray(&org_and_folder)));
dim_println(build_msg);
}
}
return Ok(());
}
git_repo.fetch()?;
let has_target = git_repo.check_branch_or_tag(&version, folder)?;
if !has_target {
dim_println(format!("↺ fetching {} at version {}", gray(&org_and_folder), gray(&version)));
git_repo.fetch()?;
dim_println(format!("fetched {} at version {}", gray(&org_and_folder), gray(&version)));
}
git_repo.checkout(&version)?;
dim_println(format!("√ checked out {} of {}", gray(&version), gray(&org_and_folder)));
let current_head = git_repo.current_head()?;
if let GitHead::Branch(branch) = current_head
&& options.pull_branch
{
dim_println(format!("↺ pulling {} at version {}", gray(&org_and_folder), gray(&version)));
git_repo.pull(&branch)?;
dim_println(format!("pulled {} at {}", gray(folder), gray(&version)));
}
if build_file.exists() {
let build_msg = call_build_script(&folder_path)?;
dim_println(format!("ran build script for {}", gray(&org_and_folder)));
dim_println(build_msg);
}
} else {
let url = if options.ci {
format!("https://github.com/{org_and_folder}.git")
} else {
format!("git@github.com:{org_and_folder}.git")
};
dim_println(format!("↺ cloning {} at version {}", gray(&org_and_folder), gray(&version)));
GitRepo::clone_to(&modules_dir, &url, &version, options.ci)?;
dim_println(format!("downloaded {} at version {}", gray(&org_and_folder), gray(&version)));
if !options.ci {
if build_file.exists() {
let build_msg = call_build_script(&folder_path)?;
dim_println(format!("ran build script for {}", gray(&org_and_folder)));
dim_println(build_msg);
}
}
}
Ok(())
}
pub const CALCIT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(FromArgs, PartialEq, Debug, Clone)]
struct TopLevelCaps {
#[argh(switch, short = 'v')]
verbose: bool,
#[argh(subcommand)]
subcommand: Option<SubCommand>,
#[argh(switch)]
pull_branch: bool,
#[argh(switch)]
ci: bool,
#[argh(switch)]
local_debug: bool,
#[argh(positional, default = "\"deps.cirru\".to_owned()")]
input: String,
}
#[derive(FromArgs, PartialEq, Debug, Clone)]
#[argh(subcommand)]
enum SubCommand {
Outdated(OutdatedCaps),
Download(DownloadCaps),
Add(AddCaps),
Remove(RemoveCaps),
}
#[derive(FromArgs, PartialEq, Debug, Clone)]
#[argh(subcommand, name = "outdated")]
struct OutdatedCaps {
#[argh(switch, short = 'y', long = "yes")]
yes: bool,
}
#[derive(FromArgs, PartialEq, Debug, Clone)]
#[argh(subcommand, name = "download")]
struct DownloadCaps {
#[argh(positional)]
packages: Vec<String>,
}
#[derive(FromArgs, PartialEq, Debug, Clone)]
#[argh(subcommand, name = "add")]
struct AddCaps {
#[argh(positional)]
packages: Vec<String>,
#[argh(option, short = 'r', default = "\"main\".to_string()")]
version: String,
}
#[derive(FromArgs, PartialEq, Debug, Clone)]
#[argh(subcommand, name = "remove")]
struct RemoveCaps {
#[argh(positional)]
packages: Vec<String>,
}
fn dim_println(msg: String) {
if msg.chars().nth(1) == Some(' ') {
println!("{}", msg.truecolor(128, 128, 128));
} else {
println!(" {}", msg.truecolor(128, 128, 128));
}
}
fn err_println(msg: String) {
if msg.chars().nth(1) == Some(' ') {
println!("{}", msg.truecolor(255, 80, 80));
} else {
println!(" {}", msg.replace('\n', "\n ").truecolor(255, 80, 80));
}
}
fn gray(msg: &str) -> ColoredString {
msg.truecolor(172, 172, 172)
}
fn indent4(msg: &str) -> String {
let ret = msg
.trim()
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<String>>()
.join("\n");
format!("\n{ret}\n")
}
fn normalize_package_name(raw: &str) -> Result<String, String> {
let mut s = raw.trim().to_string();
if let Some(rest) = s.strip_prefix("https://github.com/") {
s = rest.to_string();
} else if let Some(rest) = s.strip_prefix("http://github.com/") {
s = rest.to_string();
} else if let Some(rest) = s.strip_prefix("git@github.com:") {
s = rest.to_string();
}
if s.ends_with(".git") {
s.truncate(s.len() - 4);
}
s = s.trim_end_matches('/').to_string();
let segments: Vec<&str> = s.split('/').filter(|x| !x.is_empty()).collect();
if segments.len() < 2 {
return Err(format!("invalid package '{raw}', expected org/repo or github URL"));
}
Ok(format!("{}/{}", segments[0], segments[1]))
}
fn write_deps_file(deps_file: &str, deps: &PackageDeps) -> Result<(), String> {
let mut updated_edn = Edn::Map(cirru_edn::EdnMapView::default());
if let Edn::Map(ref mut map) = updated_edn {
if let Some(ref version) = deps.calcit_version {
map.insert(Edn::tag("calcit-version"), Edn::str(version.as_str()));
}
let mut deps_map = cirru_edn::EdnMapView::default();
for (k, v) in &deps.dependencies {
deps_map.insert(Edn::str(&**k), Edn::str(&**v));
}
map.insert(Edn::tag("dependencies"), Edn::Map(deps_map));
}
let updated_content = cirru_edn::format(&updated_edn, false)?;
fs::write(deps_file, updated_content).map_err(|e| e.to_string())?;
Ok(())
}
fn call_build_script(folder_path: &Path) -> Result<String, String> {
let output = std::process::Command::new("sh")
.arg("build.sh")
.current_dir(folder_path)
.output()
.map_err(|e| e.to_string())?;
if output.status.success() {
let msg = std::str::from_utf8(&output.stdout).unwrap_or("");
Ok(indent4(msg))
} else {
let msg = std::str::from_utf8(&output.stderr).unwrap_or("");
err_println(indent4(msg));
Err(format!("failed to build module {}", folder_path.display()))
}
}
fn outdated_tags(deps: PackageDeps, deps_file: &str, auto_yes: bool) -> Result<bool, String> {
print_column("package".dimmed(), "expected".dimmed(), "latest".dimmed(), "hint".dimmed());
println!();
let mut outdated_packages = Vec::new();
let mut children = vec![];
for (org_and_folder, version) in &deps.dependencies {
let org_and_folder_clone = org_and_folder.clone();
let version_clone = version.clone();
let ret = thread::spawn(move || {
let ret = show_package_versions(org_and_folder_clone, version_clone);
if let Err(e) = ret {
err_println(format!("{e}\n"));
return None;
}
ret.ok()
});
children.push((org_and_folder.clone(), version.clone(), ret));
}
for (org_and_folder, version, child) in children {
if let Ok(Some(Some(latest_tag))) = child.join() {
if latest_tag != *version {
outdated_packages.push((org_and_folder.to_owned(), version.to_owned(), latest_tag));
}
}
}
let calcit_version_upgrade = deps.calcit_version.as_ref().and_then(|version| {
let expected = Version::parse(version).ok()?;
let current = Version::parse(CALCIT_VERSION).ok()?;
if expected < current { Some(version.to_owned()) } else { None }
});
if !outdated_packages.is_empty() || calcit_version_upgrade.is_some() {
if auto_yes {
update_deps_file(&outdated_packages, calcit_version_upgrade.as_deref(), deps_file)?;
println!("deps.cirru updated successfully!");
return Ok(true);
}
println!();
let mut changes = Vec::new();
if !outdated_packages.is_empty() {
changes.push(format!("{} outdated package(s)", outdated_packages.len()));
}
if let Some(version) = &calcit_version_upgrade {
changes.push(format!("calcit-version {version} -> {CALCIT_VERSION}"));
}
print!("Found {}. Update deps.cirru? (y/N): ", changes.join(", "));
std::io::stdout().flush().map_err(|e| e.to_string())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input).map_err(|e| e.to_string())?;
let input = input.trim();
if input.is_empty() || input.to_lowercase() == "y" || input.to_lowercase() == "yes" {
update_deps_file(&outdated_packages, calcit_version_upgrade.as_deref(), deps_file)?;
println!("deps.cirru updated successfully!");
return Ok(true);
}
}
Ok(false)
}
fn show_package_versions(org_and_folder: Arc<str>, version: Arc<str>) -> Result<Option<String>, String> {
let (_org, folder) = org_and_folder.split_once('/').ok_or("invalid name")?;
let folder_path = dirs::home_dir().ok_or("no config dir")?.join(".config/calcit/modules").join(folder);
let git_repo = GitRepo { dir: folder_path.clone() };
if folder_path.exists() {
git_repo.fetch()?;
let latest_tag = git_repo.latest_tag()?;
let latest_timestamp = git_repo.timestamp(&latest_tag)?;
let expected_timestamp = git_repo.timestamp(&version)?;
let outdated = expected_timestamp < latest_timestamp;
if outdated {
print_column(org_and_folder.yellow(), version.yellow(), latest_tag.yellow(), "Outdated".yellow());
Ok(Some(latest_tag))
} else {
print_column(org_and_folder.dimmed(), version.dimmed(), latest_tag.dimmed(), "√".dimmed());
Ok(None)
}
} else {
print_column(org_and_folder.red(), version.red(), "not found".red(), "-".red());
Ok(None)
}
}
fn update_deps_file(
outdated_packages: &[(Arc<str>, Arc<str>, String)],
calcit_version_upgrade: Option<&str>,
deps_file: &str,
) -> Result<(), String> {
if !Path::new(deps_file).exists() {
return Err("deps.cirru file not found".to_string());
}
let content = fs::read_to_string(deps_file).map_err(|e| e.to_string())?;
let parsed = cirru_edn::parse(&content).map_err(|e| {
eprintln!("\nFailed to parse '{deps_file}':");
eprintln!("{e}");
format!("Failed to parse '{deps_file}'")
})?;
let mut deps: PackageDeps = parsed.try_into()?;
if let Some(version) = calcit_version_upgrade {
deps.calcit_version = Some(version.to_string());
}
for (org_and_folder, _old_version, new_version) in outdated_packages {
deps.dependencies.insert(org_and_folder.clone(), new_version.clone().into());
}
write_deps_file(deps_file, &deps)
}
fn print_column(pkg: ColoredString, expected: ColoredString, latest: ColoredString, hint: ColoredString) {
println!("{pkg:<32} {expected:<12} {latest:<12} {hint:<12}");
}