agy_bridge/lib.rs
1#![doc = include_str!("../README.md")]
2//! agy-bridge: Standalone reusable `PyO3` bridge for the Google Antigravity SDK.
3
4// Allow `::agy_bridge::` paths (generated by the `#[llm_tool]` proc macro) to
5// resolve when compiling tests within this crate.
6extern crate self as agy_bridge;
7
8/// Agent lifecycle management: creation, chat, shutdown.
9pub mod agent;
10/// Configuration types for agents, models, capabilities, and MCP servers.
11pub mod config;
12
13/// Multimodal content types for chat input (text, image, document, audio, video).
14pub mod content;
15/// Error types for the bridge.
16pub mod error;
17/// Pre/post-turn and tool-call lifecycle hooks.
18pub mod hooks;
19/// Policy rules for tool-call filtering and workspace scoping.
20pub mod policies;
21/// Quota tracking and backoff state.
22pub mod quota;
23/// Python runtime bridge: command dispatch over a dedicated thread.
24pub mod runtime;
25
26/// Streaming response channels for text, thought, and tool-call events.
27pub mod streaming;
28/// Custom Rust tool dispatch and definition types.
29pub mod tools;
30/// Event-driven trigger definitions.
31pub mod triggers;
32/// Shared domain types (messages, steps, usage metadata).
33pub mod types;
34
35// ── Re-exports ──────────────────────────────────────────────────────────────
36// Flat re-exports of the most commonly used types so callers can write
37// `use agy_bridge::{AgentConfig, Error, ToolRegistry, Content};`
38// without diving into sub-modules.
39
40pub use config::{
41 AgentConfig, BuiltinTools, CapabilitiesConfig, GeminiConfig, LocalAgentConfig, McpServer,
42 McpSseServer, McpStdioServer, McpStreamableHttpServer, SystemInstructions,
43};
44pub use content::{Audio, Content, ContentPrimitive, Document, Image, Video};
45pub use error::Error;
46pub use hooks::{HookCallback, HookEntry, HookPoint, HookResult, HookSet, Hooks};
47/// Re-export the `#[llm_tool]` proc-macro so users only need `agy_bridge` in
48/// their dependency list.
49pub use llm_tool_macros::llm_tool;
50pub use policies::{AskUserHandler, PolicyDecision, PolicyRule, PolicySet};
51pub use runtime::{BackendLogLevel, RuntimeConfig};
52pub use streaming::{ChatResponseHandle, ChatResult, ResponseEvent, StreamChunk};
53pub use tools::{
54 AvailableTool, RustTool, ToolContext, ToolDefinition, ToolError, ToolOutput, ToolRegistry,
55 ToolSource,
56};
57pub use triggers::{TriggerConfig, TriggerEntry};
58pub use types::{ConversationMessage, MessageRole, Step, UsageMetadata};
59
60/// Convenience prelude — pull in everything you need with a single glob import.
61///
62/// ```
63/// use agy_bridge::prelude::*;
64/// ```
65///
66/// This re-exports the most commonly used types, traits, and macros from the
67/// crate so you can get started quickly without hunting for individual paths.
68pub mod prelude {
69 pub use llm_tool_macros::llm_tool;
70
71 pub use crate::{
72 Agent, AgyBridge,
73 config::{
74 AgentConfig, BuiltinTools, CapabilitiesConfig, GeminiConfig, LocalAgentConfig,
75 McpServer, McpSseServer, McpStdioServer, McpStreamableHttpServer, SystemInstructions,
76 },
77 content::{Audio, Content, ContentPrimitive, Document, Image, Video},
78 error::Error,
79 hooks::{HookPoint, HookResult, Hooks},
80 policies::{AskUserHandler, PolicyDecision, PolicyRule, PolicySet},
81 runtime::BackendLogLevel,
82 streaming::{ChatResponseHandle, ChatResult, ResponseEvent, StreamChunk},
83 tools::{
84 AvailableTool, RustTool, ToolContext, ToolDefinition, ToolError, ToolOutput,
85 ToolRegistry, ToolSource,
86 },
87 triggers::{TriggerConfig, TriggerEntry},
88 types::{ConversationMessage, MessageRole, Step, UsageMetadata},
89 };
90}
91
92use std::sync::Arc;
93
94/// Load environment variables from a `.env` file into the process environment.
95///
96/// 1. Walks upward from `CARGO_MANIFEST_DIR` (if set) or the current working
97/// directory to find the nearest `.env` file.
98/// 2. Parses each `KEY=VALUE` line (skipping blanks and `#`-comments).
99/// 3. For every key that is **not** already present in the process
100/// environment, calls [`std::env::set_var`] to inject it.
101/// 4. Returns a [`HashMap`](std::collections::HashMap) of the newly-set
102/// key/value pairs (keys that were already set are omitted).
103///
104/// Results are cached via [`OnceLock`](std::sync::OnceLock) — the file is
105/// read and environment variables are set at most once. Subsequent calls
106/// return a clone of the cached map without re-reading the file or
107/// modifying the environment.
108///
109/// # Safety
110///
111/// This function calls [`std::env::set_var`], which is **not** thread-safe.
112/// It **must** be called during single-threaded startup, before any
113/// additional threads are spawned (including the Tokio runtime). Calling it
114/// after threads exist is undefined behaviour.
115///
116/// # Example
117///
118/// ```
119/// let new_vars = agy_bridge::load_dotenv();
120/// // OnceLock-cached: safe to call multiple times, only loads .env once.
121/// // NOLINT: example code in documentation — `let _ =` demonstrates the return value exists
122/// let _ = new_vars.len();
123/// ```
124pub fn load_dotenv() -> &'static std::collections::HashMap<String, String> {
125 use std::sync::OnceLock;
126
127 static CACHED: OnceLock<std::collections::HashMap<String, String>> = OnceLock::new();
128
129 CACHED.get_or_init(|| {
130 let start = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
131 || {
132 std::env::current_dir().unwrap_or_else(|e| {
133 tracing::debug!("load_dotenv: current_dir() failed: {e}, using fallback \".\"");
134 std::path::PathBuf::from(".")
135 })
136 },
137 std::path::PathBuf::from,
138 );
139
140 let mut dir = start.as_path();
141 loop {
142 let candidate = dir.join(".env");
143 if candidate.is_file() {
144 let mut env_map = std::collections::HashMap::new();
145 match std::fs::read_to_string(&candidate) {
146 Ok(contents) => {
147 for line in contents.lines() {
148 if let Some((k, v)) = parse_dotenv_line(line)
149 && std::env::var_os(k).is_none()
150 {
151 // SAFETY: Called inside the OnceLock closure during
152 // single-threaded initialization, before any threads
153 // are spawned. set_var is not thread-safe, but here
154 // we are the only thread.
155 unsafe {
156 std::env::set_var(k, v);
157 }
158 env_map.insert(k.to_owned(), v.to_owned());
159 }
160 }
161 }
162 Err(e) => {
163 tracing::warn!(error = %e, "Failed to read .env file at {}", candidate.display());
164 }
165 }
166 return env_map;
167 }
168 match dir.parent() {
169 Some(parent) => dir = parent,
170 None => return std::collections::HashMap::new(),
171 }
172 }
173 })
174}
175
176/// Parse a single line from a `.env` file.
177///
178/// Returns `Some((key, value))` for valid `KEY=VALUE` lines, stripping
179/// surrounding whitespace and quotes (single or double) from the value.
180/// Returns `None` for blank lines, comments, or lines without `=`.
181///
182/// This is factored out of [`load_dotenv`] for testability.
183pub(crate) fn parse_dotenv_line(line: &str) -> Option<(&str, &str)> {
184 let line = line.trim();
185 if line.is_empty() || line.starts_with('#') {
186 return None;
187 }
188 let (k, v) = line.split_once('=')?;
189 let k = k.trim();
190 if k.is_empty() {
191 return None;
192 }
193 let v = v.trim();
194 // Strip surrounding quotes (single or double)
195 let v = v
196 .strip_prefix('"')
197 .and_then(|s| s.strip_suffix('"'))
198 .or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
199 .unwrap_or(v);
200 Some((k, v))
201}
202
203/// Convenience alias for an agent backed by the bridge's runtime.
204///
205/// This hides the generic `Runtime` parameter so consumers never see the
206/// underlying Python bridge type.
207pub type Agent = agent::AgentHandle<runtime::PythonRuntime>;
208
209/// Primary entry point for the Antigravity bridge.
210///
211/// Wraps the runtime and provides a clean Rust API for creating agents.
212///
213/// # Example
214///
215/// ```rust
216/// # use agy_bridge::AgyBridge;
217/// # use agy_bridge::config::AgentConfig;
218/// # #[tokio::main]
219/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
220/// # agy_bridge::load_dotenv();
221/// // Zero-config:
222/// // let bridge = AgyBridge::builder().build()?;
223///
224/// // With custom timeouts:
225/// let bridge = AgyBridge::builder()
226/// .chat_timeout(std::time::Duration::from_secs(120))
227/// .build()?;
228///
229/// // Create an agent (simple):
230/// // let agent = bridge.agent(AgentConfig::default()).await?;
231/// # let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
232/// # let project_root = manifest_dir.parent().unwrap().parent().unwrap();
233/// # let agent = bridge.agent(
234/// # AgentConfig::builder()
235/// # .system_instructions("Reply with 'Hello!' and nothing else. Never use tools.")
236/// # .capabilities(agy_bridge::config::CapabilitiesConfig::custom_tools_only())
237/// # .workspaces(vec![project_root])
238/// # .build()
239/// # ).await?;
240///
241/// // Create an agent with tools and hooks:
242/// // let agent = bridge.agent(config)
243/// // .tools(registry)
244/// // .hooks(hooks)
245/// // .await?;
246///
247/// let answer = agent.chat("Hello!").await?.text().await?;
248/// # Ok(())
249/// # }
250/// ```
251pub struct AgyBridge {
252 runtime: Arc<runtime::PythonRuntime>,
253}
254
255/// Builder for constructing an [`AgyBridge`] instance.
256///
257/// Created via [`AgyBridge::builder()`]. All settings have sensible defaults;
258/// call [`.build()`](Self::build) to finalise.
259///
260/// # Example
261///
262/// ```
263/// # use agy_bridge::AgyBridge;
264/// let bridge = AgyBridge::builder()
265/// .chat_timeout(std::time::Duration::from_secs(120))
266/// .channel_capacity(128)
267/// .build()?;
268/// # Ok::<(), agy_bridge::error::Error>(())
269/// ```
270pub struct AgyBridgeBuilder {
271 config: runtime::RuntimeConfig,
272}
273
274impl AgyBridgeBuilder {
275 /// Set the mpsc channel buffer size for the command channel.
276 #[must_use]
277 pub fn channel_capacity(mut self, capacity: usize) -> Self {
278 self.config.channel_capacity = capacity;
279 self
280 }
281
282 /// Set the timeout for individual runtime operations.
283 #[must_use]
284 pub fn operation_timeout(mut self, timeout: std::time::Duration) -> Self {
285 self.config.operation_timeout = timeout;
286 self
287 }
288
289 /// Set the timeout for joining the Python thread on shutdown.
290 #[must_use]
291 pub fn shutdown_timeout(mut self, timeout: std::time::Duration) -> Self {
292 self.config.shutdown_timeout = timeout;
293 self
294 }
295
296 /// Set the timeout for a single `agent.chat()` round-trip.
297 #[must_use]
298 pub fn chat_timeout(mut self, timeout: std::time::Duration) -> Self {
299 self.config.chat_timeout = timeout;
300 self
301 }
302
303 /// Set the delay between successive chat commands to prevent burst requests.
304 #[must_use]
305 pub fn inter_agent_delay(mut self, delay: std::time::Duration) -> Self {
306 self.config.inter_agent_delay = delay;
307 self
308 }
309
310 /// Set the backend runtime log verbosity.
311 ///
312 /// Defaults to [`BackendLogLevel::Warn`]. Set to [`BackendLogLevel::Info`]
313 /// or [`BackendLogLevel::Debug`] for verbose protocol-level diagnostics.
314 #[must_use]
315 pub fn backend_log_level(mut self, level: runtime::BackendLogLevel) -> Self {
316 self.config.backend_log_level = level;
317 self
318 }
319
320 /// Replace the entire runtime configuration at once.
321 ///
322 /// Useful when you already have a [`RuntimeConfig`] struct. Individual
323 /// setters called *after* this will override the corresponding fields.
324 #[must_use]
325 pub fn runtime_config(mut self, config: runtime::RuntimeConfig) -> Self {
326 self.config = config;
327 self
328 }
329
330 /// Build the [`AgyBridge`], starting the Python runtime.
331 ///
332 /// # Errors
333 ///
334 /// Returns [`error::Error`] if the Python runtime cannot be started
335 /// (e.g. missing Antigravity SDK installation).
336 pub fn build(self) -> Result<AgyBridge, error::Error> {
337 Ok(AgyBridge {
338 runtime: Arc::new(runtime::PythonRuntime::new(self.config)?),
339 })
340 }
341}
342
343impl AgyBridge {
344 /// Create a new builder for configuring and constructing an [`AgyBridge`].
345 ///
346 /// # Example
347 ///
348 /// ```
349 /// # use agy_bridge::AgyBridge;
350 /// let bridge = AgyBridge::builder().build()?;
351 /// # Ok::<(), agy_bridge::error::Error>(())
352 /// ```
353 #[must_use]
354 pub fn builder() -> AgyBridgeBuilder {
355 AgyBridgeBuilder {
356 config: runtime::RuntimeConfig::default(),
357 }
358 }
359
360 /// Begin building a new agent on this bridge.
361 ///
362 /// Returns an [`AgentBuilder`] that can be directly `.await`ed for the
363 /// simple case, or chained with [`.tools()`](AgentBuilder::tools) and
364 /// [`.hooks()`](AgentBuilder::hooks) before awaiting.
365 ///
366 /// # Examples
367 ///
368 /// ```rust
369 /// # use agy_bridge::{AgyBridge, config::AgentConfig};
370 /// # #[tokio::main]
371 /// # async fn main() -> Result<(), agy_bridge::error::Error> {
372 /// # agy_bridge::load_dotenv();
373 /// # let bridge = AgyBridge::builder().build()?;
374 /// // Simple — no tools or hooks:
375 /// let agent = bridge.agent(AgentConfig::default()).await?;
376 ///
377 /// // With tools:
378 /// // let agent = bridge.agent(config).tools(registry).await?;
379 ///
380 /// // With tools and hooks:
381 /// // let agent = bridge.agent(config).tools(registry).hooks(hooks).await?;
382 /// # Ok(())
383 /// # }
384 /// ```
385 #[must_use]
386 pub fn agent(&self, config: config::AgentConfig) -> AgentBuilder<'_> {
387 AgentBuilder {
388 bridge: self,
389 config,
390 registry: None,
391 hooks: None,
392 policy_handler: None,
393 }
394 }
395
396 /// Convenience shorthand for `self.agent(AgentConfig::default())`.
397 ///
398 /// Creates an agent builder with default configuration. Chain
399 /// [`.tools()`](AgentBuilder::tools) or [`.hooks()`](AgentBuilder::hooks)
400 /// before awaiting, or `.await` directly for a bare agent.
401 ///
402 /// # Examples
403 ///
404 /// ```rust
405 /// # use agy_bridge::AgyBridge;
406 /// # #[tokio::main]
407 /// # async fn main() -> Result<(), agy_bridge::error::Error> {
408 /// # agy_bridge::load_dotenv();
409 /// # let bridge = AgyBridge::builder().build()?;
410 /// let agent = bridge.default_agent().await?;
411 /// # Ok(())
412 /// # }
413 /// ```
414 #[must_use]
415 pub fn default_agent(&self) -> AgentBuilder<'_> {
416 self.agent(config::AgentConfig::default())
417 }
418}
419
420/// Builder for creating an [`Agent`] on an [`AgyBridge`].
421///
422/// Obtained from [`AgyBridge::agent()`]. Implements [`IntoFuture`] so you can
423/// `.await` it directly, or chain optional [`.tools()`](Self::tools) /
424/// [`.hooks()`](Self::hooks) calls before awaiting.
425pub struct AgentBuilder<'a> {
426 bridge: &'a AgyBridge,
427 config: config::AgentConfig,
428 registry: Option<tools::ToolRegistry>,
429 hooks: Option<hooks::Hooks>,
430 policy_handler: Option<Arc<dyn policies::AskUserHandler>>,
431}
432
433impl AgentBuilder<'_> {
434 /// Attach a [`ToolRegistry`] containing custom
435 /// Rust tools for the agent.
436 ///
437 /// The registry's tool definitions are automatically merged into the
438 /// agent configuration.
439 ///
440 /// # Errors (at build time)
441 ///
442 /// Returns [`error::Error::InvalidConfig`] if `config.tools` is
443 /// already non-empty — pass tools via the registry **or** via
444 /// `config.tools`, not both.
445 #[must_use]
446 pub fn tools(mut self, registry: tools::ToolRegistry) -> Self {
447 self.registry = Some(registry);
448 self
449 }
450
451 /// Attach [`Hooks`] for lifecycle event
452 /// callbacks (pre/post turn, tool-call gating, etc.).
453 #[must_use]
454 pub fn hooks(mut self, hooks: hooks::Hooks) -> Self {
455 self.hooks = Some(hooks);
456 self
457 }
458
459 /// Attach a custom [`AskUserHandler`] to manage interactive tool-call confirmations.
460 #[must_use]
461 pub fn policy_handler(mut self, handler: impl policies::AskUserHandler + 'static) -> Self {
462 self.policy_handler = Some(Arc::new(handler));
463 self
464 }
465
466 /// Set a pre-existing conversation ID to resume.
467 #[must_use]
468 pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
469 self.config.conversation_id = Some(id.into());
470 self
471 }
472
473 /// Set the model backend (e.g. `"gemini-3.5-flash"`).
474 #[must_use]
475 pub fn model(mut self, model: impl Into<String>) -> Self {
476 self.config.model = model.into();
477 self
478 }
479
480 /// Set system instructions for the agent.
481 #[must_use]
482 pub fn system_instructions(
483 mut self,
484 instructions: impl Into<config::SystemInstructions>,
485 ) -> Self {
486 self.config.system_instructions = Some(instructions.into());
487 self
488 }
489
490 /// Append workspace directories the agent is allowed to access and modify.
491 #[must_use]
492 pub fn workspaces(
493 mut self,
494 workspaces: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
495 ) -> Self {
496 self.config
497 .workspaces
498 .extend(workspaces.into_iter().map(Into::into));
499 self
500 }
501
502 /// Append policy rules to govern tool execution.
503 #[must_use]
504 pub fn policies(
505 mut self,
506 policies: impl IntoIterator<Item = impl Into<policies::PolicyRule>>,
507 ) -> Self {
508 self.config
509 .policies
510 .extend(policies.into_iter().map(Into::into));
511 self
512 }
513
514 /// Append triggers that autonomously wake the agent.
515 #[must_use]
516 pub fn triggers(
517 mut self,
518 triggers: impl IntoIterator<Item = impl Into<triggers::TriggerEntry>>,
519 ) -> Self {
520 self.config
521 .triggers
522 .extend(triggers.into_iter().map(Into::into));
523 self
524 }
525
526 /// Append MCP servers for the agent.
527 #[must_use]
528 pub fn mcp_servers(
529 mut self,
530 servers: impl IntoIterator<Item = impl Into<config::McpServer>>,
531 ) -> Self {
532 self.config
533 .mcp_servers
534 .extend(servers.into_iter().map(Into::into));
535 self
536 }
537
538 /// Append paths for agent skills.
539 #[must_use]
540 pub fn skills(
541 mut self,
542 skills: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
543 ) -> Self {
544 self.config
545 .skills
546 .extend(skills.into_iter().map(Into::into));
547 self
548 }
549
550 /// Set the maximum number of quota retry attempts before giving up.
551 #[must_use]
552 pub fn max_quota_retries(mut self, retries: u32) -> Self {
553 self.config.max_quota_retries = Some(retries);
554 self
555 }
556
557 /// Validate configuration and create the agent.
558 ///
559 /// Prefer using `.await` directly on the builder (via [`IntoFuture`])
560 /// instead of calling this method explicitly.
561 ///
562 /// # Errors
563 ///
564 /// Returns [`error::Error::InvalidConfig`] if:
565 /// - `config.tools` is non-empty **and** a `ToolRegistry` was provided.
566 /// - The capabilities configuration is self-contradictory (e.g. both
567 /// `enabled_tools` and `disabled_tools` specified).
568 ///
569 /// Returns other [`error::Error`] variants if agent creation fails.
570 pub async fn build(mut self) -> Result<Agent, error::Error> {
571 // Validate capabilities.
572 if let Some(ref caps) = self.config.capabilities {
573 caps.validate().map_err(|msg| error::Error::InvalidConfig {
574 message: msg.to_string(),
575 })?;
576 }
577
578 // Handle tool registry.
579 let arc_registry = if let Some(registry) = self.registry {
580 if !self.config.tools.is_empty() {
581 return Err(error::Error::InvalidConfig {
582 message: "config.tools is non-empty and a ToolRegistry was also provided; \
583 pass tools via the registry or via config.tools, not both"
584 .to_string(),
585 });
586 }
587 self.config.tools = registry.definitions();
588 Some(Arc::new(registry))
589 } else {
590 None
591 };
592
593 // Handle hooks.
594 let arc_hooks = if let Some(hooks) = self.hooks {
595 if !self.config.hooks.is_empty() {
596 return Err(error::Error::InvalidConfig {
597 message: "config.hooks is non-empty and a Hooks instance was also provided; \
598 configure hooks via Hooks or config.hooks, not both"
599 .to_string(),
600 });
601 }
602 self.config.hooks = hooks.entries();
603 Some(Arc::new(hooks))
604 } else {
605 None
606 };
607
608 // Handle policy handler.
609 let arc_policy = self.policy_handler;
610
611 agent::AgentHandle::new(
612 Arc::clone(&self.bridge.runtime),
613 self.config,
614 arc_registry,
615 arc_hooks,
616 arc_policy,
617 )
618 .await
619 }
620}
621
622impl<'a> std::future::IntoFuture for AgentBuilder<'a> {
623 type Output = Result<Agent, error::Error>;
624 type IntoFuture =
625 std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'a>>;
626
627 fn into_future(self) -> Self::IntoFuture {
628 Box::pin(self.build())
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 // ── parse_dotenv_line regression tests ────────────────────────────
637
638 #[test]
639 fn dotenv_strips_double_quotes() {
640 let (k, v) = parse_dotenv_line(r#"API_KEY="my-secret""#).unwrap();
641 assert_eq!(k, "API_KEY");
642 assert_eq!(v, "my-secret");
643 }
644
645 #[test]
646 fn dotenv_strips_single_quotes() {
647 let (k, v) = parse_dotenv_line("TOKEN='abc123'").unwrap();
648 assert_eq!(k, "TOKEN");
649 assert_eq!(v, "abc123");
650 }
651
652 #[test]
653 fn dotenv_unquoted_value_unchanged() {
654 let (k, v) = parse_dotenv_line("FOO=bar").unwrap();
655 assert_eq!(k, "FOO");
656 assert_eq!(v, "bar");
657 }
658
659 #[test]
660 fn dotenv_mismatched_quotes_preserved() {
661 // Opening double quote but closing single quote → not stripped.
662 let (k, v) = parse_dotenv_line(r#"KEY="value'"#).unwrap();
663 assert_eq!(k, "KEY");
664 assert_eq!(v, r#""value'"#);
665 }
666
667 #[test]
668 fn dotenv_empty_quoted_value() {
669 let (k, v) = parse_dotenv_line(r#"EMPTY="""#).unwrap();
670 assert_eq!(k, "EMPTY");
671 assert_eq!(v, "");
672 }
673
674 #[test]
675 fn dotenv_whitespace_around_key_value() {
676 let (k, v) = parse_dotenv_line(" MY_VAR = \"hello world\" ").unwrap();
677 assert_eq!(k, "MY_VAR");
678 assert_eq!(v, "hello world");
679 }
680
681 #[test]
682 fn dotenv_comment_line_is_none() {
683 assert!(parse_dotenv_line("# this is a comment").is_none());
684 }
685
686 #[test]
687 fn dotenv_blank_line_is_none() {
688 assert!(parse_dotenv_line(" ").is_none());
689 }
690
691 #[test]
692 fn dotenv_empty_key_is_none() {
693 assert!(parse_dotenv_line("=value").is_none());
694 }
695
696 #[test]
697 fn dotenv_no_equals_is_none() {
698 assert!(parse_dotenv_line("JUSTKEY").is_none());
699 }
700
701 #[test]
702 fn dotenv_value_with_internal_equals() {
703 let (k, v) = parse_dotenv_line("DSN=postgres://host:5432/db?opt=1").unwrap();
704 assert_eq!(k, "DSN");
705 assert_eq!(v, "postgres://host:5432/db?opt=1");
706 }
707
708 #[test]
709 fn dotenv_value_with_embedded_quotes_not_stripped() {
710 // Quotes in the middle are not stripped — only surrounding ones.
711 let (k, v) = parse_dotenv_line(r#"MSG=say "hello""#).unwrap();
712 assert_eq!(k, "MSG");
713 assert_eq!(v, r#"say "hello""#);
714 }
715
716 #[test]
717 fn test_load_dotenv_returns_static_reference_identity() {
718 let map1 = load_dotenv();
719 let map2 = load_dotenv();
720 assert!(std::ptr::eq(map1, map2));
721 }
722}