Skip to content

Commit f8ae2ee

Browse files
domenkozarclaude
andcommitted
refactor: unify provider parsing logic in init command
- Add reflect() method to Provider trait with default error implementation - Move DotEnvProvider's reflect implementation to Provider trait impl - Update init --from to use Box<dyn Provider>::try_from() for consistency - Now supports all provider formats: "dotenv", "dotenv:.env", "dotenv://.env" - Add integration tests for init --from with various provider formats - Add unit test for default reflect() error behavior This ensures consistent provider specification parsing across all CLI commands. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4b78e2f commit f8ae2ee

File tree

6 files changed

+126
-74
lines changed

6 files changed

+126
-74
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Integrate `secrecy` crate for secure secret handling with automatic memory zeroing
12+
- Add `reflect()` method to Provider trait for provider introspection
1213

1314
### Changed
1415
- Made keyring provider optional via `keyring` feature flag (enabled by default)
16+
- Unified provider parsing logic in init command to support all provider formats consistently
1517

1618
## [0.2.0] - 2025-07-17
1719

secretspec/src/cli/mod.rs

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::provider::{dotenv::DotEnvProvider, providers};
1+
use crate::provider::{Provider, providers};
22
use crate::{Config, GlobalConfig, GlobalDefaults, Profile, Project, Secrets};
33
use clap::{Parser, Subcommand};
44
use miette::{IntoDiagnostic, Result, WrapErr, miette};
@@ -206,26 +206,20 @@ pub fn main() -> Result<()> {
206206
}
207207
}
208208

209-
// Parse the provider URL
210-
let uri = from
211-
.parse::<url::Url>()
212-
.map_err(|e| miette!("Invalid provider URL '{}': {}", from, e))?;
209+
// Create provider from the specification string
210+
// This handles various formats like "dotenv", "dotenv:.env", "dotenv://.env.production"
211+
let provider: Box<dyn Provider> = from.as_str().try_into().into_diagnostic()?;
213212

214-
// Extract scheme from URI to validate provider
215-
let scheme = uri.scheme();
216-
217-
// Currently only support dotenv provider
218-
if scheme != "dotenv" {
213+
// Check if it's a dotenv provider
214+
if provider.name() != "dotenv" {
219215
return Err(miette!(
220-
"Only 'dotenv://' provider URLs are currently supported for init --from. Got: {}",
221-
from
216+
"Only 'dotenv' provider is currently supported for init --from. Got provider: {}",
217+
provider.name()
222218
));
223219
}
224220

225-
// Create dotenv provider and reflect secrets
226-
let dotenv_config = (&uri).try_into().into_diagnostic()?;
227-
let dotenv_provider = DotEnvProvider::new(dotenv_config);
228-
let secrets = dotenv_provider.reflect().into_diagnostic()?;
221+
// Reflect secrets from the provider
222+
let secrets = provider.reflect().into_diagnostic()?;
229223

230224
// Create a new project config
231225
let mut profiles = HashMap::new();
@@ -267,6 +261,13 @@ pub fn main() -> Result<()> {
267261
.sum::<usize>();
268262
println!("✓ Created secretspec.toml with {} secrets", secret_count);
269263

264+
// If we imported from a provider, suggest migration
265+
if provider.name() == "dotenv" && secret_count > 0 {
266+
println!("\nTo migrate your secrets from {}:", from);
267+
println!(" 1. Review secretspec.toml and adjust as needed");
268+
println!(" 2. secretspec import {} # Import secret values", from);
269+
}
270+
270271
println!("\nNext steps:");
271272
println!(" 1. secretspec config init # Set up user configuration");
272273
println!(" 2. secretspec check # Verify all secrets and set them");

secretspec/src/provider/dotenv.rs

Lines changed: 35 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -156,63 +156,6 @@ impl DotEnvProvider {
156156
pub fn new(config: DotEnvConfig) -> Self {
157157
Self { config }
158158
}
159-
160-
/// Reflects all secrets available in the .env file as Secret entries.
161-
///
162-
/// This method reads the .env file and returns all environment variables
163-
/// as Secret entries with default descriptions and all marked as required.
164-
/// If the file doesn't exist, returns an empty HashMap.
165-
///
166-
/// # Returns
167-
///
168-
/// * `Ok(HashMap<String, Secret>)` - All environment variables as Secret
169-
/// * `Err(SecretSpecError)` - If reading the file fails
170-
///
171-
/// # Examples
172-
///
173-
/// ```ignore
174-
/// use secretspec::provider::dotenv::{DotEnvProvider, DotEnvConfig};
175-
///
176-
/// let provider = DotEnvProvider::new(DotEnvConfig::default());
177-
/// let secrets = provider.reflect().unwrap();
178-
/// for (key, config) in secrets {
179-
/// println!("Found secret: {} - {}", key, config.description);
180-
/// }
181-
/// ```
182-
pub fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
183-
use crate::config::Secret;
184-
185-
if !self.config.path.exists() {
186-
return Ok(HashMap::new());
187-
}
188-
189-
// Check if path is a directory
190-
if self.config.path.is_dir() {
191-
return Err(SecretSpecError::Io(std::io::Error::new(
192-
std::io::ErrorKind::IsADirectory,
193-
format!(
194-
"Expected file but found directory: {}",
195-
self.config.path.display()
196-
),
197-
)));
198-
}
199-
200-
let mut secrets = HashMap::new();
201-
let env_vars = dotenvy::from_path_iter(&self.config.path)?;
202-
for item in env_vars {
203-
let (key, _value) = item?;
204-
secrets.insert(
205-
key.clone(),
206-
Secret {
207-
description: Some(format!("{} secret", key)),
208-
required: true,
209-
default: None,
210-
},
211-
);
212-
}
213-
214-
Ok(secrets)
215-
}
216159
}
217160

218161
impl Provider for DotEnvProvider {
@@ -306,6 +249,41 @@ impl Provider for DotEnvProvider {
306249
fs::write(&self.config.path, content)?;
307250
Ok(())
308251
}
252+
253+
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
254+
use crate::config::Secret;
255+
256+
if !self.config.path.exists() {
257+
return Ok(HashMap::new());
258+
}
259+
260+
// Check if path is a directory
261+
if self.config.path.is_dir() {
262+
return Err(SecretSpecError::Io(std::io::Error::new(
263+
std::io::ErrorKind::IsADirectory,
264+
format!(
265+
"Expected file but found directory: {}",
266+
self.config.path.display()
267+
),
268+
)));
269+
}
270+
271+
let mut secrets = HashMap::new();
272+
let env_vars = dotenvy::from_path_iter(&self.config.path)?;
273+
for item in env_vars {
274+
let (key, _value) = item?;
275+
secrets.insert(
276+
key.clone(),
277+
Secret {
278+
description: Some(format!("{} secret", key)),
279+
required: true,
280+
default: None,
281+
},
282+
);
283+
}
284+
285+
Ok(secrets)
286+
}
309287
}
310288

