Skip to content

Commit fb2ebe1

Browse files
committed
chore: switch to dotenvy
1 parent 4f03046 commit fb2ebe1

5 files changed

Lines changed: 174 additions & 35 deletions

File tree

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ base64 = "0.22"
2626
clap = { version = "4", features = ["derive"] }
2727
futures = "0.3"
2828
indicatif = "0.17"
29+
dotenvy = "0.15"
2930
regex = "1"
3031
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
3132
serde = { version = "1", features = ["derive"] }

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,15 @@ For each PDF, it creates:
6767
- <output>/<pdf_stem>/log.jsonl
6868
6969
API key lookup order:
70-
1) ZAI_API_KEY from environment
71-
2) ZAI_API_KEY from --env-file
70+
1) ZAI_API_KEY from --env-file
71+
2) ZAI_API_KEY from environment
7272
7373
Usage: paperdown [OPTIONS] --input <PATH>
7474
7575
Options:
7676
--input <PATH> Input path: a single .pdf file or a directory containing .pdf files.
7777
--output <OUTPUT> Output root directory for generated markdown folders. [default: md]
78-
--env-file <ENV_FILE> Path to .env file used only if ZAI_API_KEY is not already set. [default: .env]
78+
--env-file <ENV_FILE> Path to .env file checked first for ZAI_API_KEY, before environment fallback. [default: .env]
7979
--timeout <TIMEOUT> HTTP timeout in seconds for OCR requests and figure downloads. [default: 180]
8080
--max-download-bytes <MAX_DOWNLOAD_BYTES> Maximum allowed size (bytes) for each downloaded figure file. [default: 20971520]
8181
--workers <WORKERS> Maximum number of PDFs processed concurrently in batch mode. [default: 32]
@@ -87,7 +87,7 @@ Options:
8787

8888
## API key
8989

90-
`paperdown` reads `ZAI_API_KEY` from environment first. If not found, it reads the value from `--env-file`.
90+
`paperdown` reads `ZAI_API_KEY` from `--env-file` first. If not found, it falls back to the environment.
9191

9292
### Get a key
9393

src/cli.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ For each PDF, it creates:\n\
1212
- <output>/<pdf_stem>/figures/\n\
1313
- <output>/<pdf_stem>/log.jsonl\n\n\
1414
API key lookup order:\n\
15-
1) ZAI_API_KEY from environment\n\
16-
2) ZAI_API_KEY from --env-file",
15+
1) ZAI_API_KEY from --env-file\n\
16+
2) ZAI_API_KEY from environment",
1717
after_help = "Examples:\n \
1818
paperdown --input pdf/paper.pdf\n \
1919
paperdown --input pdf/ --output md/ --workers 4\n \
@@ -41,7 +41,7 @@ pub struct Cli {
4141
#[arg(
4242
long = "env-file",
4343
default_value = ".env",
44-
help = "Path to .env file used only if ZAI_API_KEY is not already set."
44+
help = "Path to .env file checked first for ZAI_API_KEY, before environment fallback."
4545
)]
4646
pub env_file: PathBuf,
4747

@@ -148,7 +148,11 @@ mod tests {
148148
let help = cmd.render_long_help().to_string();
149149
assert!(help.contains("Examples:"));
150150
assert!(help.contains("--overwrite"));
151-
assert!(help.contains("ZAI_API_KEY"));
151+
let file_first = help.find("1) ZAI_API_KEY from --env-file");
152+
let env_second = help.find("2) ZAI_API_KEY from environment");
153+
assert!(file_first.is_some());
154+
assert!(env_second.is_some());
155+
assert!(file_first.unwrap() < env_second.unwrap());
152156
assert!(help.contains("single .pdf file or a directory"));
153157
}
154158
}

src/core.rs

Lines changed: 154 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -89,41 +89,55 @@ fn is_pdf_path(path: &Path) -> bool {
8989
}
9090

