Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 26 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ tokio = { version = "=1.32", features = ["rt-multi-thread", "macros"] }
reqwest = { version = "=0.11.22", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "=1.0.188", features = ["derive"] }
serde_json = "=1.0.107"
toml = "=0.7.8"
toml = "=0.8.0"
tar = "=0.4.40"
flate2 = "=1.0.28"
bzip2 = "=0.4.4"
Expand All @@ -31,6 +31,7 @@ indexmap = "=2.0.2"
url = "=2.4.1"
base64 = "=0.21.7"
base64ct = "=1.6.0"
atty = "=0.2.14"

[dev-dependencies]
assert_cmd = "2.0.12"
182 changes: 182 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::PathBuf;

#[derive(Debug, Clone, Deserialize)]
pub struct DownloadConfig {
#[serde(default = "default_true")]
pub show_progress: bool,
#[serde(default = "default_true")]
pub prefer_strip: bool,
#[serde(default = "default_true")]
pub verify_checksums: bool,
}

impl Default for DownloadConfig {
fn default() -> Self {
Self {
show_progress: true,
prefer_strip: true,
verify_checksums: true,
}
}
}

#[derive(Debug, Clone, Deserialize)]
pub struct OutputConfig {
#[serde(default = "default_color")]
pub color: String,
#[serde(default)]
pub quiet: bool,
#[serde(default)]
pub verbose: bool,
}

impl Default for OutputConfig {
fn default() -> Self {
Self {
color: "auto".to_string(),
quiet: false,
verbose: false,
}
}
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct Config {
#[serde(default = "default_install_dir")]
pub install_dir: PathBuf,
pub github_token: Option<String>,
#[serde(default)]
pub download: DownloadConfig,
#[serde(default)]
pub output: OutputConfig,
}

fn default_true() -> bool {
true
}

fn default_color() -> String {
"auto".to_string()
}

fn default_install_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gitclaw")
.join("bin")
}

impl Config {
/// Loads and merges config from all sources in precedence order:
/// env var > project-local > XDG > legacy > defaults
pub fn load() -> Result<Self> {
let mut config = Config::default();

if let Some(legacy) = Self::load_from_legacy()? {
config.merge(legacy);
}
if let Some(xdg) = Self::load_from_xdg()? {
config.merge(xdg);
}
if let Some(local) = Self::load_from_local()? {
config.merge(local);
}
if let Some(env) = Self::load_from_env()? {
config.merge(env);
}

Ok(config)
}

pub fn load_from_env() -> Result<Option<Self>> {
if let Ok(path) = env::var("GITCLAW_CONFIG") {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config from GITCLAW_CONFIG: {}", path))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config from GITCLAW_CONFIG: {}", path))?;
return Ok(Some(config));
}
Ok(None)
}

pub fn load_from_local() -> Result<Option<Self>> {
let path = PathBuf::from(".gitclaw.toml");
if path.exists() {
let content =
fs::read_to_string(&path).with_context(|| "Failed to read project-local config")?;
let config: Config =
toml::from_str(&content).with_context(|| "Failed to parse project-local config")?;
return Ok(Some(config));
}
Ok(None)
}

pub fn load_from_xdg() -> Result<Option<Self>> {
if let Some(config_dir) = dirs::config_dir() {
let path = config_dir.join("gitclaw").join("config.toml");
if path.exists() {
let content =
fs::read_to_string(&path).with_context(|| "Failed to read XDG config")?;
let config: Config =
toml::from_str(&content).with_context(|| "Failed to parse XDG config")?;
return Ok(Some(config));
}
}
Ok(None)
}

pub fn load_from_legacy() -> Result<Option<Self>> {
if let Some(home) = dirs::home_dir() {
let path = home.join(".gitclaw.toml");
if path.exists() {
let content =
fs::read_to_string(&path).with_context(|| "Failed to read legacy config")?;
let config: Config =
toml::from_str(&content).with_context(|| "Failed to parse legacy config")?;
return Ok(Some(config));
}
}
Ok(None)
}

pub fn merge(&mut self, other: Config) {
if let Some(token) = other.github_token {
self.github_token = Some(token);
}
if other.install_dir != default_install_dir() {
self.install_dir = other.install_dir;
}
if !other.download.show_progress {
self.download.show_progress = other.download.show_progress;
}
if !other.download.prefer_strip {
self.download.prefer_strip = other.download.prefer_strip;
}
if !other.download.verify_checksums {
self.download.verify_checksums = other.download.verify_checksums;
}
if other.output.color != "auto" {
self.output.color = other.output.color;
}
if other.output.quiet {
self.output.quiet = other.output.quiet;
}
if other.output.verbose {
self.output.verbose = other.output.verbose;
}
}