311289
#[cfg(test)]

secretspec/src/provider/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
5353
use crate::{Result, SecretSpecError};
5454
use secrecy::SecretString;
55+
use std::collections::HashMap;
5556
use std::convert::TryFrom;
5657
use url::Url;
5758

@@ -234,6 +235,32 @@ pub trait Provider: Send + Sync {
234235
///
235236
/// This should match the name registered with the provider macro.
236237
fn name(&self) -> &'static str;
238+
239+
/// Discovers and returns all secrets available in this provider.
240+
///
241+
/// This method is used to introspect the provider and find all available secrets.
242+
/// It's particularly useful for importing secrets from external sources.
243+
///
244+
/// # Returns
245+
///
246+
/// A HashMap where keys are secret names and values are `Secret` configurations.
247+
/// The default implementation returns an empty map, indicating the provider
248+
/// doesn't support reflection.
249+
///
250+
/// # Example
251+
///
252+
/// ```rust,ignore
253+
/// let secrets = provider.reflect()?;
254+
/// for (name, secret) in secrets {
255+
/// println!("Found secret: {} = {:?}", name, secret);
256+
/// }
257+
/// ```
258+
fn reflect(&self) -> Result<HashMap<String, crate::config::Secret>> {
259+
Err(SecretSpecError::ProviderOperationFailed(format!(
260+
"Provider '{}' does not support reflection",
261+
self.name()
262+
)))
263+
}
237264
}
238265

239266
impl TryFrom<String> for Box<dyn Provider> {

secretspec/src/provider/tests.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,22 @@ mod integration_tests {
405405
}
406406
}
407407
}
408+
409+
#[test]
410+
fn test_default_reflect_returns_error() {
411+
// Test that the default reflect implementation returns an error
412+
let provider = MockProvider::new();
413+
let result = provider.reflect();
414+
assert!(
415+
result.is_err(),
416+
"Default reflect implementation should return an error"
417+
);
418+
419+
let error = result.unwrap_err();
420+
let error_msg = error.to_string();
421+
assert!(
422+
error_msg.contains("does not support reflection"),
423+
"Error message should indicate reflection is not supported"
424+
);
425+
}
408426
}

tests/cli-integration.sh

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,33 @@ check_success "Set secret in production profile"
149149
secretspec config show > /dev/null
150150
check_success "Config show command works"
151151

152-
# Test 11: Default value handling
152+
# Test 11: Init from provider
153+
# Create a .env file to import from
154+
cat > .env.source << EOF
155+
API_KEY=test-api-key
156+
DATABASE_URL=postgres://localhost/test
157+
EOF
158+
159+
# Test init with bare provider name
160+
rm -f secretspec.toml
161+
secretspec init --from dotenv:.env.source
162+
check_success "Init from dotenv provider with path"
163+
164+
# Verify secrets were imported
165+
grep -q "API_KEY" secretspec.toml && grep -q "DATABASE_URL" secretspec.toml
166+
check_success "Init imported secrets from .env file"
167+
168+
# Test init with bare provider name (should use default .env)
169+
echo "DEFAULT_KEY=default-value" > .env
170+
rm -f secretspec.toml
171+
secretspec init --from dotenv
172+
check_success "Init from dotenv provider (bare name)"
173+
174+
# Verify it found the default .env
175+
grep -q "DEFAULT_KEY" secretspec.toml
176+
check_success "Init found default .env file"
177+
178+
# Test 12: Default value handling
153179
cat > secretspec.toml << EOF
154180
[project]
155181
name = "test-app"

0 commit comments

Comments
 (0)