use crate::provider::{Provider, providers};
use crate::{Config, GlobalConfig, GlobalDefaults, Profile, Project, Secrets};
use clap::{Parser, Subcommand};
use miette::{IntoDiagnostic, Result, WrapErr, miette};
use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "secretspec")]
#[command(about = "Declarative secrets, every environment, any provider - https://secretspec.dev", long_about = None)]
#[command(version)]
struct Cli {
#[arg(short = 'f', long, global = true, env = "SECRETSPEC_FILE")]
file: Option<PathBuf>,
#[arg(long, global = true, env = "SECRETSPEC_REASON")]
reason: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(long, default_value = "dotenv://.env")]
from: String,
},
Set {
name: String,
value: Option<String>,
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
},
Get {
name: String,
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
},
Run {
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
#[arg(trailing_var_arg = true)]
command: Vec<String>,
},
Check {
#[arg(short, long, env = "SECRETSPEC_PROVIDER")]
provider: Option<String>,
#[arg(short = 'P', long, env = "SECRETSPEC_PROFILE")]
profile: Option<String>,
#[arg(short = 'n', long)]
no_prompt: bool,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Import {
from_provider: String,
},
Audit {
#[arg(long)]
project: Option<String>,
#[arg(long)]
action: Option<String>,
#[arg(short = 'n', long)]
tail: Option<usize>,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Init,
Show,
#[command(subcommand)]
Provider(ProviderAction),
}
#[derive(Subcommand)]
enum ProviderAction {
Add {
name: String,
uri: String,
},
Remove {
name: String,
},
List,
}
fn get_example_toml() -> &'static str {
r#"# DATABASE_URL = { description = "Database connection string", required = true }
[profiles.development]
# Development profile inherits all secrets from default profile
# Only define secrets here that need different values or settings than default
# DATABASE_URL = { default = "sqlite:///dev.db" }
#
# New secrets
# REDIS_URL = { description = "Redis connection URL for caching", required = false, default = "redis://localhost:6379" }
"#
}
fn generate_toml_with_comments(config: &Config) -> crate::Result<String> {
use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
let mut doc = DocumentMut::new();
let mut project = Table::new();
project.insert("name", toml_edit::value(config.project.name.as_str()));
project.insert(
"revision",
toml_edit::value(config.project.revision.as_str()),
);
if let Some(extends) = &config.project.extends {
let mut arr = Array::new();
for entry in extends {
arr.push(entry.as_str());
}
project.insert("extends", toml_edit::value(arr));
}
doc.insert("project", Item::Table(project));
let mut profiles = Table::new();
profiles.set_implicit(true);
let mut profile_names: Vec<&String> = config.profiles.keys().collect();
profile_names.sort();
for (index, profile_name) in profile_names.iter().enumerate() {
let profile_config = &config.profiles[*profile_name];
let mut profile_table = Table::new();
let mut secret_names: Vec<&String> = profile_config.secrets.keys().collect();
secret_names.sort();
for secret_name in secret_names {
let secret_config = &profile_config.secrets[secret_name];
let mut inline = InlineTable::new();
inline.insert(
"description",
Value::from(secret_config.description.as_deref().unwrap_or("")),
);
if let Some(required) = secret_config.required {
inline.insert("required", Value::from(required));
}
if let Some(default) = &secret_config.default {
inline.insert("default", Value::from(default.as_str()));
}
profile_table.insert(secret_name, toml_edit::value(inline));
}
if index == 0 && config.project.extends.is_none() {
profile_table.decor_mut().set_prefix(
"\n# Extend configurations from subdirectories\n# extends = [ \"subdir1\", \"subdir2\" ]\n\n",
);
}
profiles.insert(profile_name.as_str(), Item::Table(profile_table));
}
doc.insert("profiles", Item::Table(profiles));
Ok(doc.to_string())
}
fn load_secrets(file: &Option<PathBuf>, reason: &Option<String>) -> miette::Result<Secrets> {
let secrets = match file {
Some(path) => Secrets::load_from(path),
None => Secrets::load(),
}
.into_diagnostic()
.wrap_err("Failed to load secretspec configuration")?;
Ok(match reason {
Some(reason) => secrets.with_reason(reason.clone()),
None => secrets,
})
}
#[doc(hidden)]
pub fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { from } => {
if PathBuf::from("secretspec.toml").exists() {
use inquire::Confirm;
let overwrite = Confirm::new("secretspec.toml already exists. Overwrite?")
.with_default(false)
.prompt()
.into_diagnostic()?;
if !overwrite {
println!("Cancelled.");
return Ok(());
}
}
let provider: Box<dyn Provider> = from.as_str().try_into().into_diagnostic()?;
if provider.name() != "dotenv" {
return Err(miette!(
"Only 'dotenv' provider is currently supported for init --from. Got provider: {}",
provider.name()
));
}
let secrets = provider.reflect().into_diagnostic()?;
let mut profiles = HashMap::new();
profiles.insert(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
);
let project_config = Config {
project: Project {
name: std::env::current_dir()
.into_diagnostic()?
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
..Default::default()
},
profiles,
providers: None,
};
let mut content = generate_toml_with_comments(&project_config).into_diagnostic()?;
content.push_str(get_example_toml());
fs::write("secretspec.toml", content).into_diagnostic()?;
#[cfg(unix)]
{
let metadata = fs::metadata("secretspec.toml").into_diagnostic()?;
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
fs::set_permissions("secretspec.toml", permissions).into_diagnostic()?;
}
let secret_count = project_config
.profiles
.values()
.map(|p| p.secrets.len())
.sum::<usize>();
println!("✓ Created secretspec.toml with {} secrets", secret_count);
if provider.name() == "dotenv" && secret_count > 0 {
println!("\nTo migrate your secrets from {}:", from);
println!(" 1. Review secretspec.toml and adjust as needed");
println!(" 2. secretspec import {} # Import secret values", from);
}
println!("\nNext steps:");
println!(" 1. secretspec config init # Set up user configuration");
println!(" 2. secretspec check # Verify all secrets and set them");
println!(" 3. secretspec run -- your-command # Run with secrets");
Ok(())
}
Commands::Config { action } => match action {
ConfigAction::Init => {
use inquire::Select;
let provider_choices: Vec<String> = providers()
.into_iter()
.map(|info| info.display_with_examples())
.collect();
let selected_choice =
Select::new("Select your preferred provider backend:", provider_choices)
.prompt()
.into_diagnostic()?;
let provider = selected_choice.split(':').next().unwrap_or("keyring");
let profiles = vec!["development", "default", "none"];
let profile_choice = Select::new("Select your default profile:", profiles)
.with_help_message(
"'development' is recommended for local development environments",
)
.prompt()
.into_diagnostic()?;
let profile = if profile_choice == "none" {
None
} else {
Some(profile_choice.to_string())
};
let mut config = GlobalConfig::load().into_diagnostic()?.unwrap_or_default();
config.defaults.provider = Some(provider.to_string());
config.defaults.profile = profile;
config.save().into_diagnostic()?;
println!(
"\n✓ Configuration saved to {}",
GlobalConfig::path().into_diagnostic()?.display()
);
Ok(())
}
ConfigAction::Show => {
match GlobalConfig::load().into_diagnostic()? {
Some(config) => {
println!(
"Configuration file: {}\n",
GlobalConfig::path().into_diagnostic()?.display()
);
match config.defaults.provider {
Some(provider) => println!("Provider: {}", provider),
None => println!("Provider: (none)"),
}
match config.defaults.profile {
Some(profile) => println!("Profile: {}", profile),
None => println!("Profile: (none)"),
}
if let Some(providers) = &config.defaults.providers {
println!("\nProvider Aliases:");
let mut aliases: Vec<_> = providers.iter().collect();
aliases.sort_by(|(a, _), (b, _)| a.cmp(b));
for (alias, uri) in aliases {
println!(" {} = {}", alias, uri);
}
} else {
println!("\nProvider Aliases: (none)");
}
}
None => {
println!(
"No configuration found. Run 'secretspec config init' to create one."
);
}
}
Ok(())
}
ConfigAction::Provider(action) => {
match action {
ProviderAction::Add { name, uri } => {
let mut config =
GlobalConfig::load()
.into_diagnostic()?
.unwrap_or(GlobalConfig {
defaults: GlobalDefaults {
provider: None,
profile: None,
providers: None,
},
audit: None,
});
if config.defaults.providers.is_none() {
config.defaults.providers = Some(HashMap::new());
}
if let Some(providers) = &mut config.defaults.providers {
let existing = providers.insert(name.clone(), uri.clone());
config.save().into_diagnostic()?;
if existing.is_some() {
println!("✓ Provider alias '{}' updated to '{}'", name, uri);
} else {
println!("✓ Provider alias '{}' added: '{}'", name, uri);
}
}
Ok(())
}
ProviderAction::Remove { name } => {
match GlobalConfig::load().into_diagnostic()? {
Some(mut config) => {
if let Some(providers) = &mut config.defaults.providers {
if providers.remove(&name).is_some() {
config.save().into_diagnostic()?;
println!("✓ Provider alias '{}' removed", name);
} else {
println!("✗ Provider alias '{}' not found", name);
}
} else {
println!("✗ No provider aliases configured");
}
}
None => {
println!(
"✗ No configuration found. Run 'secretspec config init' first."
);
}
}
Ok(())
}
ProviderAction::List => {
match GlobalConfig::load().into_diagnostic()? {
Some(config) => {
if let Some(providers) = config.defaults.providers {
if providers.is_empty() {
println!("No provider aliases configured.");
} else {
println!("Provider Aliases:");
let mut aliases: Vec<_> = providers.into_iter().collect();
aliases.sort_by(|(a, _), (b, _)| a.cmp(b));
for (alias, uri) in aliases {
println!(" {} = {}", alias, uri);
}
}
} else {
println!("No provider aliases configured.");
}
}
None => {
println!(
"No configuration found. Run 'secretspec config init' first."
);
}
}
Ok(())
}
}
}
},
Commands::Set {
name,
value,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file, &cli.reason)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.set(&name, value)
.into_diagnostic()
.wrap_err("Failed to set secret")?;
Ok(())
}
Commands::Get {
name,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file, &cli.reason)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.get(&name)
.into_diagnostic()
.wrap_err("Failed to get secret")?;
Ok(())
}
Commands::Run {
command,
provider,
profile,
} => {
let mut app = load_secrets(&cli.file, &cli.reason)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
app.run(command)
.into_diagnostic()
.wrap_err("Failed to run command")?;
Ok(())
}
Commands::Check {
provider,
profile,
no_prompt,
} => {
let mut app = load_secrets(&cli.file, &cli.reason)?;
if let Some(p) = provider {
app.set_provider(p);
}
if let Some(p) = profile {
app.set_profile(p);
}
let mut validated = app
.check(no_prompt)
.into_diagnostic()
.wrap_err("Failed to check secrets")?;
validated
.keep_temp_files()
.into_diagnostic()
.wrap_err("Failed to persist temporary files")?;
Ok(())
}
Commands::Import { from_provider } => {
let app = load_secrets(&cli.file, &cli.reason)?;
app.import(&from_provider)
.into_diagnostic()
.wrap_err("Failed to import secrets")?;
Ok(())
}
Commands::Audit {
project,
action,
tail,
json,
} => show_audit_log(project, action, tail, json),
}
}
fn show_audit_log(
project: Option<String>,
action: Option<String>,
tail: Option<usize>,
json: bool,
) -> Result<()> {
let audit = GlobalConfig::load()
.into_diagnostic()
.wrap_err("Failed to load global configuration")?
.and_then(|g| g.audit)
.unwrap_or_default();
let path = audit.resolved_path().ok_or_else(|| {
if audit.has_relative_path() {
miette!(
"[audit] path {} is not absolute; set an absolute path in ~/.config/secretspec/config.toml",
audit.path.as_deref().map(|p| p.display().to_string()).unwrap_or_default()
)
} else {
miette!("Could not determine the audit log location")
}
})?;
if !path.exists() {
eprintln!("No audit log found at {}", path.display());
return Ok(());
}
let content = fs::read_to_string(&path)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to read audit log at {}", path.display()))?;
for (line, value) in filter_audit_entries(&content, project.as_deref(), action.as_deref(), tail)
{
if json {
println!("{line}");
} else {
println!("{}", format_audit_line(&value));
}
}
Ok(())
}
fn filter_audit_entries<'a>(
content: &'a str,
project: Option<&str>,
action: Option<&str>,
tail: Option<usize>,
) -> Vec<(&'a str, serde_json::Value)> {
let mut entries: Vec<(&str, serde_json::Value)> = content
.lines()
.filter_map(|line| {
let v = serde_json::from_str::<serde_json::Value>(line).ok()?;
let field = |k: &str| v.get(k).and_then(|x| x.as_str());
if let Some(p) = project
&& field("project") != Some(p)
{
return None;
}
if let Some(a) = action
&& !field("action").is_some_and(|x| x.eq_ignore_ascii_case(a))
{
return None;
}
Some((line, v))
})
.collect();
if let Some(n) = tail
&& entries.len() > n
{
entries.drain(0..entries.len() - n);
}
entries
}
fn sanitize_field(s: &str) -> String {
if !s.chars().any(|c| c.is_control()) {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_control() {
out.push_str(&format!("\\x{:02x}", c as u32));
} else {
out.push(c);
}
}
out
}
fn format_audit_line(v: &serde_json::Value) -> String {
use colored::Colorize;
let str_field = |k: &str| v.get(k).and_then(|x| x.as_str());
let ts = sanitize_field(str_field("ts").unwrap_or(""));
let action = sanitize_field(str_field("action").unwrap_or("?"));
let outcome = sanitize_field(str_field("outcome").unwrap_or("?"));
let project = sanitize_field(str_field("project").unwrap_or(""));
let profile = sanitize_field(str_field("profile").unwrap_or(""));
let target = if let Some(key) = str_field("key") {
sanitize_field(key)
} else if let Some(arr) = v.get("keys").and_then(|x| x.as_array()) {
arr.iter()
.filter_map(|x| x.as_str())
.map(sanitize_field)
.collect::<Vec<_>>()
.join(",")
} else {
String::new()
};
let outcome_colored = match outcome.as_str() {
"found" | "written" | "started" => outcome.green(),
"missing" | "error" => outcome.red(),
_ => outcome.yellow(),
};
let mut s = format!("{} {:<6} {}", ts.dimmed(), action.bold(), outcome_colored);
if let Some(cmd) = str_field("command") {
s += &format!(" {}", sanitize_field(cmd).bold());
}
if !target.is_empty() {
s += &format!(" {target}");
}
s += &format!(" ({project}/{profile}");
if let Some(provider) = str_field("provider") {
s += &format!(" via {}", sanitize_field(provider));
}
s += ")";
if let Some(reason) = str_field("reason") {
s += &format!(" reason: {}", sanitize_field(reason).italic());
}
if let Some(agent) = v
.get("actor")
.and_then(|a| a.get("agent"))
.and_then(|x| x.as_str())
{
s += &format!(" [{}]", sanitize_field(agent));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Secret;
fn config_with_secret(secret: Secret) -> Config {
let mut secrets = HashMap::new();
secrets.insert("S".to_string(), secret);
Config {
project: Project {
name: "myproj".to_string(),
..Default::default()
},
profiles: HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets,
},
)]),
providers: None,
}
}
#[test]
fn sanitize_field_neutralizes_control_characters() {
assert_eq!(sanitize_field("DATABASE_URL"), "DATABASE_URL");
assert_eq!(sanitize_field(""), "");
assert_eq!(sanitize_field("a\nb"), "a\\x0ab");
assert_eq!(sanitize_field("a\tb"), "a\\x09b");
assert_eq!(sanitize_field("a\x00b"), "a\\x00b");
assert_eq!(sanitize_field("a\x7fb"), "a\\x7fb");
let injected = sanitize_field("ok\x1b[2Kforged");
assert_eq!(injected, "ok\\x1b[2Kforged");
assert!(!injected.contains('\x1b'));
}
fn sample_log() -> String {
[
r#"{"action":"get","project":"alpha","key":"A"}"#,
r#"{"action":"set","project":"beta","key":"B"}"#,
r#"{"action":"get","project":"beta","key":"C"}"#,
]
.join("\n")
}
fn keys_of(entries: &[(&str, serde_json::Value)]) -> Vec<String> {
entries
.iter()
.map(|(_, v)| v["key"].as_str().unwrap().to_string())
.collect()
}
#[test]
fn filter_audit_entries_filters_by_project_and_action() {
let log = sample_log();
assert_eq!(
keys_of(&filter_audit_entries(&log, None, None, None)),
vec!["A", "B", "C"]
);
assert_eq!(
keys_of(&filter_audit_entries(&log, Some("beta"), None, None)),
vec!["B", "C"]
);
assert_eq!(
keys_of(&filter_audit_entries(&log, None, Some("GET"), None)),
vec!["A", "C"]
);
assert_eq!(
keys_of(&filter_audit_entries(&log, Some("beta"), Some("get"), None)),
vec!["C"]
);
}
#[test]
fn filter_audit_entries_applies_tail_and_drops_invalid_lines() {
let log = sample_log();
assert_eq!(
keys_of(&filter_audit_entries(&log, None, None, Some(2))),
vec!["B", "C"]
);
assert_eq!(
keys_of(&filter_audit_entries(&log, None, None, Some(99))),
vec!["A", "B", "C"]
);
let torn = format!("{}\nnot json\n\n", sample_log());
assert_eq!(
keys_of(&filter_audit_entries(&torn, None, None, None)),
vec!["A", "B", "C"]
);
}
#[test]
fn format_audit_line_renders_fields() {
colored::control::set_override(false);
let single: serde_json::Value = serde_json::from_str(
r#"{"ts":"2026-06-07T00:00:00Z","action":"get","outcome":"found",
"project":"demo","profile":"prod","key":"DB","provider":"dotenv://.env",
"reason":"deploy","actor":{"agent":"claude-code"}}"#,
)
.unwrap();
let line = format_audit_line(&single);
assert!(line.contains("get"));
assert!(line.contains("found"));
assert!(line.contains("DB"));
assert!(line.contains("(demo/prod via dotenv://.env)"));
assert!(line.contains("reason: deploy"));
assert!(line.contains("[claude-code]"));
let bulk: serde_json::Value = serde_json::from_str(
r#"{"ts":"t","action":"run","outcome":"started","project":"demo",
"profile":"prod","keys":["A","B"],"command":"./deploy.sh"}"#,
)
.unwrap();
let line = format_audit_line(&bulk);
assert!(line.contains("./deploy.sh"));
assert!(line.contains("A,B"));
colored::control::unset_override();
}
#[test]
fn generate_toml_quotes_dotted_secret_name_and_round_trips() {
let mut secrets = HashMap::new();
secrets.insert(
"FOO.BAR".to_string(),
Secret {
description: Some("dotted".to_string()),
..Default::default()
},
);
let mut config = config_with_secret(Secret::default());
config.profiles.get_mut("default").unwrap().secrets = secrets;
let generated = generate_toml_with_comments(&config).unwrap();
assert!(
generated.contains("\"FOO.BAR\" = {"),
"key must be quoted, got: {generated}"
);
let parsed: Config = toml::from_str(&generated).expect("must round-trip");
assert!(parsed.profiles["default"].secrets.contains_key("FOO.BAR"));
}
#[test]
fn generate_toml_emits_and_round_trips_extends() {
let mut config = config_with_secret(Secret {
description: Some("desc".to_string()),
..Default::default()
});
config.project.extends = Some(vec!["../shared".to_string()]);
let generated = generate_toml_with_comments(&config).unwrap();
let parsed: Config = toml::from_str(&generated).expect("must round-trip");
assert_eq!(
parsed.project.extends.as_deref(),
Some(["../shared".to_string()].as_slice())
);
}
#[test]
fn generate_toml_round_trips_control_character() {
let config = config_with_secret(Secret {
description: Some("a\u{7f}b".to_string()),
..Default::default()
});
let generated = generate_toml_with_comments(&config).unwrap();
let parsed: Config = toml::from_str(&generated).expect("must round-trip");
assert_eq!(
parsed.profiles["default"].secrets["S"]
.description
.as_deref(),
Some("a\u{7f}b")
);
}
#[test]
fn generate_toml_round_trips_values_with_special_chars() {
let config = Config {
project: Project {
name: "weird \"name\"".to_string(),
..Default::default()
},
profiles: HashMap::from([(
"default".to_string(),
Profile {
defaults: None,
secrets: HashMap::from([(
"DATABASE_URL".to_string(),
Secret {
description: Some("he said \"hi\"\nthen left\\".to_string()),
default: Some("a\"b\\c".to_string()),
..Default::default()
},
)]),
},
)]),
providers: None,
};
let generated = generate_toml_with_comments(&config).unwrap();
let parsed: Config =
toml::from_str(&generated).expect("generated TOML must be valid and re-parseable");
assert_eq!(parsed.project.name, "weird \"name\"");
let secret = &parsed.profiles["default"].secrets["DATABASE_URL"];
assert_eq!(
secret.description.as_deref(),
Some("he said \"hi\"\nthen left\\")
);
assert_eq!(secret.default.as_deref(), Some("a\"b\\c"));
}
#[test]
fn generate_toml_none_branch_emits_empty_description_and_omits_fields() {
let out = generate_toml_with_comments(&config_with_secret(Secret::default())).unwrap();
assert!(out.contains("S = { description = \"\" }"), "got: {out}");
assert!(!out.contains("required = "));
assert!(!out.contains("default = "));
}
#[test]
fn generate_toml_some_branch_emits_required_and_default() {
let secret = Secret {
description: Some("desc".to_string()),
required: Some(false),
default: Some("v".to_string()),
..Default::default()
};
let out = generate_toml_with_comments(&config_with_secret(secret)).unwrap();
assert!(out.contains(", required = false"), "got: {out}");
assert!(out.contains(", default = \"v\""), "got: {out}");
}
#[test]
fn generated_config_with_example_template_is_valid_toml() {
let mut out = generate_toml_with_comments(&config_with_secret(Secret {
description: Some("desc".to_string()),
..Default::default()
}))
.unwrap();
out.push_str(get_example_toml());
toml::from_str::<Config>(&out).expect("init output template must be valid TOML");
}
#[test]
fn cli_command_definition_is_valid() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn init_defaults_from_to_dotenv() {
let cli = Cli::try_parse_from(["secretspec", "init"]).unwrap();
match cli.command {
Commands::Init { from } => assert_eq!(from, "dotenv://.env"),
_ => panic!("expected Init command"),
}
}
#[test]
fn run_captures_trailing_args() {
let cli =
Cli::try_parse_from(["secretspec", "run", "--", "npm", "start", "--flag"]).unwrap();
match cli.command {
Commands::Run { command, .. } => {
assert_eq!(command, vec!["npm", "start", "--flag"]);
}
_ => panic!("expected Run command"),
}
}
#[test]
fn check_parses_no_prompt_short_flag() {
let cli = Cli::try_parse_from(["secretspec", "check", "-n"]).unwrap();
match cli.command {
Commands::Check { no_prompt, .. } => assert!(no_prompt),
_ => panic!("expected Check command"),
}
}
}