Skip to content

Commit 1336e4e

Browse files
montfortclaude
andauthored
feat: add arborist update subcommand for self-updating (#9)
Restructure CLI from flat args to optional subcommands (backward compatible). Add `arborist update` to self-update from GitHub Releases and `arborist update --check` to check for new versions. Detects cargo-installed binaries and suggests `cargo install` instead. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 915071d commit 1336e4e

9 files changed

Lines changed: 2435 additions & 339 deletions

File tree

Cargo.lock

Lines changed: 2285 additions & 318 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ignore = "0.4"
2020
thiserror = "2"
2121
comfy-table = "7"
2222
csv = "1"
23+
self_update = { version = "0.43", features = ["archive-tar", "archive-zip", "compression-flate2"] }
2324

2425
[dev-dependencies]
2526
assert_cmd = "2"

src/analysis.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ use std::path::{Path, PathBuf};
33

44
use arborist::{AnalysisConfig, FileReport, Language};
55

6-
use crate::cli::CliArgs;
6+
use crate::cli::AnalyzeArgs;
77
use crate::error::ArboristError;
88
use crate::traversal;
99

10-
pub fn build_config(args: &CliArgs) -> AnalysisConfig {
10+
pub fn build_config(args: &AnalyzeArgs) -> AnalysisConfig {
1111
AnalysisConfig {
1212
cognitive_threshold: args.threshold,
1313
include_methods: !args.no_methods,
@@ -36,7 +36,7 @@ pub fn analyze_stdin(language: &str, config: &AnalysisConfig) -> Result<FileRepo
3636
pub fn analyze_paths(
3737
paths: &[PathBuf],
3838
config: &AnalysisConfig,
39-
args: &CliArgs,
39+
args: &AnalyzeArgs,
4040
) -> Result<(Vec<FileReport>, Vec<String>), ArboristError> {
4141
let files = traversal::collect_files(paths, args.languages.as_deref(), args.gitignore)?;
4242

@@ -53,7 +53,7 @@ pub fn analyze_paths(
5353
Ok((reports, errors))
5454
}
5555

56-
pub fn apply_filters(reports: &[FileReport], args: &CliArgs) -> (Vec<FileReport>, bool) {
56+
pub fn apply_filters(reports: &[FileReport], args: &AnalyzeArgs) -> (Vec<FileReport>, bool) {
5757
let mut threshold_exceeded = false;
5858
let mut filtered: Vec<FileReport> = Vec::new();
5959

src/cli.rs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::PathBuf;
22

3-
use clap::{Parser, ValueEnum};
3+
use clap::{Parser, Subcommand, ValueEnum};
44

55
#[derive(Debug, Clone, ValueEnum)]
66
pub enum OutputFormat {
@@ -23,7 +23,26 @@ pub enum SortMetric {
2323
version,
2424
about = "Code complexity metrics powered by arborist-metrics"
2525
)]
26-
pub struct CliArgs {
26+
pub struct Cli {
27+
#[command(subcommand)]
28+
pub command: Option<Command>,
29+
30+
#[command(flatten)]
31+
pub analyze: AnalyzeArgs,
32+
}
33+
34+
#[derive(Debug, Subcommand)]
35+
pub enum Command {
36+
/// Check for updates and install the latest version
37+
Update {
38+
/// Only check for available updates without installing
39+
#[arg(long)]
40+
check: bool,
41+
},
42+
}
43+
44+
#[derive(Debug, clap::Args)]
45+
pub struct AnalyzeArgs {
2746
/// Files or directories to analyze
2847
#[arg()]
2948
pub paths: Vec<PathBuf>,

src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ pub mod cli;
33
pub mod error;
44
pub mod output;
55
pub mod traversal;
6+
pub mod update;
67

7-
use cli::CliArgs;
8+
use cli::AnalyzeArgs;
89
use error::{ArboristError, ExitReport};
910

10-
pub fn run(args: &CliArgs) -> Result<ExitReport, ArboristError> {
11+
pub fn run(args: &AnalyzeArgs) -> Result<ExitReport, ArboristError> {
1112
let is_stdin = args.paths.is_empty() && atty::is(atty::Stream::Stdin).not_tty();
1213

1314
if is_stdin {

src/main.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@ use std::process::ExitCode;
22

33
use clap::Parser;
44

5-
use arborist_cli::cli::CliArgs;
5+
use arborist_cli::cli::{Cli, Command};
66

77
fn main() -> ExitCode {
8-
let args = CliArgs::parse();
8+
let cli = Cli::parse();
99

10-
match arborist_cli::run(&args) {
11-
Ok(report) => report.exit_code(),
12-
Err(err) => {
13-
eprintln!("error: {err}");
14-
ExitCode::from(2)
15-
}
10+
match cli.command {
11+
Some(Command::Update { check }) => arborist_cli::update::run(check),
12+
None => match arborist_cli::run(&cli.analyze) {
13+
Ok(report) => report.exit_code(),
14+
Err(err) => {
15+
eprintln!("error: {err}");
16+
ExitCode::from(2)
17+
}
18+
},
1619
}
1720
}

src/output/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ pub mod table;
55
use arborist::FileReport;
66

77
use crate::analysis;
8-
use crate::cli::{CliArgs, OutputFormat};
8+
use crate::cli::{AnalyzeArgs, OutputFormat};
99
use crate::error::ArboristError;
1010

1111
pub fn write_output(
1212
reports: &[FileReport],
13-
args: &CliArgs,
13+
args: &AnalyzeArgs,
1414
_threshold_exceeded: bool,
1515
) -> Result<(), ArboristError> {
1616
let use_flat_mode = args.sort.is_some() || args.top.is_some();

src/output/table.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ use arborist::FileReport;
44
use comfy_table::{ContentArrangement, Table};
55

66
use crate::analysis::FlatFunction;
7-
use crate::cli::CliArgs;
7+
use crate::cli::AnalyzeArgs;
88
use crate::error::ArboristError;
99

10-
pub fn write_reports(reports: &[FileReport], args: &CliArgs) -> Result<(), ArboristError> {
10+
pub fn write_reports(reports: &[FileReport], args: &AnalyzeArgs) -> Result<(), ArboristError> {
1111
let stdout = io::stdout();
1212
let mut out = stdout.lock();
1313
let is_tty = stdout.is_terminal();
@@ -71,7 +71,7 @@ pub fn write_reports(reports: &[FileReport], args: &CliArgs) -> Result<(), Arbor
7171
Ok(())
7272
}
7373

74-
pub fn write_flat(flat: &[FlatFunction], args: &CliArgs) -> Result<(), ArboristError> {
74+
pub fn write_flat(flat: &[FlatFunction], args: &AnalyzeArgs) -> Result<(), ArboristError> {
7575
let stdout = io::stdout();
7676
let mut out = stdout.lock();
7777
let is_tty = stdout.is_terminal();

src/update.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
use std::process::ExitCode;
2+
3+
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
4+
const REPO_OWNER: &str = "StrangeDaysTech";
5+
const REPO_NAME: &str = "arborist-cli";
6+
7+
pub fn run(check_only: bool) -> ExitCode {
8+
println!("arborist-cli v{CURRENT_VERSION}");
9+
10+
if is_cargo_installed() {
11+
eprintln!(
12+
"hint: this binary appears to be installed via cargo. \
13+
To update, run: cargo install arborist-cli"
14+
);
15+
if check_only {
16+
return check_latest_version();
17+
}
18+
return ExitCode::SUCCESS;
19+
}
20+
21+
if check_only {
22+
return check_latest_version();
23+
}
24+
25+
perform_update()
26+
}
27+
28+
fn check_latest_version() -> ExitCode {
29+
print!("Checking for updates... ");
30+
31+
match self_update::backends::github::Update::configure()
32+
.repo_owner(REPO_OWNER)
33+
.repo_name(REPO_NAME)
34+
.bin_name("arborist-cli")
35+
.current_version(CURRENT_VERSION)
36+
.build()
37+
{
38+
Ok(updater) => match updater.get_latest_release() {
39+
Ok(release) => {
40+
let latest = &release.version;
41+
if latest == CURRENT_VERSION {
42+
println!("already up to date.");
43+
ExitCode::SUCCESS
44+
} else {
45+
println!("v{latest} available (current: v{CURRENT_VERSION})");
46+
println!("Run `arborist update` to install it.");
47+
ExitCode::SUCCESS
48+
}
49+
}
50+
Err(e) => {
51+
eprintln!("failed to check for updates: {e}");
52+
ExitCode::from(2)
53+
}
54+
},
55+
Err(e) => {
56+
eprintln!("failed to configure updater: {e}");
57+
ExitCode::from(2)
58+
}
59+
}
60+
}
61+
62+
fn perform_update() -> ExitCode {
63+
println!("Checking for updates...");
64+
65+
let result = self_update::backends::github::Update::configure()
66+
.repo_owner(REPO_OWNER)
67+
.repo_name(REPO_NAME)
68+
.bin_name("arborist-cli")
69+
.current_version(CURRENT_VERSION)
70+
.show_download_progress(true)
71+
.show_output(false)
72+
.no_confirm(true)
73+
.build();
74+
75+
match result {
76+
Ok(updater) => match updater.update() {
77+
Ok(status) => {
78+
if status.updated() {
79+
println!("Updated to v{}.", status.version());
80+
} else {
81+
println!("Already up to date (v{CURRENT_VERSION}).");
82+
}
83+
ExitCode::SUCCESS
84+
}
85+
Err(e) => {
86+
eprintln!("error: update failed: {e}");
87+
ExitCode::from(2)
88+
}
89+
},
90+
Err(e) => {
91+
eprintln!("error: failed to configure updater: {e}");
92+
ExitCode::from(2)
93+
}
94+
}
95+
}
96+
97+
/// Heuristic: if the binary is inside a cargo bin directory, it was likely
98+
/// installed via `cargo install`.
99+
fn is_cargo_installed() -> bool {
100+
let Ok(exe) = std::env::current_exe() else {
101+
return false;
102+
};
103+
let path = exe.to_string_lossy();
104+
path.contains(".cargo/bin") || path.contains(".cargo\\bin")
105+
}

0 commit comments

Comments
 (0)