#[allow(dead_code)]
pub fn github_token(&self) -> Option<&str> {
self.github_token.as_deref()
}

#[allow(dead_code)]
pub fn install_dir(&self) -> &PathBuf {
&self.install_dir
}
}
5 changes: 3 additions & 2 deletions src/github.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::config::Config;
use anyhow::{bail, Context, Result};
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
Expand Down Expand Up @@ -436,11 +437,11 @@ pub fn parse_package(input: &str) -> Result<(String, String, Option<String>)> {
}

/// Search and display releases for a package
pub async fn search_releases(package: &str, limit: usize) -> Result<()> {
pub async fn search_releases(package: &str, limit: usize, config: &Config) -> Result<()> {
let (owner, repo, _) = parse_package(package)?;
println!("Releases for {}/{}:\n", owner, repo);

let client = GithubClient::new(None)?;
let client = GithubClient::new(config.github_token.clone())?;

// We need to access the internal method - using the public API
// Since get_releases is private, we'll use get_release with "latest" and fetch via API
Expand Down
21 changes: 11 additions & 10 deletions src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ use std::fs;
use std::path::{Path, PathBuf};
use tracing::warn;

use crate::config::Config;
use crate::extract::extract_archive;
use crate::github::{find_matching_asset, parse_package, Asset, GithubClient, Platform, Release};
use crate::registry::{bin_dir, InstalledPackage, Registry};

pub async fn handle_install(package: &str, force: bool) -> Result<()> {
pub async fn handle_install(package: &str, force: bool, config: &Config) -> Result<()> {
let (owner, repo, version) = parse_package(package)?;
let key = format!("{}/{}", owner, repo);

Expand All @@ -21,7 +22,7 @@ pub async fn handle_install(package: &str, force: bool) -> Result<()> {
return Ok(());
}

let client = GithubClient::new(None)?;
let client = GithubClient::new(config.github_token.clone())?;
let release = match &version {
Some(v) => client.get_release(&owner, &repo, v).await?,
None => client.get_release(&owner, &repo, "latest").await?,
Expand Down Expand Up @@ -67,14 +68,14 @@ pub async fn handle_install(package: &str, force: bool) -> Result<()> {
Ok(())
}

pub async fn handle_update(package: Option<&str>) -> Result<()> {
pub async fn handle_update(package: Option<&str>, config: &Config) -> Result<()> {
match package {
Some(p) => update_one(p).await,
None => update_all().await,
Some(p) => update_one(p, config).await,
None => update_all(config).await,
}
}

async fn update_one(package: &str) -> Result<()> {
async fn update_one(package: &str, config: &Config) -> Result<()> {
let (owner, repo, _) = parse_package(package)?;
let key = format!("{}/{}", owner, repo);
let reg = Registry::load()?;
Expand All @@ -84,7 +85,7 @@ async fn update_one(package: &str) -> Result<()> {
let installed = reg.packages.get(&key).unwrap();
println!("Checking {} (current: {})...", key, installed.version);

let client = GithubClient::new(None)?;
let client = GithubClient::new(config.github_token.clone())?;
let latest = client.get_release(&owner, &repo, "latest").await?;

if latest.tag_name == installed.version {
Expand All @@ -96,10 +97,10 @@ async fn update_one(package: &str) -> Result<()> {
installed.version, latest.tag_name
);
crate::registry::uninstall(package)?;
handle_install(package, false).await
handle_install(package, false, config).await
}

async fn update_all() -> Result<()> {
async fn update_all(config: &Config) -> Result<()> {
let reg = Registry::load()?;
if reg.packages.is_empty() {
println!("No packages installed.");
Expand All @@ -109,7 +110,7 @@ async fn update_all() -> Result<()> {
let mut updated = 0u32;
let mut current = 0u32;
for name in &names {
match update_one(name).await {
match update_one(name, config).await {
Ok(()) => updated += 1,
Err(e) => {
if e.to_string().contains("up to date") {
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod cli;
pub mod config;
pub mod extract;
pub mod github;
pub mod install;
Expand Down
Loading
Loading