From 365b295433638ce6ee32c15f2559d4b2d155e527 Mon Sep 17 00:00:00 2001 From: Mikkel Mork Hegnhoj Date: Mon, 6 Dec 2021 23:01:33 +0100 Subject: [PATCH] feat(azure): Azure module (#3275) * Azure module * make Semantic PR bot happy * Responding to review * Changing severity of logging event --- docs/config/README.md | 26 ++++ src/configs/azure.rs | 22 +++ src/configs/mod.rs | 3 + src/configs/starship_root.rs | 1 + src/module.rs | 1 + src/modules/azure.rs | 267 +++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 3 + 7 files changed, 323 insertions(+) create mode 100644 src/configs/azure.rs create mode 100644 src/modules/azure.rs diff --git a/docs/config/README.md b/docs/config/README.md index f123ce6c..f8899751 100644 --- a/docs/config/README.md +++ b/docs/config/README.md @@ -240,6 +240,7 @@ $memory_usage\ $aws\ $gcloud\ $openstack\ +$azure\ $env_var\ $crystal\ $custom\ @@ -344,6 +345,31 @@ style = "bold blue" symbol = "🅰 " ``` +## Azure + +The `azure` module shows the current Azure Subscription. This is based on showing the name of the default subscription, as defined in the `~/.azure/azureProfile.json` file. + +### Options + +| Variable | Default | Description | +| ----------------- | ---------------------------------------- | ------------------------------------------ | +| `format` | `"on [$symbol($subscription)]($style) "` | The format for the Azure module to render. | +| `symbol` | `"ﴃ "` | The symbol used in the format. | +| `style` | `"blue bold"` | The style used in the format. | +| `disabled` | `true` | Disables the `azure` module. | + +### Example + +```toml +# ~/.config/starship.toml + +[azure] +disabled = false +format = "on [$symbol($subscription)]($style) " +symbol = "ﴃ " +style = "blue bold" +``` + ## Battery The `battery` module shows how charged the device's battery is and its current charging status. diff --git a/src/configs/azure.rs b/src/configs/azure.rs new file mode 100644 index 00000000..c22fad98 --- /dev/null +++ b/src/configs/azure.rs @@ -0,0 +1,22 @@ +use crate::config::ModuleConfig; +use serde::Serialize; +use starship_module_config_derive::ModuleConfig; + +#[derive(Clone, ModuleConfig, Serialize)] +pub struct AzureConfig<'a> { + pub format: &'a str, + pub symbol: &'a str, + pub style: &'a str, + pub disabled: bool, +} + +impl<'a> Default for AzureConfig<'a> { + fn default() -> Self { + AzureConfig { + format: "on [$symbol($subscription)]($style) ", + symbol: "ﴃ ", + style: "blue bold", + disabled: true, + } + } +} diff --git a/src/configs/mod.rs b/src/configs/mod.rs index e1434b66..4073bf56 100644 --- a/src/configs/mod.rs +++ b/src/configs/mod.rs @@ -4,6 +4,7 @@ use serde::{self, Serialize}; use starship_module_config_derive::ModuleConfig; pub mod aws; +pub mod azure; pub mod battery; pub mod character; pub mod cmake; @@ -84,6 +85,7 @@ pub struct FullConfig<'a> { pub add_newline: bool, // modules aws: aws::AwsConfig<'a>, + azure: azure::AzureConfig<'a>, battery: battery::BatteryConfig<'a>, character: character::CharacterConfig<'a>, cmake: cmake::CMakeConfig<'a>, @@ -161,6 +163,7 @@ impl<'a> Default for FullConfig<'a> { add_newline: true, aws: Default::default(), + azure: Default::default(), battery: Default::default(), character: Default::default(), cmake: Default::default(), diff --git a/src/configs/starship_root.rs b/src/configs/starship_root.rs index a5b7f394..8a70a2f5 100644 --- a/src/configs/starship_root.rs +++ b/src/configs/starship_root.rs @@ -73,6 +73,7 @@ pub const PROMPT_ORDER: &[&str] = &[ "aws", "gcloud", "openstack", + "azure", "env_var", "crystal", "custom", diff --git a/src/module.rs b/src/module.rs index 6c7bfb97..f3b24197 100644 --- a/src/module.rs +++ b/src/module.rs @@ -9,6 +9,7 @@ use std::time::Duration; // Default ordering is handled in configs/starship_root.rs pub const ALL_MODULES: &[&str] = &[ "aws", + "azure", #[cfg(feature = "battery")] "battery", "character", diff --git a/src/modules/azure.rs b/src/modules/azure.rs new file mode 100644 index 00000000..7921ebcb --- /dev/null +++ b/src/modules/azure.rs @@ -0,0 +1,267 @@ +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::{Path, PathBuf}; + +use super::{Context, Module, RootModuleConfig}; + +type JValue = serde_json::Value; + +use crate::configs::azure::AzureConfig; +use crate::formatter::StringFormatter; + +type SubscriptionName = String; + +pub fn module<'a>(context: &'a Context) -> Option> { + let mut module = context.new_module("azure"); + let config = AzureConfig::try_load(module.config); + + if config.disabled { + return None; + }; + + let subscription_name: Option = get_azure_subscription_name(context); + if subscription_name.is_none() { + log::info!("Could not find Azure subscription name"); + return None; + }; + + let parsed = StringFormatter::new(config.format).and_then(|formatter| { + formatter + .map_meta(|variable, _| match variable { + "symbol" => Some(config.symbol), + _ => None, + }) + .map_style(|variable| match variable { + "style" => Some(Ok(config.style)), + _ => None, + }) + .map(|variable| match variable { + "subscription" => Some(Ok(subscription_name.as_ref().unwrap())), + _ => None, + }) + .parse(None, Some(context)) + }); + + module.set_segments(match parsed { + Ok(segments) => segments, + Err(error) => { + log::warn!("Error in module `azure`:\n{}", error); + return None; + } + }); + + Some(module) +} + +fn get_azure_subscription_name(context: &Context) -> Option { + let mut config_path = get_config_file_location(context)?; + config_path.push("azureProfile.json"); + + let parsed_json = parse_json(&config_path)?; + + let subscriptions = parsed_json.get("subscriptions")?.as_array()?; + let subscription_name = subscriptions.iter().find_map(|s| { + if s.get("isDefault")? == true { + Some(s.get("name")?.as_str()?.to_string()) + } else { + None + } + }); + if subscription_name.is_some() { + subscription_name + } else { + log::info!("Could not find subscription name"); + None + } +} + +fn get_config_file_location(context: &Context) -> Option { + context + .get_env("AZURE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| { + let mut home = context.get_home()?; + home.push(".azure"); + Some(home) + }) +} + +fn parse_json(json_file_path: &Path) -> Option { + let mut buffer: Vec = Vec::new(); + + let json_file = File::open(&json_file_path).ok()?; + let mut reader = BufReader::new(json_file); + reader.read_to_end(&mut buffer).ok()?; + + let bytes = buffer.as_mut_slice(); + let decodedbuffer = bytes.strip_prefix(&[239, 187, 191]).unwrap_or(bytes); + + if let Ok(parsed_json) = serde_json::from_slice(decodedbuffer) { + Some(parsed_json) + } else { + log::info!("Failed to parse json"); + None + } +} + +#[cfg(test)] +mod tests { + use crate::modules::azure::parse_json; + use crate::test::ModuleRenderer; + use ansi_term::Color; + use ini::Ini; + use std::fs::File; + use std::io::{self, Write}; + use std::path::PathBuf; + + use tempfile::TempDir; + + fn generate_test_config(dir: &TempDir, azure_profile_contents: &str) -> io::Result<()> { + save_string_to_file( + dir, + azure_profile_contents.to_string(), + String::from("azureProfile.json"), + )?; + + Ok(()) + } + #[test] + fn subscription_set_correctly() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let azure_profile_contents = r#"{ + "installationId": "3deacd2a-b9db-77e1-aa42-23e2f8dfffc3", + "subscriptions": [ + { + "id": "f568c543-d12e-de0b-3d85-69843598b565", + "name": "Subscription 2", + "state": "Enabled", + "user": { + "name": "user@domain.com", + "type": "user" + }, + "isDefault": false, + "tenantId": "0e8a15ec-b0f5-d355-7062-8ece54c59aee", + "environmentName": "AzureCloud", + "homeTenantId": "0e8a15ec-b0f5-d355-7062-8ece54c59aee", + "managedByTenants": [] + }, + { + "id": "d4442d26-ea6d-46c4-07cb-4f70b8ae5465", + "name": "Subscription 3", + "state": "Enabled", + "user": { + "name": "user@domain.com", + "type": "user" + }, + "tenantId": "a4e1bb4b-5330-2d50-339d-b9674d3a87bc", + "environmentName": "AzureCloud", + "homeTenantId": "a4e1bb4b-5330-2d50-339d-b9674d3a87bc", + "managedByTenants": [] + }, + { + "id": "f3935dc9-92b5-9a93-da7b-42c325d86939", + "name": "Subscription 1", + "state": "Enabled", + "user": { + "name": "user@domain.com", + "type": "user" + }, + "isDefault": true, + "tenantId": "f0273a19-7779-e40a-00a1-53b8331b3bb6", + "environmentName": "AzureCloud", + "homeTenantId": "f0273a19-7779-e40a-00a1-53b8331b3bb6", + "managedByTenants": [] + } + ] + } + "#; + + generate_test_config(&dir, azure_profile_contents)?; + let dir_path = &dir.path().to_string_lossy(); + let actual = ModuleRenderer::new("azure") + .config(toml::toml! { + [azure] + disabled = false + }) + .env("AZURE_CONFIG_DIR", dir_path.as_ref()) + .collect(); + let expected = Some(format!( + "on {} ", + Color::Blue.bold().paint("ﴃ Subscription 1") + )); + assert_eq!(actual, expected); + dir.close() + } + + #[test] + fn subscription_azure_profile_empty() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let mut clouds_config_ini = Ini::new(); + clouds_config_ini + .with_section(Some("AzureCloud")) + .set("subscription", "f3935dc9-92b5-9a93-da7b-42c325d86939"); + + let azure_profile_contents = r#"{ + "installationId": "3deacd2a-b9db-77e1-aa42-23e2f8dfffc3", + "subscriptions": [] + } + "#; + + generate_test_config(&dir, azure_profile_contents)?; + let dir_path = &dir.path().to_string_lossy(); + let actual = ModuleRenderer::new("azure") + .config(toml::toml! { + [azure] + disabled = false + }) + .env("AZURE_CONFIG_DIR", dir_path.as_ref()) + .collect(); + let expected = None; + assert_eq!(actual, expected); + dir.close() + } + + #[test] + fn files_missing() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let dir_path = &dir.path().to_string_lossy(); + + let actual = ModuleRenderer::new("azure") + .env("AZURE_CONFIG_DIR", dir_path.as_ref()) + .collect(); + let expected = None; + assert_eq!(actual, expected); + dir.close() + } + + #[test] + fn json_parsing() -> io::Result<()> { + let dir = tempfile::tempdir()?; + + let bom = vec![239, 187, 191]; + let mut bom_str = String::from_utf8(bom).unwrap(); + let json_str = r#"{"testKey": "testValue"}"#; + bom_str.push_str(json_str); + + let dir_path_no_bom = save_string_to_file(&dir, bom_str, String::from("bom.json"))?; + let parsed_json = parse_json(&dir_path_no_bom).unwrap(); + + assert_eq!(parsed_json.get("testKey").unwrap(), "testValue"); + dir.close() + } + + fn save_string_to_file( + dir: &TempDir, + contents: String, + file_name: String, + ) -> Result { + let bom_file_path = dir.path().join(file_name); + let mut bom_file = File::create(&bom_file_path)?; + bom_file.write_all(contents.as_bytes())?; + bom_file.sync_all()?; + Ok(bom_file_path) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 15ec1793..8d25c63f 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,5 +1,6 @@ // While adding out new module add out module to src/module.rs ALL_MODULES const array also. mod aws; +mod azure; mod character; mod cmake; mod cmd_duration; @@ -84,6 +85,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { // Keep these ordered alphabetically. // Default ordering is handled in configs/starship_root.rs "aws" => aws::module(context), + "azure" => azure::module(context), #[cfg(feature = "battery")] "battery" => battery::module(context), "character" => character::module(context), @@ -171,6 +173,7 @@ pub fn handle<'a>(module: &str, context: &'a Context) -> Option> { pub fn description(module: &str) -> &'static str { match module { "aws" => "The current AWS region and profile", + "azure" => "The current Azure subscription", "battery" => "The current charge of the device's battery and its current charging status", "character" => { "A character (usually an arrow) beside where the text is entered in your terminal"