diff --git a/src/config.rs b/src/config.rs index 9c2bafe..a9e78f6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -59,7 +59,11 @@ impl AppConfig { /// Fügt ein Repository zur Konfiguration hinzu oder aktualisiert ein bestehendes. pub fn add_or_update_repo(&mut self, mut repo: RepoConfig) { repo.name = Self::normalize_repo_name(&repo.name); - if let Some(existing) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&repo.name)) { + if let Some(existing) = self + .repositories + .iter_mut() + .find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&repo.name)) + { existing.name = repo.name; existing.include_prereleases = repo.include_prereleases; } else { @@ -71,20 +75,27 @@ impl AppConfig { pub fn remove_repo(&mut self, repo_name: &str) -> bool { let normalized = Self::normalize_repo_name(repo_name); let before_len = self.repositories.len(); - self.repositories.retain(|r| !Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)); + self.repositories + .retain(|r| !Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)); self.repositories.len() < before_len } /// Sucht ein Repository anhand des Namens. pub fn find_repo(&self, repo_name: &str) -> Option<&RepoConfig> { let normalized = Self::normalize_repo_name(repo_name); - self.repositories.iter().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) + self.repositories + .iter() + .find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) } /// Aktualisiert den zuletzt synchronisierten Tag für ein bestimmtes Repository. pub fn update_last_synced_tag(&mut self, repo_name: &str, tag: String) { let normalized = Self::normalize_repo_name(repo_name); - if let Some(repo) = self.repositories.iter_mut().find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) { + if let Some(repo) = self + .repositories + .iter_mut() + .find(|r| Self::normalize_repo_name(&r.name).eq_ignore_ascii_case(&normalized)) + { repo.last_synced_tag = Some(tag); } } diff --git a/src/gitea.rs b/src/gitea.rs index 2de5ea3..db4e987 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -1,5 +1,5 @@ use crate::github::PackageType; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use std::path::Path; use tokio::fs::File; @@ -12,7 +12,11 @@ pub struct GiteaConfig { } impl GiteaConfig { - pub fn new(base_url: impl Into, token: impl Into, owner: impl Into) -> Self { + pub fn new( + base_url: impl Into, + token: impl Into, + owner: impl Into, + ) -> Self { let mut base_url = base_url.into(); while base_url.ends_with('/') { base_url.pop(); @@ -44,17 +48,29 @@ pub fn get_target_upload_urls( match pkg_type { PackageType::Debian => { if prerelease { - vec![format!("{}/api/packages/{}/debian/pool/testing/main/upload", base, owner)] + vec![format!( + "{}/api/packages/{}/debian/pool/testing/main/upload", + base, owner + )] } else { vec![ - format!("{}/api/packages/{}/debian/pool/stable/main/upload", base, owner), - format!("{}/api/packages/{}/debian/pool/testing/main/upload", base, owner), + format!( + "{}/api/packages/{}/debian/pool/stable/main/upload", + base, owner + ), + format!( + "{}/api/packages/{}/debian/pool/testing/main/upload", + base, owner + ), ] } } PackageType::Rpm => { if prerelease { - vec![format!("{}/api/packages/{}/rpm/testing/upload", base, owner)] + vec![format!( + "{}/api/packages/{}/rpm/testing/upload", + base, owner + )] } else { vec![format!("{}/api/packages/{}/rpm/stable/upload", base, owner)] } @@ -82,7 +98,10 @@ impl GiteaClient { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_static(concat!("mirror-package/", env!("CARGO_PKG_VERSION"))), + reqwest::header::HeaderValue::from_static(concat!( + "mirror-package/", + env!("CARGO_PKG_VERSION") + )), ); let client = reqwest::Client::builder() diff --git a/src/github.rs b/src/github.rs index 231a8cd..da98fc5 100644 --- a/src/github.rs +++ b/src/github.rs @@ -91,7 +91,10 @@ impl GitHubClient { let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::USER_AGENT, - reqwest::header::HeaderValue::from_static(concat!("mirror-package/", env!("CARGO_PKG_VERSION"))), + reqwest::header::HeaderValue::from_static(concat!( + "mirror-package/", + env!("CARGO_PKG_VERSION") + )), ); headers.insert( reqwest::header::ACCEPT, @@ -111,7 +114,6 @@ impl GitHubClient { self.token.as_deref() } - /// Ruft Releases für ein angegebenes Repository ab (Format "owner/repo"). /// /// Wenn `history` false ist, werden nur die neuesten Releases abgerufen. @@ -139,7 +141,10 @@ impl GitHubClient { req = req.bearer_auth(token); } - let resp = req.send().await.with_context(|| format!("Failed to send request to {}", url))?; + let resp = req + .send() + .await + .with_context(|| format!("Failed to send request to {}", url))?; let status = resp.status(); if !status.is_success() { @@ -153,10 +158,12 @@ impl GitHubClient { ); } - let raw_releases: Vec = resp - .json() - .await - .with_context(|| format!("Failed to parse GitHub releases JSON for {}/{}", owner, name))?; + let raw_releases: Vec = resp.json().await.with_context(|| { + format!( + "Failed to parse GitHub releases JSON for {}/{}", + owner, name + ) + })?; if raw_releases.is_empty() { break; diff --git a/src/main.rs b/src/main.rs index 7c8176e..c017766 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ use anyhow::Result; use clap::Parser; -use logger_ctdra::{log, set_log_level, LogLevel}; +use logger_ctdra::{LogLevel, log, set_log_level}; use mirror_package::cli::{Cli, Commands, ConfigAction}; use mirror_package::config::{ - get_config_file_path, init_config_path, load_config, modify_config, AppConfig, RepoConfig, + AppConfig, RepoConfig, get_config_file_path, init_config_path, load_config, modify_config, }; -use mirror_package::pipeline::{sync_single_repository, SyncOptions, SyncReport}; +use mirror_package::pipeline::{SyncOptions, SyncReport, sync_single_repository}; #[tokio::main] async fn main() -> Result<()> { @@ -65,7 +65,9 @@ async fn main() -> Result<()> { let mut report = SyncReport::default(); for repo in repos_to_sync { - if let Err(e) = sync_single_repository(&repo, &app_config, &options, &mut report).await { + if let Err(e) = + sync_single_repository(&repo, &app_config, &options, &mut report).await + { log( "sync", &format!("Failed to sync repository '{}': {:#}", repo.name, e), @@ -128,23 +130,38 @@ async fn main() -> Result<()> { log( "config", - &format!("Repository '{}' removed from configuration.", normalized_name), + &format!( + "Repository '{}' removed from configuration.", + normalized_name + ), LogLevel::Info, ); } Commands::List => { - println!("Configured repositories (Config file: {:?}):", get_config_file_path()); + println!( + "Configured repositories (Config file: {:?}):", + get_config_file_path() + ); if app_config.repositories.is_empty() { - println!(" (None configured yet. Use 'mirror-package add ' to add one.)"); + println!( + " (None configured yet. Use 'mirror-package add ' to add one.)" + ); } else { - println!("{:<40} {:<15} {:<20}", "REPOSITORY", "PRE-RELEASES", "LAST SYNCED TAG"); + println!( + "{:<40} {:<15} {:<20}", + "REPOSITORY", "PRE-RELEASES", "LAST SYNCED TAG" + ); println!("{:-<40} {:-<15} {:-<20}", "", "", ""); for repo in &app_config.repositories { println!( "{:<40} {:<15} {:<20}", repo.name, - if repo.include_prereleases { "yes" } else { "no" }, + if repo.include_prereleases { + "yes" + } else { + "no" + }, repo.last_synced_tag.as_deref().unwrap_or("-") ); } @@ -154,17 +171,40 @@ async fn main() -> Result<()> { Commands::Config(config_args) => match config_args.action { None | Some(ConfigAction::Show) => { println!("Configuration location: {:?}", get_config_file_path()); - println!("Gitea URL: {}", app_config.gitea_url.as_deref().unwrap_or("(not configured)")); - println!("Registry Owner: {}", app_config.registry_owner.as_deref().unwrap_or("(not configured)")); + println!( + "Gitea URL: {}", + app_config + .gitea_url + .as_deref() + .unwrap_or("(not configured)") + ); + println!( + "Registry Owner: {}", + app_config + .registry_owner + .as_deref() + .unwrap_or("(not configured)") + ); println!( "Gitea Token: {}", - if app_config.gitea_token.is_some() { "******** (set)" } else { "(not configured)" } + if app_config.gitea_token.is_some() { + "******** (set)" + } else { + "(not configured)" + } ); println!( "GitHub Token: {}", - if app_config.github_token.is_some() { "******** (set)" } else { "(not configured, public access only)" } + if app_config.github_token.is_some() { + "******** (set)" + } else { + "(not configured, public access only)" + } + ); + println!( + "Repositories: {} configured", + app_config.repositories.len() ); - println!("Repositories: {} configured", app_config.repositories.len()); } Some(ConfigAction::Set { @@ -188,7 +228,11 @@ async fn main() -> Result<()> { } })?; - log("config", "Configuration successfully updated.", LogLevel::Info); + log( + "config", + "Configuration successfully updated.", + LogLevel::Info, + ); } }, } diff --git a/src/pipeline.rs b/src/pipeline.rs index 9b0e0d2..fc50742 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,11 +1,11 @@ -use crate::config::{modify_config, AppConfig, RepoConfig}; -use crate::gitea::{get_target_upload_urls, GiteaClient, GiteaConfig, UploadStatus}; +use crate::config::{AppConfig, RepoConfig, modify_config}; +use crate::gitea::{GiteaClient, GiteaConfig, UploadStatus, get_target_upload_urls}; use crate::github::GitHubClient; -use anyhow::{bail, Context, Result}; -use logger_ctdra::{log, LogLevel}; +use anyhow::{Context, Result, bail}; +use logger_ctdra::{LogLevel, log}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use tokio::fs::{remove_file, File}; +use tokio::fs::{File, remove_file}; use tokio::io::AsyncWriteExt; /// Zusammenfassende Statistiken eines Synchronisationsvorgangs. @@ -52,7 +52,9 @@ pub async fn sync_single_repository( let gitea_token = app_config.gitea_token.as_deref().unwrap_or_default(); let registry_owner = app_config.registry_owner.as_deref().unwrap_or_default(); - if !options.dry_run && (gitea_url.is_empty() || gitea_token.is_empty() || registry_owner.is_empty()) { + if !options.dry_run + && (gitea_url.is_empty() || gitea_token.is_empty() || registry_owner.is_empty()) + { bail!( "Missing Gitea configuration. Ensure --gitea-url, --gitea-token, and --registry-owner are provided or configured." ); @@ -61,12 +63,8 @@ pub async fn sync_single_repository( let github_client = GitHubClient::new(app_config.github_token.clone()) .context("Failed to initialize GitHub client")?; - let gitea_client = GiteaClient::new(GiteaConfig::new( - gitea_url, - gitea_token, - registry_owner, - )) - .context("Failed to initialize Gitea client")?; + let gitea_client = GiteaClient::new(GiteaConfig::new(gitea_url, gitea_token, registry_owner)) + .context("Failed to initialize Gitea client")?; let releases = github_client .fetch_releases(repo_name, options.history, include_prereleases) @@ -97,7 +95,11 @@ pub async fn sync_single_repository( for release in &releases { report.releases_processed += 1; - let release_type_str = if release.prerelease { "pre-release" } else { "stable release" }; + let release_type_str = if release.prerelease { + "pre-release" + } else { + "stable release" + }; log( "sync", @@ -156,9 +158,10 @@ pub async fn sync_single_repository( } // Asset in temporäre Datei herunterladen - let temp_file_path = download_to_temp_file(&github_client, &asset.download_url, &asset.name) - .await - .with_context(|| format!("Failed to download asset '{}'", asset.name))?; + let temp_file_path = + download_to_temp_file(&github_client, &asset.download_url, &asset.name) + .await + .with_context(|| format!("Failed to download asset '{}'", asset.name))?; // Asset auf alle Ziel-Distributions-URLs hochladen for url in &target_urls { @@ -168,7 +171,10 @@ pub async fn sync_single_repository( LogLevel::Debug, ); - match gitea_client.upload_file(&temp_file_path, url, options.dry_run).await { + match gitea_client + .upload_file(&temp_file_path, url, options.dry_run) + .await + { Ok(UploadStatus::Uploaded) => { log( "upload", @@ -220,14 +226,13 @@ pub async fn sync_single_repository( } // Zuletzt synchronisierten Tag persistieren, wenn kein Dry-Run - if !options.dry_run { - if let Some(tag) = latest_synced_tag { + if !options.dry_run + && let Some(tag) = latest_synced_tag { let name_copy = repo_name.to_string(); let _ = modify_config(move |cfg| { cfg.update_last_synced_tag(&name_copy, tag); }); } - } report.repositories_processed += 1; Ok(()) @@ -254,7 +259,10 @@ async fn download_to_temp_file( log( "download", - &format!("Downloading '{}' to temporary path {:?}...", filename, temp_path), + &format!( + "Downloading '{}' to temporary path {:?}...", + filename, temp_path + ), LogLevel::Debug, ); @@ -263,7 +271,11 @@ async fn download_to_temp_file( .await .with_context(|| format!("Failed to create temp file {:?}", temp_path))?; - while let Some(chunk) = response.chunk().await.context("Error reading download stream")? { + while let Some(chunk) = response + .chunk() + .await + .context("Error reading download stream")? + { file.write_all(&chunk) .await .context("Error writing chunk to temp file")?; diff --git a/src/utils.rs b/src/utils.rs index 317d8e9..149bf33 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,3 @@ - /// Sanitizes a string by removing surrounding quotes (single or double). /// /// # Arguments @@ -12,7 +11,10 @@ pub fn sanitize_string(input: &str) -> String { let trimmed = input.trim(); if let Some(stripped) = trimmed.strip_prefix('"').and_then(|s| s.strip_suffix('"')) { stripped.to_string() - } else if let Some(stripped) = trimmed.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { + } else if let Some(stripped) = trimmed + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + { stripped.to_string() } else { trimmed.to_string() @@ -25,7 +27,7 @@ pub fn sanitize_string(input: &str) -> String { /// /// * `env_vars` - A reference to a mutable map of environment variables. pub fn sanitize_env_vars(env_vars: &mut std::collections::HashMap) { - for (_key, value) in env_vars.iter_mut() { + for value in env_vars.values_mut() { *value = sanitize_string(value); } } @@ -35,15 +37,13 @@ pub fn sanitize_env_vars(env_vars: &mut std::collections::HashMap &str { let mut trimmed = input.trim(); - if (trimmed.starts_with('"') && trimmed.ends_with('"')) - || (trimmed.starts_with('\'') && trimmed.ends_with('\'')) - { - if trimmed.len() >= 2 { + if ((trimmed.starts_with('"') && trimmed.ends_with('"')) + || (trimmed.starts_with('\'') && trimmed.ends_with('\''))) + && trimmed.len() >= 2 { trimmed = trimmed[1..trimmed.len() - 1].trim(); } - } - let without_query_or_fragment = match trimmed.find(|c| c == '?' || c == '#') { + let without_query_or_fragment = match trimmed.find(['?', '#']) { Some(idx) => &trimmed[..idx], None => trimmed, }; diff --git a/tests/config_tests.rs b/tests/config_tests.rs index 972e675..5895675 100644 --- a/tests/config_tests.rs +++ b/tests/config_tests.rs @@ -5,14 +5,23 @@ use std::collections::HashMap; #[test] fn test_sanitize_env_vars() { let mut env_vars = HashMap::new(); - env_vars.insert("GITEA_URL".to_string(), "\"https://gitea.example.com\"".to_string()); + env_vars.insert( + "GITEA_URL".to_string(), + "\"https://gitea.example.com\"".to_string(), + ); env_vars.insert("GITEA_TOKEN".to_string(), "\"token123\"".to_string()); env_vars.insert("REGISTRY_OWNER".to_string(), "\"owner\"".to_string()); - env_vars.insert("GITHUB_TOKEN".to_string(), "\"github_token123\"".to_string()); + env_vars.insert( + "GITHUB_TOKEN".to_string(), + "\"github_token123\"".to_string(), + ); sanitize_env_vars(&mut env_vars); - assert_eq!(env_vars.get("GITEA_URL").unwrap(), "https://gitea.example.com"); + assert_eq!( + env_vars.get("GITEA_URL").unwrap(), + "https://gitea.example.com" + ); assert_eq!(env_vars.get("GITEA_TOKEN").unwrap(), "token123"); assert_eq!(env_vars.get("REGISTRY_OWNER").unwrap(), "owner"); assert_eq!(env_vars.get("GITHUB_TOKEN").unwrap(), "github_token123"); @@ -31,7 +40,10 @@ fn test_load_config_with_env_vars() { // Only assert if the environment variables are set if std::env::var("GITEA_URL").is_ok() { - assert_eq!(config.gitea_url, Some("https://gitea.example.com".to_string())); + assert_eq!( + config.gitea_url, + Some("https://gitea.example.com".to_string()) + ); assert_eq!(config.gitea_token, Some("token123".to_string())); assert_eq!(config.registry_owner, Some("owner".to_string())); assert_eq!(config.github_token, Some("github_token123".to_string())); @@ -40,8 +52,14 @@ fn test_load_config_with_env_vars() { #[test] fn test_sanitize_string() { - assert_eq!(sanitize_string("\"https://gitea.example.com\""), "https://gitea.example.com"); - assert_eq!(sanitize_string("'https://gitea.example.com'"), "https://gitea.example.com"); + assert_eq!( + sanitize_string("\"https://gitea.example.com\""), + "https://gitea.example.com" + ); + assert_eq!( + sanitize_string("'https://gitea.example.com'"), + "https://gitea.example.com" + ); assert_eq!(sanitize_string("\"token123\""), "token123"); assert_eq!(sanitize_string("'token123'"), "token123"); assert_eq!(sanitize_string("\"owner\""), "owner"); @@ -68,11 +86,23 @@ fn test_sanitize_string() { #[test] fn test_clean_repo_input() { assert_eq!(clean_repo_input("owner/repo"), "owner/repo"); - assert_eq!(clean_repo_input("https://github.com/owner/repo"), "owner/repo"); - assert_eq!(clean_repo_input("http://github.com/owner/repo"), "owner/repo"); + assert_eq!( + clean_repo_input("https://github.com/owner/repo"), + "owner/repo" + ); + assert_eq!( + clean_repo_input("http://github.com/owner/repo"), + "owner/repo" + ); assert_eq!(clean_repo_input("github.com/owner/repo"), "owner/repo"); - assert_eq!(clean_repo_input("https://github.com/owner/repo.git"), "owner/repo"); - assert_eq!(clean_repo_input("https://github.com/owner/repo/"), "owner/repo"); + assert_eq!( + clean_repo_input("https://github.com/owner/repo.git"), + "owner/repo" + ); + assert_eq!( + clean_repo_input("https://github.com/owner/repo/"), + "owner/repo" + ); assert_eq!( clean_repo_input("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file"), "raspberrypi/rpi-imager" @@ -123,14 +153,14 @@ fn test_add_or_update_repo() { config.add_or_update_repo(repo.clone()); assert_eq!(config.repositories.len(), 1); assert_eq!(config.repositories[0].name, "owner/repo"); - assert_eq!(config.repositories[0].include_prereleases, true); + assert!(config.repositories[0].include_prereleases); // Updating existing repo with a URL with query param let repo_updated = RepoConfig::new("https://github.com/owner/repo#readme", false); config.add_or_update_repo(repo_updated); assert_eq!(config.repositories.len(), 1); assert_eq!(config.repositories[0].name, "owner/repo"); - assert_eq!(config.repositories[0].include_prereleases, false); + assert!(!config.repositories[0].include_prereleases); } #[test] @@ -159,5 +189,8 @@ fn test_update_last_synced_tag() { let repo = RepoConfig::new("owner/repo", true); config.add_or_update_repo(repo.clone()); config.update_last_synced_tag("owner/repo", "v1.0.0".to_string()); - assert_eq!(config.find_repo("owner/repo").unwrap().last_synced_tag, Some("v1.0.0".to_string())); -} \ No newline at end of file + assert_eq!( + config.find_repo("owner/repo").unwrap().last_synced_tag, + Some("v1.0.0".to_string()) + ); +} diff --git a/tests/github_tests.rs b/tests/github_tests.rs index 16ad8dc..c8264ca 100644 --- a/tests/github_tests.rs +++ b/tests/github_tests.rs @@ -41,7 +41,10 @@ fn test_package_classification() { ); // Nicht unterstützte Paketformate sollten ignoriert werden - assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.AppImage"), None); + assert_eq!( + PackageType::from_filename("rpi-imager-1.8.5.AppImage"), + None + ); assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.dmg"), None); assert_eq!(PackageType::from_filename("rpi-imager-1.8.5.exe"), None); assert_eq!(PackageType::from_filename("source-code.tar.gz"), None); @@ -57,19 +60,25 @@ fn test_parse_repo_owner_name() { ("raspberrypi", "rpi-imager") ); assert_eq!( - parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher").unwrap(), + parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher") + .unwrap(), ("Heroic-Games-Launcher", "HeroicGamesLauncher") ); assert_eq!( - parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git").unwrap(), + parse_repo_owner_name("https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher.git") + .unwrap(), ("Heroic-Games-Launcher", "HeroicGamesLauncher") ); assert_eq!( - parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file").unwrap(), + parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager?tab=readme-ov-file") + .unwrap(), ("raspberrypi", "rpi-imager") ); assert_eq!( - parse_repo_owner_name("https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file#install").unwrap(), + parse_repo_owner_name( + "https://github.com/raspberrypi/rpi-imager/?tab=readme-ov-file#install" + ) + .unwrap(), ("raspberrypi", "rpi-imager") ); assert_eq!( @@ -86,14 +95,17 @@ async fn test_github_client_empty_token() { use mirror_package::github::GitHubClient; // Test that a client with an empty or whitespace token sanitizes it to None - let client = GitHubClient::new(Some("".to_string())).expect("Failed to create client with empty token"); + let client = + GitHubClient::new(Some("".to_string())).expect("Failed to create client with empty token"); assert_eq!(client.token(), None); - let client_whitespace = GitHubClient::new(Some(" ".to_string())).expect("Failed to create client with whitespace token"); + let client_whitespace = GitHubClient::new(Some(" ".to_string())) + .expect("Failed to create client with whitespace token"); assert_eq!(client_whitespace.token(), None); // Test with a valid token - let client_with_token = GitHubClient::new(Some("valid_token".to_string())).expect("Failed to create client with valid token"); + let client_with_token = GitHubClient::new(Some("valid_token".to_string())) + .expect("Failed to create client with valid token"); assert_eq!(client_with_token.token(), Some("valid_token")); // Test with no token