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::{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 settings:
225/// let bridge = AgyBridge::builder().channel_capacity(128).build()?;
226///
227/// // Create an agent (simple):
228/// // let agent = bridge.agent(AgentConfig::default()).await?;
229/// # let manifest_dir = std::path::PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
230/// # let project_root = manifest_dir.parent().unwrap().parent().unwrap();
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/// #         .workspaces(vec![project_root])
236/// #         .build()
237/// # ).await?;
238///
239/// // Create an agent with tools and hooks:
240/// // let agent = bridge.agent(config)
241/// //     .tools(registry)
242/// //     .hooks(hooks)
243/// //     .await?;
244///
245/// let answer = agent.chat("Hello!").await?.text().await?;
246/// # Ok(())
247/// # }
248/// ```
249pub struct AgyBridge {
250    runtime: Arc<runtime::PythonRuntime>,
251}
252
253/// Builder for constructing an [`AgyBridge`] instance.
254///
255/// Created via [`AgyBridge::builder()`]. All settings have sensible defaults;
256/// call [`.build()`](Self::build) to finalise.
257///
258/// # Example
259///
260/// ```
261/// # use agy_bridge::AgyBridge;
262/// let bridge = AgyBridge::builder().channel_capacity(128).build()?;
263/// # Ok::<(), agy_bridge::error::Error>(())
264/// ```
265pub struct AgyBridgeBuilder {
266    config: runtime::RuntimeConfig,
267}
268
269impl AgyBridgeBuilder {
270    /// Set the mpsc channel buffer size for the command channel.
271    #[must_use]
272    pub fn channel_capacity(mut self, capacity: usize) -> Self {
273        self.config.channel_capacity = capacity;
274        self
275    }
276
277    /// Set the timeout for joining the Python thread on shutdown.
278    #[must_use]
279    pub fn shutdown_timeout(mut self, timeout: std::time::Duration) -> Self {
280        self.config.shutdown_timeout = timeout;
281        self
282    }
283
284    /// Set the delay between successive chat commands to prevent burst requests.
285    #[must_use]
286    pub fn inter_agent_delay(mut self, delay: std::time::Duration) -> Self {
287        self.config.inter_agent_delay = delay;
288        self
289    }
290
291    /// Set the backend runtime log verbosity.
292    ///
293    /// Defaults to [`BackendLogLevel::Warn`]. Set to [`BackendLogLevel::Info`]
294    /// or [`BackendLogLevel::Debug`] for verbose protocol-level diagnostics.
295    #[must_use]
296    pub fn backend_log_level(mut self, level: runtime::BackendLogLevel) -> Self {
297        self.config.backend_log_level = level;
298        self
299    }
300
301    /// Replace the entire runtime configuration at once.
302    ///
303    /// Useful when you already have a [`RuntimeConfig`] struct. Individual
304    /// setters called *after* this will override the corresponding fields.
305    #[must_use]
306    pub fn runtime_config(mut self, config: runtime::RuntimeConfig) -> Self {
307        self.config = config;
308        self
309    }
310
311    /// Build the [`AgyBridge`], starting the Python runtime.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`error::Error`] if the Python runtime cannot be started
316    /// (e.g. missing Antigravity SDK installation).
317    pub fn build(self) -> Result<AgyBridge, error::Error> {
318        Ok(AgyBridge {
319            runtime: Arc::new(runtime::PythonRuntime::new(self.config)?),
320        })
321    }
322}
323
324impl AgyBridge {
325    /// Create a new builder for configuring and constructing an [`AgyBridge`].
326    ///
327    /// # Example
328    ///
329    /// ```
330    /// # use agy_bridge::AgyBridge;
331    /// let bridge = AgyBridge::builder().build()?;
332    /// # Ok::<(), agy_bridge::error::Error>(())
333    /// ```
334    #[must_use]
335    pub fn builder() -> AgyBridgeBuilder {
336        AgyBridgeBuilder {
337            config: runtime::RuntimeConfig::default(),
338        }
339    }
340
341    /// Begin building a new agent on this bridge.
342    ///
343    /// Returns an [`AgentBuilder`] that can be directly `.await`ed for the
344    /// simple case, or chained with [`.tools()`](AgentBuilder::tools) and
345    /// [`.hooks()`](AgentBuilder::hooks) before awaiting.
346    ///
347    /// # Examples
348    ///
349    /// ```rust
350    /// # use agy_bridge::{AgyBridge, config::AgentConfig};
351    /// # #[tokio::main]
352    /// # async fn main() -> Result<(), agy_bridge::error::Error> {
353    /// # agy_bridge::load_dotenv();
354    /// # let bridge = AgyBridge::builder().build()?;
355    /// // Simple — no tools or hooks:
356    /// let agent = bridge.agent(AgentConfig::default()).await?;
357    ///
358    /// // With tools:
359    /// // let agent = bridge.agent(config).tools(registry).await?;
360    ///
361    /// // With tools and hooks:
362    /// // let agent = bridge.agent(config).tools(registry).hooks(hooks).await?;
363    /// # Ok(())
364    /// # }
365    /// ```
366    #[must_use]
367    pub fn agent(&self, config: config::AgentConfig) -> AgentBuilder<'_> {
368        AgentBuilder {
369            bridge: self,
370            config,
371            registry: None,
372            hooks: None,
373            policy_handler: None,
374        }
375    }
376
377    /// Convenience shorthand for `self.agent(AgentConfig::default())`.
378    ///
379    /// Creates an agent builder with default configuration. Chain
380    /// [`.tools()`](AgentBuilder::tools) or [`.hooks()`](AgentBuilder::hooks)
381    /// before awaiting, or `.await` directly for a bare agent.
382    ///
383    /// # Examples
384    ///
385    /// ```rust
386    /// # use agy_bridge::AgyBridge;
387    /// # #[tokio::main]
388    /// # async fn main() -> Result<(), agy_bridge::error::Error> {
389    /// # agy_bridge::load_dotenv();
390    /// # let bridge = AgyBridge::builder().build()?;
391    /// let agent = bridge.default_agent().await?;
392    /// # Ok(())
393    /// # }
394    /// ```
395    #[must_use]
396    pub fn default_agent(&self) -> AgentBuilder<'_> {
397        self.agent(config::AgentConfig::default())
398    }
399
400    /// Return the number of agents currently live on this bridge.
401    ///
402    /// Counts agents that have been created but not yet shut down or dropped.
403    /// Because a bridge owns a single runtime with a single agent registry,
404    /// this reflects exactly the agents belonging to *this* bridge — it is
405    /// unaffected by agents on other [`AgyBridge`] instances.
406    ///
407    /// Useful for observability and for asserting clean teardown: once every
408    /// agent has been shut down or dropped, the count returns to zero.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`error::Error`] if the runtime thread has exited.
413    pub async fn active_agent_count(&self) -> Result<usize, error::Error> {
414        self.runtime.active_agent_count().await
415    }
416}
417
418/// Builder for creating an [`Agent`] on an [`AgyBridge`].
419///
420/// Obtained from [`AgyBridge::agent()`]. Implements [`IntoFuture`] so you can
421/// `.await` it directly, or chain optional [`.tools()`](Self::tools) /
422/// [`.hooks()`](Self::hooks) calls before awaiting.
423pub struct AgentBuilder<'a> {
424    bridge: &'a AgyBridge,
425    config: config::AgentConfig,
426    registry: Option<tools::ToolRegistry>,
427    hooks: Option<hooks::Hooks>,
428    policy_handler: Option<Arc<dyn policies::AskUserHandler>>,
429}
430
431impl AgentBuilder<'_> {
432    /// Attach a [`ToolRegistry`] containing custom
433    /// Rust tools for the agent.
434    ///
435    /// The registry's tool definitions are automatically merged into the
436    /// agent configuration.
437    ///
438    /// # Errors (at build time)
439    ///
440    /// Returns [`error::Error::InvalidConfig`] if `config.tools` is
441    /// already non-empty — pass tools via the registry **or** via
442    /// `config.tools`, not both.
443    #[must_use]
444    pub fn tools(mut self, registry: tools::ToolRegistry) -> Self {
445        self.registry = Some(registry);
446        self
447    }
448
449    /// Attach [`Hooks`] for lifecycle event
450    /// callbacks (pre/post turn, tool-call gating, etc.).
451    #[must_use]
452    pub fn hooks(mut self, hooks: hooks::Hooks) -> Self {
453        self.hooks = Some(hooks);
454        self
455    }
456
457    /// Attach a custom [`AskUserHandler`] to manage interactive tool-call confirmations.
458    #[must_use]
459    pub fn policy_handler(mut self, handler: impl policies::AskUserHandler + 'static) -> Self {
460        self.policy_handler = Some(Arc::new(handler));
461        self
462    }
463
464    /// Set a pre-existing conversation ID to resume.
465    #[must_use]
466    pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
467        self.config.conversation_id = Some(id.into());
468        self
469    }
470
471    /// Set the model backend (e.g. `"gemini-3.5-flash"`).
472    #[must_use]
473    pub fn model(mut self, model: impl Into<String>) -> Self {
474        self.config.model = model.into();
475        self
476    }
477
478    /// Set system instructions for the agent.
479    #[must_use]
480    pub fn system_instructions(
481        mut self,
482        instructions: impl Into<config::SystemInstructions>,
483    ) -> Self {
484        self.config.system_instructions = Some(instructions.into());
485        self
486    }
487
488    /// Append workspace directories the agent is allowed to access and modify.
489    #[must_use]
490    pub fn workspaces(
491        mut self,
492        workspaces: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
493    ) -> Self {
494        self.config
495            .workspaces
496            .extend(workspaces.into_iter().map(Into::into));
497        self
498    }
499
500    /// Append policy rules to govern tool execution.
501    #[must_use]
502    pub fn policies(
503        mut self,
504        policies: impl IntoIterator<Item = impl Into<policies::PolicyRule>>,
505    ) -> Self {
506        self.config
507            .policies
508            .extend(policies.into_iter().map(Into::into));
509        self
510    }
511
512    /// Append triggers that autonomously wake the agent.
513    #[must_use]
514    pub fn triggers(
515        mut self,
516        triggers: impl IntoIterator<Item = impl Into<triggers::TriggerEntry>>,
517    ) -> Self {
518        self.config
519            .triggers
520            .extend(triggers.into_iter().map(Into::into));
521        self
522    }
523
524    /// Append MCP servers for the agent.
525    #[must_use]
526    pub fn mcp_servers(
527        mut self,
528        servers: impl IntoIterator<Item = impl Into<config::McpServer>>,
529    ) -> Self {
530        self.config
531            .mcp_servers
532            .extend(servers.into_iter().map(Into::into));
533        self
534    }
535
536    /// Append paths for agent skills.
537    #[must_use]
538    pub fn skills(
539        mut self,
540        skills: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
541    ) -> Self {
542        self.config
543            .skills
544            .extend(skills.into_iter().map(Into::into));
545        self
546    }
547
548    /// Set the maximum number of quota retry attempts before giving up.
549    #[must_use]
550    pub fn max_quota_retries(mut self, retries: u32) -> Self {
551        self.config.max_quota_retries = Some(retries);
552        self
553    }
554
555    /// Validate configuration and create the agent.
556    ///
557    /// Prefer using `.await` directly on the builder (via [`IntoFuture`])
558    /// instead of calling this method explicitly.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`error::Error::InvalidConfig`] if:
563    /// - `config.tools` is non-empty **and** a `ToolRegistry` was provided.
564    /// - The capabilities configuration is self-contradictory (e.g. both
565    ///   `enabled_tools` and `disabled_tools` specified).
566    ///
567    /// Returns other [`error::Error`] variants if agent creation fails.
568    pub async fn build(mut self) -> Result<Agent, error::Error> {
569        // Validate capabilities.
570        if let Some(ref caps) = self.config.capabilities {
571            caps.validate().map_err(|msg| error::Error::InvalidConfig {
572                message: msg.to_string(),
573            })?;
574        }
575
576        // Handle tool registry.
577        let arc_registry = if let Some(registry) = self.registry {
578            if !self.config.tools.is_empty() {
579                return Err(error::Error::InvalidConfig {
580                    message: "config.tools is non-empty and a ToolRegistry was also provided; \
581                              pass tools via the registry or via config.tools, not both"
582                        .to_string(),
583                });
584            }
585            self.config.tools = registry.definitions();
586            Some(Arc::new(registry))
587        } else {
588            None
589        };
590
591        // Handle hooks.
592        let arc_hooks = if let Some(hooks) = self.hooks {
593            if !self.config.hooks.is_empty() {
594                return Err(error::Error::InvalidConfig {
595                    message: "config.hooks is non-empty and a Hooks instance was also provided; \
596                              configure hooks via Hooks or config.hooks, not both"
597                        .to_string(),
598                });
599            }
600            self.config.hooks = hooks.entries();
601            Some(Arc::new(hooks))
602        } else {
603            None
604        };
605
606        // Handle policy handler.
607        let arc_policy = self.policy_handler;
608
609        agent::AgentHandle::new(
610            Arc::clone(&self.bridge.runtime),
611            self.config,
612            arc_registry,
613            arc_hooks,
614            arc_policy,
615        )
616        .await
617    }
618}
619
620impl<'a> std::future::IntoFuture for AgentBuilder<'a> {
621    type Output = Result<Agent, error::Error>;
622    type IntoFuture =
623        std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'a>>;
624
625    fn into_future(self) -> Self::IntoFuture {
626        Box::pin(self.build())
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    // ── parse_dotenv_line regression tests ────────────────────────────
635
636    #[test]
637    fn dotenv_strips_double_quotes() {
638        let (k, v) = parse_dotenv_line(r#"API_KEY="my-secret""#).unwrap();
639        assert_eq!(k, "API_KEY");
640        assert_eq!(v, "my-secret");
641    }
642
643    #[test]
644    fn dotenv_strips_single_quotes() {
645        let (k, v) = parse_dotenv_line("TOKEN='abc123'").unwrap();
646        assert_eq!(k, "TOKEN");
647        assert_eq!(v, "abc123");
648    }
649
650    #[test]
651    fn dotenv_unquoted_value_unchanged() {
652        let (k, v) = parse_dotenv_line("FOO=bar").unwrap();
653        assert_eq!(k, "FOO");
654        assert_eq!(v, "bar");
655    }
656
657    #[test]
658    fn dotenv_mismatched_quotes_preserved() {
659        // Opening double quote but closing single quote → not stripped.
660        let (k, v) = parse_dotenv_line(r#"KEY="value'"#).unwrap();
661        assert_eq!(k, "KEY");
662        assert_eq!(v, r#""value'"#);
663    }
664
665    #[test]
666    fn dotenv_empty_quoted_value() {
667        let (k, v) = parse_dotenv_line(r#"EMPTY="""#).unwrap();
668        assert_eq!(k, "EMPTY");
669        assert_eq!(v, "");
670    }
671
672    #[test]
673    fn dotenv_whitespace_around_key_value() {
674        let (k, v) = parse_dotenv_line("  MY_VAR  =  \"hello world\"  ").unwrap();
675        assert_eq!(k, "MY_VAR");
676        assert_eq!(v, "hello world");
677    }
678
679    #[test]
680    fn dotenv_comment_line_is_none() {
681        assert!(parse_dotenv_line("# this is a comment").is_none());
682    }
683
684    #[test]
685    fn dotenv_blank_line_is_none() {
686        assert!(parse_dotenv_line("   ").is_none());
687    }
688
689    #[test]
690    fn dotenv_empty_key_is_none() {
691        assert!(parse_dotenv_line("=value").is_none());
692    }
693
694    #[test]
695    fn dotenv_no_equals_is_none() {
696        assert!(parse_dotenv_line("JUSTKEY").is_none());
697    }
698
699    #[test]
700    fn dotenv_value_with_internal_equals() {
701        let (k, v) = parse_dotenv_line("DSN=postgres://host:5432/db?opt=1").unwrap();
702        assert_eq!(k, "DSN");
703        assert_eq!(v, "postgres://host:5432/db?opt=1");
704    }
705
706    #[test]
707    fn dotenv_value_with_embedded_quotes_not_stripped() {
708        // Quotes in the middle are not stripped — only surrounding ones.
709        let (k, v) = parse_dotenv_line(r#"MSG=say "hello""#).unwrap();
710        assert_eq!(k, "MSG");
711        assert_eq!(v, r#"say "hello""#);
712    }
713
714    #[test]
715    fn test_load_dotenv_returns_static_reference_identity() {
716        let map1 = load_dotenv();
717        let map2 = load_dotenv();
718        assert!(std::ptr::eq(map1, map2));
719    }
720}