9191
pub fn load_api_key(env_file: &Path) -> Result<String> {
92+
let env_file_exists = env_file.try_exists().with_context(|| {
93+
format!(
94+
"Failed to access env file metadata: {}",
95+
env_file.display()
96+
)
97+
})?;
98+
99+
if env_file_exists {
100+
let entries = dotenvy::from_path_iter(env_file).with_context(|| {
101+
format!(
102+
"Failed to read or parse env file: {}",
103+
env_file.display()
104+
)
105+
})?;
106+
let mut file_key = None;
107+
for entry in entries {
108+
let (key, value) = entry.with_context(|| {
109+
format!(
110+
"Failed to read or parse env file: {}",
111+
env_file.display()
112+
)
113+
})?;
114+
if key == "ZAI_API_KEY" {
115+
if value.trim().is_empty() {
116+
file_key = None;
117+
} else {
118+
file_key = Some(value);
119+
}
120+
}
121+
}
122+
if let Some(key) = file_key {
123+
return Ok(key);
124+
}
125+
}
126+
92127
if let Ok(api_key) = std::env::var("ZAI_API_KEY")
93128
&& !api_key.trim().is_empty()
94129
{
95130
return Ok(api_key);
96131
}
97132

98-
let content = std::fs::read_to_string(env_file).with_context(|| {
99-
format!(
133+
if !env_file_exists {
134+
return Err(anyhow!(
100135
"ZAI_API_KEY is not set and env file was not found: {}",
101136
env_file.display()
102-
)
103-
})?;
104-
105-
for line in content.lines() {
106-
let stripped = line.trim();
107-
if stripped.is_empty() || stripped.starts_with('#') || !stripped.contains('=') {
108-
continue;
109-
}
110-
let mut split = stripped.splitn(2, '=');
111-
let key = split.next().unwrap_or_default().trim();
112-
let value = split
113-
.next()
114-
.unwrap_or_default()
115-
.trim()
116-
.trim_matches('"')
117-
.trim_matches('\'');
118-
if key == "ZAI_API_KEY" && !value.is_empty() {
119-
return Ok(value.to_string());
120-
}
137+
));
121138
}
122139

123-
Err(anyhow!(
124-
"ZAI_API_KEY was not found in {}",
125-
env_file.display()
126-
))
140+
Err(anyhow!("ZAI_API_KEY was not found in {}", env_file.display()))
127141
}
128142

129143
pub async fn process_pdf(
@@ -701,6 +715,119 @@ mod tests {
701715
assert!(err.contains("ZAI_API_KEY was not found"));
702716
}
703717

718+
#[test]
719+
fn load_api_key_missing_file_falls_back_to_environment() {
720+
let _guard = env_lock().lock().unwrap();
721+
let tmp = TempDir::new().unwrap();
722+
let missing = tmp.path().join("missing.env");
723+
unsafe {
724+
std::env::set_var("ZAI_API_KEY", "env-fallback-key");
725+
}
726+
let key = load_api_key(&missing).unwrap();
727+
unsafe {
728+
std::env::remove_var("ZAI_API_KEY");
729+
}
730+
assert_eq!(key, "env-fallback-key");
731+
}
732+
733+
#[test]
734+
fn load_api_key_blank_file_value_falls_back_to_environment() {
735+
let _guard = env_lock().lock().unwrap();
736+
let tmp = TempDir::new().unwrap();
737+
let env_file = tmp.path().join(".env");
738+
std::fs::write(&env_file, "ZAI_API_KEY= \n").unwrap();
739+
unsafe {
740+
std::env::set_var("ZAI_API_KEY", "env-fallback-key");
741+
}
742+
let key = load_api_key(&env_file).unwrap();
743+
unsafe {
744+
std::env::remove_var("ZAI_API_KEY");
745+
}
746+
assert_eq!(key, "env-fallback-key");
747+
}
748+
749+
#[test]
750+
fn load_api_key_duplicate_entries_last_wins() {
751+
let _guard = env_lock().lock().unwrap();
752+
unsafe {
753+
std::env::remove_var("ZAI_API_KEY");
754+
}
755+
let tmp = TempDir::new().unwrap();
756+
let env_file = tmp.path().join(".env");
757+
std::fs::write(&env_file, "ZAI_API_KEY=first\nZAI_API_KEY=second\n").unwrap();
758+
let key = load_api_key(&env_file).unwrap();
759+
assert_eq!(key, "second");
760+
}
761+
762+
#[test]
763+
fn load_api_key_export_statement_is_parsed() {
764+
let _guard = env_lock().lock().unwrap();
765+
unsafe {
766+
std::env::remove_var("ZAI_API_KEY");
767+
}
768+
let tmp = TempDir::new().unwrap();
769+
let env_file = tmp.path().join(".env");
770+
std::fs::write(&env_file, "export ZAI_API_KEY=from-export\n").unwrap();
771+
let key = load_api_key(&env_file).unwrap();
772+
assert_eq!(key, "from-export");
773+
}
774+
775+
#[test]
776+
fn load_api_key_interpolation_follows_dotenvy_behavior() {
777+
let _guard = env_lock().lock().unwrap();
778+
let tmp = TempDir::new().unwrap();
779+
let env_file = tmp.path().join(".env");
780+
unsafe {
781+
std::env::set_var("BASE_KEY", "root");
782+
std::env::remove_var("ZAI_API_KEY");
783+
}
784+
std::fs::write(&env_file, "ZAI_API_KEY=${BASE_KEY}-suffix\n").unwrap();
785+
let key = load_api_key(&env_file).unwrap();
786+
unsafe {
787+
std::env::remove_var("BASE_KEY");
788+
}
789+
assert_eq!(key, "root-suffix");
790+
}
791+
792+
#[test]
793+
fn load_api_key_invalid_file_is_hard_error() {
794+
let _guard = env_lock().lock().unwrap();
795+
let tmp = TempDir::new().unwrap();
796+
let env_file = tmp.path().join(".env");
797+
unsafe {
798+
std::env::set_var("ZAI_API_KEY", "env-fallback-key");
799+
}
800+
std::fs::write(&env_file, "ZAI_API_KEY='unterminated\n").unwrap();
801+
let err = load_api_key(&env_file).unwrap_err().to_string();
802+
unsafe {
803+
std::env::remove_var("ZAI_API_KEY");
804+
}
805+
assert!(err.contains("Failed to read or parse env file"));
806+
}
807+
808+
#[cfg(unix)]
809+
#[test]
810+
fn load_api_key_unreadable_file_does_not_fall_back_to_environment() {
811+
use std::os::unix::fs::PermissionsExt;
812+
813+
let _guard = env_lock().lock().unwrap();
814+
let tmp = TempDir::new().unwrap();
815+
let locked_dir = tmp.path().join("locked");
816+
std::fs::create_dir_all(&locked_dir).unwrap();
817+
let env_file = locked_dir.join(".env");
818+
std::fs::write(&env_file, "ZAI_API_KEY=file-key\n").unwrap();
819+
unsafe {
820+
std::env::set_var("ZAI_API_KEY", "env-fallback-key");
821+
}
822+
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
823+
let err = load_api_key(&env_file).unwrap_err().to_string();
824+
std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
825+
unsafe {
826+
std::env::remove_var("ZAI_API_KEY");
827+
}
828+
assert!(err.contains("Failed to access env file metadata"));
829+
}
830+
704831
#[test]
705832
fn validate_layout_response_success_with_usage() {
706833
let (md, details, usage) = validate_layout_response(json!({
@@ -1103,7 +1230,7 @@ mod tests {
11031230
}
11041231

11051232
#[test]
1106-
fn load_api_key_prefers_environment_variable() {
1233+
fn load_api_key_prefers_env_file_over_environment_variable() {
11071234
let _guard = env_lock().lock().unwrap();
11081235
let tmp = TempDir::new().unwrap();
11091236
let env_file = tmp.path().join(".env");
@@ -1117,7 +1244,7 @@ mod tests {
11171244
std::env::remove_var("ZAI_API_KEY");
11181245
}
11191246

1120-
assert_eq!(key, "env-key");
1247+
assert_eq!(key, "file-key");
11211248
}
11221249

11231250
#[test]

0 commit comments

Comments
 (0)