Skip to main content

trader_bot/lib/rust/lino_args/
lenv.rs

1//! Links Notation Environment (lenv) file loading.
2//!
3//! Provides functionality to load configuration from `.lenv` files using
4//! [Links Notation](https://github.com/link-foundation/links-notation) format.
5//!
6//! # Format
7//!
8//! The lenv format uses `: ` (colon-space) as separator following Links Notation:
9//! - `KEY: value` - Simple key-value pairs
10//! - `KEY: "value with spaces"` - Quoted values
11//! - `KEY: 'value with spaces'` - Single-quoted values
12//! - `# comment` - Comments (lines starting with #)
13//! - Empty lines are ignored
14//! - Indented nested structures are supported
15//!
16//! # Example
17//!
18//! ```text
19//! # API tokens
20//! TBANK_API_TOKEN: your_token_here
21//! BINANCE_API_KEY: your_key_here
22//!
23//! # Settings
24//! TRADER_BOT_LOG_LEVEL: info
25//! TRADER_BOT_VERBOSE: false
26//! ```
27
28use std::collections::HashMap;
29use std::fs;
30use std::io;
31use std::path::Path;
32
33/// Error type for lenv file operations.
34#[derive(Debug)]
35pub enum LenvError {
36    /// IO error when reading the file.
37    Io(io::Error),
38    /// Parse error with line number and description.
39    Parse {
40        /// Line number where the error occurred.
41        line: usize,
42        /// Description of the parse error.
43        message: String,
44    },
45}
46
47impl std::fmt::Display for LenvError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            Self::Io(e) => write!(f, "IO error: {e}"),
51            Self::Parse { line, message } => write!(f, "Parse error on line {line}: {message}"),
52        }
53    }
54}
55
56impl std::error::Error for LenvError {}
57
58impl From<io::Error> for LenvError {
59    fn from(err: io::Error) -> Self {
60        Self::Io(err)
61    }
62}
63
64/// Parses a lenv file content into a key-value map.
65///
66/// # Format
67///
68/// The lenv format uses Links Notation with `: ` (colon-space) separator:
69/// - `KEY: value` - Simple key-value pairs
70/// - `KEY: "value with spaces"` - Quoted values
71/// - `KEY: 'value with spaces'` - Single-quoted values
72/// - `# comment` - Comments (lines starting with #)
73/// - Empty lines are ignored
74///
75/// For backwards compatibility, `=` separator is also supported.
76///
77/// # Examples
78///
79/// ```
80/// use trader_bot::lino_args::parse_lenv;
81///
82/// // Links Notation format (preferred)
83/// let content = "# Configuration\nAPI_KEY: my_secret_key\nPORT: 8080\nDEBUG: true\n";
84///
85/// let vars = parse_lenv(content).unwrap();
86/// assert_eq!(vars.get("API_KEY"), Some(&"my_secret_key".to_string()));
87/// assert_eq!(vars.get("PORT"), Some(&"8080".to_string()));
88/// ```
89pub fn parse_lenv(content: &str) -> Result<HashMap<String, String>, LenvError> {
90    let mut vars = HashMap::new();
91
92    for (line_num, line) in content.lines().enumerate() {
93        let trimmed = line.trim();
94
95        // Skip empty lines and comments
96        if trimmed.is_empty() || trimmed.starts_with('#') {
97            continue;
98        }
99
100        // Try Links Notation format first (": " separator)
101        // Then fall back to traditional format ("=" separator)
102        let (key, value) = if let Some(colon_pos) = trimmed.find(": ") {
103            let key = trimmed[..colon_pos].trim();
104            let value = trimmed[colon_pos + 2..].trim();
105            (key, value)
106        } else if let Some(eq_pos) = trimmed.find('=') {
107            let key = trimmed[..eq_pos].trim();
108            let value = trimmed[eq_pos + 1..].trim();
109            (key, value)
110        } else {
111            return Err(LenvError::Parse {
112                line: line_num + 1,
113                message: "Missing ': ' or '=' separator".to_string(),
114            });
115        };
116
117        if key.is_empty() {
118            return Err(LenvError::Parse {
119                line: line_num + 1,
120                message: "Empty key".to_string(),
121            });
122        }
123
124        // Handle quoted values
125        let parsed_value = if (value.starts_with('"') && value.ends_with('"'))
126            || (value.starts_with('\'') && value.ends_with('\''))
127        {
128            // Remove quotes
129            if value.len() >= 2 {
130                value[1..value.len() - 1].to_string()
131            } else {
132                value.to_string()
133            }
134        } else {
135            value.to_string()
136        };
137
138        vars.insert(key.to_string(), parsed_value);
139    }
140
141    Ok(vars)
142}
143
144/// Loads a lenv file and returns its key-value pairs.
145///
146/// # Arguments
147///
148/// * `path` - Path to the lenv file
149///
150/// # Errors
151///
152/// Returns an error if the file cannot be read or parsed.
153pub fn load_lenv<P: AsRef<Path>>(path: P) -> Result<HashMap<String, String>, LenvError> {
154    let content = fs::read_to_string(path)?;
155    parse_lenv(&content)
156}
157
158/// Loads a lenv file and sets the values as environment variables.
159///
160/// Existing environment variables are NOT overwritten. This follows the
161/// priority chain where existing env vars take precedence over file values.
162///
163/// # Arguments
164///
165/// * `path` - Path to the lenv file
166///
167/// # Errors
168///
169/// Returns an error if the file cannot be read or parsed.
170pub fn load_lenv_into_env<P: AsRef<Path>>(path: P) -> Result<usize, LenvError> {
171    let vars = load_lenv(path)?;
172    let mut loaded = 0;
173
174    for (key, value) in vars {
175        // Only set if not already defined
176        if std::env::var(&key).is_err() {
177            std::env::set_var(&key, value);
178            loaded += 1;
179        }
180    }
181
182    Ok(loaded)
183}
184
185/// Tries to load a lenv file from common locations.
186///
187/// Searches in order:
188/// 1. Path specified in `--lenv` or `-l` CLI argument
189/// 2. `LENV_FILE` environment variable
190/// 3. `.lenv` in current directory
191/// 4. `.lenv` in home directory
192///
193/// # Returns
194///
195/// Returns `Ok(Some(count))` if a file was found and loaded (count = vars loaded),
196/// `Ok(None)` if no file was found, or `Err` if a file was found but couldn't be parsed.
197pub fn auto_load_lenv() -> Result<Option<usize>, LenvError> {
198    // Check CLI argument first
199    let args: Vec<String> = std::env::args().collect();
200    for i in 0..args.len() {
201        if (args[i] == "--lenv" || args[i] == "-l") && i + 1 < args.len() {
202            let loaded = load_lenv_into_env(&args[i + 1])?;
203            return Ok(Some(loaded));
204        }
205        if args[i].starts_with("--lenv=") {
206            let path = &args[i][7..];
207            let loaded = load_lenv_into_env(path)?;
208            return Ok(Some(loaded));
209        }
210    }
211
212    // Check LENV_FILE environment variable
213    if let Ok(path) = std::env::var("LENV_FILE") {
214        if Path::new(&path).exists() {
215            let loaded = load_lenv_into_env(&path)?;
216            return Ok(Some(loaded));
217        }
218    }
219
220    // Check current directory
221    if Path::new(".lenv").exists() {
222        let loaded = load_lenv_into_env(".lenv")?;
223        return Ok(Some(loaded));
224    }
225
226    // Check home directory
227    if let Some(home) = dirs_home() {
228        let home_lenv = home.join(".lenv");
229        if home_lenv.exists() {
230            let loaded = load_lenv_into_env(&home_lenv)?;
231            return Ok(Some(loaded));
232        }
233    }
234
235    Ok(None)
236}
237
238/// Gets the home directory path.
239fn dirs_home() -> Option<std::path::PathBuf> {
240    std::env::var("HOME")
241        .ok()
242        .map(std::path::PathBuf::from)
243        .or_else(|| {
244            std::env::var("USERPROFILE")
245                .ok()
246                .map(std::path::PathBuf::from)
247        })
248}