diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b6a6088 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["Gitea"] +} diff --git a/Cargo.lock b/Cargo.lock index ba02a44..7999805 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,12 +488,14 @@ dependencies = [ "clap", "colored", "dialoguer", + "dirs", "keyring", "octocrab", "reqwest", "serde", "serde_json", "tokio", + "toml", ] [[package]] @@ -697,6 +699,27 @@ dependencies = [ "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]] name = "displaydoc" version = "0.2.6" @@ -1538,6 +1561,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1757,6 +1789,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-stream" version = "0.2.0" @@ -2111,6 +2149,17 @@ dependencies = [ "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]] name = "regex" version = "1.13.0" @@ -2505,6 +2554,15 @@ dependencies = [ "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]] name = "serde_urlencoded" version = "0.7.1" @@ -2856,6 +2914,21 @@ dependencies = [ "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]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2886,6 +2959,12 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index aba6b3d..a15499e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,3 +13,5 @@ octocrab = "0.54.0" dialoguer = "0.12" keyring = "4.1.4" colored = "3.1.1" +toml = "1.1.2+spec-1.1.0" +dirs = "6.0.0" diff --git a/flake.nix b/flake.nix index ba5bf98..e038118 100644 --- a/flake.nix +++ b/flake.nix @@ -26,23 +26,39 @@ ... }@inputs: let - system = "aarch64-darwin"; - pkgs = nixpkgs.legacyPackages.${system}; + systems = [ + "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 { - devShells.${system}.default = devenv.lib.mkShell { - inherit inputs pkgs; - modules = [ - ./devenv.nix - ]; - }; + devShells = eachSystem ( + system: pkgs: { + default = devenv.lib.mkShell { + inherit inputs pkgs; + modules = [ + ./devenv.nix + ]; + }; + } + ); - packages.${system}.default = naerskLib.buildPackage { - name = "clonee"; - version = "1.0"; - src = ./.; - }; + packages = eachSystem ( + system: pkgs: + let + naerskLib = pkgs.callPackage naersk { }; + in + { + default = naerskLib.buildPackage { + name = "clonee"; + version = "1.0.0"; + src = ./.; + }; + } + ); }; } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8058531 --- /dev/null +++ b/src/config.rs @@ -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, +} + +// 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, + }, + #[serde(rename = "gitea")] + Gitea { + friendly_name: String, + base_url: String, + username: String, + token: Option, + }, +} + +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> { + match self { + ProviderClient::Github(g) => g.repositories().await, + ProviderClient::Gitea(g) => g.repositories().await, + } + } + + async fn clone( + &self, + repo: crate::providers::Repository, + folder: Option, + 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 { + 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) +} diff --git a/src/main.rs b/src/main.rs index 9266866..d8986a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,26 +1,24 @@ -use crate::{ - providers::{Provider, github::Github}, - tui::user_select_repo, -}; +use crate::{config::parse_config, providers::Provider, tui::user_select_repo}; use colored::*; use dialoguer::{Select, theme::ColorfulTheme}; use std::io::Write; use std::{io, process}; + +mod config; mod providers; mod tui; +const CONFIG_PATH: &str = "~/.config/clonee"; + enum DefaultAnswer { Yes, No, } -fn ask_yes_no(message: &str, default: Option) -> bool { - let options = match default { - Some(DefaultAnswer::Yes) => vec!["Yes", "No"], - Some(DefaultAnswer::No) => vec!["No", "Yes"], - None => vec!["Yes", "No"], - }; - +fn ask_option(message: &str, options: Vec) -> T +where + T: std::fmt::Display + Clone, +{ let selection = Select::with_theme(&ColorfulTheme::default()) .with_prompt(message.to_string()) .items(&options) @@ -28,7 +26,18 @@ fn ask_yes_no(message: &str, default: Option) -> bool { .interact() .unwrap(); - match options[selection] { + options[selection].clone() +} + +fn ask_yes_no(message: &str, default: Option) -> 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, "No" => false, _ => unreachable!(), @@ -47,12 +56,24 @@ fn get_input(message: &str) -> String { #[tokio::main] async fn main() { - // Testing to see if github provider works. - let gh = Github { - username: "pure-sagacity".to_string(), + let config_file = format!("{}/config.toml", CONFIG_PATH); + let config = match parse_config(config_file.clone()).await { + 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 { 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 => { let folder: Option = if ask_yes_no("Use a custom folder?", Some(DefaultAnswer::No)) { @@ -72,7 +93,7 @@ async fn main() { None }; - match gh.clone(selection, folder, false).await { + match provider.clone(selection, folder, false).await { Ok(_) => println!("Repo cloned!"), Err(_) => { println!("{}", "Error cloning repo.".red()) diff --git a/src/providers/gitea.rs b/src/providers/gitea.rs new file mode 100644 index 0000000..e7ed146 --- /dev/null +++ b/src/providers/gitea.rs @@ -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, +} + +impl Gitea { + pub fn new(base_url: String, username: String, token: Option) -> 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, +} + +#[derive(Deserialize, Debug)] +struct GiteaOwnerDto { + login: String, +} + +// 2. Map the Gitea DTO to your generic Repository struct +impl From 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> { + 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 = 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) + } +} diff --git a/src/providers/github.rs b/src/providers/github.rs index 7464909..eff69ce 100644 --- a/src/providers/github.rs +++ b/src/providers/github.rs @@ -8,6 +8,12 @@ pub struct Github { pub username: String, } +impl Github { + fn new(username: String) -> Self { + Self { username } + } +} + // 1. This struct matches GitHub's exact API shape. // It remains completely hidden inside your GitHub provider module. #[derive(Deserialize, Debug)] diff --git a/src/providers/mod.rs b/src/providers/mod.rs index c88663b..0e28c36 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,4 +1,5 @@ pub mod github; +pub mod gitea; use serde::{Deserialize, Serialize}; use tokio::process::Command;