Skip to main content

purple_ssh/
import.rs

1use std::io::BufRead;
2use std::path::Path;
3
4use log::{debug, info};
5
6use crate::quick_add;
7use crate::ssh_config::model::{HostEntry, SshConfigFile};
8
9/// Import hosts from a file with one `[user@]host[:port]` per line.
10/// Returns (imported, skipped, parse_failures, read_errors).
11pub fn import_from_file(
12    config: &mut SshConfigFile,
13    path: &Path,
14    group: Option<&str>,
15) -> Result<(usize, usize, usize, usize), String> {
16    info!("[config] Import started: source={}", path.display());
17    let file = std::fs::File::open(path)
18        .map_err(|e| crate::messages::import_open_failed(&path.display(), &e))?;
19    let reader = std::io::BufReader::new(file);
20
21    let mut read_errors = 0;
22    let mut parse_failures = 0;
23    let lines: Vec<String> = reader
24        .lines()
25        .filter_map(|r| match r {
26            Ok(line) => Some(line),
27            Err(_) => {
28                read_errors += 1;
29                None
30            }
31        })
32        .filter(|line| {
33            let trimmed = line.trim();
34            !trimmed.is_empty() && !trimmed.starts_with('#')
35        })
36        .collect();
37
38    let mut entries = Vec::new();
39    for line in &lines {
40        let trimmed = line.trim();
41        match quick_add::parse_target(trimmed) {
42            Ok(parsed) => {
43                let alias = parsed
44                    .hostname
45                    .split('.')
46                    .next()
47                    .unwrap_or(&parsed.hostname)
48                    .to_string();
49                // Skip entries whose derived alias is an SSH pattern (*, ?, [, !)
50                if crate::ssh_config::model::is_host_pattern(&alias) {
51                    parse_failures += 1;
52                    continue;
53                }
54                entries.push(HostEntry {
55                    alias,
56                    hostname: parsed.hostname,
57                    user: parsed.user,
58                    port: parsed.port,
59                    ..Default::default()
60                });
61            }
62            Err(_) => {
63                debug!("[config] Import: skipped unparseable line: {trimmed}");
64                parse_failures += 1;
65            }
66        }
67    }
68
69    let (imported, skipped) = add_entries(config, &entries, group)?;
70    info!("[config] Import completed: {imported} hosts added, {skipped} skipped");
71    Ok((imported, skipped, parse_failures, read_errors))
72}
73
74/// Count how many importable entries exist in ~/.ssh/known_hosts.
75/// Returns the count of parseable hostname entries, or 0 if the file
76/// doesn't exist or can't be read.
77pub fn count_known_hosts_candidates(paths: Option<&crate::runtime::env::Paths>) -> usize {
78    let Some(paths) = paths else {
79        return 0;
80    };
81    let known_hosts_path = paths.ssh_dir().join("known_hosts");
82    let file = match std::fs::File::open(&known_hosts_path) {
83        Ok(f) => f,
84        Err(_) => return 0,
85    };
86    let reader = std::io::BufReader::new(file);
87    reader
88        .lines()
89        .map_while(Result::ok)
90        .filter(|line| {
91            let trimmed = line.trim();
92            !trimmed.is_empty() && !trimmed.starts_with('#')
93        })
94        .filter(|line| matches!(parse_known_hosts_line(line), KnownHostResult::Parsed(_)))
95        .count()
96}
97
98/// Import hosts from ~/.ssh/known_hosts.
99/// Returns (imported, skipped, parse_failures, read_errors).
100pub fn import_from_known_hosts(
101    paths: Option<&crate::runtime::env::Paths>,
102    config: &mut SshConfigFile,
103    group: Option<&str>,
104) -> Result<(usize, usize, usize, usize), String> {
105    info!("[config] Import started: source=~/.ssh/known_hosts");
106    let known_hosts_path = paths
107        .ok_or(crate::messages::IMPORT_HOME_DIR_UNKNOWN)?
108        .ssh_dir()
109        .join("known_hosts");
110
111    if !known_hosts_path.exists() {
112        return Err(crate::messages::IMPORT_KNOWN_HOSTS_MISSING.to_string());
113    }
114
115    let file = std::fs::File::open(&known_hosts_path)
116        .map_err(|e| crate::messages::import_known_hosts_open_failed(&e))?;
117    let reader = std::io::BufReader::new(file);
118
119    let mut read_errors = 0;
120    let mut parse_failures = 0;
121    let lines: Vec<String> = reader
122        .lines()
123        .filter_map(|r| match r {
124            Ok(line) => Some(line),
125            Err(_) => {
126                read_errors += 1;
127                None
128            }
129        })
130        .filter(|line| {
131            let trimmed = line.trim();
132            !trimmed.is_empty() && !trimmed.starts_with('#')
133        })
134        .collect();
135
136    let mut entries = Vec::new();
137    for line in &lines {
138        match parse_known_hosts_line(line) {
139            KnownHostResult::Parsed(entry) => entries.push(entry),
140            KnownHostResult::Skipped => {} // Intentional skip (hashed, marker, IP-only, wildcard)
141            KnownHostResult::Failed => parse_failures += 1,
142        }
143    }
144
145    let (imported, skipped) = add_entries(config, &entries, group)?;
146    info!("[config] Import completed: {imported} hosts added, {skipped} skipped");
147    Ok((imported, skipped, parse_failures, read_errors))
148}
149
150/// Check if a hostname is a bare IP address (not an FQDN).
151fn is_bare_ip(host: &str) -> bool {
152    // IPv4: digits and dots only (e.g., "192.168.1.1")
153    if !host.is_empty() && host.chars().all(|c| c.is_ascii_digit() || c == '.') {
154        return true;
155    }
156    // IPv6: hex digits + colons + optional zone ID (e.g., "2001:db8::1", "fe80::1%en0")
157    let ipv6_part = host.split('%').next().unwrap_or(host);
158    ipv6_part.contains(':') && ipv6_part.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
159}
160
161/// Result of parsing a known_hosts line.
162#[allow(clippy::large_enum_variant)]
163enum KnownHostResult {
164    /// Successfully parsed into a HostEntry.
165    Parsed(HostEntry),
166    /// Intentionally skipped (hashed, marker, IP-only, wildcard).
167    Skipped,
168    /// Failed to parse (malformed line).
169    Failed,
170}
171
172/// Parse a single known_hosts line into a HostEntry.
173fn parse_known_hosts_line(line: &str) -> KnownHostResult {
174    let parts: Vec<&str> = line.split_whitespace().collect();
175    if parts.len() < 3 {
176        return KnownHostResult::Failed;
177    }
178
179    // Skip marker lines (@cert-authority, @revoked)
180    if parts[0].starts_with('@') {
181        return KnownHostResult::Skipped;
182    }
183    let host_part = parts[0];
184
185    // Skip hashed entries (start with |)
186    if host_part.starts_with('|') {
187        return KnownHostResult::Skipped;
188    }
189
190    // Pick first non-IP host from comma-separated list.
191    // known_hosts may have ip,hostname or hostname,ip pairs.
192    let host = host_part
193        .split(',')
194        .find(|entry| {
195            let bare = if entry.starts_with('[') {
196                entry
197                    .get(1..entry.find(']').unwrap_or(entry.len()))
198                    .unwrap_or(entry)
199            } else {
200                entry
201            };
202            !is_bare_ip(bare)
203        })
204        .unwrap_or_else(|| host_part.split(',').next().unwrap_or(host_part));
205
206    // Handle [host]:port format
207    let (hostname, port) = if host.starts_with('[') {
208        let Some(end) = host.find(']') else {
209            return KnownHostResult::Failed;
210        };
211        let h = &host[1..end];
212        let rest = &host[end + 1..];
213        let p = if rest.is_empty() {
214            22
215        } else if let Some(port_str) = rest.strip_prefix(':') {
216            if port_str.is_empty() {
217                return KnownHostResult::Failed; // [host]: with no port
218            }
219            match port_str.parse::<u16>() {
220                Ok(port) if port > 0 => port,
221                _ => return KnownHostResult::Failed,
222            }
223        } else {
224            return KnownHostResult::Failed; // [host]junk with no colon
225        };
226        (h.to_string(), p)
227    } else {
228        (host.to_string(), 22)
229    };
230
231    // Skip empty hostname
232    if hostname.is_empty() {
233        return KnownHostResult::Failed;
234    }
235
236    // Skip bare IP addresses (not FQDNs) before alias extraction.
237    if is_bare_ip(&hostname) {
238        return KnownHostResult::Skipped;
239    }
240
241    let alias = hostname.split('.').next().unwrap_or(&hostname).to_string();
242
243    // Skip wildcard/pattern entries
244    if crate::ssh_config::model::is_host_pattern(&alias) {
245        return KnownHostResult::Skipped;
246    }
247
248    KnownHostResult::Parsed(HostEntry {
249        alias,
250        hostname,
251        port,
252        ..Default::default()
253    })
254}
255
256/// Add entries to config, skipping exact alias duplicates.
257fn add_entries(
258    config: &mut SshConfigFile,
259    entries: &[HostEntry],
260    group: Option<&str>,
261) -> Result<(usize, usize), String> {
262    let mut imported = 0;
263    let mut skipped = 0;
264    let mut header_written = false;
265
266    for entry in entries {
267        if config.has_host(&entry.alias) {
268            skipped += 1;
269            continue;
270        }
271
272        // Write group header before the first actually-imported host
273        if let Some(group_name) = group.filter(|_| !header_written) {
274            if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
275                config
276                    .elements
277                    .push(crate::ssh_config::model::ConfigElement::GlobalLine(
278                        String::new(),
279                    ));
280            }
281            config
282                .elements
283                .push(crate::ssh_config::model::ConfigElement::GlobalLine(
284                    format!("# {}", group_name),
285                ));
286            header_written = true;
287        }
288
289        if group.is_some() && imported == 0 {
290            // Push first host directly after group comment (no blank separator between them)
291            let block = SshConfigFile::entry_to_block(entry);
292            config
293                .elements
294                .push(crate::ssh_config::model::ConfigElement::HostBlock(block));
295        } else {
296            config.add_host(entry);
297        }
298        imported += 1;
299    }
300
301    Ok((imported, skipped))
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    /// Every Host block must be preceded by a blank line or a top-level
309    /// group-header comment, matching add_host's separator guarantee.
310    fn assert_host_blocks_separated(output: &str) {
311        let lines: Vec<&str> = output.lines().collect();
312        for w in lines.windows(2) {
313            let (prev, cur) = (w[0], w[1]);
314            if cur.starts_with("Host ") {
315                let prev_is_blank = prev.trim().is_empty();
316                let prev_is_top_level_comment = prev.starts_with('#');
317                assert!(
318                    prev_is_blank || prev_is_top_level_comment,
319                    "Host block glued to previous content (no blank separator):\n{output}"
320                );
321            }
322        }
323    }
324
325    #[test]
326    fn test_import_group_hosts_blank_separated() {
327        // Importing several hosts under a group header must leave a blank line
328        // between every host (the first stays glued to the header by design,
329        // like provider sync), so imported blocks never run into one another.
330        let mut config = SshConfigFile {
331            elements: Vec::new(),
332            path: std::path::PathBuf::new(),
333            crlf: false,
334            bom: false,
335        };
336        let entries = vec![
337            HostEntry {
338                alias: "web".into(),
339                hostname: "1.1.1.1".into(),
340                ..Default::default()
341            },
342            HostEntry {
343                alias: "db".into(),
344                hostname: "2.2.2.2".into(),
345                ..Default::default()
346            },
347            HostEntry {
348                alias: "api".into(),
349                hostname: "3.3.3.3".into(),
350                ..Default::default()
351            },
352        ];
353        let (imported, skipped) = add_entries(&mut config, &entries, Some("Imported")).unwrap();
354        assert_eq!(imported, 3);
355        assert_eq!(skipped, 0);
356
357        let output = config.serialize();
358        assert_host_blocks_separated(&output);
359        assert!(!output.contains("\n\n\n"), "triple blank lines:\n{output}");
360    }
361
362    #[test]
363    fn test_parse_known_hosts_simple() {
364        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("example.com ssh-rsa AAAA...")
365        else {
366            panic!("expected Parsed");
367        };
368        assert_eq!(entry.hostname, "example.com");
369        assert_eq!(entry.alias, "example");
370        assert_eq!(entry.port, 22);
371    }
372
373    #[test]
374    fn test_parse_known_hosts_with_port() {
375        let KnownHostResult::Parsed(entry) =
376            parse_known_hosts_line("[myhost.com]:2222 ssh-ed25519 AAAA...")
377        else {
378            panic!("expected Parsed");
379        };
380        assert_eq!(entry.hostname, "myhost.com");
381        assert_eq!(entry.alias, "myhost");
382        assert_eq!(entry.port, 2222);
383    }
384
385    #[test]
386    fn test_parse_known_hosts_hashed() {
387        assert!(matches!(
388            parse_known_hosts_line("|1|abc=|def= ssh-rsa AAAA..."),
389            KnownHostResult::Skipped
390        ));
391    }
392
393    #[test]
394    fn test_parse_known_hosts_ip_only() {
395        assert!(matches!(
396            parse_known_hosts_line("192.168.1.1 ssh-rsa AAAA..."),
397            KnownHostResult::Skipped
398        ));
399    }
400
401    #[test]
402    fn test_parse_known_hosts_ipv6_skipped() {
403        // Bare IPv6 addresses should be skipped (hex digits + colons)
404        assert!(matches!(
405            parse_known_hosts_line("2001:db8::1 ssh-rsa AAAA..."),
406            KnownHostResult::Skipped
407        ));
408        assert!(matches!(
409            parse_known_hosts_line("fe80::1 ssh-ed25519 AAAA..."),
410            KnownHostResult::Skipped
411        ));
412    }
413
414    #[test]
415    fn test_parse_known_hosts_hex_hostname_not_skipped() {
416        // Pure hex hostnames without colons are valid hostnames, not IPs
417        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("deadbeef ssh-rsa AAAA...")
418        else {
419            panic!("expected Parsed");
420        };
421        assert_eq!(entry.alias, "deadbeef");
422
423        let KnownHostResult::Parsed(entry) =
424            parse_known_hosts_line("cafe.example.com ssh-rsa AAAA...")
425        else {
426            panic!("expected Parsed");
427        };
428        assert_eq!(entry.alias, "cafe");
429    }
430
431    #[test]
432    fn test_parse_known_hosts_invalid_port() {
433        // Non-numeric port
434        assert!(matches!(
435            parse_known_hosts_line("[myhost]:abc ssh-rsa AAAA..."),
436            KnownHostResult::Failed
437        ));
438        // Port out of u16 range
439        assert!(matches!(
440            parse_known_hosts_line("[myhost]:70000 ssh-rsa AAAA..."),
441            KnownHostResult::Failed
442        ));
443        // Port 0
444        assert!(matches!(
445            parse_known_hosts_line("[myhost]:0 ssh-rsa AAAA..."),
446            KnownHostResult::Failed
447        ));
448    }
449
450    #[test]
451    fn test_parse_known_hosts_comma_separated() {
452        let KnownHostResult::Parsed(entry) =
453            parse_known_hosts_line("myserver.com,192.168.1.1 ssh-ed25519 AAAA...")
454        else {
455            panic!("expected Parsed");
456        };
457        assert_eq!(entry.hostname, "myserver.com");
458        assert_eq!(entry.alias, "myserver");
459    }
460
461    #[test]
462    fn test_parse_known_hosts_malformed_is_failure() {
463        // Too few fields = parse failure
464        assert!(matches!(
465            parse_known_hosts_line("onlyhost ssh-rsa"),
466            KnownHostResult::Failed
467        ));
468        // Unclosed bracket = parse failure
469        assert!(matches!(
470            parse_known_hosts_line("[broken ssh-rsa AAAA..."),
471            KnownHostResult::Failed
472        ));
473    }
474
475    #[test]
476    fn test_parse_known_hosts_marker_is_skipped() {
477        assert!(matches!(
478            parse_known_hosts_line("@cert-authority *.example.com ssh-rsa AAAA..."),
479            KnownHostResult::Skipped
480        ));
481        assert!(matches!(
482            parse_known_hosts_line("@revoked host.com ssh-rsa AAAA..."),
483            KnownHostResult::Skipped
484        ));
485    }
486
487    #[test]
488    fn test_parse_known_hosts_numeric_first_label_not_skipped() {
489        // "123.example.com" has a numeric first label but is a valid FQDN, not an IP
490        let KnownHostResult::Parsed(entry) =
491            parse_known_hosts_line("123.example.com ssh-rsa AAAA...")
492        else {
493            panic!("expected Parsed");
494        };
495        assert_eq!(entry.hostname, "123.example.com");
496        assert_eq!(entry.alias, "123");
497    }
498
499    #[test]
500    fn test_parse_known_hosts_bracket_trailing_colon_fails() {
501        // [host]: with no port number should fail
502        assert!(matches!(
503            parse_known_hosts_line("[myhost]: ssh-rsa AAAA..."),
504            KnownHostResult::Failed
505        ));
506    }
507
508    #[test]
509    fn test_parse_known_hosts_bracket_junk_after_close_fails() {
510        // [host]junk with no colon separator should fail
511        assert!(matches!(
512            parse_known_hosts_line("[myhost]junk ssh-rsa AAAA..."),
513            KnownHostResult::Failed
514        ));
515    }
516
517    #[test]
518    fn test_parse_known_hosts_bracket_no_port() {
519        // [host] with no port should default to 22
520        let KnownHostResult::Parsed(entry) = parse_known_hosts_line("[myhost.com] ssh-rsa AAAA...")
521        else {
522            panic!("expected Parsed");
523        };
524        assert_eq!(entry.hostname, "myhost.com");
525        assert_eq!(entry.port, 22);
526    }
527
528    #[test]
529    fn test_parse_known_hosts_wildcard_is_skipped() {
530        assert!(matches!(
531            parse_known_hosts_line("*.example.com ssh-rsa AAAA..."),
532            KnownHostResult::Skipped
533        ));
534    }
535
536    #[test]
537    fn test_parse_known_hosts_bracket_pattern_skipped() {
538        // OpenSSH character class pattern [12] should be skipped
539        assert!(matches!(
540            parse_known_hosts_line("web[12].example.com ssh-rsa AAAA..."),
541            KnownHostResult::Skipped
542        ));
543    }
544
545    #[test]
546    fn test_parse_known_hosts_negation_pattern_skipped() {
547        assert!(matches!(
548            parse_known_hosts_line("!prod.example.com ssh-rsa AAAA..."),
549            KnownHostResult::Skipped
550        ));
551    }
552
553    #[test]
554    fn test_parse_known_hosts_ip_first_comma_picks_hostname() {
555        // When IP comes before hostname in comma list, hostname should still be used
556        let KnownHostResult::Parsed(entry) =
557            parse_known_hosts_line("192.0.2.10,web.example.com ssh-rsa AAAA...")
558        else {
559            panic!("expected Parsed");
560        };
561        assert_eq!(entry.hostname, "web.example.com");
562        assert_eq!(entry.alias, "web");
563    }
564
565    #[test]
566    fn test_parse_known_hosts_ipv6_first_comma_picks_hostname() {
567        let KnownHostResult::Parsed(entry) =
568            parse_known_hosts_line("2001:db8::1,server.example.com ssh-rsa AAAA...")
569        else {
570            panic!("expected Parsed");
571        };
572        assert_eq!(entry.hostname, "server.example.com");
573        assert_eq!(entry.alias, "server");
574    }
575
576    #[test]
577    fn test_parse_known_hosts_all_ips_comma_skipped() {
578        // If all comma entries are IPs, skip the whole line
579        assert!(matches!(
580            parse_known_hosts_line("192.0.2.10,10.0.0.1 ssh-rsa AAAA..."),
581            KnownHostResult::Skipped
582        ));
583    }
584
585    #[test]
586    fn test_parse_known_hosts_bracketed_ip_first_comma_picks_hostname() {
587        // [ip]:port,hostname format should pick the hostname
588        let KnownHostResult::Parsed(entry) =
589            parse_known_hosts_line("[192.0.2.10]:2222,web.example.com ssh-rsa AAAA...")
590        else {
591            panic!("expected Parsed");
592        };
593        assert_eq!(entry.hostname, "web.example.com");
594        assert_eq!(entry.alias, "web");
595    }
596
597    // =========================================================================
598    // Additional parse_known_hosts_line edge cases
599    // =========================================================================
600
601    #[test]
602    fn test_parse_known_hosts_empty_string() {
603        // Empty line should be filtered before parsing, but if it reaches the parser:
604        assert!(matches!(
605            parse_known_hosts_line(""),
606            KnownHostResult::Failed
607        ));
608    }
609
610    #[test]
611    fn test_parse_known_hosts_single_field() {
612        // Only one field, not enough for a valid known_hosts line
613        assert!(matches!(
614            parse_known_hosts_line("example.com"),
615            KnownHostResult::Failed
616        ));
617    }
618
619    #[test]
620    fn test_parse_known_hosts_hostname_with_hyphen() {
621        let KnownHostResult::Parsed(entry) =
622            parse_known_hosts_line("my-server.example.com ssh-rsa AAAA...")
623        else {
624            panic!("expected Parsed");
625        };
626        assert_eq!(entry.hostname, "my-server.example.com");
627        assert_eq!(entry.alias, "my-server");
628    }
629
630    #[test]
631    fn test_parse_known_hosts_multiple_hostnames_comma() {
632        // Two non-IP hostnames: first one should be picked
633        let KnownHostResult::Parsed(entry) =
634            parse_known_hosts_line("primary.example.com,secondary.example.com ssh-rsa AAAA...")
635        else {
636            panic!("expected Parsed");
637        };
638        assert_eq!(entry.hostname, "primary.example.com");
639        assert_eq!(entry.alias, "primary");
640    }
641
642    #[test]
643    fn test_parse_known_hosts_ipv6_zone_id_skipped() {
644        // IPv6 with zone ID should be detected as bare IP and skipped
645        assert!(matches!(
646            parse_known_hosts_line("fe80::1%eth0 ssh-rsa AAAA..."),
647            KnownHostResult::Skipped
648        ));
649    }
650
651    #[test]
652    fn test_parse_known_hosts_question_mark_pattern_skipped() {
653        // ? is a pattern character in SSH
654        assert!(matches!(
655            parse_known_hosts_line("web?.example.com ssh-rsa AAAA..."),
656            KnownHostResult::Skipped
657        ));
658    }
659
660    // =========================================================================
661    // Import results and status message formatting
662    // =========================================================================
663
664    #[test]
665    fn test_import_status_pluralization() {
666        // Verify the exact format strings used in handler.rs
667        let fmt = |imported: usize, skipped: usize| -> String {
668            format!(
669                "Imported {} host{}, skipped {} duplicate{}",
670                imported,
671                if imported == 1 { "" } else { "s" },
672                skipped,
673                if skipped == 1 { "" } else { "s" },
674            )
675        };
676        assert_eq!(fmt(1, 0), "Imported 1 host, skipped 0 duplicates");
677        assert_eq!(fmt(1, 1), "Imported 1 host, skipped 1 duplicate");
678        assert_eq!(fmt(5, 0), "Imported 5 hosts, skipped 0 duplicates");
679        assert_eq!(fmt(5, 3), "Imported 5 hosts, skipped 3 duplicates");
680        assert_eq!(fmt(0, 5), "Imported 0 hosts, skipped 5 duplicates");
681    }
682
683    #[test]
684    fn test_import_all_duplicates_message() {
685        let msg_single = if 1 == 1 {
686            "Host already exists".to_string()
687        } else {
688            format!("All {} hosts already exist", 1)
689        };
690        assert_eq!(msg_single, "Host already exists");
691
692        let msg_multi = if 5 == 1 {
693            "Host already exists".to_string()
694        } else {
695            format!("All {} hosts already exist", 5)
696        };
697        assert_eq!(msg_multi, "All 5 hosts already exist");
698    }
699
700    // =========================================================================
701    // import_from_known_hosts with in-memory config
702    // =========================================================================
703
704    #[test]
705    fn test_import_from_known_hosts_adds_to_config() {
706        // Create a temporary known_hosts-style file and import via import_from_file
707        let dir = std::env::temp_dir().join(format!(
708            "purple_test_import_{:?}",
709            std::thread::current().id()
710        ));
711        let _ = std::fs::remove_dir_all(&dir);
712        std::fs::create_dir_all(&dir).unwrap();
713
714        let hosts_file = dir.join("hosts.txt");
715        std::fs::write(&hosts_file, "web.example.com\ndb.example.com\n").unwrap();
716
717        let mut config = SshConfigFile {
718            elements: Vec::new(),
719            path: dir.join("config"),
720            crlf: false,
721            bom: false,
722        };
723
724        let result = import_from_file(&mut config, &hosts_file, Some("test-import"));
725        assert!(result.is_ok());
726        let (imported, skipped, _, _) = result.unwrap();
727        assert_eq!(imported, 2);
728        assert_eq!(skipped, 0);
729
730        // Verify hosts are in config
731        assert!(config.has_host("web"));
732        assert!(config.has_host("db"));
733
734        let _ = std::fs::remove_dir_all(&dir);
735    }
736
737    #[test]
738    fn test_import_skips_duplicates() {
739        let dir = std::env::temp_dir().join(format!(
740            "purple_test_import_dup_{:?}",
741            std::thread::current().id()
742        ));
743        let _ = std::fs::remove_dir_all(&dir);
744        std::fs::create_dir_all(&dir).unwrap();
745
746        let hosts_file = dir.join("hosts.txt");
747        std::fs::write(&hosts_file, "web.example.com\n").unwrap();
748
749        let mut config = SshConfigFile {
750            elements: Vec::new(),
751            path: dir.join("config"),
752            crlf: false,
753            bom: false,
754        };
755
756        // First import
757        let (imported, _, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
758        assert_eq!(imported, 1);
759
760        // Second import - should be all duplicates
761        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
762        assert_eq!(imported, 0);
763        assert_eq!(skipped, 1);
764
765        let _ = std::fs::remove_dir_all(&dir);
766    }
767
768    #[test]
769    fn test_import_from_file_nonexistent() {
770        let mut config = SshConfigFile {
771            elements: Vec::new(),
772            path: std::path::PathBuf::from("/dev/null"),
773            crlf: false,
774            bom: false,
775        };
776        let result = import_from_file(&mut config, Path::new("/nonexistent/file"), None);
777        assert!(result.is_err());
778    }
779
780    #[test]
781    fn test_import_empty_file() {
782        let dir = std::env::temp_dir().join(format!(
783            "purple_test_import_empty_{:?}",
784            std::thread::current().id()
785        ));
786        let _ = std::fs::remove_dir_all(&dir);
787        std::fs::create_dir_all(&dir).unwrap();
788
789        let hosts_file = dir.join("hosts.txt");
790        std::fs::write(&hosts_file, "").unwrap();
791
792        let mut config = SshConfigFile {
793            elements: Vec::new(),
794            path: dir.join("config"),
795            crlf: false,
796            bom: false,
797        };
798
799        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
800        assert_eq!(imported, 0);
801        assert_eq!(skipped, 0);
802
803        let _ = std::fs::remove_dir_all(&dir);
804    }
805
806    #[test]
807    fn test_import_comments_and_blanks_only() {
808        let dir = std::env::temp_dir().join(format!(
809            "purple_test_import_comments_{:?}",
810            std::thread::current().id()
811        ));
812        let _ = std::fs::remove_dir_all(&dir);
813        std::fs::create_dir_all(&dir).unwrap();
814
815        let hosts_file = dir.join("hosts.txt");
816        std::fs::write(&hosts_file, "# comment\n\n# another\n").unwrap();
817
818        let mut config = SshConfigFile {
819            elements: Vec::new(),
820            path: dir.join("config"),
821            crlf: false,
822            bom: false,
823        };
824
825        let (imported, skipped, _, _) = import_from_file(&mut config, &hosts_file, None).unwrap();
826        assert_eq!(imported, 0);
827        assert_eq!(skipped, 0);
828
829        let _ = std::fs::remove_dir_all(&dir);
830    }
831
832    #[test]
833    fn test_is_bare_ip() {
834        assert!(is_bare_ip("192.168.1.1"));
835        assert!(is_bare_ip("10.0.0.1"));
836        assert!(is_bare_ip("2001:db8::1"));
837        assert!(is_bare_ip("fe80::1"));
838        assert!(is_bare_ip("fe80::1%en0"));
839        assert!(is_bare_ip("fe80::1%eth0"));
840        assert!(!is_bare_ip("example.com"));
841        assert!(!is_bare_ip("123.example.com"));
842        assert!(!is_bare_ip("deadbeef"));
843        assert!(!is_bare_ip(""));
844    }
845}