From 6a2110914e5961e542d020bf360566168cfdc595 Mon Sep 17 00:00:00 2001 From: Santiago Carmuega Date: Fri, 18 Jul 2025 14:12:16 -0300 Subject: [PATCH] feat: support external checks for updates --- src/banner.rs | 24 ++++++++++++++++++ src/cmds/check.rs | 60 +++++++++++++++++++++++++++++++++++++-------- src/cmds/install.rs | 8 ++++++ src/cmds/show.rs | 8 +++++- src/cmds/use.rs | 11 ++++++--- src/main.rs | 44 +++++++++++++++++++-------------- src/updates.rs | 50 ++++++++++++++++++++++++++++++++++--- 7 files changed, 167 insertions(+), 38 deletions(-) create mode 100644 src/banner.rs diff --git a/src/banner.rs b/src/banner.rs new file mode 100644 index 0000000..b625d44 --- /dev/null +++ b/src/banner.rs @@ -0,0 +1,24 @@ +use color_print::{cprintln, cstr}; + +use crate::Config; + +pub const BANNER: &str = color_print::cstr! { +r#" +<#FFFFFF>████████╗<#999999>██╗ ██╗<#FF007F>██████╗ +<#FFFFFF>╚══██╔══╝<#999999>╚██╗██╔╝<#FF007F>╚════██╗ +<#FFFFFF> ██║ <#999999> ╚███╔╝ <#FF007F> █████╔╝ +<#FFFFFF> ██║ <#999999> ██╔██╗ <#FF007F> ╚═══██╗ +<#FFFFFF> ██║ <#999999>██╔╝ ██╗<#FF007F>██████╔╝ +<#FFFFFF> ╚═╝ <#999999>╚═╝ ╚═╝<#FF007F>╚═════╝ "# +}; + +pub fn print_banner(config: &Config) { + println!("\n{}\n", BANNER.trim_start()); + + cprintln!( + "root dir: <#FFFFFF>{}", + config.root_dir().display() + ); + cprintln!("channel: <#FFFFFF>{}", config.ensure_channel()); + println!(); +} diff --git a/src/cmds/check.rs b/src/cmds/check.rs index c855b15..0485f69 100644 --- a/src/cmds/check.rs +++ b/src/cmds/check.rs @@ -1,17 +1,36 @@ use clap::Parser; +use clap::ValueEnum; +use crate::ArgsCommon; use crate::{Config, manifest, updates}; +#[derive(Clone, Debug, ValueEnum)] +pub enum OutputFormat { + Json, + Text, +} + #[derive(Parser, Default)] pub struct Args { #[arg(short, long)] pub silent: bool, + /// Force #[arg(short, long)] pub force: bool, + /// Print details of each update #[arg(short, long)] pub verbose: bool, + + #[arg(short, long)] + pub output: Option, +} + +impl ArgsCommon for Args { + fn skip_banner(&self) -> bool { + self.silent || matches!(self.output, Some(OutputFormat::Json)) + } } fn print_update(update: &updates::Update, manifest: &manifest::Manifest) -> anyhow::Result<()> { @@ -28,21 +47,17 @@ fn print_update(update: &updates::Update, manifest: &manifest::Manifest) -> anyh Ok(()) } -pub async fn run(args: &Args, config: &Config) -> anyhow::Result<()> { - let manifest = manifest::load_latest_manifest(config, args.force).await?; - - let updates = updates::load_updates(&manifest, config, args.force).await?; - - if args.silent { - return Ok(()); - } - +fn text_output( + updates: &[updates::Update], + manifest: &manifest::Manifest, + verbose: bool, +) -> anyhow::Result<()> { if updates.is_empty() { println!("You are up to date 🎉"); return Ok(()); } - if !args.verbose { + if !verbose { println!("You have {} update/s to install 📦", updates.len()); } else { for update in updates { @@ -52,3 +67,28 @@ pub async fn run(args: &Args, config: &Config) -> anyhow::Result<()> { Ok(()) } + +fn json_output(updates: &[updates::Update]) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(&updates)?; + println!("{}", json); + Ok(()) +} + +pub async fn run(args: &Args, config: &Config) -> anyhow::Result<()> { + let manifest = manifest::load_latest_manifest(config, args.force).await?; + + let updates = updates::load_updates(&manifest, config, args.force).await?; + + if args.silent { + return Ok(()); + } + + let output = args.output.as_ref().unwrap_or(&OutputFormat::Text); + + match output { + OutputFormat::Json => json_output(&updates)?, + OutputFormat::Text => text_output(&updates, &manifest, args.verbose)?, + }; + + Ok(()) +} diff --git a/src/cmds/install.rs b/src/cmds/install.rs index 44af735..d189328 100644 --- a/src/cmds/install.rs +++ b/src/cmds/install.rs @@ -15,15 +15,23 @@ use std::path::PathBuf; use tar::Archive; use xz2::read::XzDecoder; +use crate::ArgsCommon; use crate::manifest; use crate::updates; use crate::{Config, manifest::*}; #[derive(Parser, Default)] pub struct Args { + #[arg(short, long)] release: Option, } +impl ArgsCommon for Args { + fn skip_banner(&self) -> bool { + false + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct VersionsFile { tools: Vec, diff --git a/src/cmds/show.rs b/src/cmds/show.rs index 26c4b46..a239632 100644 --- a/src/cmds/show.rs +++ b/src/cmds/show.rs @@ -1,12 +1,18 @@ use std::process::Command; -use crate::{Config, manifest}; +use crate::{ArgsCommon, Config, manifest}; #[derive(Debug, clap::Parser)] pub struct Args { pub tool: Option, } +impl ArgsCommon for Args { + fn skip_banner(&self) -> bool { + false + } +} + fn print_tool(tool: &crate::manifest::Tool, config: &Config) -> anyhow::Result<()> { println!("bin path: {}", tool.bin_path(config).display()); diff --git a/src/cmds/use.rs b/src/cmds/use.rs index c967548..b59aa53 100644 --- a/src/cmds/use.rs +++ b/src/cmds/use.rs @@ -1,9 +1,6 @@ -use anyhow::Result; use clap::Parser; -use std::os::unix::fs::symlink; -use std::{fs, path::Path}; -use crate::{Config, perm_path}; +use crate::{ArgsCommon, Config, perm_path}; #[derive(Parser)] pub struct Args { @@ -11,6 +8,12 @@ pub struct Args { pub new_channel: String, } +impl ArgsCommon for Args { + fn skip_banner(&self) -> bool { + false + } +} + impl Default for Args { fn default() -> Self { Self { diff --git a/src/main.rs b/src/main.rs index 898ac92..a5658bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,24 +3,15 @@ use std::path::PathBuf; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; +mod banner; mod bin; mod cmds; mod manifest; mod perm_path; mod updates; -pub const BANNER: &str = color_print::cstr! { -r#" -<#FFFFFF>████████╗<#999999>██╗ ██╗<#FF007F>██████╗ -<#FFFFFF>╚══██╔══╝<#999999>╚██╗██╔╝<#FF007F>╚════██╗ -<#FFFFFF> ██║ <#999999> ╚███╔╝ <#FF007F> █████╔╝ -<#FFFFFF> ██║ <#999999> ██╔██╗ <#FF007F> ╚═══██╗ -<#FFFFFF> ██║ <#999999>██╔╝ ██╗<#FF007F>██████╔╝ -<#FFFFFF> ╚═╝ <#999999>╚═╝ ╚═╝<#FF007F>╚═════╝ "# -}; - #[derive(Parser)] -#[command(author, version, about, long_about = Some(BANNER))] +#[command(author, version, about, long_about = Some(banner::BANNER))] struct Cli { #[arg(global = true, short, long, env = "TX3_ROOT")] root_dir: Option, @@ -47,6 +38,22 @@ enum Commands { Show(cmds::show::Args), } +pub trait ArgsCommon { + fn skip_banner(&self) -> bool; +} + +impl Commands { + fn skip_banner(&self) -> bool { + match self { + Commands::Install(x) => x.skip_banner(), + Commands::Check(x) => x.skip_banner(), + Commands::Use(x) => x.skip_banner(), + Commands::Show(x) => x.skip_banner(), + Commands::Uninstall => true, + } + } +} + pub struct Config { root_dir: Option, channel: Option, @@ -101,7 +108,8 @@ impl Config { std::fs::remove_file(&fixed_channel_dir)?; } - // Create new symlink + std::fs::create_dir_all(&channel_dir)?; + std::os::unix::fs::symlink(&channel_dir, &fixed_channel_dir)?; Ok(()) @@ -124,8 +132,7 @@ impl Config { pub fn ensure_channel(&self) -> String { match self.channel() { Ok(channel) => channel, - Err(e) => { - eprintln!("Error getting channel: {}", e); + Err(_) => { self.set_fixed_channel("stable").unwrap(); "stable".to_string() } @@ -159,11 +166,11 @@ async fn main() -> anyhow::Result<()> { channel: cli.channel, }; - println!("\n{}\n", BANNER.trim_start()); + let skip_banner = cli.command.as_ref().map_or(false, |c| c.skip_banner()); - println!("root dir: {}", config.root_dir().display()); - println!("current channel: {}", config.ensure_channel()); - println!(); + if !skip_banner { + banner::print_banner(&config); + } if let Some(command) = cli.command { match command { @@ -175,7 +182,6 @@ async fn main() -> anyhow::Result<()> { } } else { cmds::install::run(&cmds::install::Args::default(), &config).await?; - cmds::r#use::run(&cmds::r#use::Args::default(), &config).await?; } Ok(()) diff --git a/src/updates.rs b/src/updates.rs index 52706ad..18bf6f4 100644 --- a/src/updates.rs +++ b/src/updates.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, SystemTime}; + use anyhow::Context as _; use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; @@ -81,7 +83,13 @@ async fn save_updates(updates: &[Update], config: &Config) -> anyhow::Result<()> } pub async fn clear_updates(config: &Config) -> anyhow::Result<()> { - fs::remove_file(config.updates_file()) + let updates_file = config.updates_file(); + + if !updates_file.exists() { + return Ok(()); + } + + fs::remove_file(updates_file) .await .context("removing updates file")?; @@ -97,22 +105,56 @@ pub async fn check_updates(manifest: &Manifest, config: &Config) -> anyhow::Resu } } - save_updates(&updates, config).await?; + if updates.is_empty() { + clear_updates(config).await?; + } else { + save_updates(&updates, config).await?; + } Ok(updates) } +async fn check_updates_timestamp(config: &Config) -> anyhow::Result> { + let updates_file = config.updates_file(); + + if !updates_file.exists() { + return Ok(None); + } + + let metadata = fs::metadata(updates_file) + .await + .context("getting updates file metadata")?; + + let modified = metadata + .modified() + .context("getting updates file modified time")?; + + Ok(Some(modified)) +} + +const UPDATES_STALE_THRESHOLD: Duration = Duration::from_secs(60 * 60 * 24); + +fn updates_are_stale(timestamp: Option) -> bool { + timestamp.is_none() || timestamp.unwrap() < SystemTime::now() - UPDATES_STALE_THRESHOLD +} + pub async fn load_updates( manifest: &Manifest, config: &Config, force_check: bool, ) -> anyhow::Result> { - let updates_file = config.updates_file(); + let timestamp = check_updates_timestamp(config).await?; - if !updates_file.exists() || force_check { + if force_check || updates_are_stale(timestamp) { check_updates(manifest, config).await?; } + let updates_file = config.updates_file(); + + if !updates_file.exists() { + return Ok(vec![]); + } + let updates = fs::read_to_string(updates_file) .await .context("reading updates file")?;