added config parsing, and the selection between multiple providers.

This commit is contained in:
2026-07-10 21:03:45 -05:00
parent 4283b1af2a
commit 8348946c3c
9 changed files with 351 additions and 32 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"cSpell.words": ["Gitea"]
}
Generated
+79
View File
@@ -488,12 +488,14 @@ dependencies = [
"clap", "clap",
"colored", "colored",
"dialoguer", "dialoguer",
"dirs",
"keyring", "keyring",
"octocrab", "octocrab",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"toml",
] ]
[[package]] [[package]]
@@ -697,6 +699,27 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.6" version = "0.2.6"
@@ -1538,6 +1561,15 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -1757,6 +1789,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]] [[package]]
name = "ordered-stream" name = "ordered-stream"
version = "0.2.0" version = "0.2.0"
@@ -2111,6 +2149,17 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.13.0" version = "1.13.0"
@@ -2505,6 +2554,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "serde_spanned"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -2856,6 +2914,21 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -2886,6 +2959,12 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
+2
View File
@@ -13,3 +13,5 @@ octocrab = "0.54.0"
dialoguer = "0.12" dialoguer = "0.12"
keyring = "4.1.4" keyring = "4.1.4"
colored = "3.1.1" colored = "3.1.1"
toml = "1.1.2+spec-1.1.0"
dirs = "6.0.0"
+30 -14
View File
@@ -26,23 +26,39 @@
... ...
}@inputs: }@inputs:
let let
system = "aarch64-darwin"; systems = [
pkgs = nixpkgs.legacyPackages.${system}; "x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
naerskLib = pkgs.callPackage naersk { }; eachSystem = f: nixpkgs.lib.genAttrs systems (system: f system (nixpkgs.legacyPackages.${system}));
in in
{ {
devShells.${system}.default = devenv.lib.mkShell { devShells = eachSystem (
inherit inputs pkgs; system: pkgs: {
modules = [ default = devenv.lib.mkShell {
./devenv.nix inherit inputs pkgs;
]; modules = [
}; ./devenv.nix
];
};
}
);
packages.${system}.default = naerskLib.buildPackage { packages = eachSystem (
name = "clonee"; system: pkgs:
version = "1.0"; let
src = ./.; naerskLib = pkgs.callPackage naersk { };
}; in
{
default = naerskLib.buildPackage {
name = "clonee";
version = "1.0.0";
src = ./.;
};
}
);
}; };
} }
+105
View File
@@ -0,0 +1,105 @@
use std::path::PathBuf;
use crate::providers::Result;
use serde::{Deserialize, Serialize};
use tokio::fs;
#[derive(Debug, Deserialize)]
pub struct Config {
pub provider: Vec<Provider>,
}
// Added #[serde(tag = "type")] so Serde knows to look for a "type" field
// instead of nesting the entire structure inside the variant name.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum Provider {
#[serde(rename = "github")]
Github {
friendly_name: String,
username: String,
token: Option<String>,
},
#[serde(rename = "gitea")]
Gitea {
friendly_name: String,
base_url: String,
username: String,
token: Option<String>,
},
}
pub enum ProviderClient {
Github(crate::providers::github::Github),
Gitea(crate::providers::gitea::Gitea),
}
impl crate::providers::Provider for ProviderClient {
async fn repositories(&self) -> crate::providers::Result<Vec<crate::providers::Repository>> {
match self {
ProviderClient::Github(g) => g.repositories().await,
ProviderClient::Gitea(g) => g.repositories().await,
}
}
async fn clone(
&self,
repo: crate::providers::Repository,
folder: Option<String>,
use_ssh: bool,
) -> crate::providers::Result<()> {
match self {
ProviderClient::Github(g) => g.clone(repo, folder, use_ssh).await,
ProviderClient::Gitea(g) => g.clone(repo, folder, use_ssh).await,
}
}
}
impl Provider {
pub fn friendly_name(&self) -> &str {
match self {
Provider::Github { friendly_name, .. } => friendly_name,
Provider::Gitea { friendly_name, .. } => friendly_name,
}
}
pub fn into_provider_trait(self) -> ProviderClient {
match self {
Provider::Github {
username, token, ..
} => {
todo!("Return ProviderClient::Github(...)")
}
Provider::Gitea {
base_url,
username,
token,
..
} => ProviderClient::Gitea(crate::providers::gitea::Gitea::new(
base_url, username, token,
)),
}
}
}
impl std::fmt::Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.friendly_name())
}
}
pub async fn parse_config(file_path: String) -> Result<Config> {
let mut path = PathBuf::from(file_path);
if path.starts_with("~") {
if let Some(home_dir) = dirs::home_dir() {
// Strip the "~" and join the rest of the path to the home directory
path = home_dir.join(path.strip_prefix("~").unwrap());
}
}
let config = fs::read_to_string(&path).await?;
let config: Config = toml::from_str(&config)?;
Ok(config)
}
+39 -18
View File
@@ -1,26 +1,24 @@
use crate::{ use crate::{config::parse_config, providers::Provider, tui::user_select_repo};
providers::{Provider, github::Github},
tui::user_select_repo,
};
use colored::*; use colored::*;
use dialoguer::{Select, theme::ColorfulTheme}; use dialoguer::{Select, theme::ColorfulTheme};
use std::io::Write; use std::io::Write;
use std::{io, process}; use std::{io, process};
mod config;
mod providers; mod providers;
mod tui; mod tui;
const CONFIG_PATH: &str = "~/.config/clonee";
enum DefaultAnswer { enum DefaultAnswer {
Yes, Yes,
No, No,
} }
fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool { fn ask_option<T>(message: &str, options: Vec<T>) -> T
let options = match default { where
Some(DefaultAnswer::Yes) => vec!["Yes", "No"], T: std::fmt::Display + Clone,
Some(DefaultAnswer::No) => vec!["No", "Yes"], {
None => vec!["Yes", "No"],
};
let selection = Select::with_theme(&ColorfulTheme::default()) let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(message.to_string()) .with_prompt(message.to_string())
.items(&options) .items(&options)
@@ -28,7 +26,18 @@ fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
.interact() .interact()
.unwrap(); .unwrap();
match options[selection] { options[selection].clone()
}
fn ask_yes_no(message: &str, default: Option<DefaultAnswer>) -> bool {
let options: Vec<&str> = match default {
Some(DefaultAnswer::Yes) => vec!["Yes", "No"],
Some(DefaultAnswer::No) => vec!["No", "Yes"],
None => vec!["Yes", "No"],
};
// &str implements both Display and Copy!
match ask_option(message, options) {
"Yes" => true, "Yes" => true,
"No" => false, "No" => false,
_ => unreachable!(), _ => unreachable!(),
@@ -47,12 +56,24 @@ fn get_input(message: &str) -> String {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Testing to see if github provider works. let config_file = format!("{}/config.toml", CONFIG_PATH);
let gh = Github { let config = match parse_config(config_file.clone()).await {
username: "pure-sagacity".to_string(), Ok(c) => c,
Err(e) => {
let top = "Failed to parse config.".red();
let bottom = "Maybe there's an error in the config?".dimmed();
println!("{}\n{}", top, bottom);
eprintln!("Error at {}: {}", config_file, e);
process::exit(1);
}
}; };
let repos = gh.repositories().await.expect("Failed to get repos."); let selected_provider = ask_option("Choose a provider:", config.provider);
let provider: crate::config::ProviderClient = selected_provider.into_provider_trait();
let repos = provider.repositories().await.expect("Failed to get repos.");
let selection = match user_select_repo(repos).await { let selection = match user_select_repo(repos).await {
Ok(r) => r, Ok(r) => r,
@@ -62,7 +83,7 @@ async fn main() {
} }
}; };
match ask_yes_no("Clone the repo?", None) { match ask_yes_no("Clone the repo?", Some(DefaultAnswer::Yes)) {
true => { true => {
let folder: Option<String> = let folder: Option<String> =
if ask_yes_no("Use a custom folder?", Some(DefaultAnswer::No)) { if ask_yes_no("Use a custom folder?", Some(DefaultAnswer::No)) {
@@ -72,7 +93,7 @@ async fn main() {
None None
}; };
match gh.clone(selection, folder, false).await { match provider.clone(selection, folder, false).await {
Ok(_) => println!("Repo cloned!"), Ok(_) => println!("Repo cloned!"),
Err(_) => { Err(_) => {
println!("{}", "Error cloning repo.".red()) println!("{}", "Error cloning repo.".red())
+86
View File
@@ -0,0 +1,86 @@
use super::{Provider, Repository, Result};
use serde::Deserialize;
pub struct Gitea {
pub base_url: String,
pub username: String,
pub token: Option<String>,
}
impl Gitea {
pub fn new(base_url: String, username: String, token: Option<String>) -> Self {
Self {
base_url,
username,
token,
}
}
}
// 1. Matches Gitea's API response schema exactly
#[derive(Deserialize, Debug)]
struct GiteaRepoDto {
id: u64,
name: String,
owner: GiteaOwnerDto,
ssh_url: String,
clone_url: String,
stars_count: u64,
description: Option<String>,
}
#[derive(Deserialize, Debug)]
struct GiteaOwnerDto {
login: String,
}
// 2. Map the Gitea DTO to your generic Repository struct
impl From<GiteaRepoDto> for Repository {
fn from(gt: GiteaRepoDto) -> Self {
Repository {
id: gt.id,
name: gt.name,
owner: gt.owner.login,
ssh_url: Some(gt.ssh_url),
https_url: gt.clone_url,
stars: gt.stars_count,
description: gt.description,
}
}
}
impl Provider for Gitea {
async fn repositories(&self) -> Result<Vec<Repository>> {
let client = reqwest::Client::new();
let base_endpoint = self.base_url.trim_end_matches('/');
// Route to the authenticated user's endpoint if a token exists,
// otherwise route to the target user's public repositories endpoint.
let url = match self.token.is_some() {
true => format!("{}/api/v1/user/repos", base_endpoint),
false => format!("{}/api/v1/users/{}/repos", base_endpoint, self.username),
};
let mut request = client.get(&url).header("Accept", "application/json");
if let Some(ref token) = self.token {
request = request.bearer_auth(token);
}
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
let err = format!("Gitea API Error ({}): {}", status, error_text);
return Err(err.into());
}
let gitea_repos: Vec<GiteaRepoDto> = response.json().await?;
// Convert the Gitea DTOs seamlessly into your generic repository structs
let repos = gitea_repos.into_iter().map(Repository::from).collect();
Ok(repos)
}
}
+6
View File
@@ -8,6 +8,12 @@ pub struct Github {
pub username: String, pub username: String,
} }
impl Github {
fn new(username: String) -> Self {
Self { username }
}
}
// 1. This struct matches GitHub's exact API shape. // 1. This struct matches GitHub's exact API shape.
// It remains completely hidden inside your GitHub provider module. // It remains completely hidden inside your GitHub provider module.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
+1
View File
@@ -1,4 +1,5 @@
pub mod github; pub mod github;
pub mod gitea;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::process::Command; use tokio::process::Command;