added the provider trait, github provider, and a fzf selector.

i forgot to commit :(
This commit is contained in:
2026-07-10 18:53:36 -05:00
parent b476cd1e85
commit 0e2155c1b3
6 changed files with 3818 additions and 3 deletions
Generated
+3568 -1
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
serde = { version = "1.0.228", features = ["derive"] }
reqwest = { version = "0.13.4", features = ["json"] }
clap = { version = "4.6.1", features = ["derive"] }
tokio = { version = "1.52.3", features = ["full"] }
serde_json = "1.0.150"
octocrab = "0.54.0"
dialoguer = "0.12"
keyring = "4.1.4"
+41 -2
View File
@@ -1,3 +1,42 @@
fn main() { use crate::{
println!("Hello, world!"); providers::{Provider, github::Github},
tui::user_select_repo,
};
use dialoguer::{Select, theme::ColorfulTheme};
mod providers;
mod tui;
pub fn ask_yes_no(message: &str) -> bool {
let options = vec!["Yes", "No"];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt(message.to_string())
.items(&options)
.default(0)
.interact()
.unwrap();
match options[selection] {
"Yes" => true,
"No" => false,
_ => unreachable!(),
}
}
#[tokio::main]
async fn main() {
// Testing to see if github provider works.
let gh = Github {
username: "pure-sagacity".to_string(),
};
let repos = gh.repositories().await.expect("Failed to get repos.");
let selection = user_select_repo(repos)
.await
.expect("Failed to select repository.");
println!("{:#?}", selection);
println!("{}", ask_yes_no("Clone the repo?"));
} }
+85
View File
@@ -0,0 +1,85 @@
use super::Repository;
use serde::Deserialize;
use crate::providers::Provider;
// TODO: add email and password authentication for viewing private repos.
pub struct Github {
pub username: String,
}
// 1. This struct matches GitHub's exact API shape.
// It remains completely hidden inside your GitHub provider module.
#[derive(Deserialize, Debug)]
struct GithubRepoDto {
id: u64,
name: String,
owner: GithubOwnerDto,
ssh_url: Option<String>,
clone_url: String, // Or html_url
stargazers_count: u64,
description: Option<String>,
}
#[derive(Deserialize, Debug)]
struct GithubOwnerDto {
login: String,
}
// 2. Map the GitHub DTO to your general Repository struct
impl From<GithubRepoDto> for Repository {
fn from(gh: GithubRepoDto) -> Self {
Repository {
id: gh.id,
name: gh.name,
owner: gh.owner.login,
ssh_url: gh.ssh_url,
https_url: gh.clone_url,
stars: gh.stargazers_count,
description: gh.description,
}
}
}
impl Provider for Github {
async fn repositories(&self) -> super::Result<Vec<Repository>> {
let client = reqwest::Client::new();
let mut page = 1;
let mut repos: Vec<Repository> = Vec::new();
loop {
let url = format!(
"https://api.github.com/users/{}/repos?per_page=100&page={}",
&self.username, page
);
let response = client
.get(&url)
.header("User-Agent", "my-app")
.send()
.await?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await?;
let err = format!("Github API Error ({}): {}", status, error_text);
return Err(err.into());
}
let gh_page: Vec<GithubRepoDto> = response.json().await?;
if gh_page.is_empty() {
break;
}
// Convert the GitHub DTOs seamlessly into your clean domain structs
let domain_repos = gh_page.into_iter().map(Repository::from);
repos.extend(domain_repos);
page += 1;
}
Ok(repos)
}
}
+53
View File
@@ -0,0 +1,53 @@
pub mod github;
use serde::{Deserialize, Serialize};
use tokio::process::Command;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[derive(Serialize, Deserialize, Debug)]
pub struct Repository {
pub id: u64,
pub name: String,
pub owner: String,
pub ssh_url: Option<String>,
pub https_url: String,
pub stars: u64,
pub description: Option<String>,
}
pub trait Provider {
async fn repositories(&self) -> Result<Vec<Repository>>;
async fn clone(&self, repo: Repository, folder: Option<String>, use_ssh: bool) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("clone");
match use_ssh {
true => match repo.ssh_url {
Some(u) => cmd.arg(u),
None => return Err("Repo doesn't have an SSH URL.".into()),
},
false => cmd.arg(repo.https_url),
};
if let Some(f) = folder {
cmd.arg(f);
}
match cmd.status().await {
Ok(status) if status.success() => {
println!("Git clone completed successfully!");
Ok(())
}
Ok(status) => {
// The command ran, but exited with an error code (e.g., 128)
Err(format!("Git clone failed with exit code: {}", status).into())
}
Err(e) => {
// The command couldn't run at all (e.g., 'git' isn't installed)
Err(format!("Failed to execute git process: {}", e).into())
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
use crate::providers::{Repository, Result};
use std::process::Stdio;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
pub async fn user_select_repo(repositories: Vec<Repository>) -> Result<Repository> {
let mut fzf_input = String::new();
for (index, repo) in repositories.iter().enumerate() {
let line = format!(
"{}|{}/{}|{}|{}|{}\n",
index,
repo.owner,
repo.name,
repo.id,
repo.stars,
repo.description
.as_deref()
.unwrap_or("No description available")
);
fzf_input.push_str(&line);
}
let mut child = Command::new("fzf")
.arg("--delimiter").arg("\\|")
.arg("--with-nth").arg("2")
.arg("--preview")
.arg("echo 'Repository: {2}\n-------------------\nID: {3}\nStars: ⭐ {4}\nDescription: {5}'")
.arg("--preview-window").arg("right:50%:wrap")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(fzf_input.as_bytes()).await?;
}
let mut selection = String::new();
if let Some(mut stdout) = child.stdout.take() {
stdout.read_to_string(&mut selection).await?;
}
let status = child.wait().await?;
if !status.success() {
return Err("fzf closed or cancelled".into());
}
// Extract the index from the beginning of the selected line
let first_field = selection
.split('|')
.next()
.ok_or("Invalid selection format")?
.trim();
let index: usize = first_field.parse()?;
// Move the repository out of the vector and return it
// (Consumes the vector item matching the index chosen)
let mut repos = repositories;
if index < repos.len() {
Ok(repos.remove(index))
} else {
Err("Selected index out of bounds".into())
}
}