Skip to main content

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