lino_env/
lib.rs

1//! `LinoEnv` - A Rust library to read and write `.lenv` files.
2//!
3//! `.lenv` files use `: ` instead of `=` for key-value separation.
4//! Example: `GITHUB_TOKEN: gh_....`
5
6use std::collections::HashMap;
7use std::fs;
8use std::io::{self, BufRead, BufReader, Write};
9use std::path::Path;
10
11/// Package version (matches Cargo.toml version).
12pub const VERSION: &str = env!("CARGO_PKG_VERSION");
13
14/// `LinoEnv` - A struct to read and write `.lenv` files.
15///
16/// `.lenv` files use `: ` instead of `=` for key-value separation.
17///
18/// # Examples
19///
20/// ```
21/// use lino_env::LinoEnv;
22/// use std::fs;
23///
24/// // Create a temporary file for testing
25/// let path = std::env::temp_dir().join("test_lino_env_example.lenv");
26/// let path = path.to_str().unwrap();
27///
28/// let mut env = LinoEnv::new(path);
29/// env.set("GITHUB_TOKEN", "gh_test123");
30/// env.set("TELEGRAM_TOKEN", "054test456");
31/// env.write().unwrap();
32///
33/// // Read it back
34/// let mut env2 = LinoEnv::new(path);
35/// env2.read().unwrap();
36/// assert_eq!(env2.get("GITHUB_TOKEN"), Some("gh_test123".to_string()));
37///
38/// // Clean up
39/// fs::remove_file(path).ok();
40/// ```
41#[derive(Debug, Clone)]
42pub struct LinoEnv {
43    file_path: String,
44    data: HashMap<String, Vec<String>>,
45}
46
47impl LinoEnv {
48    /// Create a new `LinoEnv` instance.
49    ///
50    /// # Arguments
51    ///
52    /// * `file_path` - Path to the .lenv file
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use lino_env::LinoEnv;
58    /// let env = LinoEnv::new(".lenv");
59    /// ```
60    #[must_use]
61    pub fn new<P: AsRef<str>>(file_path: P) -> Self {
62        Self {
63            file_path: file_path.as_ref().to_string(),
64            data: HashMap::new(),
65        }
66    }
67
68    /// Read and parse the .lenv file.
69    ///
70    /// Stores all instances of each key (duplicates are allowed).
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if the file cannot be read.
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use lino_env::LinoEnv;
80    /// let mut env = LinoEnv::new(".lenv");
81    /// // Will return Ok even if file doesn't exist (data will be empty)
82    /// let _ = env.read();
83    /// ```
84    pub fn read(&mut self) -> io::Result<&mut Self> {
85        self.data.clear();
86
87        let path = Path::new(&self.file_path);
88        if !path.exists() {
89            return Ok(self);
90        }
91
92        let file = fs::File::open(path)?;
93        let reader = BufReader::new(file);
94
95        for line in reader.lines() {
96            let line = line?;
97            let trimmed = line.trim();
98
99            // Skip empty lines and comments
100            if trimmed.is_empty() || trimmed.starts_with('#') {
101                continue;
102            }
103
104            // Parse line with `: ` separator
105            if let Some(separator_index) = line.find(": ") {
106                let key = line[..separator_index].trim().to_string();
107                let value = line[separator_index + 2..].to_string(); // Don't trim value to preserve spaces
108
109                self.data.entry(key).or_default().push(value);
110            }
111        }
112
113        Ok(self)
114    }
115
116    /// Get the last instance of a reference (key).
117    ///
118    /// # Arguments
119    ///
120    /// * `reference` - The key to look up
121    ///
122    /// # Returns
123    ///
124    /// The last value associated with the key, or None if not found.
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// use lino_env::LinoEnv;
130    /// let mut env = LinoEnv::new(".lenv");
131    /// env.set("KEY", "value");
132    /// assert_eq!(env.get("KEY"), Some("value".to_string()));
133    /// assert_eq!(env.get("NONEXISTENT"), None);
134    /// ```
135    #[must_use]
136    pub fn get(&self, reference: &str) -> Option<String> {
137        self.data
138            .get(reference)
139            .and_then(|values| values.last().cloned())
140    }
141
142    /// Get all instances of a reference (key).
143    ///
144    /// # Arguments
145    ///
146    /// * `reference` - The key to look up
147    ///
148    /// # Returns
149    ///
150    /// All values associated with the key, or an empty vector if not found.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use lino_env::LinoEnv;
156    /// let mut env = LinoEnv::new(".lenv");
157    /// env.add("KEY", "value1");
158    /// env.add("KEY", "value2");
159    /// assert_eq!(env.get_all("KEY"), vec!["value1", "value2"]);
160    /// ```
161    #[must_use]
162    pub fn get_all(&self, reference: &str) -> Vec<String> {
163        self.data.get(reference).cloned().unwrap_or_default()
164    }
165
166    /// Set all instances of a reference to a new value.
167    ///
168    /// Replaces all existing instances with a single new value.
169    ///
170    /// # Arguments
171    ///
172    /// * `reference` - The key to set
173    /// * `value` - The new value
174    ///
175    /// # Examples
176    ///
177    /// ```
178    /// use lino_env::LinoEnv;
179    /// let mut env = LinoEnv::new(".lenv");
180    /// env.add("KEY", "old1");
181    /// env.add("KEY", "old2");
182    /// env.set("KEY", "new_value");
183    /// assert_eq!(env.get_all("KEY"), vec!["new_value"]);
184    /// ```
185    pub fn set(&mut self, reference: &str, value: &str) -> &mut Self {
186        self.data
187            .insert(reference.to_string(), vec![value.to_string()]);
188        self
189    }
190
191    /// Add a new instance of a reference (allows duplicates).
192    ///
193    /// # Arguments
194    ///
195    /// * `reference` - The key to add
196    /// * `value` - The value to add
197    ///
198    /// # Examples
199    ///
200    /// ```
201    /// use lino_env::LinoEnv;
202    /// let mut env = LinoEnv::new(".lenv");
203    /// env.add("KEY", "value1");
204    /// env.add("KEY", "value2");
205    /// assert_eq!(env.get_all("KEY"), vec!["value1", "value2"]);
206    /// ```
207    pub fn add(&mut self, reference: &str, value: &str) -> &mut Self {
208        self.data
209            .entry(reference.to_string())
210            .or_default()
211            .push(value.to_string());
212        self
213    }
214
215    /// Write the current data back to the .lenv file.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the file cannot be written.
220    ///
221    /// # Examples
222    ///
223    /// ```
224    /// use lino_env::LinoEnv;
225    /// use std::fs;
226    ///
227    /// let path = std::env::temp_dir().join("test_lino_env_write.lenv");
228    /// let path = path.to_str().unwrap();
229    /// let mut env = LinoEnv::new(path);
230    /// env.set("KEY", "value");
231    /// env.write().unwrap();
232    ///
233    /// // Clean up
234    /// fs::remove_file(path).ok();
235    /// ```
236    pub fn write(&self) -> io::Result<&Self> {
237        let mut file = fs::File::create(&self.file_path)?;
238
239        for (key, values) in &self.data {
240            for value in values {
241                writeln!(file, "{key}: {value}")?;
242            }
243        }
244
245        Ok(self)
246    }
247
248    /// Check if a reference exists.
249    ///
250    /// # Arguments
251    ///
252    /// * `reference` - The key to check
253    ///
254    /// # Returns
255    ///
256    /// `true` if the key exists and has at least one value.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use lino_env::LinoEnv;
262    /// let mut env = LinoEnv::new(".lenv");
263    /// env.set("KEY", "value");
264    /// assert!(env.has("KEY"));
265    /// assert!(!env.has("NONEXISTENT"));
266    /// ```
267    #[must_use]
268    pub fn has(&self, reference: &str) -> bool {
269        self.data
270            .get(reference)
271            .is_some_and(|values| !values.is_empty())
272    }
273
274    /// Delete all instances of a reference.
275    ///
276    /// # Arguments
277    ///
278    /// * `reference` - The key to delete
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use lino_env::LinoEnv;
284    /// let mut env = LinoEnv::new(".lenv");
285    /// env.set("KEY", "value");
286    /// env.delete("KEY");
287    /// assert!(!env.has("KEY"));
288    /// ```
289    pub fn delete(&mut self, reference: &str) -> &mut Self {
290        self.data.remove(reference);
291        self
292    }
293
294    /// Get all keys.
295    ///
296    /// # Returns
297    ///
298    /// A vector of all keys in the environment.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use lino_env::LinoEnv;
304    /// let mut env = LinoEnv::new(".lenv");
305    /// env.set("KEY1", "value1");
306    /// env.set("KEY2", "value2");
307    /// let keys = env.keys();
308    /// assert!(keys.contains(&"KEY1".to_string()));
309    /// assert!(keys.contains(&"KEY2".to_string()));
310    /// ```
311    #[must_use]
312    pub fn keys(&self) -> Vec<String> {
313        self.data.keys().cloned().collect()
314    }
315
316    /// Get all entries as a `HashMap` (with last instance of each key).
317    ///
318    /// # Returns
319    ///
320    /// A `HashMap` with each key mapped to its last value.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use lino_env::LinoEnv;
326    /// let mut env = LinoEnv::new(".lenv");
327    /// env.add("KEY1", "value1a");
328    /// env.add("KEY1", "value1b");
329    /// env.set("KEY2", "value2");
330    /// let obj = env.to_hash_map();
331    /// assert_eq!(obj.get("KEY1"), Some(&"value1b".to_string()));
332    /// ```
333    #[must_use]
334    pub fn to_hash_map(&self) -> HashMap<String, String> {
335        let mut result = HashMap::new();
336        for (key, values) in &self.data {
337            if let Some(last_value) = values.last() {
338                result.insert(key.clone(), last_value.clone());
339            }
340        }
341        result
342    }
343}
344
345/// Convenience function to read a .lenv file.
346///
347/// # Arguments
348///
349/// * `file_path` - Path to the .lenv file
350///
351/// # Errors
352///
353/// Returns an error if the file cannot be read.
354///
355/// # Examples
356///
357/// ```
358/// use lino_env::read_lino_env;
359/// // Will work even if file doesn't exist
360/// let env = read_lino_env(".lenv");
361/// ```
362pub fn read_lino_env<P: AsRef<str>>(file_path: P) -> io::Result<LinoEnv> {
363    let mut env = LinoEnv::new(file_path);
364    env.read()?;
365    Ok(env)
366}
367
368/// Convenience function to create and write a .lenv file.
369///
370/// # Arguments
371///
372/// * `file_path` - Path to the .lenv file
373/// * `data` - Key-value pairs to write
374///
375/// # Errors
376///
377/// Returns an error if the file cannot be written.
378///
379/// # Examples
380///
381/// ```
382/// use lino_env::write_lino_env;
383/// use std::collections::HashMap;
384/// use std::fs;
385///
386/// let path = std::env::temp_dir().join("test_write_lino_env.lenv");
387/// let path = path.to_str().unwrap();
388/// let mut data = HashMap::new();
389/// data.insert("KEY".to_string(), "value".to_string());
390/// write_lino_env(path, &data).unwrap();
391///
392/// // Clean up
393/// fs::remove_file(path).ok();
394/// ```
395#[allow(clippy::implicit_hasher)]
396pub fn write_lino_env<P: AsRef<str>>(
397    file_path: P,
398    data: &HashMap<String, String>,
399) -> io::Result<LinoEnv> {
400    let mut env = LinoEnv::new(file_path);
401    for (key, value) in data {
402        env.set(key, value);
403    }
404    env.write()?;
405    Ok(env)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::fs;
412
413    fn cleanup(path: &str) {
414        fs::remove_file(path).ok();
415    }
416
417    fn test_file(name: &str) -> String {
418        std::env::temp_dir()
419            .join(format!("lino_env_test_{name}.lenv"))
420            .to_string_lossy()
421            .to_string()
422    }
423
424    mod basic_tests {
425        use super::*;
426
427        #[test]
428        fn test_create_and_write() {
429            let test_file = test_file("basic_create_write");
430            cleanup(&test_file);
431            let mut env = LinoEnv::new(&test_file);
432            env.set("GITHUB_TOKEN", "gh_test123");
433            env.set("TELEGRAM_TOKEN", "054test456");
434            env.write().unwrap();
435
436            assert!(Path::new(&test_file).exists());
437            cleanup(&test_file);
438        }
439
440        #[test]
441        fn test_read() {
442            let test_file = test_file("basic_read");
443            cleanup(&test_file);
444            // First create a file
445            let mut env1 = LinoEnv::new(&test_file);
446            env1.set("GITHUB_TOKEN", "gh_test123");
447            env1.set("TELEGRAM_TOKEN", "054test456");
448            env1.write().unwrap();
449
450            // Then read it
451            let mut env2 = LinoEnv::new(&test_file);
452            env2.read().unwrap();
453
454            assert_eq!(env2.get("GITHUB_TOKEN"), Some("gh_test123".to_string()));
455            assert_eq!(env2.get("TELEGRAM_TOKEN"), Some("054test456".to_string()));
456            cleanup(&test_file);
457        }
458    }
459
460    mod get_tests {
461        use super::*;
462
463        #[test]
464        fn test_get_last_instance() {
465            let test_file = test_file("get_last_instance");
466            cleanup(&test_file);
467            let mut env = LinoEnv::new(&test_file);
468            env.add("API_KEY", "value1");
469            env.add("API_KEY", "value2");
470            env.add("API_KEY", "value3");
471
472            assert_eq!(env.get("API_KEY"), Some("value3".to_string()));
473            cleanup(&test_file);
474        }
475
476        #[test]
477        fn test_get_nonexistent() {
478            let test_file = test_file("get_nonexistent");
479            let env = LinoEnv::new(&test_file);
480            assert_eq!(env.get("NON_EXISTENT"), None);
481        }
482    }
483
484    mod get_all_tests {
485        use super::*;
486
487        #[test]
488        fn test_get_all_instances() {
489            let test_file = test_file("get_all_instances");
490            cleanup(&test_file);
491            let mut env = LinoEnv::new(&test_file);
492            env.add("API_KEY", "value1");
493            env.add("API_KEY", "value2");
494            env.add("API_KEY", "value3");
495
496            assert_eq!(env.get_all("API_KEY"), vec!["value1", "value2", "value3"]);
497            cleanup(&test_file);
498        }
499
500        #[test]
501        fn test_get_all_nonexistent() {
502            let test_file = test_file("get_all_nonexistent");
503            let env = LinoEnv::new(&test_file);
504            assert!(env.get_all("NON_EXISTENT").is_empty());
505        }
506    }
507
508    mod set_tests {
509        use super::*;
510
511        #[test]
512        fn test_set_replaces_all() {
513            let test_file = test_file("set_replaces_all");
514            cleanup(&test_file);
515            let mut env = LinoEnv::new(&test_file);
516            env.add("API_KEY", "value1");
517            env.add("API_KEY", "value2");
518            env.set("API_KEY", "new_value");
519
520            assert_eq!(env.get("API_KEY"), Some("new_value".to_string()));
521            assert_eq!(env.get_all("API_KEY"), vec!["new_value"]);
522            cleanup(&test_file);
523        }
524    }
525
526    mod add_tests {
527        use super::*;
528
529        #[test]
530        fn test_add_duplicates() {
531            let test_file = test_file("add_duplicates");
532            cleanup(&test_file);
533            let mut env = LinoEnv::new(&test_file);
534            env.add("KEY", "value1");
535            env.add("KEY", "value2");
536            env.add("KEY", "value3");
537
538            assert_eq!(env.get_all("KEY"), vec!["value1", "value2", "value3"]);
539            cleanup(&test_file);
540        }
541    }
542
543    mod has_tests {
544        use super::*;
545
546        #[test]
547        fn test_has_existing() {
548            let test_file = test_file("has_existing");
549            cleanup(&test_file);
550            let mut env = LinoEnv::new(&test_file);
551            env.set("KEY", "value");
552
553            assert!(env.has("KEY"));
554            cleanup(&test_file);
555        }
556
557        #[test]
558        fn test_has_nonexistent() {
559            let test_file = test_file("has_nonexistent");
560            let env = LinoEnv::new(&test_file);
561            assert!(!env.has("NON_EXISTENT"));
562        }
563    }
564
565    mod delete_tests {
566        use super::*;
567
568        #[test]
569        fn test_delete_all_instances() {
570            let test_file = test_file("delete_all_instances");
571            cleanup(&test_file);
572            let mut env = LinoEnv::new(&test_file);
573            env.add("KEY", "value1");
574            env.add("KEY", "value2");
575            env.delete("KEY");
576
577            assert!(!env.has("KEY"));
578            assert_eq!(env.get("KEY"), None);
579            cleanup(&test_file);
580        }
581    }
582
583    mod keys_tests {
584        use super::*;
585
586        #[test]
587        fn test_keys() {
588            let test_file = test_file("keys");
589            cleanup(&test_file);
590            let mut env = LinoEnv::new(&test_file);
591            env.set("KEY1", "value1");
592            env.set("KEY2", "value2");
593            env.set("KEY3", "value3");
594
595            let keys = env.keys();
596            assert!(keys.contains(&"KEY1".to_string()));
597            assert!(keys.contains(&"KEY2".to_string()));
598            assert!(keys.contains(&"KEY3".to_string()));
599            assert_eq!(keys.len(), 3);
600            cleanup(&test_file);
601        }
602    }
603
604    mod to_hash_map_tests {
605        use super::*;
606
607        #[test]
608        fn test_to_hash_map() {
609            let test_file = test_file("to_hash_map");
610            cleanup(&test_file);
611            let mut env = LinoEnv::new(&test_file);
612            env.add("KEY1", "value1a");
613            env.add("KEY1", "value1b");
614            env.set("KEY2", "value2");
615
616            let obj = env.to_hash_map();
617            assert_eq!(obj.get("KEY1"), Some(&"value1b".to_string()));
618            assert_eq!(obj.get("KEY2"), Some(&"value2".to_string()));
619            cleanup(&test_file);
620        }
621    }
622
623    mod persistence_tests {
624        use super::*;
625
626        #[test]
627        fn test_persist_duplicates() {
628            let test_file = test_file("persist_duplicates");
629            cleanup(&test_file);
630            let mut env1 = LinoEnv::new(&test_file);
631            env1.add("KEY", "value1");
632            env1.add("KEY", "value2");
633            env1.add("KEY", "value3");
634            env1.write().unwrap();
635
636            let mut env2 = LinoEnv::new(&test_file);
637            env2.read().unwrap();
638
639            assert_eq!(env2.get_all("KEY"), vec!["value1", "value2", "value3"]);
640            assert_eq!(env2.get("KEY"), Some("value3".to_string()));
641            cleanup(&test_file);
642        }
643    }
644
645    mod convenience_function_tests {
646        use super::*;
647
648        #[test]
649        fn test_read_lino_env() {
650            let test_file_path = test_file("convenience_read");
651            cleanup(&test_file_path);
652            let mut data = HashMap::new();
653            data.insert("GITHUB_TOKEN".to_string(), "gh_test".to_string());
654            data.insert("TELEGRAM_TOKEN".to_string(), "054test".to_string());
655            write_lino_env(&test_file_path, &data).unwrap();
656
657            let env = read_lino_env(&test_file_path).unwrap();
658            assert_eq!(env.get("GITHUB_TOKEN"), Some("gh_test".to_string()));
659            assert_eq!(env.get("TELEGRAM_TOKEN"), Some("054test".to_string()));
660            cleanup(&test_file_path);
661        }
662
663        #[test]
664        fn test_write_lino_env() {
665            let test_file_path = test_file("convenience_write");
666            cleanup(&test_file_path);
667            let mut data = HashMap::new();
668            data.insert("API_KEY".to_string(), "test_key".to_string());
669            data.insert("SECRET".to_string(), "test_secret".to_string());
670            write_lino_env(&test_file_path, &data).unwrap();
671
672            let env = read_lino_env(&test_file_path).unwrap();
673            assert_eq!(env.get("API_KEY"), Some("test_key".to_string()));
674            assert_eq!(env.get("SECRET"), Some("test_secret".to_string()));
675            cleanup(&test_file_path);
676        }
677    }
678
679    mod format_tests {
680        use super::*;
681
682        #[test]
683        fn test_values_with_colons() {
684            let test_file_path = test_file("format_colons");
685            cleanup(&test_file_path);
686            let mut env = LinoEnv::new(&test_file_path);
687            env.set("URL", "https://example.com:8080");
688            env.write().unwrap();
689
690            let mut env2 = LinoEnv::new(&test_file_path);
691            env2.read().unwrap();
692            assert_eq!(
693                env2.get("URL"),
694                Some("https://example.com:8080".to_string())
695            );
696            cleanup(&test_file_path);
697        }
698
699        #[test]
700        fn test_values_with_spaces() {
701            let test_file_path = test_file("format_spaces");
702            cleanup(&test_file_path);
703            let mut env = LinoEnv::new(&test_file_path);
704            env.set("MESSAGE", "Hello World");
705            env.write().unwrap();
706
707            let mut env2 = LinoEnv::new(&test_file_path);
708            env2.read().unwrap();
709            assert_eq!(env2.get("MESSAGE"), Some("Hello World".to_string()));
710            cleanup(&test_file_path);
711        }
712    }
713
714    mod edge_case_tests {
715        use super::*;
716
717        #[test]
718        fn test_nonexistent_file() {
719            let test_file_path = test_file("nonexistent");
720            cleanup(&test_file_path);
721            let mut env = LinoEnv::new(&test_file_path);
722            env.read().unwrap();
723
724            assert_eq!(env.get("ANY_KEY"), None);
725            assert!(env.keys().is_empty());
726        }
727
728        #[test]
729        fn test_empty_values() {
730            let test_file_path = test_file("empty_values");
731            cleanup(&test_file_path);
732            let mut env = LinoEnv::new(&test_file_path);
733            env.set("EMPTY_KEY", "");
734            env.write().unwrap();
735
736            let mut env2 = LinoEnv::new(&test_file_path);
737            env2.read().unwrap();
738            assert_eq!(env2.get("EMPTY_KEY"), Some(String::new()));
739            cleanup(&test_file_path);
740        }
741    }
742}