mirror of
https://github.com/edera-dev/sprout.git
synced 2025-12-19 15:20:17 +00:00
feat(autoconfigure): find vmlinuz and initramfs pairs with linux autoconfigure module
This commit is contained in:
@@ -4,9 +4,14 @@ use uefi::fs::FileSystem;
|
|||||||
use uefi::proto::device_path::DevicePath;
|
use uefi::proto::device_path::DevicePath;
|
||||||
use uefi::proto::media::fs::SimpleFileSystem;
|
use uefi::proto::media::fs::SimpleFileSystem;
|
||||||
|
|
||||||
/// bls: autodetect and configure BLS-enabled systems.
|
/// bls: autodetect and configure BLS-enabled filesystems.
|
||||||
pub mod bls;
|
pub mod bls;
|
||||||
|
|
||||||
|
/// linux: autodetect and configure Linux kernels.
|
||||||
|
/// This autoconfiguration module should not be activated
|
||||||
|
/// on BLS-enabled filesystems as it may make duplicate entries.
|
||||||
|
pub mod linux;
|
||||||
|
|
||||||
/// Generate a [RootConfiguration] based on the environment.
|
/// Generate a [RootConfiguration] based on the environment.
|
||||||
/// Intakes a `config` to use as the basis of the autoconfiguration.
|
/// Intakes a `config` to use as the basis of the autoconfiguration.
|
||||||
pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
|
pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
|
||||||
@@ -31,8 +36,14 @@ pub fn autoconfigure(config: &mut RootConfiguration) -> Result<()> {
|
|||||||
let mut filesystem = FileSystem::new(filesystem);
|
let mut filesystem = FileSystem::new(filesystem);
|
||||||
|
|
||||||
// Scan the filesystem for BLS supported configurations.
|
// Scan the filesystem for BLS supported configurations.
|
||||||
bls::scan(&mut filesystem, &root, config)
|
let bls_found = bls::scan(&mut filesystem, &root, config)
|
||||||
.context("unable to scan for bls configurations")?;
|
.context("unable to scan for bls configurations")?;
|
||||||
|
|
||||||
|
// If BLS was not found, scan for Linux configurations.
|
||||||
|
if !bls_found {
|
||||||
|
linux::scan(&mut filesystem, &root, config)
|
||||||
|
.context("unable to scan for linux configurations")?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
209
src/autoconfigure/linux.rs
Normal file
209
src/autoconfigure/linux.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use crate::actions::ActionDeclaration;
|
||||||
|
use crate::actions::chainload::ChainloadConfiguration;
|
||||||
|
use crate::config::RootConfiguration;
|
||||||
|
use crate::entries::EntryDeclaration;
|
||||||
|
use crate::generators::GeneratorDeclaration;
|
||||||
|
use crate::generators::list::ListConfiguration;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use uefi::CString16;
|
||||||
|
use uefi::fs::{FileSystem, Path};
|
||||||
|
use uefi::proto::device_path::DevicePath;
|
||||||
|
use uefi::proto::device_path::text::{AllowShortcuts, DisplayOnly};
|
||||||
|
|
||||||
|
/// The name prefix of the Linux chainload action that will be used to boot Linux.
|
||||||
|
const LINUX_CHAINLOAD_ACTION_PREFIX: &str = "linux-chainload-";
|
||||||
|
|
||||||
|
/// The locations to scan for kernel pairs.
|
||||||
|
/// We will check for symlinks and if this directory is a symlink, we will skip it.
|
||||||
|
const SCAN_LOCATIONS: &[&str] = &["/boot", "/"];
|
||||||
|
|
||||||
|
/// Prefixes of kernel files to scan for.
|
||||||
|
const KERNEL_PREFIXES: &[&str] = &["vmlinuz"];
|
||||||
|
|
||||||
|
/// Prefixes of initramfs files to match to.
|
||||||
|
const INITRAMFS_PREFIXES: &[&str] = &["initramfs", "initrd"];
|
||||||
|
|
||||||
|
/// Pair of kernel and initramfs.
|
||||||
|
/// This is what scanning a directory is meant to find.
|
||||||
|
struct KernelPair {
|
||||||
|
/// The path to a kernel.
|
||||||
|
kernel: String,
|
||||||
|
/// The path to an initramfs, if any.
|
||||||
|
initramfs: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the specified `filesystem` at `path` for [KernelPair] results.
|
||||||
|
fn scan_directory(filesystem: &mut FileSystem, path: &str) -> Result<Vec<KernelPair>> {
|
||||||
|
// All the discovered kernel pairs.
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
|
||||||
|
// Construct a filesystem path from the path string.
|
||||||
|
let path = CString16::try_from(path).context("unable to convert path to CString16")?;
|
||||||
|
let path = Path::new(&path);
|
||||||
|
let path = path.to_path_buf();
|
||||||
|
|
||||||
|
// Check if the path exists and is a directory.
|
||||||
|
let exists = filesystem
|
||||||
|
.metadata(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|metadata| metadata.is_directory())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// If the path does not exist, return an empty list.
|
||||||
|
if !exists {
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a directory iterator on the path to scan.
|
||||||
|
// Ignore errors here as in some scenarios this might fail due to symlinks.
|
||||||
|
let Some(directory) = filesystem.read_dir(&path).ok() else {
|
||||||
|
return Ok(pairs);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For each item in the directory, find a kernel.
|
||||||
|
for item in directory {
|
||||||
|
let item = item.context("unable to read directory item")?;
|
||||||
|
|
||||||
|
// Skip over any items that are not regular files.
|
||||||
|
if !item.is_regular_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the name from a CString16 to a String.
|
||||||
|
let name = item.file_name().to_string();
|
||||||
|
|
||||||
|
// Find a kernel prefix that matches, if any.
|
||||||
|
let Some(prefix) = KERNEL_PREFIXES
|
||||||
|
.iter()
|
||||||
|
.find(|prefix| name == **prefix || name.starts_with(&format!("{}-", prefix)))
|
||||||
|
else {
|
||||||
|
// Skip over anything that doesn't match a kernel prefix.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Acquire the suffix of the name, this will be used to match an initramfs.
|
||||||
|
let suffix = &name[prefix.len()..];
|
||||||
|
|
||||||
|
// Find a matching initramfs, if any.
|
||||||
|
let mut initramfs_prefix_iter = INITRAMFS_PREFIXES.iter();
|
||||||
|
let initramfs = loop {
|
||||||
|
let Some(prefix) = initramfs_prefix_iter.next() else {
|
||||||
|
break None;
|
||||||
|
};
|
||||||
|
// Construct an initramfs path.
|
||||||
|
let initramfs = format!("{}{}", prefix, suffix);
|
||||||
|
let initramfs = CString16::try_from(initramfs.as_str())
|
||||||
|
.context("unable to convert initramfs name to CString16")?;
|
||||||
|
let mut initramfs_path = path.clone();
|
||||||
|
initramfs_path.push(Path::new(&initramfs));
|
||||||
|
// Check if the initramfs path exists, if it does, break out of the loop.
|
||||||
|
if filesystem
|
||||||
|
.try_exists(&initramfs_path)
|
||||||
|
.context("unable to check if initramfs path exists")?
|
||||||
|
{
|
||||||
|
break Some(initramfs_path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct a kernel path from the kernel name.
|
||||||
|
let mut kernel = path.clone();
|
||||||
|
kernel.push(Path::new(&item.file_name()));
|
||||||
|
let kernel = kernel.to_string();
|
||||||
|
let initramfs = initramfs.map(|initramfs| initramfs.to_string());
|
||||||
|
|
||||||
|
// Produce a kernel pair.
|
||||||
|
let pair = KernelPair { kernel, initramfs };
|
||||||
|
pairs.push(pair);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the specified `filesystem` for Linux kernels and matching initramfs.
|
||||||
|
pub fn scan(
|
||||||
|
filesystem: &mut FileSystem,
|
||||||
|
root: &DevicePath,
|
||||||
|
config: &mut RootConfiguration,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
|
||||||
|
// Convert the device path root to a string we can use in the configuration.
|
||||||
|
let mut root = root
|
||||||
|
.to_string(DisplayOnly(false), AllowShortcuts(false))
|
||||||
|
.context("unable to convert device root to string")?
|
||||||
|
.to_string();
|
||||||
|
// Add a trailing slash to the root to ensure the path is valid.
|
||||||
|
root.push('/');
|
||||||
|
|
||||||
|
// Scan all locations for kernel pairs, adding them to the list.
|
||||||
|
for location in SCAN_LOCATIONS {
|
||||||
|
let scanned = scan_directory(filesystem, location)
|
||||||
|
.with_context(|| format!("unable to scan directory {}", location))?;
|
||||||
|
pairs.extend(scanned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no kernel pairs were found, return false.
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique name for the linux chainload action.
|
||||||
|
let chainload_action_name = format!("{}{}", LINUX_CHAINLOAD_ACTION_PREFIX, root);
|
||||||
|
|
||||||
|
// Kernel pairs are detected, generate a list configuration for it.
|
||||||
|
let generator = ListConfiguration {
|
||||||
|
entry: EntryDeclaration {
|
||||||
|
title: "Boot Linux $kernel".to_string(),
|
||||||
|
actions: vec![chainload_action_name.clone()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
values: pairs
|
||||||
|
.into_iter()
|
||||||
|
.map(|pair| {
|
||||||
|
BTreeMap::from_iter(vec![
|
||||||
|
("kernel".to_string(), pair.kernel),
|
||||||
|
("initrd".to_string(), pair.initramfs.unwrap_or_default()),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a unique name for the Linux generator and insert the generator into the configuration.
|
||||||
|
config.generators.insert(
|
||||||
|
format!("autoconfigure-linux-{}", root),
|
||||||
|
GeneratorDeclaration {
|
||||||
|
list: Some(generator),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert a default value for the linux-options if it doesn't exist.
|
||||||
|
if !config.values.contains_key("linux-options") {
|
||||||
|
config
|
||||||
|
.values
|
||||||
|
.insert("linux-options".to_string(), "".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a chainload configuration for the list generator.
|
||||||
|
// The list will provide these values to us.
|
||||||
|
// Note that we don't need an extra \\ in the paths here.
|
||||||
|
// The root already contains a trailing slash.
|
||||||
|
let chainload = ChainloadConfiguration {
|
||||||
|
path: format!("{}$kernel", root),
|
||||||
|
options: vec!["$linux-options".to_string()],
|
||||||
|
linux_initrd: Some(format!("{}$initrd", root)),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert the chainload action into the configuration.
|
||||||
|
config.actions.insert(
|
||||||
|
chainload_action_name,
|
||||||
|
ActionDeclaration {
|
||||||
|
chainload: Some(chainload),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// We had a Linux kernel, so return true to indicate something was found.
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::context::SproutContext;
|
use crate::context::SproutContext;
|
||||||
use crate::entries::BootableEntry;
|
use crate::entries::BootableEntry;
|
||||||
use crate::generators::bls::BlsConfiguration;
|
use crate::generators::bls::BlsConfiguration;
|
||||||
|
use crate::generators::list::ListConfiguration;
|
||||||
use crate::generators::matrix::MatrixConfiguration;
|
use crate::generators::matrix::MatrixConfiguration;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
@@ -8,6 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
pub mod bls;
|
pub mod bls;
|
||||||
|
pub mod list;
|
||||||
pub mod matrix;
|
pub mod matrix;
|
||||||
|
|
||||||
/// Declares a generator configuration.
|
/// Declares a generator configuration.
|
||||||
@@ -32,6 +34,9 @@ pub struct GeneratorDeclaration {
|
|||||||
/// It will generate a sprout entry for every supported BLS entry.
|
/// It will generate a sprout entry for every supported BLS entry.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub bls: Option<BlsConfiguration>,
|
pub bls: Option<BlsConfiguration>,
|
||||||
|
/// List generator configuration.
|
||||||
|
/// Allows you to specify a list of values to generate an entry from.
|
||||||
|
pub list: Option<ListConfiguration>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs the generator specified by the `generator` option.
|
/// Runs the generator specified by the `generator` option.
|
||||||
@@ -45,6 +50,8 @@ pub fn generate(
|
|||||||
matrix::generate(context, matrix)
|
matrix::generate(context, matrix)
|
||||||
} else if let Some(bls) = &generator.bls {
|
} else if let Some(bls) = &generator.bls {
|
||||||
bls::generate(context, bls)
|
bls::generate(context, bls)
|
||||||
|
} else if let Some(list) = &generator.list {
|
||||||
|
list::generate(context, list)
|
||||||
} else {
|
} else {
|
||||||
bail!("unknown generator configuration");
|
bail!("unknown generator configuration");
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/generators/list.rs
Normal file
52
src/generators/list.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use crate::context::SproutContext;
|
||||||
|
use crate::entries::{BootableEntry, EntryDeclaration};
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// List generator configuration.
|
||||||
|
/// The matrix generator produces multiple entries based
|
||||||
|
/// on a set of input maps.
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
|
||||||
|
pub struct ListConfiguration {
|
||||||
|
/// The template entry to use for each generated entry.
|
||||||
|
#[serde(default)]
|
||||||
|
pub entry: EntryDeclaration,
|
||||||
|
/// The values to use as the input for the matrix.
|
||||||
|
#[serde(default)]
|
||||||
|
pub values: Vec<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a set of entries using the specified `matrix` configuration in the `context`.
|
||||||
|
pub fn generate(
|
||||||
|
context: Rc<SproutContext>,
|
||||||
|
list: &ListConfiguration,
|
||||||
|
) -> Result<Vec<BootableEntry>> {
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
// For each combination, create a new context and entry.
|
||||||
|
for (index, combination) in list.values.iter().enumerate() {
|
||||||
|
let mut context = context.fork();
|
||||||
|
// Insert the combination into the context.
|
||||||
|
context.insert(combination);
|
||||||
|
let context = context.freeze();
|
||||||
|
|
||||||
|
// Stamp the entry title and actions from the template.
|
||||||
|
let mut entry = list.entry.clone();
|
||||||
|
entry.actions = entry
|
||||||
|
.actions
|
||||||
|
.into_iter()
|
||||||
|
.map(|action| context.stamp(action))
|
||||||
|
.collect();
|
||||||
|
// Push the entry into the list with the new context.
|
||||||
|
entries.push(BootableEntry::new(
|
||||||
|
index.to_string(),
|
||||||
|
entry.title.clone(),
|
||||||
|
context,
|
||||||
|
entry,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::context::SproutContext;
|
use crate::context::SproutContext;
|
||||||
use crate::entries::{BootableEntry, EntryDeclaration};
|
use crate::entries::{BootableEntry, EntryDeclaration};
|
||||||
|
use crate::generators::list;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -57,30 +58,12 @@ pub fn generate(
|
|||||||
) -> Result<Vec<BootableEntry>> {
|
) -> Result<Vec<BootableEntry>> {
|
||||||
// Produce all the combinations of the input values.
|
// Produce all the combinations of the input values.
|
||||||
let combinations = build_matrix(&matrix.values);
|
let combinations = build_matrix(&matrix.values);
|
||||||
let mut entries = Vec::new();
|
// Use the list generator to generate entries for each combination.
|
||||||
|
list::generate(
|
||||||
// For each combination, create a new context and entry.
|
|
||||||
for (index, combination) in combinations.into_iter().enumerate() {
|
|
||||||
let mut context = context.fork();
|
|
||||||
// Insert the combination into the context.
|
|
||||||
context.insert(&combination);
|
|
||||||
let context = context.freeze();
|
|
||||||
|
|
||||||
// Stamp the entry title and actions from the template.
|
|
||||||
let mut entry = matrix.entry.clone();
|
|
||||||
entry.actions = entry
|
|
||||||
.actions
|
|
||||||
.into_iter()
|
|
||||||
.map(|action| context.stamp(action))
|
|
||||||
.collect();
|
|
||||||
// Push the entry into the list with the new context.
|
|
||||||
entries.push(BootableEntry::new(
|
|
||||||
index.to_string(),
|
|
||||||
entry.title.clone(),
|
|
||||||
context,
|
context,
|
||||||
entry,
|
&list::ListConfiguration {
|
||||||
));
|
entry: matrix.entry.clone(),
|
||||||
}
|
values: combinations,
|
||||||
|
},
|
||||||
Ok(entries)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/main.rs
31
src/main.rs
@@ -8,7 +8,7 @@ use crate::options::SproutOptions;
|
|||||||
use crate::options::parser::OptionsRepresentable;
|
use crate::options::parser::OptionsRepresentable;
|
||||||
use crate::phases::phase;
|
use crate::phases::phase;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use log::info;
|
use log::{error, info};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::ops::Deref;
|
use std::ops::Deref;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -54,13 +54,8 @@ pub mod options;
|
|||||||
/// utils: Utility functions that are used by other parts of Sprout.
|
/// utils: Utility functions that are used by other parts of Sprout.
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
/// The main entrypoint of sprout.
|
/// Run Sprout, returning an error if one occurs.
|
||||||
/// It is possible this function will not return if actions that are executed
|
fn run() -> Result<()> {
|
||||||
/// exit boot services or do not return control to sprout.
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
// Initialize the basic UEFI environment.
|
|
||||||
setup::init()?;
|
|
||||||
|
|
||||||
// Parse the options to the sprout executable.
|
// Parse the options to the sprout executable.
|
||||||
let options = SproutOptions::parse().context("unable to parse options")?;
|
let options = SproutOptions::parse().context("unable to parse options")?;
|
||||||
|
|
||||||
@@ -240,6 +235,26 @@ fn main() -> Result<()> {
|
|||||||
.context(format!("unable to execute action '{}'", action))?;
|
.context(format!("unable to execute action '{}'", action))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The main entrypoint of sprout.
|
||||||
|
/// It is possible this function will not return if actions that are executed
|
||||||
|
/// exit boot services or do not return control to sprout.
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// Initialize the basic UEFI environment.
|
||||||
|
setup::init()?;
|
||||||
|
|
||||||
|
// Run Sprout, then handle the error.
|
||||||
|
let result = run();
|
||||||
|
if let Err(ref error) = result {
|
||||||
|
// Print an error trace.
|
||||||
|
error!("sprout encountered an error");
|
||||||
|
for (index, stack) in error.chain().enumerate() {
|
||||||
|
error!("[{}]: {}", index, stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sprout doesn't necessarily guarantee anything was booted.
|
// Sprout doesn't necessarily guarantee anything was booted.
|
||||||
// If we reach here, we will exit back to whoever called us.
|
// If we reach here, we will exit back to whoever called us.
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ fn select_with_input<'a>(
|
|||||||
// Entry was selected by number. If the number is invalid, we continue.
|
// Entry was selected by number. If the number is invalid, we continue.
|
||||||
MenuOperation::Number(index) => {
|
MenuOperation::Number(index) => {
|
||||||
let Some(entry) = entries.get(index) else {
|
let Some(entry) = entries.get(index) else {
|
||||||
println!("invalid entry number");
|
info!("invalid entry number");
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
return Ok(entry);
|
return Ok(entry);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
|
use log::info;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// The type of option. This disambiguates different behavior
|
/// The type of option. This disambiguates different behavior
|
||||||
@@ -113,9 +114,9 @@ pub trait OptionsRepresentable {
|
|||||||
// Handle the --help flag case.
|
// Handle the --help flag case.
|
||||||
if description.form == OptionForm::Help {
|
if description.form == OptionForm::Help {
|
||||||
// Generic configured options output.
|
// Generic configured options output.
|
||||||
println!("Configured Options:");
|
info!("Configured Options:");
|
||||||
for (name, description) in &configured {
|
for (name, description) in &configured {
|
||||||
println!(
|
info!(
|
||||||
" --{}{}: {}",
|
" --{}{}: {}",
|
||||||
name,
|
name,
|
||||||
if description.form == OptionForm::Value {
|
if description.form == OptionForm::Value {
|
||||||
|
|||||||
Reference in New Issue
Block a user