netrc_parser/
lib.rs

1#![feature(closure_lifetime_binder)]
2
3//! A modern and idiomatic .netrc parser in Rust
4//!
5//! This library provides a robust parser for `.netrc` files, supporting machine
6//! entries, login credentials, accounts, and macro definitions (`macdef`). It
7//! includes serialization to JSON and TOML, file I/O, and comprehensive error
8//! handling.
9//!
10//! The parser handles standard `.netrc` file formats, validates machine names,
11//! and supports flexible input with whitespace. Errors are reported via the
12//! `NetrcError` enum, which includes detailed parse error messages and input
13//! context.
14//!
15//! # Example
16//!
17//! ```
18//! use netrc_parser::{Netrc, NetrcError};
19//!
20//! let input = "machine example.com login user password pass";
21//! let netrc = Netrc::parse_from_str(input)?;
22//! let creds =
23//!     netrc.get("example.com").ok_or_else(|| NetrcError::NotFound("example.com".to_string()))?;
24//! assert_eq!(creds.login, "user");
25//! assert_eq!(creds.password, "pass");
26//! # Ok::<(), NetrcError>(())
27//! ```
28
29mod error;
30pub use error::NetrcError;
31use log::{debug, error, info, warn};
32use nom::{IResult, Parser,
33          branch::alt,
34          bytes::complete::{tag, take_while1},
35          character::complete::{line_ending, multispace0, multispace1, not_line_ending},
36          combinator::{all_consuming, eof, opt},
37          multi::many0};
38use serde::{Deserialize, Serialize};
39use std::{collections::HashMap, fs, path::Path};
40
41/// Represents a single machine entry in a `.netrc` file.
42///
43/// Each entry contains a machine name (or "default"), login credentials, an
44/// optional account, and an optional macro definition (`macdef`).
45#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
46pub struct NetrcMachine {
47    pub machine: String,
48    pub login: String,
49    pub password: String,
50    pub account: Option<String>,
51    pub macdef: Option<String>,
52}
53
54#[derive(Debug, PartialEq, Serialize, Deserialize)]
55pub struct Credentials {
56    pub email: String,
57    pub token: String,
58}
59
60/// Represents a complete `.netrc` file with multiple machine entries.
61///
62/// Stores machine entries in a `HashMap` keyed by machine name for efficient
63/// lookup. Provides methods for parsing, serialization, and manipulation of
64/// entries.
65#[derive(Debug, Default, Deserialize, Serialize)]
66pub struct Netrc {
67    pub machines: HashMap<String, NetrcMachine>,
68}
69
70/// Checks if a character is valid for a token (non-whitespace).
71///
72/// Returns `true` for any non-whitespace character, used in token parsing.
73fn is_token_char(c: char) -> bool {
74    !c.is_whitespace()
75}
76
77/// Parses a single token from input.
78///
79/// A token is a sequence of non-whitespace characters. Returns an error if no
80/// valid token is found.
81fn parse_token(input: &str) -> IResult<&str, &str> {
82    take_while1(is_token_char)(input)
83}
84
85/// Parses a single machine entry from the input string.
86///
87/// The parser supports `machine` or `default` entries with `login`, `password`,
88/// `account`, and `macdef` fields. A `macdef` block is terminated by an empty
89/// line or end-of-input. Invalid machine names (empty or reserved keywords like
90/// `login`, `password`, `account`, `macdef`) result in a parse error.
91fn parse_machine(input: &str) -> IResult<&str, NetrcMachine> {
92    debug!("Parsing machine entry from input: {:?}", input);
93    let (input, _) = multispace0(input)?;
94    let (input, key): (&str, &str) = alt((tag("machine"), tag("default"))).parse(input)?;
95    debug!("Parsed key: {}", key);
96    let (input, _) = multispace1(input)?;
97    let (input, machine_name) = if key == "default" {
98        (input, "default")
99    } else {
100        let (input, name) = parse_token(input).map_err(|_| {
101            debug!("Failed to parse machine name");
102            nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Verify))
103        })?;
104        if name.trim().is_empty()
105            || name == "login"
106            || name == "password"
107            || name == "account"
108            || name == "macdef"
109        {
110            debug!("Invalid machine name: {}", name);
111            return Err(nom::Err::Error(nom::error::Error::new(
112                input,
113                nom::error::ErrorKind::Verify,
114            )));
115        }
116        (input, name)
117    };
118    debug!("Parsed machine name: {}", machine_name);
119
120    let mut login = String::new();
121    let mut password = String::new();
122    let mut account = None;
123    let mut macdef = None;
124    let mut rest = input;
125
126    loop {
127        let (next_input, _) = multispace0(rest)?;
128        if let Ok((next_input, _)) = eof::<_, nom::error::Error<_>>(next_input) {
129            debug!("Reached end of input for machine: {}", machine_name);
130            rest = next_input;
131            break;
132        }
133
134        let (next_input, token): (&str, Option<&str>) = opt(parse_token).parse(next_input)?;
135        match token {
136            Some("machine") | Some("default") => {
137                debug!(
138                    "Encountered new machine or default, stopping parsing for: {}",
139                    machine_name
140                );
141                break;
142            },
143            Some("login") => {
144                let (next_input, _) = multispace1(next_input)?;
145                let (next_input, val) = parse_token(next_input).map_err(|_| {
146                    debug!("Failed to parse login token for machine: {}", machine_name);
147                    nom::Err::Failure(nom::error::Error::new(
148                        next_input,
149                        nom::error::ErrorKind::Verify,
150                    ))
151                })?;
152                login = val.to_string();
153                debug!("Parsed login: {} for machine: {}", login, machine_name);
154                rest = next_input;
155            },
156            Some("password") => {
157                let (next_input, _) = multispace1(next_input)?;
158                let (next_input, val) = parse_token(next_input).map_err(|_| {
159                    debug!("Failed to parse password token for machine: {}", machine_name);
160                    nom::Err::Error(nom::error::Error::new(
161                        next_input,
162                        nom::error::ErrorKind::Verify,
163                    ))
164                })?;
165                password = val.to_string();
166                debug!("Parsed password for machine: {}", machine_name);
167                rest = next_input;
168            },
169            Some("account") => {
170                let (next_input, _) = multispace1(next_input)?;
171                let (next_input, val) = parse_token(next_input).map_err(|_| {
172                    debug!("Failed to parse account token for machine: {}", machine_name);
173                    nom::Err::Error(nom::error::Error::new(
174                        next_input,
175                        nom::error::ErrorKind::Verify,
176                    ))
177                })?;
178                account = Some(val.to_string());
179                debug!("Parsed account: {} for machine: {}", val, machine_name);
180                rest = next_input;
181            },
182            Some("macdef") => {
183                let (next_input, _) = multispace1(next_input)?;
184                let (next_input, macdef_name) = parse_token(next_input).map_err(|_| {
185                    debug!("Failed to parse macdef name for machine: {}", machine_name);
186                    nom::Err::Error(nom::error::Error::new(
187                        next_input,
188                        nom::error::ErrorKind::Verify,
189                    ))
190                })?;
191                debug!("Parsing macdef: {} for machine: {}", macdef_name, machine_name);
192                let (next_input, _) = line_ending(next_input)?;
193
194                let mut lines = Vec::new();
195                let mut current_input = next_input;
196                loop {
197                    let (next, _) = multispace0(current_input)?;
198                    let (next, line) = not_line_ending(next)?;
199                    let (next, line_end) = opt(line_ending).parse(next)?;
200                    if line.trim().is_empty() && line_end.is_some() {
201                        debug!("Reached empty line, ending macdef for machine: {}", machine_name);
202                        current_input = next;
203                        break;
204                    }
205                    if next.is_empty() {
206                        debug!("Reached end of macdef for machine: {}", machine_name);
207                        current_input = next;
208                        break;
209                    }
210                    lines.push(line.to_string());
211                    current_input = next;
212                }
213
214                let macdef_content = lines.join("\n").trim_end().to_string();
215                macdef =
216                    Some(if macdef_content.is_empty() { "" } else { &macdef_content }.to_string());
217                debug!("Parsed macdef content: {:?} for machine: {}", macdef, machine_name);
218                rest = current_input;
219            },
220            Some(token) => {
221                warn!("Unexpected token: {} for machine: {}, skipping", token, machine_name);
222                let (next_input, _) = multispace0(next_input)?;
223                rest = next_input;
224            },
225            None => {
226                debug!("No more tokens for machine: {}, stopping parsing", machine_name);
227                rest = next_input;
228                break;
229            },
230        }
231    }
232
233    if login.is_empty() {
234        warn!("No login provided for machine: {}", machine_name);
235    }
236    if password.is_empty() {
237        warn!("No password provided for machine: {}", machine_name);
238    }
239
240    Ok((rest, NetrcMachine { machine: machine_name.to_string(), login, password, account, macdef }))
241}
242
243/// Parses an entire `.netrc` file content into a `Netrc` struct.
244///
245/// Consumes the input string and returns a `Netrc` containing all parsed
246/// machine entries. Duplicate machine names result in a parse error.
247fn parse_netrc(input: &str) -> IResult<&str, Netrc> {
248    info!("Parsing entire .netrc content");
249    let (input, machine_list) = all_consuming(many0(parse_machine)).parse(input)?;
250    debug!("Parsed {} machine entries", machine_list.len());
251    let mut machines = HashMap::new();
252    for machine in machine_list {
253        if machines.contains_key(&machine.machine) {
254            error!("Duplicate machine entry found: {}", machine.machine);
255            return Err(nom::Err::Failure(nom::error::Error::new(
256                input,
257                nom::error::ErrorKind::Many1,
258            )));
259        }
260        debug!("Adding machine entry: {}", machine.machine);
261        machines.insert(machine.machine.clone(), machine);
262    }
263    info!("Successfully parsed .netrc with {} machines", machines.len());
264    Ok((input, Netrc { machines }))
265}
266
267impl Netrc {
268    /// Parses a `.netrc` string into a `Netrc` struct.
269    ///
270    /// Returns a `Netrc` containing all machine entries or a `NetrcError` if
271    /// parsing fails.
272    ///
273    /// # Example
274    ///
275    /// ```
276    /// use netrc_parser::{Netrc, NetrcError};
277    ///
278    /// let input = "machine example.com login user password pass";
279    /// let netrc = Netrc::parse_from_str(input)?;
280    /// assert_eq!(netrc.get("example.com").unwrap().login, "user");
281    /// # Ok::<(), NetrcError>(())
282    /// ```
283    pub fn parse_from_str(input: &str) -> Result<Self, NetrcError> {
284        info!("Parsing .netrc string");
285        match parse_netrc(input) {
286            Ok((_, parsed)) => {
287                info!("Successfully parsed .netrc string");
288                Ok(parsed)
289            },
290            Err(e) => {
291                let err = match e {
292                    nom::Err::Incomplete(_) => {
293                        NetrcError::Parse {
294                            message: "incomplete input".to_string(),
295                            input: input.to_string(),
296                        }
297                    },
298                    nom::Err::Error(e) => {
299                        NetrcError::Parse {
300                            message: format!("parse error: {:?}", e),
301                            input: input.to_string(),
302                        }
303                    },
304                    nom::Err::Failure(e) if e.code == nom::error::ErrorKind::Many1 => {
305                        NetrcError::DuplicateEntry("duplicate machine entry".to_string())
306                    },
307                    nom::Err::Failure(e) => {
308                        NetrcError::Parse {
309                            message: format!("parse failure: {:?}", e),
310                            input: input.to_string(),
311                        }
312                    },
313                };
314                error!("Failed to parse .netrc string: {}", err);
315                Err(err)
316            },
317        }
318    }
319
320    /// Reads and parses a `.netrc` file from the given path.
321    ///
322    /// Checks file permissions (must be 0600 or stricter on Unix) and returns a
323    /// `Netrc` struct. Returns a `NetrcError` for I/O or parsing errors.
324    ///
325    /// # Example
326    ///
327    /// ```
328    /// use netrc_parser::{Netrc, NetrcError};
329    /// use std::fs;
330    ///
331    /// let temp_file = std::env::temp_dir().join("test_netrc_doc");
332    /// fs::write(&temp_file, "machine example.com login user password pass")?;
333    /// #[cfg(unix)]
334    /// {
335    ///     use std::os::unix::fs::PermissionsExt;
336    ///     fs::set_permissions(&temp_file, fs::Permissions::from_mode(0o600))?;
337    /// }
338    /// let netrc = Netrc::parse_from_path(&temp_file)?;
339    /// if let Some(creds) = netrc.get("example.com") {
340    ///     println!("Login: {}", creds.login);
341    /// }
342    /// fs::remove_file(&temp_file)?;
343    /// # Ok::<(), NetrcError>(())
344    /// ```
345    ///
346    /// # Note
347    ///
348    /// On Unix systems, the file must have permissions set to `0600` (owner
349    /// read/write only). Files with more permissive settings (e.g., group
350    /// or world readable) will result in `NetrcError::InsecurePermissions`.
351    pub fn parse_from_path<P: AsRef<Path>>(path: P) -> Result<Self, NetrcError> {
352        let path = path.as_ref();
353        info!("Reading and parsing .netrc file from path: {:?}", path);
354        let metadata = fs::metadata(path).map_err(|e| {
355            error!("Failed to read metadata for {:?}: {}", path, e);
356            if e.kind() == std::io::ErrorKind::NotFound {
357                NetrcError::FileNotFound(path.display().to_string())
358            } else {
359                NetrcError::Io(e)
360            }
361        })?;
362        #[cfg(unix)]
363        {
364            use std::os::unix::fs::PermissionsExt;
365            let mode = metadata.permissions().mode();
366            if mode & 0o077 != 0 {
367                error!("File permissions for {:?} are too open: {:o}", path, mode);
368                return Err(NetrcError::InsecurePermissions);
369            }
370        }
371        match fs::read_to_string(path) {
372            Ok(content) => {
373                debug!("Successfully read .netrc file from {:?}", path);
374                Self::parse_from_str(&content)
375            },
376            Err(e) => {
377                error!("Failed to read .netrc file from {:?}: Error: {}", path, e);
378                if e.kind() == std::io::ErrorKind::NotFound {
379                    Err(NetrcError::FileNotFound(path.display().to_string()))
380                } else {
381                    Err(NetrcError::Io(e))
382                }
383            },
384        }
385    }
386
387    pub fn get_credentials(&self, machine: &str) -> Option<Credentials> {
388        self.get(machine).map(|m| Credentials { email: m.login.clone(), token: m.password.clone() })
389    }
390
391    /// Retrieves a machine entry by its name.
392    ///
393    /// Returns `Some(&NetrcMachine)` if found, or `None` if no entry exists.
394    pub fn get(&self, machine: &str) -> Option<&NetrcMachine> {
395        debug!("Retrieving machine entry for: {}", machine);
396        let result = self.machines.get(machine);
397        if result.is_none() {
398            warn!("No machine entry found for: {}", machine);
399        }
400        result
401    }
402
403    /// Serializes the `Netrc` struct to JSON format.
404    ///
405    /// Returns a pretty-printed JSON string or a `NetrcError` if serialization
406    /// fails.
407    pub fn to_json(&self) -> Result<String, NetrcError> {
408        info!("Serializing .netrc to JSON");
409        match serde_json::to_string_pretty(self) {
410            Ok(json) => {
411                debug!("Successfully serialized .netrc to JSON");
412                Ok(json)
413            },
414            Err(e) => {
415                error!("Failed to serialize .netrc to JSON: {}", e);
416                Err(NetrcError::Serialize(e.to_string()))
417            },
418        }
419    }
420
421    /// Serializes the `Netrc` struct to TOML format.
422    ///
423    /// Returns a pretty-printed TOML string or a `NetrcError` if serialization
424    /// fails.
425    #[cfg(feature = "toml")]
426    pub fn to_toml(&self) -> Result<String, NetrcError> {
427        info!("Serializing .netrc to TOML");
428        match toml::to_string_pretty(self) {
429            Ok(toml) => {
430                debug!("Successfully serialized .netrc to TOML");
431                Ok(toml)
432            },
433            Err(e) => {
434                error!("Failed to serialize .netrc to TOML: {}", e);
435                Err(NetrcError::Serialize(e.to_string()))
436            },
437        }
438    }
439
440    /// Inserts or replaces a machine entry in the `Netrc`.
441    ///
442    /// Overwrites any existing entry with the same machine name.
443    pub fn insert_machine(&mut self, machine: NetrcMachine) {
444        info!("Inserting or replacing machine entry: {}", machine.machine);
445        self.machines.insert(machine.machine.clone(), machine);
446        debug!("Machine entry inserted: {}", self.machines.len());
447    }
448
449    /// Removes a machine entry by name.
450    ///
451    /// Returns the removed `NetrcMachine` if found, or `None` if no entry
452    /// exists.
453    pub fn remove_machine(&mut self, machine_name: &str) -> Option<NetrcMachine> {
454        info!("Removing machine entry: {}", machine_name);
455        let result = self.machines.remove(machine_name);
456        if result.is_some() {
457            debug!("Successfully removed machine entry: {}", machine_name);
458        } else {
459            warn!("No machine entry found to remove: {}", machine_name);
460        }
461        result
462    }
463
464    /// Updates a machine entry with the provided function.
465    ///
466    /// Applies the closure to the entry if found, returning `Ok(())` on success
467    /// or `NetrcError::NotFound` if no entry exists.
468    ///
469    /// # Example
470    ///
471    /// ```
472    /// use netrc_parser::{Netrc, NetrcError, NetrcMachine};
473    ///
474    /// let mut netrc = Netrc::default();
475    /// netrc.insert_machine(NetrcMachine {
476    ///     machine: "example.com".to_string(),
477    ///     login: "user".to_string(),
478    ///     password: "pass".to_string(),
479    ///     account: None,
480    ///     macdef: None,
481    /// });
482    /// netrc.update_machine("example.com", |m| m.login = "new_user".to_string())?;
483    /// assert_eq!(netrc.get("example.com").unwrap().login, "new_user");
484    /// # Ok::<(), NetrcError>(())
485    /// ```
486    pub fn update_machine<F>(&mut self, machine_name: &str, update_fn: F) -> Result<(), NetrcError>
487    where
488        F: FnOnce(&mut NetrcMachine),
489    {
490        info!("Updating machine entry: {}", machine_name);
491        if let Some(machine) = self.machines.get_mut(machine_name) {
492            update_fn(machine);
493            debug!("Successfully updated machine entry: {}", machine_name);
494            Ok(())
495        } else {
496            error!("Failed to update machine entry: {} not found", machine_name);
497            Err(NetrcError::NotFound(machine_name.to_string()))
498        }
499    }
500
501    /// Serializes the `Netrc` struct to `.netrc` format.
502    ///
503    /// Returns a string in the standard `.netrc` file format.
504    pub fn to_netrc_string(&self) -> String {
505        info!("Serializing .netrc to string format");
506        let mut output = String::new();
507        for machine in self.machines.values() {
508            debug!("Serializing machine entry: {}", machine.machine);
509            if machine.machine == "default" {
510                output.push_str("default\n");
511            } else {
512                output.push_str(&format!("machine {}\n", machine.machine));
513            }
514            output.push_str(&format!("  login {}\n", machine.login));
515            output.push_str(&format!("  password {}\n", machine.password));
516            if let Some(account) = &machine.account {
517                output.push_str(&format!("  account {}\n", account));
518            }
519            if let Some(macdef) = &machine.macdef {
520                output.push_str(&format!("  macdef init\n{}\n\n", macdef));
521            }
522        }
523        debug!("Completed serialization to .netrc string");
524        output
525    }
526
527    /// Saves the `.netrc` content to the specified path.
528    ///
529    /// Writes the serialized `.netrc` content to the given file path, setting
530    /// permissions to `0600` (owner read/write only) on Unix systems. Returns
531    /// `Ok(())` on success or a `NetrcError` for I/O errors.
532    ///
533    /// # Example
534    ///
535    /// ```
536    /// use netrc_parser::{Netrc, NetrcError, NetrcMachine};
537    /// use std::fs;
538    ///
539    /// let temp_file = std::env::temp_dir().join("test_netrc_save");
540    /// let mut netrc = Netrc::default();
541    /// netrc.insert_machine(NetrcMachine {
542    ///     machine: "example.com".to_string(),
543    ///     login: "user".to_string(),
544    ///     password: "pass".to_string(),
545    ///     account: None,
546    ///     macdef: None,
547    /// });
548    /// netrc.save_to_path(&temp_file)?;
549    /// let loaded = Netrc::parse_from_path(&temp_file)?;
550    /// assert_eq!(loaded.get("example.com").unwrap().login, "user");
551    /// fs::remove_file(&temp_file)?;
552    /// # Ok::<(), NetrcError>(())
553    /// ```
554    pub fn save_to_path<P: AsRef<Path>>(&self, path: P) -> Result<(), NetrcError> {
555        let path = path.as_ref();
556        info!("Saving .netrc to path: {:?}", path);
557        let netrc_string = self.to_netrc_string();
558        match fs::write(path, &netrc_string) {
559            Ok(()) => {
560                #[cfg(unix)]
561                {
562                    use std::os::unix::fs::PermissionsExt;
563                    fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| {
564                        error!("Failed to set permissions for {:?}: {}", path, e);
565                        NetrcError::Io(e)
566                    })?;
567                }
568                debug!("Successfully saved .netrc to {:?}", path);
569                Ok(())
570            },
571            Err(e) => {
572                error!("Failed to save .netrc to {:?}: {}", path, e);
573                Err(NetrcError::Io(e))
574            },
575        }
576    }
577
578    pub fn set_credentials(
579        &mut self,
580        machine: &str,
581        login: &str,
582        password: &str,
583    ) -> Result<(), NetrcError> {
584        let machine_entry = NetrcMachine {
585            machine: machine.to_string(),
586            login: login.to_string(),
587            password: password.to_string(),
588            account: None,
589            macdef: None,
590        };
591        self.insert_machine(machine_entry);
592        Ok(())
593    }
594
595    pub fn remove_credentials(&mut self, machine: &str) -> Option<NetrcMachine> {
596        self.remove_machine(machine)
597    }
598
599    pub fn merge(&mut self, other: Netrc) {
600        for (name, machine) in other.machines {
601            self.machines.insert(name, machine);
602        }
603    }
604
605    // pub fn diff(
606    //     &self,
607    //     other: &Netrc,
608    // ) -> HashMap<String, (Option<&NetrcMachine>, Option<&NetrcMachine>)> {
609    //     let mut diff = HashMap::new();
610    //     for name in self.machines.keys().chain(other.machines.keys()) {
611    //         diff.insert(name.clone(), (self.machines.get(name),
612    // other.machines.get(name)));     }
613    //     diff
614    // }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use log::Level;
621    use std::cell::RefCell;
622
623    thread_local! {
624        static LOG_MESSAGES: RefCell<Vec<(Level, String)>> = const { RefCell::new(Vec::new()) };
625    }
626
627    // Custom log handler for capturing log messages in tests
628    struct TestLogger;
629
630    impl log::Log for TestLogger {
631        fn enabled(&self, metadata: &log::Metadata) -> bool {
632            metadata.level() <= Level::Debug
633        }
634
635        fn log(&self, record: &log::Record) {
636            if self.enabled(record.metadata()) {
637                eprintln!("Log: {} - {}", record.level(), record.args());
638                LOG_MESSAGES.with(|messages| {
639                    messages.borrow_mut().push((record.level(), format!("{}", record.args())));
640                });
641            }
642        }
643
644        fn flush(&self) {
645        }
646    }
647
648    fn init_logger() {
649        eprintln!("Initializing logger with RUST_LOG={:?}", std::env::var("RUST_LOG"));
650        let _ = log::set_logger(&TestLogger).map(|()| log::set_max_level(log::LevelFilter::Debug));
651        LOG_MESSAGES.with(|messages| messages.borrow_mut().clear());
652    }
653
654    // Helper to safely get log messages
655    fn get_log_messages() -> Vec<(Level, String)> {
656        LOG_MESSAGES.with(|messages| messages.borrow().clone())
657    }
658
659    // Tests parsing a basic .netrc entry
660    #[test]
661    fn parse_basic_entry() {
662        init_logger();
663        let input = "machine example.com login user password pass";
664        let netrc = Netrc::parse_from_str(input).unwrap();
665        let creds = netrc.get("example.com").unwrap();
666
667        assert_eq!(creds.login, "user");
668        assert_eq!(creds.password, "pass");
669        assert!(creds.account.is_none());
670
671        let messages = get_log_messages();
672        assert!(messages.iter().any(|(level, msg)| {
673            *level == Level::Info && msg.contains("Parsing .netrc string")
674        }));
675        assert!(messages.iter().any(|(level, msg)| {
676            *level == Level::Debug && msg.contains("Parsed machine name: example.com")
677        }));
678    }
679
680    // Tests parsing an entry with an account field
681    #[test]
682    fn parse_with_account() {
683        init_logger();
684        let input = "machine api.com login alice password secret account dev";
685        let netrc = Netrc::parse_from_str(input).unwrap();
686        let creds = netrc.get("api.com").unwrap();
687
688        assert_eq!(creds.account.as_deref(), Some("dev"));
689
690        let messages = get_log_messages();
691        assert!(
692            messages
693                .iter()
694                .any(|(level, msg)| *level == Level::Debug && msg.contains("Parsed account: dev"))
695        );
696    }
697
698    // Tests parsing a default entry
699    #[test]
700    fn parse_default_entry() {
701        init_logger();
702        let input = "default login guest password guess123";
703        let netrc = Netrc::parse_from_str(input).unwrap();
704        let creds = netrc.get("default").unwrap();
705
706        assert_eq!(creds.login, "guest");
707        assert_eq!(creds.password, "guess123");
708
709        let messages = get_log_messages();
710        assert!(
711            messages
712                .iter()
713                .any(|(level, msg)| *level == Level::Debug && msg.contains("Parsed key: default"))
714        );
715    }
716
717    // Tests parsing an entry with macdef and account
718    #[test]
719    fn parse_macdef_and_account() {
720        init_logger();
721        let input = r#"
722        machine internal login root password rootpass account admin
723        macdef init
724        echo Initializing connection...
725
726        "#;
727
728        let netrc = Netrc::parse_from_str(input).unwrap();
729        let creds = netrc.get("internal").unwrap();
730
731        assert_eq!(creds.account.as_deref(), Some("admin"));
732        assert!(creds.macdef.is_some());
733        assert!(
734            creds
735                .macdef
736                .as_ref()
737                .map(|m| m.contains("echo Initializing connection"))
738                .unwrap_or(false)
739        );
740
741        let messages = get_log_messages();
742        assert!(messages.iter().any(|(level, msg)| {
743            *level == Level::Debug && msg.contains("Parsing macdef: init")
744        }));
745    }
746
747    // Tests parsing an empty input
748    #[test]
749    fn empty_input_returns_empty_netrc() {
750        init_logger();
751        let netrc = Netrc::parse_from_str("").unwrap();
752        assert!(netrc.machines.is_empty());
753
754        let messages = get_log_messages();
755        assert!(messages.iter().any(|(level, msg)| {
756            *level == Level::Info && msg.contains("Successfully parsed .netrc with 0 machines")
757        }));
758    }
759
760    // Tests parsing an entry with missing login and password
761    #[test]
762    fn missing_login_password_fields() {
763        init_logger();
764        let input = "machine foo.com";
765        let netrc = Netrc::parse_from_str(input).unwrap();
766        let creds = netrc.get("foo.com").unwrap();
767        assert_eq!(creds.login, "");
768        assert_eq!(creds.password, "");
769
770        let messages = get_log_messages();
771        assert!(messages.iter().any(|(level, msg)| {
772            *level == Level::Warn && msg.contains("No login provided for machine: foo.com")
773        }));
774        assert!(messages.iter().any(|(level, msg)| {
775            *level == Level::Warn && msg.contains("No password provided for machine: foo.com")
776        }));
777    }
778
779    #[test]
780    fn parse_duplicate_machine_fails() {
781        init_logger();
782        let input = "machine example.com login user1 password pass1\nmachine example.com login user2 password pass2";
783        let result = Netrc::parse_from_str(input);
784        assert!(matches!(result, Err(NetrcError::DuplicateEntry(_))));
785    }
786
787    #[test]
788    fn parse_invalid_token_after_login() {
789        init_logger();
790        let input = "machine example.com login ";
791        let result = Netrc::parse_from_str(input);
792        assert!(matches!(result, Err(NetrcError::Parse { message: _, input: _ })));
793    }
794
795    #[test]
796    fn parse_multiple_machines() {
797        init_logger();
798        let input = "machine example.com login user1 password pass1\nmachine api.com login user2 password pass2";
799        let netrc = Netrc::parse_from_str(input).unwrap();
800        assert_eq!(netrc.machines.len(), 2);
801        assert!(netrc.get("example.com").is_some());
802        assert!(netrc.get("api.com").is_some());
803    }
804
805    #[test]
806    fn parse_whitespace_heavy_input() {
807        init_logger();
808        let input = "\t\n  machine   example.com  \n\t  login  \t user  \n  password  pass  \n";
809        let netrc = Netrc::parse_from_str(input).unwrap();
810        let creds = netrc.get("example.com").unwrap();
811        assert_eq!(creds.login, "user");
812        assert_eq!(creds.password, "pass");
813
814        let messages = get_log_messages();
815        assert!(
816            messages
817                .iter()
818                .any(|(level, msg)| *level == Level::Debug && msg.contains("Parsed login: user"))
819        );
820    }
821
822    #[test]
823    fn parse_empty_macdef() {
824        init_logger();
825        let input = "machine example.com login user password pass macdef init\n\n";
826        let netrc = Netrc::parse_from_str(input).unwrap();
827        let creds = netrc.get("example.com").unwrap();
828        assert_eq!(creds.macdef, Some("".to_string()));
829    }
830
831    #[test]
832    fn insert_and_update_machine() {
833        init_logger();
834        let mut netrc = Netrc::default();
835        let machine = NetrcMachine {
836            machine: "example.com".to_string(),
837            login: "user".to_string(),
838            password: "pass".to_string(),
839            account: None,
840            macdef: None,
841        };
842        netrc.insert_machine(machine.clone());
843        assert_eq!(netrc.get("example.com").unwrap().login, "user");
844
845        netrc.update_machine("example.com", |m| m.login = "new_user".to_string()).unwrap();
846        assert_eq!(netrc.get("example.com").unwrap().login, "new_user");
847
848        let result = netrc.update_machine("nonexistent.com", |_| {});
849        assert!(matches!(result, Err(NetrcError::NotFound(_))));
850
851        let messages = get_log_messages();
852        assert!(messages.iter().any(|(level, msg)| {
853            *level == Level::Info
854                && msg.contains("Inserting or replacing machine entry: example.com")
855        }));
856        assert!(messages.iter().any(|(level, msg)| {
857            *level == Level::Info && msg.contains("Updating machine entry: example.com")
858        }));
859        assert!(messages.iter().any(|(level, msg)| {
860            *level == Level::Error
861                && msg.contains("Failed to update machine entry: nonexistent.com")
862        }));
863    }
864
865    #[test]
866    fn remove_machine() {
867        init_logger();
868        let mut netrc = Netrc::default();
869        let machine = NetrcMachine {
870            machine: "example.com".to_string(),
871            login: "user".to_string(),
872            password: "pass".to_string(),
873            account: None,
874            macdef: None,
875        };
876        netrc.insert_machine(machine.clone());
877        let removed = netrc.remove_machine("example.com").unwrap();
878        assert_eq!(removed, machine);
879        assert!(netrc.get("example.com").is_none());
880        assert!(netrc.remove_machine("example.com").is_none());
881
882        let messages = get_log_messages();
883        assert!(messages.iter().any(|(level, msg)| {
884            *level == Level::Info && msg.contains("Removing machine entry: example.com")
885        }));
886        assert!(messages.iter().any(|(level, msg)| {
887            *level == Level::Debug
888                && msg.contains("Successfully removed machine entry: example.com")
889        }));
890        assert!(messages.iter().any(|(level, msg)| {
891            *level == Level::Warn && msg.contains("No machine entry found to remove: example.com")
892        }));
893    }
894
895    #[test]
896    fn serialize_to_json_and_toml() {
897        init_logger();
898        let mut netrc = Netrc::default();
899        let machine = NetrcMachine {
900            machine: "example.com".to_string(),
901            login: "user".to_string(),
902            password: "pass".to_string(),
903            account: Some("dev".to_string()),
904            macdef: None,
905        };
906        netrc.insert_machine(machine);
907
908        let json = netrc.to_json().unwrap();
909        assert!(json.contains(r#""machine": "example.com""#));
910        assert!(json.contains(r#""login": "user""#));
911
912        let toml = netrc.to_toml().unwrap();
913        assert!(toml.contains("machine = \"example.com\""));
914        assert!(toml.contains("login = \"user\""));
915
916        let messages = get_log_messages();
917        assert!(messages.iter().any(|(level, msg)| {
918            *level == Level::Info && msg.contains("Serializing .netrc to JSON")
919        }));
920        assert!(messages.iter().any(|(level, msg)| {
921            *level == Level::Info && msg.contains("Serializing .netrc to TOML")
922        }));
923    }
924
925    #[test]
926    fn round_trip_serialization() {
927        init_logger();
928        let mut netrc = Netrc::default();
929        let machine = NetrcMachine {
930            machine: "example.com".to_string(),
931            login: "user".to_string(),
932            password: "pass".to_string(),
933            account: Some("dev".to_string()),
934            macdef: Some("echo test".to_string()),
935        };
936        netrc.insert_machine(machine.clone());
937
938        let netrc_string = netrc.to_netrc_string();
939        let parsed_netrc = Netrc::parse_from_str(&netrc_string).unwrap();
940        assert_eq!(parsed_netrc.get("example.com").unwrap(), &machine);
941
942        let messages = get_log_messages();
943        assert!(messages.iter().any(|(level, msg)| {
944            *level == Level::Info && msg.contains("Serializing .netrc to string format")
945        }));
946        assert!(messages.iter().any(|(level, msg)| {
947            *level == Level::Info && msg.contains("Parsing .netrc string")
948        }));
949    }
950
951    #[test]
952    fn file_io_round_trip() {
953        init_logger();
954        let temp_file = std::env::temp_dir().join("test_netrc");
955        let mut netrc = Netrc::default();
956        let machine = NetrcMachine {
957            machine: "example.com".to_string(),
958            login: "user".to_string(),
959            password: "pass".to_string(),
960            account: None,
961            macdef: None,
962        };
963        netrc.insert_machine(machine.clone());
964
965        netrc.save_to_path(&temp_file).unwrap();
966        let loaded_netrc = Netrc::parse_from_path(&temp_file).unwrap();
967        assert_eq!(loaded_netrc.get("example.com").unwrap(), &machine);
968
969        std::fs::remove_file(&temp_file).unwrap();
970
971        let messages = get_log_messages();
972        assert!(messages.iter().any(|(level, msg)| {
973            *level == Level::Info && msg.contains("Saving .netrc to path")
974        }));
975        assert!(messages.iter().any(|(level, msg)| {
976            *level == Level::Info && msg.contains("Reading and parsing .netrc file")
977        }));
978    }
979
980    #[test]
981    fn parse_invalid_file_path() {
982        init_logger();
983        let result = Netrc::parse_from_path("/nonexistent/path/netrc");
984        assert!(matches!(result, Err(NetrcError::FileNotFound(_))));
985        let messages = get_log_messages();
986        assert!(messages.iter().any(|(level, msg)| {
987            *level == Level::Error && msg.contains("Failed to read metadata for")
988        }));
989    }
990
991    #[test]
992    fn parse_complex_macdef() {
993        init_logger();
994        let input = r#"
995    machine example.com login user password pass
996    macdef init
997    echo Starting...
998    sleep 1
999    echo Done
1000
1001    "#;
1002        let netrc = Netrc::parse_from_str(input).unwrap();
1003        let creds = netrc.get("example.com").unwrap();
1004        assert!(creds.macdef.is_some());
1005        let macdef = creds.macdef.as_ref().unwrap();
1006        assert!(macdef.contains("echo Starting..."));
1007        assert!(macdef.contains("sleep 1"));
1008        assert!(macdef.contains("echo Done"));
1009
1010        let messages = get_log_messages();
1011        assert!(messages.iter().any(|(level, msg)| {
1012            *level == Level::Debug && msg.contains("Parsed macdef content")
1013        }));
1014    }
1015
1016    #[test]
1017    fn parse_empty_machine_name() {
1018        init_logger();
1019        let input = "machine  login user password pass";
1020        let result = Netrc::parse_from_str(input);
1021        assert!(matches!(result, Err(NetrcError::Parse { message: _, input: _ })));
1022    }
1023
1024    #[test]
1025    fn test_logging() {
1026        init_logger();
1027        let input = "machine example.com login user password pass";
1028        let netrc = Netrc::parse_from_str(input).unwrap();
1029        netrc.to_json().unwrap();
1030        netrc.to_toml().unwrap();
1031        netrc.to_netrc_string();
1032        netrc.get("example.com").unwrap();
1033        netrc.get("nonexistent.com");
1034
1035        let temp_file = std::env::temp_dir().join("test_netrc_log");
1036        netrc.save_to_path(&temp_file).unwrap();
1037        let _ = Netrc::parse_from_path(&temp_file);
1038        std::fs::remove_file(&temp_file).unwrap();
1039
1040        let mut netrc = Netrc::default();
1041        let machine = NetrcMachine {
1042            machine: "test.com".to_string(),
1043            login: "test".to_string(),
1044            password: "test".to_string(),
1045            account: None,
1046            macdef: None,
1047        };
1048        netrc.insert_machine(machine.clone());
1049        netrc.update_machine("test.com", |m| m.login = "updated".to_string()).unwrap();
1050        netrc.remove_machine("test.com");
1051
1052        let messages = get_log_messages();
1053        assert!(messages.iter().any(|(level, msg)| {
1054            *level == Level::Info && msg.contains("Parsing .netrc string")
1055        }));
1056        assert!(messages.iter().any(|(level, msg)| {
1057            *level == Level::Info && msg.contains("Serializing .netrc to JSON")
1058        }));
1059        assert!(messages.iter().any(|(level, msg)| {
1060            *level == Level::Info && msg.contains("Serializing .netrc to TOML")
1061        }));
1062        assert!(messages.iter().any(|(level, msg)| {
1063            *level == Level::Info && msg.contains("Serializing .netrc to string format")
1064        }));
1065        assert!(messages.iter().any(|(level, msg)| {
1066            *level == Level::Debug && msg.contains("Retrieving machine entry for: example.com")
1067        }));
1068        assert!(messages.iter().any(|(level, msg)| {
1069            *level == Level::Warn && msg.contains("No machine entry found for: nonexistent.com")
1070        }));
1071        assert!(messages.iter().any(|(level, msg)| {
1072            *level == Level::Info && msg.contains("Saving .netrc to path")
1073        }));
1074        assert!(messages.iter().any(|(level, msg)| {
1075            *level == Level::Info && msg.contains("Reading and parsing .netrc file")
1076        }));
1077        assert!(messages.iter().any(|(level, msg)| {
1078            *level == Level::Info && msg.contains("Inserting or replacing machine entry: test.com")
1079        }));
1080        assert!(messages.iter().any(|(level, msg)| {
1081            *level == Level::Info && msg.contains("Updating machine entry: test.com")
1082        }));
1083        assert!(messages.iter().any(|(level, msg)| {
1084            *level == Level::Info && msg.contains("Removing machine entry: test.com")
1085        }));
1086    }
1087
1088    #[test]
1089    fn parse_macdef_with_trailing_whitespace() {
1090        init_logger();
1091        let input = "machine example.com login user password pass macdef init\n  \n";
1092        let netrc = Netrc::parse_from_str(input).unwrap();
1093        let creds = netrc.get("example.com").unwrap();
1094        assert_eq!(creds.macdef, Some("".to_string()));
1095    }
1096
1097    #[test]
1098    fn parse_macdef_with_multiple_empty_lines() {
1099        init_logger();
1100        let input = "machine example.com login user password pass macdef init\n\n\n";
1101        let netrc = Netrc::parse_from_str(input).unwrap();
1102        let creds = netrc.get("example.com").unwrap();
1103        assert_eq!(creds.macdef, Some("".to_string()));
1104    }
1105
1106    #[cfg(unix)]
1107    #[test]
1108    fn parse_file_with_insecure_permissions() {
1109        init_logger();
1110        let temp_file = std::env::temp_dir().join("test_netrc_perm");
1111        fs::write(&temp_file, "machine example.com login user password pass").unwrap();
1112        use std::os::unix::fs::PermissionsExt;
1113        fs::set_permissions(&temp_file, fs::Permissions::from_mode(0o666)).unwrap();
1114        let result = Netrc::parse_from_path(&temp_file);
1115        assert!(matches!(result, Err(NetrcError::InsecurePermissions)));
1116        let messages = get_log_messages();
1117        assert!(messages.iter().any(|(level, msg)| {
1118            *level == Level::Error && msg.contains("File permissions for")
1119        }));
1120        std::fs::remove_file(&temp_file).unwrap();
1121    }
1122}