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