Create initial project structure

The initial project structure includes the base scaffolding of the
application as well as the ability to fetch the repo list from the
remote endpoint.
This commit is contained in:
Anthony Oteri
2023-09-13 17:37:28 -04:00
parent 556a715b42
commit 7b0c3f1dcc
9 changed files with 348 additions and 1 deletions
+5
View File
@@ -12,3 +12,8 @@ Cargo.lock
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Added by cargo
/target
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "dredge"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
async-std = { version = "1.12.0", features = ["async-attributes", "attributes", "tokio1"] }
clap = { version = "4.4.3", features = ["derive", "env", "wrap_help"] }
femme = "2.2.1"
http = "0.2.9"
log = "0.4.20"
reqwest = { version = "0.11.20", features = ["json", "gzip", "multipart", "native-tls-vendored"] }
serde = { version = "1.0.188", features = ["derive"] }
serde_toml = "0.0.1"
thiserror = "1.0.48"
toml = "0.8.0"
url = { version = "2.4.1", features = ["serde"] }
xdg = "2.5.2"
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2023 Anthony Oteri
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+70
View File
@@ -0,0 +1,70 @@
#![allow(unused_imports)]
use clap::Args;
use clap::Parser;
use clap::Subcommand;
use clap::ValueEnum;
use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "dredge", version, author)]
#[command(about = "A Docker Registry CLI tool", long_about = None)]
pub(crate) struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short = 'c', long = "config")]
pub config: Option<OsString>,
#[arg(
long = "log-level",
require_equals = true,
value_name = "LEVEL",
num_args = 0..=1,
default_value_t = LogLevel::Info,
default_missing_value="info",
value_enum
)]
pub log_level: LogLevel,
}
#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
Off,
}
impl From<LogLevel> for log::LevelFilter {
fn from(lvl: LogLevel) -> Self {
match lvl {
LogLevel::Trace => Self::Trace,
LogLevel::Debug => Self::Debug,
LogLevel::Info => Self::Info,
LogLevel::Warn => Self::Warn,
LogLevel::Error => Self::Error,
LogLevel::Off => Self::Off,
}
}
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Repo(RepoArgs),
}
#[derive(Debug, Args)]
pub struct RepoArgs {
#[command(subcommand)]
pub command: RepoCommands,
}
#[derive(Debug, Subcommand)]
pub enum RepoCommands {
List,
}
+1
View File
@@ -0,0 +1 @@
pub mod repo;
+59
View File
@@ -0,0 +1,59 @@
use crate::cli::{RepoArgs, RepoCommands};
use crate::config::Config;
use crate::error::ApiError;
use serde::Deserialize;
const BASE_CATALOG_URI: &str = "/v2/_catalog";
pub async fn handler(config: &Config, args: &RepoArgs) -> Result<(), ApiError> {
log::trace!("handler()");
match args.command {
RepoCommands::List => handle_list(config, args).await?,
}
Ok(())
}
#[derive(Deserialize)]
struct CatalogResponse {
repositories: Vec<String>,
}
async fn handle_list(config: &Config, _args: &RepoArgs) -> Result<(), ApiError> {
log::trace!("handle_list()");
let mut url = config.registry_url.join(BASE_CATALOG_URI)?;
let mut responses = Vec::new();
loop {
log::debug!("Using url {url}");
let response = reqwest::get(url.clone()).await?;
let headers = response.headers().to_owned();
let body: CatalogResponse = response.json().await?;
responses.push(body);
if let Some(link_value) = headers.get(http::header::LINK) {
let link_str = link_value.to_str()?;
let parts:Vec<&str> = link_str.split(';').collect();
if let Some(url_part) = parts.first() {
if let Some(uri) = url_part.trim().strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
url = config.registry_url.join(uri)?;
}
}
} else {
break;
}
}
let repo_list: Vec<&str> = responses.iter().flat_map(|r| r.repositories.iter().map(String::as_str)).collect();
for repo in repo_list {
println!("{repo}");
}
Ok(())
}
+28
View File
@@ -0,0 +1,28 @@
use crate::error::ConfigError;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use url::Url;
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Config {
pub registry_url: Url,
}
impl Default for Config {
fn default() -> Self {
Self {
registry_url: Url::parse("https://localhost:5000").unwrap(),
}
}
}
impl TryFrom<&Path> for Config {
type Error = ConfigError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
let contents = fs::read_to_string(path)?;
let config: Self = toml::from_str(&contents)?;
Ok(config)
}
}
+71
View File
@@ -0,0 +1,71 @@
#![allow(clippy::enum_variant_names)]
use thiserror::Error;
/// The common error type for this Application.
#[derive(Error, Debug)]
pub enum DredgeError {
/// An error related to the configuration of the program.
#[error(transparent)]
ConfigError(#[from] ConfigError),
/// An error communicating with the Registry API
#[error(transparent)]
ApiError(#[from] ApiError),
}
/// An error related to the configuration fo the program.
#[derive(Error, Debug)]
pub enum ConfigError {
/// An error parsing the configuration from disk.
#[error("Failed to parse configuration file")]
ParseError(Box<dyn std::error::Error>),
/// An error writing the configuration to disk.
#[error("Failed to write configuration data")]
WriteError(Box<dyn std::error::Error>),
/// A generic IOError
#[error(transparent)]
IOError(#[from] std::io::Error),
}
impl From<toml::ser::Error> for ConfigError {
fn from(other: toml::ser::Error) -> Self {
Self::WriteError(Box::from(other))
}
}
impl From<toml::de::Error> for ConfigError {
fn from(other: toml::de::Error) -> Self {
Self::ParseError(Box::from(other))
}
}
impl From<xdg::BaseDirectoriesError> for ConfigError {
fn from(other: xdg::BaseDirectoriesError) -> Self {
Self::WriteError(Box::from(other))
}
}
/// An error related to the communication with the registry API.
#[derive(Error, Debug)]
pub enum ApiError {
/// Error parsing a URL
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
/// Error in HTTP Request
#[error(transparent)]
HttpError(#[from] reqwest::Error),
#[error("Failed to parse response headers")]
ResponseHeaderParseError(Box<dyn std::error::Error>),
}
impl From<reqwest::header::ToStrError> for ApiError {
fn from(other: reqwest::header::ToStrError) -> Self {
Self::ResponseHeaderParseError(Box::from(other))
}
}
+93
View File
@@ -0,0 +1,93 @@
use crate::cli::Cli;
use crate::config::Config;
use crate::error::ConfigError;
use crate::error::DredgeError;
use clap::Parser;
use std::ffi::OsString;
use std::path::PathBuf;
use crate::cli::Commands;
pub(crate) mod cli;
mod commands;
mod config;
mod error;
/// The default basename of the main configuration file.
const CONFIG_FILE_NAME: &str = "dredge.toml";
/// The XDG directory prefix.
const CONFIG_PREFIX: &str = "dredge";
/// Locate the absolute path to the saved configuration file on disk.
///
/// If given an optional `path` to a configuration file, and that file
/// exists on disk, the absoulte path to that file will be returned.
/// Otherwise, the XDG configuration path will be used. If neither the
/// optional `path` parameter refers to an existing file on disk, nor a
/// suitable configuration file can be located within the XDG configuration
/// path, the `None` variant will be returned.
fn locate_config_file(path: Option<OsString>) -> Option<PathBuf> {
log::trace!("locate_config_file({path:?})");
match path {
Some(path) => {
let p = PathBuf::from(path);
log::debug!("Checking if path {p:?} exists");
p.try_exists().map(|_| Some(p)).unwrap_or(None)
}
None => {
let xdg_dirs = xdg::BaseDirectories::with_prefix(CONFIG_PREFIX).ok()?;
let search_paths: Vec<PathBuf> = vec![xdg_dirs.get_config_home()]
.into_iter()
.chain(xdg_dirs.get_config_dirs())
.collect();
log::debug!(
"Searching configuration directories for {CONFIG_FILE_NAME} {search_paths:?}"
);
xdg_dirs.find_config_file(CONFIG_FILE_NAME)
}
}
}
/// Attempt to create a default configuration file in the XDG configuration
/// path. Any sub-directories of the XDG configuration path which do not
/// already exist will be created automatically.
///
/// # Errors:
///
/// This returns a `ConfigError` if a problem occured which prevented either
/// the creation of the directory tree, or in writing the default configuration
/// to the file.
fn create_default_config_file() -> Result<PathBuf, ConfigError> {
log::trace!("create_default_config_file()");
let xdg_dirs = xdg::BaseDirectories::with_prefix(CONFIG_PREFIX)?;
let config_path = xdg_dirs.place_config_file(CONFIG_FILE_NAME)?;
let default_config = toml::to_string_pretty(&Config::default())?;
std::fs::write(&config_path, default_config)?;
Ok(config_path)
}
#[async_std::main]
async fn main() -> Result<(), DredgeError> {
let args = Cli::parse();
// -- Initialize logging
let log_level = args.log_level.clone();
femme::with_level(log::LevelFilter::from(log_level));
// -- Load and parse configuration file
let config_file =
locate_config_file(args.config).map_or_else(create_default_config_file, Ok)?;
log::debug!("Using configuration file {config_file:?}");
#[allow(unused_variables)]
let config = Config::try_from(config_file.as_ref())?;
match args.command {
Commands::Repo(repo_args) => commands::repo::handler(&config, &repo_args).await?,
}
Ok(())
}