Skip to main content

agy_bridge/
lib.rs

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