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