Skip to main content

nornir_rs/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2//! Core types for describing Kore inventory models and transports.
3//!
4//! This crate is intentionally opinionated: every public structure mirrors the
5//! flat data that Nornir (Python) consumes, so loaders, CLIs, and programmatic
6//! callers can stitch together inventories without juggling bespoke schemas.
7//! Whether the inventory originates from TOML files, HTTP APIs, or an adhoc
8//! in-memory fixture, the goal is the same—normalize data into these structs,
9//! merge them, and let higher layers run tasks with minimal ceremony.
10//!
11//! The types in this module power both the CLI binary shipped in this repo and
12//! any downstream applications that want to embed Kore as a library.
13
14pub mod builder;
15pub mod cli;
16pub mod collections;
17pub mod config;
18pub mod devices;
19pub mod filter;
20pub mod getters;
21pub mod inventory;
22pub mod nornir;
23pub mod pipeline;
24pub mod render;
25pub mod sdk;
26pub mod secrets;
27pub mod tasks;
28pub mod textfsm;
29pub mod transport;
30pub mod util;
31pub mod vars;
32pub mod vault;
33
34use crate::util::env::{self, InterpolationMeta};
35use clap::ValueEnum;
36use indexmap::IndexMap;
37use reqwest::Url;
38use serde::{
39    Deserialize, Serialize,
40    de::{self, DeserializeOwned, Deserializer, Error as DeError},
41};
42use std::{
43    collections::HashMap,
44    fmt, fs,
45    path::{Path, PathBuf},
46    time::Duration,
47};
48
49/// CLI session mode requested by the task runner.
50#[derive(Debug, Clone, Copy, ValueEnum)]
51pub enum CliMode {
52    /// Stay in exec/user mode.
53    Exec,
54    /// Elevate to enable/privileged mode.
55    Enable,
56    /// Enter configuration mode.
57    Config,
58}
59
60impl<'de> serde::Deserialize<'de> for CliMode {
61    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62    where
63        D: Deserializer<'de>,
64    {
65        let raw = String::deserialize(deserializer)?;
66        CliMode::from_str(&raw, true).map_err(DeError::custom)
67    }
68}
69
70/// Generic key/value bag for metadata and user-defined variables.
71///
72/// Each [`Host`], [`Group`], and [`Defaults`] entry exposes a `data: Variables`
73/// field so callers can stash arbitrary typed metadata alongside strongly-typed
74/// credentials and connection parameters. The values gracefully accept any
75/// serde-compatible JSON data, allowing hierarchies such as:
76///
77/// ```json
78/// {
79///   "site": "dc1",
80///   "region": { "country": "US", "metro": "NYC" },
81///   "maintenance": { "window": "sunday-night", "ticket": 1234 }
82/// }
83/// ```
84///
85/// These variables surface inside filters (`--filter data__site=dc1`) and are
86/// preserved verbatim when inventories are serialized and reloaded.
87pub type Variables = HashMap<String, serde_json::Value>;
88
89/// Order-preserving map used for host/group inventories.
90pub type OrderedMap<K, V> = IndexMap<K, V>;
91/// Hosts keyed by name with deterministic iteration order.
92pub type HostMap = OrderedMap<String, Host>;
93/// Groups keyed by name with deterministic iteration order.
94pub type GroupMap = OrderedMap<String, Group>;
95
96/// Helper enum that lets hosts be specified as either a mapping or a sequence.
97#[derive(Deserialize)]
98#[serde(untagged)]
99enum HostEntries {
100    Map(HostMap),
101    Seq(Vec<Host>),
102}
103
104/// Normalizes host entries by ensuring every host has a name and storing them
105/// in a map keyed by name.
106fn normalize_host_entries(entries: HostEntries) -> Result<HostMap, String> {
107    match entries {
108        HostEntries::Map(map) => {
109            let mut normalized = HostMap::new();
110            for (key, mut host) in map {
111                if host.name.is_empty() {
112                    host.name = key.clone();
113                }
114                if host.name.is_empty() {
115                    return Err("host entry missing name".into());
116                }
117                normalized.insert(host.name.clone(), host);
118            }
119            Ok(normalized)
120        }
121        HostEntries::Seq(list) => {
122            let mut normalized = HostMap::new();
123            for host in list {
124                if host.name.is_empty() {
125                    return Err("host entry missing name".into());
126                }
127                normalized.insert(host.name.clone(), host);
128            }
129            Ok(normalized)
130        }
131    }
132}
133
134/// Custom deserializer that accepts hosts as either map or list form.
135fn hosts_from_any<'de, D>(deserializer: D) -> Result<HostMap, D::Error>
136where
137    D: Deserializer<'de>,
138{
139    let entries = Option::<HostEntries>::deserialize(deserializer)?;
140    match entries {
141        Some(inner) => normalize_host_entries(inner).map_err(de::Error::custom),
142        None => Ok(HostMap::new()),
143    }
144}
145
146/// A fully materialized view of the network inventory.
147///
148/// [`Inventory`] is the lingua franca between every loader and consumer. File-,
149/// HTTP-, or inline-based sources hydrate an `Inventory`, the builder merges
150/// them, and finally executors read the resolved hosts/groups/defaults to wire
151/// connections. The structure favors a flat, predictable layout so callers can
152/// serialize it to YAML/JSON/TOML or manipulate it directly in Rust.
153///
154/// # Examples
155/// Build a single-host inventory in-memory:
156///
157/// ```
158/// use nornir_rs::{Host, Inventory};
159///
160/// let mut inventory = Inventory::default();
161/// inventory.hosts.insert(
162///     "rtr1".to_string(),
163///     Host {
164///         name: "rtr1".into(),
165///         hostname: Some("10.0.0.1".into()),
166///         platform: Some("iosxe".into()),
167///         ..Host::default()
168///     },
169/// );
170///
171/// assert_eq!(inventory.host("rtr1").unwrap().hostname.as_deref(), Some("10.0.0.1"));
172/// ```
173///
174/// Combine groups/defaults with host-specific overrides:
175///
176/// ```
177/// # use nornir_rs::{
178/// #     Credentials, Defaults, Group, Host, Inventory, InventoryBuilder, Secret, StaticInventorySource
179/// # };
180/// let mut inventory = Inventory::default();
181/// inventory.defaults = Defaults {
182///     credentials: Some(Credentials {
183///         username: Some("netops".into()),
184///         password: Some(Secret::new("hunter2")),
185///         ..Credentials::default()
186///     }),
187///     ..Defaults::default()
188/// };
189/// inventory.groups.insert(
190///     "core".into(),
191///     Group {
192///         name: "core".into(),
193///         parents: vec![],
194///         data: [("role".into(), serde_json::json!("core-router"))]
195///             .into_iter()
196///             .collect(),
197///         ..Group::default()
198///     },
199/// );
200/// inventory.hosts.insert(
201///     "core-rt".into(),
202///     Host {
203///         name: "core-rt".into(),
204///         hostname: Some("198.51.100.10".into()),
205///         groups: vec!["core".into()],
206///         ..Host::default()
207///     },
208/// );
209///
210/// let merged = InventoryBuilder::new()
211///     .with_source(StaticInventorySource::new("inline", inventory))
212///     .build()
213///     .unwrap();
214/// assert_eq!(
215///     merged.host("core-rt").unwrap().credentials.as_ref().unwrap().username.as_deref(),
216///     Some("netops")
217/// );
218/// ```
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct Inventory {
221    /// All hosts keyed by logical name.
222    #[serde(default, deserialize_with = "hosts_from_any")]
223    pub hosts: HostMap,
224    /// All groups keyed by logical name.
225    #[serde(default)]
226    pub groups: GroupMap,
227    /// Defaults applied when hosts and groups omit a field.
228    #[serde(default)]
229    pub defaults: Defaults,
230}
231
232impl Inventory {
233    /// Returns a host by name if it exists.
234    ///
235    /// Many helper APIs (filters, runners, tests) call this to grab a single
236    /// host record after the builder has merged/normalized sources.
237    pub fn host(&self, name: &str) -> Option<&Host> {
238        self.hosts.get(name)
239    }
240
241    /// Layer another inventory on top of this one.
242    ///
243    /// Hosts, groups, and defaults are merged in place using intuitive
244    /// precedence rules:
245    ///
246    /// * Hosts with the same name are merged field-by-field, so overriding
247    ///   structures can selectively replace platform/connection details without
248    ///   losing group membership.
249    /// * Groups inherit parents and merged credentials/metadata when duplicates
250    ///   appear.
251    /// * Defaults act as the final fallback; the last merge wins.
252    ///
253    /// This mirrors how Nornir composes multiple SimpleInventory directories.
254    pub fn merge(&mut self, other: Inventory) {
255        for (name, host) in other.hosts {
256            match self.hosts.get_mut(&name) {
257                Some(existing) => existing.merge(host),
258                None => {
259                    self.hosts.insert(name, host);
260                }
261            }
262        }
263
264        for (name, group) in other.groups {
265            match self.groups.get_mut(&name) {
266                Some(existing) => existing.merge(group),
267                None => {
268                    self.groups.insert(name, group);
269                }
270            }
271        }
272
273        self.defaults.merge(other.defaults);
274    }
275
276    /// Applies defaults/groups to every host so downstream consumers see the
277    /// fully merged view.
278    fn apply_inheritance(&mut self) {
279        let defaults = self.defaults.clone();
280        let groups = self.groups.clone();
281        for host in self.hosts.values_mut() {
282            let original_creds = host.credentials.clone();
283            let original_data = host.data.clone();
284            let original_connections = host.connections.clone();
285
286            apply_defaults(&defaults, host);
287            apply_group_layers(&groups, host);
288
289            if let Some(creds) = original_creds {
290                match host.credentials.as_mut() {
291                    Some(existing) => existing.merge(creds),
292                    None => host.credentials = Some(creds),
293                }
294            }
295            merge_connections(&mut host.connections, original_connections);
296            merge_variables(&mut host.data, original_data);
297        }
298    }
299}
300
301/// Canonical representation of a network endpoint.
302///
303/// Hosts are intentionally lightweight: name, optional hostname, optional
304/// platform, and a handful of connection/metadata fields. Groups/defaults fill
305/// in missing values so this struct always reflects the fully merged view seen
306/// by filters and task runners.
307#[derive(Debug, Clone, Default, Serialize, Deserialize)]
308pub struct Host {
309    /// Logical host name.
310    #[serde(default)]
311    pub name: String,
312    /// Reachable address or FQDN.
313    pub hostname: Option<String>,
314    /// Operating system or platform identifier.
315    pub platform: Option<String>,
316    /// TCP port to use when connecting.
317    pub port: Option<u16>,
318    /// Group names to inherit from.
319    #[serde(default)]
320    pub groups: Vec<String>,
321    /// Inline credential overrides.
322    pub credentials: Option<Credentials>,
323    /// Transport connection settings keyed by transport id.
324    #[serde(default)]
325    pub connections: HashMap<String, TransportSettings>,
326    /// User-defined metadata.
327    #[serde(default)]
328    pub data: Variables,
329    /// Optional config capture overrides.
330    pub config_store: Option<ConfigStoreOverride>,
331}
332
333impl Host {
334    /// Merges another host (typically defaults or group material) into this one.
335    fn merge(&mut self, other: Host) {
336        if self.name.is_empty() {
337            self.name = other.name;
338        }
339        if other.hostname.is_some() {
340            self.hostname = other.hostname;
341        }
342        if other.platform.is_some() {
343            self.platform = other.platform;
344        }
345        if other.port.is_some() {
346            self.port = other.port;
347        }
348        for group in other.groups {
349            if !self.groups.contains(&group) {
350                self.groups.push(group);
351            }
352        }
353        if let Some(creds) = other.credentials {
354            match self.credentials.as_mut() {
355                Some(existing) => existing.merge(creds),
356                None => self.credentials = Some(creds),
357            }
358        }
359        merge_connections(&mut self.connections, other.connections);
360        merge_variables(&mut self.data, other.data);
361        merge_config_store(&mut self.config_store, other.config_store);
362    }
363}
364
365/// Per-host overrides when capturing configuration files (command, suffix, etc.).
366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
367pub struct ConfigStoreOverride {
368    pub command: Option<String>,
369    pub default_command: Option<String>,
370}
371
372impl ConfigStoreOverride {
373    fn merge(&mut self, other: ConfigStoreOverride) {
374        if other.command.is_some() {
375            self.command = other.command;
376        }
377        if other.default_command.is_some() {
378            self.default_command = other.default_command;
379        }
380    }
381}
382
383/// Applies an incoming config-store override onto an existing option.
384fn merge_config_store(
385    target: &mut Option<ConfigStoreOverride>,
386    incoming: Option<ConfigStoreOverride>,
387) {
388    if let Some(incoming) = incoming {
389        match target {
390            Some(existing) => existing.merge(incoming),
391            None => *target = Some(incoming),
392        }
393    }
394}
395
396/// Applies a [`Defaults`] record to the host.
397fn apply_defaults(defaults: &Defaults, host: &mut Host) {
398    if let Some(creds) = defaults.credentials.clone() {
399        match host.credentials.as_mut() {
400            Some(existing) => existing.merge(creds),
401            None => host.credentials = Some(creds),
402        }
403    }
404    merge_connections(&mut host.connections, defaults.connections.clone());
405    merge_variables(&mut host.data, defaults.data.clone());
406    merge_config_store(&mut host.config_store, defaults.config_store.clone());
407}
408
409/// Walks the group hierarchy and applies every group's settings to the host.
410fn apply_group_layers(groups: &GroupMap, host: &mut Host) {
411    let mut visited = std::collections::HashSet::new();
412    for group_name in host.groups.clone() {
413        apply_group_recursive(host, &group_name, groups, &mut visited);
414    }
415}
416
417/// Recursively applies a group's parents before merging the group's data.
418fn apply_group_recursive(
419    host: &mut Host,
420    name: &str,
421    groups: &GroupMap,
422    visited: &mut std::collections::HashSet<String>,
423) {
424    if !visited.insert(name.to_string()) {
425        return;
426    }
427    let Some(group) = groups.get(name) else {
428        return;
429    };
430
431    for parent in &group.parents {
432        apply_group_recursive(host, parent, groups, visited);
433    }
434
435    if let Some(creds) = group.credentials.clone() {
436        match host.credentials.as_mut() {
437            Some(existing) => existing.merge(creds),
438            None => host.credentials = Some(creds),
439        }
440    }
441    merge_connections(&mut host.connections, group.connections.clone());
442    merge_variables(&mut host.data, group.data.clone());
443    merge_config_store(&mut host.config_store, group.config_store.clone());
444}
445
446/// Collection of hosts that share credentials, connection tuning, or metadata.
447///
448/// Groups can inherit from parent groups (think: hierarchy of "dc" → "pod" →
449/// "role") and every host listed in `groups` automatically benefits from those
450/// shared settings. This is how you implement “all edge routers use this SSH
451/// key” or “everything in dc1 has `data.region = us-east`”.
452#[derive(Debug, Clone, Default, Serialize, Deserialize)]
453pub struct Group {
454    /// Logical group name.
455    #[serde(default)]
456    pub name: String,
457    /// Parent group names to inherit from.
458    #[serde(default)]
459    pub parents: Vec<String>,
460    /// Shared credential overrides.
461    pub credentials: Option<Credentials>,
462    /// Shared transport options keyed by transport id.
463    #[serde(default)]
464    pub connections: HashMap<String, TransportSettings>,
465    /// Arbitrary metadata.
466    #[serde(default)]
467    pub data: Variables,
468    /// Optional config capture overrides.
469    pub config_store: Option<ConfigStoreOverride>,
470}
471
472impl Group {
473    fn merge(&mut self, other: Group) {
474        if self.name.is_empty() {
475            self.name = other.name;
476        }
477        for parent in other.parents {
478            if !self.parents.contains(&parent) {
479                self.parents.push(parent);
480            }
481        }
482        if let Some(creds) = other.credentials {
483            match self.credentials.as_mut() {
484                Some(existing) => existing.merge(creds),
485                None => self.credentials = Some(creds),
486            }
487        }
488        merge_connections(&mut self.connections, other.connections);
489        merge_variables(&mut self.data, other.data);
490        merge_config_store(&mut self.config_store, other.config_store);
491    }
492}
493
494/// Global fallbacks applied to every host after groups inherit.
495///
496/// In practice this is where you place organization-wide credentials,
497/// connection timeouts, or metadata such as `owner`, `environment`, or
498/// `ticketing_queue`. Hosts and groups can still override fields; defaults are
499/// only consulted when the more specific layers leave them empty.
500#[derive(Debug, Clone, Default, Serialize, Deserialize)]
501pub struct Defaults {
502    /// Baseline credentials.
503    pub credentials: Option<Credentials>,
504    /// Baseline connection settings per transport.
505    #[serde(default)]
506    pub connections: HashMap<String, TransportSettings>,
507    /// Baseline metadata.
508    #[serde(default)]
509    pub data: Variables,
510    /// Optional config capture overrides applied to all hosts.
511    pub config_store: Option<ConfigStoreOverride>,
512}
513
514impl Defaults {
515    fn merge(&mut self, other: Defaults) {
516        if let Some(creds) = other.credentials {
517            match self.credentials.as_mut() {
518                Some(existing) => existing.merge(creds),
519                None => self.credentials = Some(creds),
520            }
521        }
522        merge_connections(&mut self.connections, other.connections);
523        merge_variables(&mut self.data, other.data);
524    }
525}
526
527/// Authentication material shared by hosts, groups, and defaults.
528///
529/// All fields are optional to allow layered overrides. For example, defaults
530/// can supply the username while a specific host overrides the private key.
531#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct Credentials {
533    /// Login username.
534    pub username: Option<String>,
535    /// Password or passphrase.
536    pub password: Option<Secret>,
537    /// Private key material or reference.
538    pub private_key: Option<Secret>,
539    /// Enable-mode secret used for privilege escalation (e.g., Cisco `enable`).
540    pub enable_secret: Option<Secret>,
541}
542
543impl Credentials {
544    fn merge(&mut self, other: Credentials) {
545        if other.username.is_some() {
546            self.username = other.username;
547        }
548        if other.password.is_some() {
549            self.password = other.password;
550        }
551        if other.private_key.is_some() {
552            self.private_key = other.private_key;
553        }
554        if other.enable_secret.is_some() {
555            self.enable_secret = other.enable_secret;
556        }
557    }
558}
559
560/// Basic representation of sensitive text.
561///
562/// Secrets are intentionally lightweight. They exist to make field intent
563/// explicit while still allowing serde to (de)serialize them. Consumers are
564/// free to encrypt the contents before serialization and set `encrypted = true`
565/// so downstream tooling can decide whether/how to decrypt them.
566
567#[derive(Debug, Clone, Serialize, Default)]
568pub struct Secret {
569    /// Cleartext or encrypted payload; callers decide how to manage it.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub value: Option<String>,
572    /// Whether the value still requires decryption.
573    #[serde(default)]
574    pub encrypted: bool,
575    /// Optional reference resolved via a secrets provider.
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub reference: Option<SecretReference>,
578}
579
580impl Secret {
581    /// Creates a new plaintext secret.
582    pub fn new(value: impl Into<String>) -> Self {
583        Self {
584            value: Some(value.into()),
585            encrypted: false,
586            reference: None,
587        }
588    }
589
590    /// Returns the inline value when present.
591    pub fn as_str(&self) -> Option<&str> {
592        self.value.as_deref()
593    }
594}
595
596#[derive(Deserialize)]
597#[serde(untagged)]
598enum SecretRepr {
599    String(String),
600    Map(SecretFields),
601}
602
603#[derive(Deserialize, Default)]
604struct SecretFields {
605    #[serde(default)]
606    value: Option<String>,
607    #[serde(default)]
608    encrypted: bool,
609    #[serde(rename = "ref")]
610    reference_key: Option<String>,
611    #[serde(default)]
612    provider: Option<String>,
613    #[serde(default)]
614    optional: Option<bool>,
615    #[serde(default)]
616    reference: Option<SecretReferenceRepr>,
617}
618
619#[derive(Deserialize)]
620#[serde(untagged)]
621enum SecretReferenceRepr {
622    String(String),
623    Map(SecretReferenceFields),
624}
625
626#[derive(Deserialize, Default)]
627struct SecretReferenceFields {
628    #[serde(rename = "ref")]
629    key_ref: Option<String>,
630    #[serde(default)]
631    key: Option<String>,
632    #[serde(default)]
633    provider: Option<String>,
634    #[serde(default)]
635    optional: Option<bool>,
636}
637
638impl SecretReferenceRepr {
639    fn into_reference(self) -> Result<SecretReference, String> {
640        match self {
641            SecretReferenceRepr::String(key) => Ok(SecretReference {
642                provider: None,
643                key,
644                optional: false,
645            }),
646            SecretReferenceRepr::Map(fields) => {
647                let key = fields
648                    .key
649                    .or(fields.key_ref)
650                    .ok_or_else(|| "reference requires a key".to_string())?;
651                Ok(SecretReference {
652                    provider: fields.provider,
653                    key,
654                    optional: fields.optional.unwrap_or(false),
655                })
656            }
657        }
658    }
659}
660
661impl<'de> Deserialize<'de> for Secret {
662    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
663    where
664        D: Deserializer<'de>,
665    {
666        match SecretRepr::deserialize(deserializer)? {
667            SecretRepr::String(value) => Ok(Secret {
668                value: Some(value),
669                encrypted: false,
670                reference: None,
671            }),
672            SecretRepr::Map(fields) => {
673                let mut reference = if let Some(raw) = fields.reference {
674                    Some(raw.into_reference().map_err(de::Error::custom)?)
675                } else {
676                    None
677                };
678                if reference.is_none() {
679                    if let Some(key) = fields.reference_key.clone() {
680                        reference = Some(SecretReference {
681                            provider: fields.provider.clone(),
682                            key,
683                            optional: fields.optional.unwrap_or(false),
684                        });
685                    }
686                } else if let Some(ref mut reference) = reference {
687                    if reference.provider.is_none() {
688                        reference.provider = fields.provider.clone();
689                    }
690                    if let Some(optional) = fields.optional {
691                        reference.optional = optional;
692                    }
693                }
694                if fields.provider.is_some() && reference.is_none() {
695                    return Err(de::Error::custom(
696                        "secret provider requires a matching ref".to_string(),
697                    ));
698                }
699                if fields.value.is_none() && reference.is_none() {
700                    return Err(de::Error::custom(
701                        "secret requires either value or ref".to_string(),
702                    ));
703                }
704                Ok(Secret {
705                    value: fields.value,
706                    encrypted: fields.encrypted,
707                    reference,
708                })
709            }
710        }
711    }
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
715pub struct SecretReference {
716    /// Optional provider identifier (maps to entries in `secrets.providers`).
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub provider: Option<String>,
719    /// Provider-specific key or lookup reference.
720    #[serde(rename = "ref")]
721    pub key: String,
722    /// Whether missing references should be tolerated.
723    #[serde(default)]
724    pub optional: bool,
725}
726
727impl SecretReference {
728    /// Convenience constructor for plain references without providers.
729    pub fn new(key: impl Into<String>) -> Self {
730        Self {
731            provider: None,
732            key: key.into(),
733            optional: false,
734        }
735    }
736}
737
738/// Transport-specific configuration.
739///
740/// Each entry describes how to instantiate a particular transport (SSH, HTTP,
741/// mock, etc.) for a host. The `transport` field names the registered factory,
742/// and `params` holds the JSON-serializable options that the factory expects.
743/// By keeping the payload loosely typed, inventories can express vendor-specific
744/// knobs without bloating the core schema.
745#[derive(Debug, Clone, Default, Serialize, Deserialize)]
746pub struct TransportSettings {
747    /// Transport identifier (e.g., "ssh", "netmiko").
748    pub transport: String,
749    /// Flat map of key/value settings consumed by the transport.
750    #[serde(default)]
751    pub params: Variables,
752}
753
754impl TransportSettings {
755    /// Deserializes the parameter map into a strongly typed configuration.
756    ///
757    /// # Examples
758    /// ```
759    /// use nornir_rs::{TransportSettings, Variables};
760    /// use serde::Deserialize;
761    ///
762    /// #[derive(Debug, Deserialize, PartialEq)]
763    /// struct SshConfig {
764    ///     username: String,
765    ///     timeout: Option<u64>,
766    /// }
767    ///
768    /// let mut params = Variables::default();
769    /// params.insert("username".into(), serde_json::json!("netops"));
770    /// params.insert("timeout".into(), serde_json::json!(30));
771    ///
772    /// let settings = TransportSettings {
773    ///     transport: "ssh".into(),
774    ///     params,
775    /// };
776    ///
777    /// let parsed: SshConfig = settings.params_as().unwrap();
778    /// assert_eq!(
779    ///     parsed,
780    ///     SshConfig {
781    ///         username: "netops".into(),
782    ///         timeout: Some(30)
783    ///     }
784    /// );
785    /// ```
786    pub fn params_as<T>(&self) -> Result<T, serde_json::Error>
787    where
788        T: DeserializeOwned,
789    {
790        let map = serde_json::Map::from_iter(self.params.clone());
791        serde_json::from_value(serde_json::Value::Object(map))
792    }
793}
794
795/// Merges transport settings, letting incoming values override the existing map.
796fn merge_connections(
797    existing: &mut HashMap<String, TransportSettings>,
798    incoming: HashMap<String, TransportSettings>,
799) {
800    for (name, mut settings) in incoming {
801        match existing.get_mut(&name) {
802            Some(current) => {
803                if settings.transport.is_empty() {
804                    settings.transport = current.transport.clone();
805                }
806                if !settings.transport.is_empty() {
807                    current.transport = settings.transport;
808                }
809                merge_variables(&mut current.params, settings.params);
810            }
811            None => {
812                existing.insert(name, settings);
813            }
814        }
815    }
816}
817
818/// Shallowly merges arbitrary metadata maps.
819fn merge_variables(existing: &mut Variables, incoming: Variables) {
820    for (k, v) in incoming {
821        existing.insert(k, v);
822    }
823}
824
825/// Errors raised by inventory loaders and builders.
826///
827/// Parsing and IO code funnels failures through this enum so callers receive
828/// actionable diagnostics regardless of the backing source (files, HTTP,
829/// custom implementations). The CLI surfaces these messages verbatim to help
830/// contributors fix broken inventories quickly.
831#[derive(Debug)]
832pub enum InventoryError {
833    /// Underlying file or network IO failure.
834    Io(std::io::Error),
835    /// HTTP request failed (connection errors, non-success status, etc.).
836    Http(reqwest::Error),
837    /// Structured data parsing error.
838    Parse(String),
839}
840
841impl fmt::Display for InventoryError {
842    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
843        match self {
844            InventoryError::Io(err) => write!(f, "io error: {}", err),
845            InventoryError::Http(err) => write!(f, "http error: {}", err),
846            InventoryError::Parse(msg) => write!(f, "parse error: {}", msg),
847        }
848    }
849}
850
851impl std::error::Error for InventoryError {
852    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
853        match self {
854            InventoryError::Io(err) => Some(err),
855            InventoryError::Http(err) => Some(err),
856            InventoryError::Parse(_) => None,
857        }
858    }
859}
860
861impl From<std::io::Error> for InventoryError {
862    fn from(value: std::io::Error) -> Self {
863        InventoryError::Io(value)
864    }
865}
866
867impl From<reqwest::Error> for InventoryError {
868    fn from(value: reqwest::Error) -> Self {
869        InventoryError::Http(value)
870    }
871}
872
873/// Unified interface for inventory data sources.
874///
875/// Implementors may pull data from files, HTTP endpoints, databases, or
876/// in-memory fixtures. Each source emits a fully merged [`Inventory`] snapshot
877/// representing *just that source*. The [`InventoryBuilder`] then layers the
878/// snapshots in order, making it trivial to stack defaults, groups, adhoc test
879/// records, and remote inventories without worrying about parsing details in
880/// consumer code.
881pub trait InventorySource: Send + Sync {
882    /// Loads inventory data.
883    fn load(&self) -> Result<Inventory, InventoryError>;
884
885    /// Human-friendly identifier for logging/observability.
886    fn name(&self) -> &str;
887}
888
889/// Builder that merges multiple inventory sources.
890///
891/// Most callers never construct an [`Inventory`] manually. Instead, they add one
892/// or more [`InventorySource`] implementations to the builder and let it merge
893/// them in order, applying group/default inheritance once everything is loaded.
894/// This mirrors how the CLI layers `defaults.yaml`, `groups.yaml`, and
895/// `hosts.yaml` (plus optional HTTP responses) into a single view before
896/// running tasks.
897///
898/// # Examples
899/// ```
900/// use nornir_rs::{Host, Inventory, InventoryBuilder, StaticInventorySource};
901///
902/// let mut inventory = Inventory::default();
903/// inventory.hosts.insert(
904///     "sw1".into(),
905///     Host {
906///         name: "sw1".into(),
907///         hostname: Some("10.0.0.11".into()),
908///         ..Host::default()
909///     },
910/// );
911///
912/// let built = InventoryBuilder::new()
913///     .with_source(StaticInventorySource::new("memory", inventory))
914///     .build()
915///     .unwrap();
916///
917/// assert!(built.host("sw1").is_some());
918/// ```
919#[derive(Default)]
920pub struct InventoryBuilder {
921    sources: Vec<Box<dyn InventorySource>>,
922}
923
924impl InventoryBuilder {
925    /// Starts a new builder with no sources.
926    pub fn new() -> Self {
927        Self::default()
928    }
929
930    /// Adds an inventory source, returning the builder for chaining.
931    ///
932    /// Use this in fluent style when composing multiple loaders:
933    ///
934    /// ```
935    /// # use nornir_rs::{InventoryBuilder, StaticInventorySource, Inventory};
936    /// # fn build_inline() -> Inventory { Inventory::default() }
937    /// let builder = InventoryBuilder::new()
938    ///     .with_source(StaticInventorySource::new("inline", build_inline()));
939    /// let inventory = builder.build().unwrap();
940    /// assert_eq!(inventory.hosts.len(), 0);
941    /// ```
942    pub fn with_source(mut self, source: impl InventorySource + 'static) -> Self {
943        self.sources.push(Box::new(source));
944        self
945    }
946
947    /// Adds an inventory source without consuming the builder.
948    ///
949    /// Useful when sources are determined dynamically (e.g., reading a plugin
950    /// list at runtime or iterating over multiple directories).
951    pub fn add_source(&mut self, source: impl InventorySource + 'static) {
952        self.sources.push(Box::new(source));
953    }
954
955    /// Loads and merges all registered sources in order.
956    ///
957    /// Each source is loaded eagerly; failures bubble up immediately with an
958    /// [`InventoryError`], so callers can surface useful diagnostics. After all
959    /// sources succeed, the builder applies group/default inheritance so the
960    /// resulting [`Inventory`] is ready for filtering and task execution.
961    pub fn build(mut self) -> Result<Inventory, InventoryError> {
962        let mut inventory = Inventory::default();
963        for source in self.sources.drain(..) {
964            let data = source.load()?;
965            inventory.merge(data);
966        }
967        inventory.apply_inheritance();
968        Ok(inventory)
969    }
970}
971
972/// In-memory helper useful for tests, fixtures, and doc examples.
973///
974/// # Examples
975/// ```
976/// use nornir_rs::{Host, Inventory, InventorySource, StaticInventorySource};
977///
978/// let mut inventory = Inventory::default();
979/// inventory.hosts.insert(
980///     "r1".into(),
981///     Host {
982///         name: "r1".into(),
983///         hostname: Some("10.0.0.1".into()),
984///         ..Host::default()
985///     },
986/// );
987///
988/// let source = StaticInventorySource::new("inline", inventory);
989/// let loaded = source.load().unwrap();
990/// assert!(loaded.host("r1").is_some());
991/// ```
992/// In-memory implementation of [`InventorySource`] useful for tests, fixtures,
993/// or precomputed inventories.
994pub struct StaticInventorySource {
995    name: String,
996    inventory: Inventory,
997}
998
999impl StaticInventorySource {
1000    /// Creates a new source with a descriptive name.
1001    pub fn new(name: impl Into<String>, inventory: Inventory) -> Self {
1002        Self {
1003            name: name.into(),
1004            inventory,
1005        }
1006    }
1007}
1008
1009impl InventorySource for StaticInventorySource {
1010    fn load(&self) -> Result<Inventory, InventoryError> {
1011        Ok(self.inventory.clone())
1012    }
1013
1014    fn name(&self) -> &str {
1015        &self.name
1016    }
1017}
1018
1019/// Supported file formats when loading from disk or HTTP.
1020///
1021/// The loader automatically infers formats from file extensions or explicit
1022/// labels and uses serde under the hood to deserialize them into [`Inventory`]
1023/// structures.
1024#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1025pub enum InventoryFormat {
1026    Yaml,
1027    Json,
1028    Toml,
1029}
1030
1031impl InventoryFormat {
1032    /// Infers format from a filesystem path using the extension.
1033    fn from_path(path: &Path) -> Option<Self> {
1034        path.extension()
1035            .and_then(|ext| ext.to_str())
1036            .and_then(Self::from_label)
1037    }
1038
1039    /// Attempts to parse a file extension (without dot) into a format.
1040    pub fn from_extension(ext: &str) -> Option<Self> {
1041        match ext {
1042            "yaml" | "yml" => Some(InventoryFormat::Yaml),
1043            "json" => Some(InventoryFormat::Json),
1044            "toml" => Some(InventoryFormat::Toml),
1045            _ => None,
1046        }
1047    }
1048
1049    /// Parses a user-provided label such as "yaml" or "json".
1050    pub fn from_label(label: &str) -> Option<Self> {
1051        let lowered = label.trim().to_ascii_lowercase();
1052        Self::from_extension(lowered.as_str())
1053    }
1054}
1055
1056/// Reads inventory data from a file path.
1057///
1058/// # Examples
1059/// ```
1060/// use nornir_rs::{FileInventorySource, InventoryFormat, InventorySource};
1061/// use tempfile::NamedTempFile;
1062/// use std::io::Write;
1063///
1064/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1065/// let mut file = NamedTempFile::new()?;
1066/// writeln!(
1067///     file,
1068///     r#"
1069/// hosts:
1070///   r1:
1071///     name: r1
1072///     hostname: 10.0.0.5
1073/// "#
1074/// )?;
1075///
1076/// let source = FileInventorySource::new(file.path(), InventoryFormat::Yaml);
1077/// let inventory = source.load()?;
1078/// assert_eq!(
1079///     inventory.host("r1").unwrap().hostname.as_deref(),
1080///     Some("10.0.0.5")
1081/// );
1082/// # Ok(())
1083/// # }
1084/// ```
1085/// Loads inventory documents from disk using serde.
1086pub struct FileInventorySource {
1087    path: PathBuf,
1088    format: InventoryFormat,
1089    name: String,
1090}
1091
1092impl FileInventorySource {
1093    /// Creates a source inferring format from the file extension.
1094    pub fn infer(path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1095        let path = path.into();
1096        let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1097            InventoryError::Parse(format!("unsupported file extension for {}", path.display()))
1098        })?;
1099
1100        Ok(Self {
1101            name: path.display().to_string(),
1102            path,
1103            format,
1104        })
1105    }
1106
1107    /// Creates a source using an explicit format.
1108    pub fn new(path: impl Into<PathBuf>, format: InventoryFormat) -> Self {
1109        let path = path.into();
1110        Self {
1111            name: path.display().to_string(),
1112            path,
1113            format,
1114        }
1115    }
1116}
1117
1118/// Helper that annotates IO errors with the offending path for better logs.
1119fn io_error_with_path(err: std::io::Error, path: &Path) -> std::io::Error {
1120    let message = format!("{} ({})", err, path.display());
1121    std::io::Error::new(err.kind(), message)
1122}
1123
1124impl InventorySource for FileInventorySource {
1125    fn load(&self) -> Result<Inventory, InventoryError> {
1126        let raw = fs::read_to_string(&self.path)
1127            .map_err(|err| InventoryError::Io(io_error_with_path(err, &self.path)))?;
1128        let meta = InterpolationMeta::for_path(&self.path);
1129        let processed = env::interpolate_with_meta(&raw, Some(&meta))
1130            .map_err(|err| InventoryError::Parse(err.to_string()))?;
1131        let inventory = match self.format {
1132            InventoryFormat::Yaml => serde_yaml::from_str(&processed)
1133                .map_err(|err| InventoryError::Parse(err.to_string()))?,
1134            InventoryFormat::Json => serde_json::from_str(&processed)
1135                .map_err(|err| InventoryError::Parse(err.to_string()))?,
1136            InventoryFormat::Toml => {
1137                toml::from_str(&processed).map_err(|err| InventoryError::Parse(err.to_string()))?
1138            }
1139        };
1140        Ok(inventory)
1141    }
1142
1143    fn name(&self) -> &str {
1144        &self.name
1145    }
1146}
1147
1148/// Loads hosts/groups/defaults from separate files and merges them.
1149///
1150/// This mirrors Nornir’s `SimpleInventory` layout where each YAML file plays a
1151/// specific role. Environment variable tokens (`${VAR}`) inside the files are
1152/// interpolated automatically, making it trivial to keep sensitive values out
1153/// of version control.
1154///
1155/// # Examples
1156/// ```
1157/// use nornir_rs::{CompositeFileInventorySource, InventorySource};
1158/// use std::fs;
1159/// use tempfile::tempdir;
1160///
1161/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1162/// let dir = tempdir()?;
1163/// let hosts_path = dir.path().join("hosts.yaml");
1164/// let groups_path = dir.path().join("groups.yaml");
1165///
1166/// fs::write(
1167///     &hosts_path,
1168///     "r1:\n  hostname: 10.0.0.10\n  groups: [core]\n",
1169/// )?;
1170/// fs::write(
1171///     &groups_path,
1172///     "core:\n  data:\n    role: access\n",
1173/// )?;
1174///
1175/// let inventory = CompositeFileInventorySource::new()
1176///     .hosts(&hosts_path)?
1177///     .groups(&groups_path)?
1178///     .load()?;
1179///
1180/// assert_eq!(
1181///     inventory.host("r1").unwrap().hostname.as_deref(),
1182///     Some("10.0.0.10")
1183/// );
1184/// assert_eq!(
1185///     inventory.groups.get("core").unwrap().data.get("role").unwrap(),
1186///     "access"
1187/// );
1188/// # Ok(())
1189/// # }
1190/// ```
1191/// Inventory source that loads separate hosts/groups/defaults files and merges
1192/// them into a single [`Inventory`].
1193pub struct CompositeFileInventorySource {
1194    hosts: Option<(PathBuf, InventoryFormat)>,
1195    groups: Option<(PathBuf, InventoryFormat)>,
1196    defaults: Option<(PathBuf, InventoryFormat)>,
1197    name: String,
1198}
1199
1200impl Default for CompositeFileInventorySource {
1201    fn default() -> Self {
1202        Self::new()
1203    }
1204}
1205
1206impl CompositeFileInventorySource {
1207    /// Creates a new source with no files configured.
1208    pub fn new() -> Self {
1209        Self {
1210            hosts: None,
1211            groups: None,
1212            defaults: None,
1213            name: "composite-files".into(),
1214        }
1215    }
1216
1217    /// Sets the 'hosts' file.
1218    pub fn hosts(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1219        let path = path.into();
1220        let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1221            InventoryError::Parse(format!(
1222                "unsupported host file extension for {}",
1223                path.display()
1224            ))
1225        })?;
1226        self.hosts = Some((path, format));
1227        Ok(self)
1228    }
1229
1230    /// Sets the groups file.
1231    pub fn groups(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1232        let path = path.into();
1233        let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1234            InventoryError::Parse(format!(
1235                "unsupported group file extension for {}",
1236                path.display()
1237            ))
1238        })?;
1239        self.groups = Some((path, format));
1240        Ok(self)
1241    }
1242
1243    /// Sets the defaults file.
1244    pub fn defaults(mut self, path: impl Into<PathBuf>) -> Result<Self, InventoryError> {
1245        let path = path.into();
1246        let format = InventoryFormat::from_path(&path).ok_or_else(|| {
1247            InventoryError::Parse(format!(
1248                "unsupported defaults file extension for {}",
1249                path.display()
1250            ))
1251        })?;
1252        self.defaults = Some((path, format));
1253        Ok(self)
1254    }
1255}
1256
1257impl InventorySource for CompositeFileInventorySource {
1258    fn load(&self) -> Result<Inventory, InventoryError> {
1259        let mut inventory = Inventory::default();
1260
1261        if let Some((path, format)) = &self.hosts {
1262            let raw = fs::read_to_string(path)
1263                .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1264            let meta = InterpolationMeta::for_path(path);
1265            let processed = env::interpolate_with_meta(&raw, Some(&meta))
1266                .map_err(|err| InventoryError::Parse(err.to_string()))?;
1267            let hosts = parse_hosts_document(&processed, *format)?;
1268            inventory.hosts.extend(hosts);
1269        }
1270
1271        if let Some((path, format)) = &self.groups {
1272            let groups_map = fs::read_to_string(path)
1273                .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1274            let meta = InterpolationMeta::for_path(path);
1275            let groups_map = env::interpolate_with_meta(&groups_map, Some(&meta))
1276                .map_err(|err| InventoryError::Parse(err.to_string()))?;
1277            let groups: GroupMap = match format {
1278                InventoryFormat::Yaml => serde_yaml::from_str(&groups_map)
1279                    .map_err(|err| InventoryError::Parse(err.to_string()))?,
1280                InventoryFormat::Json => serde_json::from_str(&groups_map)
1281                    .map_err(|err| InventoryError::Parse(err.to_string()))?,
1282                InventoryFormat::Toml => toml::from_str(&groups_map)
1283                    .map_err(|err| InventoryError::Parse(err.to_string()))?,
1284            };
1285            for (name, mut group) in groups.into_iter() {
1286                if group.name.is_empty() {
1287                    group.name = name.clone();
1288                }
1289                inventory.groups.insert(name, group);
1290            }
1291        }
1292
1293        if let Some((path, format)) = &self.defaults {
1294            let raw = fs::read_to_string(path)
1295                .map_err(|err| InventoryError::Io(io_error_with_path(err, path)))?;
1296            let meta = InterpolationMeta::for_path(path);
1297            let raw = env::interpolate_with_meta(&raw, Some(&meta))
1298                .map_err(|err| InventoryError::Parse(err.to_string()))?;
1299            let defaults: Defaults = match format {
1300                InventoryFormat::Yaml => serde_yaml::from_str(&raw)
1301                    .map_err(|err| InventoryError::Parse(err.to_string()))?,
1302                InventoryFormat::Json => serde_json::from_str(&raw)
1303                    .map_err(|err| InventoryError::Parse(err.to_string()))?,
1304                InventoryFormat::Toml => {
1305                    toml::from_str(&raw).map_err(|err| InventoryError::Parse(err.to_string()))?
1306                }
1307            };
1308            inventory.defaults = defaults;
1309        }
1310
1311        Ok(inventory)
1312    }
1313
1314    fn name(&self) -> &str {
1315        &self.name
1316    }
1317}
1318
1319/// Parses a host document (YAML/JSON/TOML) into the canonical host map.
1320fn parse_hosts_document(raw: &str, format: InventoryFormat) -> Result<HostMap, InventoryError> {
1321    let entries = match format {
1322        InventoryFormat::Yaml => serde_yaml::from_str::<HostEntries>(raw)
1323            .map_err(|err| InventoryError::Parse(err.to_string()))?,
1324        InventoryFormat::Json => serde_json::from_str::<HostEntries>(raw)
1325            .map_err(|err| InventoryError::Parse(err.to_string()))?,
1326        InventoryFormat::Toml => toml::from_str::<HostEntries>(raw)
1327            .map_err(|err| InventoryError::Parse(err.to_string()))?,
1328    };
1329    normalize_host_entries(entries).map_err(InventoryError::Parse)
1330}
1331
1332/// Fetches inventory data from an HTTP endpoint.
1333///
1334/// Perfect for dynamic inventories backed by CMDBs, GraphQL, REST APIs, or
1335/// static web-hosted files. The client eagerly fetches the document,
1336/// interpolates environment variables, and parses it using the requested
1337/// [`InventoryFormat`]. Convenience builders make it easy to add headers or
1338/// authentication inline—ideal for CI pipelines or tooling that needs to
1339/// refresh inventory snapshots periodically.
1340///
1341/// # Examples
1342/// ```no_run
1343/// use nornir_rs::{HttpInventorySource, InventoryFormat};
1344///
1345/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1346/// let source = HttpInventorySource::new("https://example.com/inventory", InventoryFormat::Json)?;
1347/// // Configure headers or TLS options here.
1348/// let _source = source.with_header("Authorization", "Bearer <token>");
1349/// # Ok(())
1350/// # }
1351/// ```
1352pub struct HttpInventorySource {
1353    client: reqwest::blocking::Client,
1354    url: String,
1355    format: InventoryFormat,
1356    headers: Vec<(String, String)>,
1357    auth: Option<HttpAuth>,
1358}
1359
1360#[derive(Debug, Clone)]
1361enum HttpAuth {
1362    Basic { username: String, password: String },
1363    Bearer { token: String },
1364}
1365
1366impl HttpInventorySource {
1367    /// Creates a source that fetches data from `url` and parses it using `format`.
1368    pub fn new(url: impl Into<String>, format: InventoryFormat) -> Result<Self, InventoryError> {
1369        Ok(Self {
1370            client: reqwest::blocking::Client::new(),
1371            url: url.into(),
1372            format,
1373            headers: Vec::new(),
1374            auth: None,
1375        })
1376    }
1377
1378    /// Adds an HTTP header to the GET request.
1379    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1380        self.headers.push((key.into(), value.into()));
1381        self
1382    }
1383
1384    /// Configures HTTP basic authentication.
1385    pub fn with_basic_auth(
1386        mut self,
1387        username: impl Into<String>,
1388        password: impl Into<String>,
1389    ) -> Self {
1390        self.auth = Some(HttpAuth::Basic {
1391            username: username.into(),
1392            password: password.into(),
1393        });
1394        self
1395    }
1396
1397    /// Configures bearer token authentication.
1398    pub fn with_bearer_token(mut self, token: impl Into<String>) -> Self {
1399        self.auth = Some(HttpAuth::Bearer {
1400            token: token.into(),
1401        });
1402        self
1403    }
1404
1405    /// Adds an API key header (e.g., `X-API-Key`).
1406    pub fn with_api_key(mut self, header: impl Into<String>, value: impl Into<String>) -> Self {
1407        self.headers.push((header.into(), value.into()));
1408        self
1409    }
1410}
1411
1412impl InventorySource for HttpInventorySource {
1413    fn load(&self) -> Result<Inventory, InventoryError> {
1414        let mut request = self.client.get(&self.url);
1415        for (key, value) in &self.headers {
1416            request = request.header(key, value);
1417        }
1418        if let Some(auth) = &self.auth {
1419            match auth {
1420                HttpAuth::Basic { username, password } => {
1421                    request = request.basic_auth(username, Some(password));
1422                }
1423                HttpAuth::Bearer { token } => {
1424                    request = request.bearer_auth(token);
1425                }
1426            }
1427        }
1428        let response = request.send()?.error_for_status()?;
1429        let body = response.text()?;
1430        parse_inventory_body(self.format, &body)
1431    }
1432
1433    fn name(&self) -> &str {
1434        &self.url
1435    }
1436}
1437
1438/// Deserializes an inventory document fetched from HTTP.
1439fn parse_inventory_body(format: InventoryFormat, body: &str) -> Result<Inventory, InventoryError> {
1440    match format {
1441        InventoryFormat::Yaml => {
1442            serde_yaml::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1443        }
1444        InventoryFormat::Json => {
1445            serde_json::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1446        }
1447        InventoryFormat::Toml => {
1448            toml::from_str(body).map_err(|err| InventoryError::Parse(err.to_string()))
1449        }
1450    }
1451}
1452
1453const NAUTOBOT_DEVICES_ENDPOINT: &str = "dcim/devices/";
1454const NAUTOBOT_VMS_ENDPOINT: &str = "virtualization/virtual-machines/";
1455const NAUTOBOT_DEFAULT_DEPTH: &str = "1";
1456const NETBOX_DEVICES_ENDPOINT: &str = "api/dcim/devices/";
1457const NETBOX_VMS_ENDPOINT: &str = "api/virtualization/virtual-machines/";
1458const NETBOX_PLATFORMS_ENDPOINT: &str = "api/dcim/platforms/";
1459
1460/// Configuration consumed by the Nautobot inventory source.
1461pub struct NautobotInventoryConfig {
1462    pub base_url: String,
1463    pub token: String,
1464    pub verify_tls: bool,
1465    pub timeout: Duration,
1466    pub include_virtual_machines: bool,
1467    pub flatten_custom_fields: bool,
1468    pub tags_as_groups: bool,
1469    pub sites_as_groups: bool,
1470    pub roles_as_groups: bool,
1471    pub tenants_as_groups: bool,
1472    pub page_size: u32,
1473    pub device_filters: Vec<(String, String)>,
1474    pub virtual_machine_filters: Vec<(String, String)>,
1475}
1476
1477/// Inventory source that pulls devices/VMs from Nautobot's REST API.
1478pub struct NautobotInventorySource {
1479    client: reqwest::blocking::Client,
1480    base_url: Url,
1481    token: String,
1482    include_virtual_machines: bool,
1483    flatten_custom_fields: bool,
1484    tags_as_groups: bool,
1485    sites_as_groups: bool,
1486    roles_as_groups: bool,
1487    tenants_as_groups: bool,
1488    page_size: u32,
1489    device_filters: Vec<(String, String)>,
1490    vm_filters: Vec<(String, String)>,
1491}
1492
1493impl NautobotInventorySource {
1494    /// Creates a new source using the provided configuration.
1495    pub fn new(config: NautobotInventoryConfig) -> Result<Self, InventoryError> {
1496        let mut builder = reqwest::blocking::Client::builder()
1497            .timeout(config.timeout)
1498            .user_agent("kore/nautobot");
1499        if !config.verify_tls {
1500            builder = builder.danger_accept_invalid_certs(true);
1501        }
1502        let client = builder.build()?;
1503        let base_url = Url::parse(&config.base_url)
1504            .map_err(|err| InventoryError::Parse(format!("invalid Nautobot url: {err}")))?;
1505        Ok(Self {
1506            client,
1507            base_url,
1508            token: config.token,
1509            include_virtual_machines: config.include_virtual_machines,
1510            flatten_custom_fields: config.flatten_custom_fields,
1511            tags_as_groups: config.tags_as_groups,
1512            sites_as_groups: config.sites_as_groups,
1513            roles_as_groups: config.roles_as_groups,
1514            tenants_as_groups: config.tenants_as_groups,
1515            page_size: config.page_size.max(1),
1516            device_filters: config.device_filters,
1517            vm_filters: config.virtual_machine_filters,
1518        })
1519    }
1520
1521    fn fetch_collection(
1522        &self,
1523        endpoint: &str,
1524        filters: &[(String, String)],
1525    ) -> Result<Vec<serde_json::Value>, InventoryError> {
1526        let mut cursor = Some(self.base_url.join(endpoint).map_err(|err| {
1527            InventoryError::Parse(format!("failed to construct Nautobot URL: {err}"))
1528        })?);
1529        if let Some(current) = cursor.as_mut() {
1530            {
1531                let mut pairs = current.query_pairs_mut();
1532                pairs.append_pair("limit", &self.page_size.to_string());
1533                pairs.append_pair("offset", "0");
1534                pairs.append_pair("depth", NAUTOBOT_DEFAULT_DEPTH);
1535                for (key, value) in filters {
1536                    pairs.append_pair(key, value);
1537                }
1538            }
1539        }
1540        let mut results = Vec::new();
1541        while let Some(current) = cursor.take() {
1542            let payload = self.get_page(current)?;
1543            results.extend(payload.results.into_iter());
1544            cursor = match payload.next {
1545                Some(next) => Some(self.parse_next_url(&next)?),
1546                None => None,
1547            };
1548        }
1549        Ok(results)
1550    }
1551
1552    fn get_page(&self, url: Url) -> Result<NautobotListResponse, InventoryError> {
1553        let response = self
1554            .client
1555            .get(url.clone())
1556            .header("Authorization", format!("Token {}", self.token))
1557            .header("Accept", "application/json")
1558            .send()?
1559            .error_for_status()?;
1560        Ok(response.json()?)
1561    }
1562
1563    fn parse_next_url(&self, raw: &str) -> Result<Url, InventoryError> {
1564        match Url::parse(raw) {
1565            Ok(url) => Ok(url),
1566            Err(_) => self.base_url.join(raw).map_err(|err| {
1567                InventoryError::Parse(format!("invalid Nautobot pagination URL: {err}"))
1568            }),
1569        }
1570    }
1571
1572    fn build_device_host(&self, record: &serde_json::Value) -> Result<Host, InventoryError> {
1573        self.build_host(record, NautobotObjectKind::Device)
1574    }
1575
1576    fn build_vm_host(&self, record: &serde_json::Value) -> Result<Host, InventoryError> {
1577        self.build_host(record, NautobotObjectKind::VirtualMachine)
1578    }
1579
1580    fn build_host(
1581        &self,
1582        record: &serde_json::Value,
1583        kind: NautobotObjectKind,
1584    ) -> Result<Host, InventoryError> {
1585        let name = self
1586            .string_field(record, "name")
1587            .or_else(|| self.string_field(record, "display"))
1588            .or_else(|| record.get("id").map(|id| id.to_string()))
1589            .ok_or_else(|| InventoryError::Parse("nautobot record missing name".into()))?;
1590        let hostname = self
1591            .primary_ip(record)
1592            .or_else(|| self.string_field(record, "name"))
1593            .unwrap_or_else(|| name.clone());
1594        let mut host = Host {
1595            name: name.clone(),
1596            hostname: Some(hostname),
1597            platform: self.platform_slug(record),
1598            ..Host::default()
1599        };
1600        host.groups = self.derive_groups(record, kind);
1601        host.data = self.build_data(record, kind);
1602        Ok(host)
1603    }
1604
1605    fn build_data(&self, record: &serde_json::Value, kind: NautobotObjectKind) -> Variables {
1606        let mut data = Variables::new();
1607        data.insert("nautobot".into(), record.clone());
1608        data.insert("kind".into(), serde_json::json!(kind.as_str()));
1609        if let Some(status) = self.nested_field(record, &["status", "value"]) {
1610            data.insert("status".into(), serde_json::json!(status));
1611        }
1612        if let Some(serial) = self.string_field(record, "serial") {
1613            data.insert("serial".into(), serde_json::json!(serial));
1614        }
1615        if let Some(asset) = self.string_field(record, "asset_tag") {
1616            data.insert("asset_tag".into(), serde_json::json!(asset));
1617        }
1618        if let Some(role) = self
1619            .nested_field(record, &["role", "slug"])
1620            .or_else(|| self.nested_field(record, &["device_role", "slug"]))
1621        {
1622            data.insert("role".into(), serde_json::json!(role));
1623        }
1624        if let Some(site) = self.nested_field(record, &["site", "slug"]) {
1625            data.insert("site".into(), serde_json::json!(site));
1626        }
1627        if let Some(cluster) = self.nested_field(record, &["cluster", "slug"]) {
1628            data.insert("cluster".into(), serde_json::json!(cluster));
1629        }
1630        if let Some(tenant) = self.nested_field(record, &["tenant", "slug"]) {
1631            data.insert("tenant".into(), serde_json::json!(tenant));
1632        }
1633        if let Some(ipv4) = self.primary_ip_field(record, "primary_ip4") {
1634            data.insert("primary_ip4".into(), serde_json::json!(ipv4));
1635        }
1636        if let Some(ipv6) = self.primary_ip_field(record, "primary_ip6") {
1637            data.insert("primary_ip6".into(), serde_json::json!(ipv6));
1638        }
1639        if let Some(primary) = self.primary_ip_field(record, "primary_ip") {
1640            data.insert("primary_ip".into(), serde_json::json!(primary));
1641        }
1642        if let Some(tags) = record
1643            .get("tags")
1644            .and_then(|value| value.as_array())
1645            .map(|list| {
1646                list.iter()
1647                    .filter_map(Self::tag_slug)
1648                    .map(|slug| serde_json::json!(slug))
1649                    .collect::<Vec<_>>()
1650            })
1651        {
1652            data.insert("tags".into(), serde_json::Value::Array(tags));
1653        }
1654        if let Some(cf) = record.get("custom_fields") {
1655            if self.flatten_custom_fields {
1656                if let Some(map) = cf.as_object() {
1657                    for (key, value) in map {
1658                        data.insert(key.clone(), value.clone());
1659                    }
1660                }
1661            } else {
1662                data.insert("custom_fields".into(), cf.clone());
1663            }
1664        }
1665        data
1666    }
1667
1668    fn derive_groups(&self, record: &serde_json::Value, kind: NautobotObjectKind) -> Vec<String> {
1669        let mut groups = Vec::new();
1670        self.push_group(&mut groups, Some(kind.as_str()), "kind:");
1671        if self.roles_as_groups
1672            && let Some(role) = self
1673                .nested_field(record, &["role", "slug"])
1674                .or_else(|| self.nested_field(record, &["device_role", "slug"]))
1675        {
1676            self.push_group(&mut groups, Some(&role), "role:");
1677        }
1678        if self.sites_as_groups
1679            && let Some(site) = self.nested_field(record, &["site", "slug"])
1680        {
1681            self.push_group(&mut groups, Some(&site), "site:");
1682        }
1683        if self.tenants_as_groups
1684            && let Some(tenant) = self.nested_field(record, &["tenant", "slug"])
1685        {
1686            self.push_group(&mut groups, Some(&tenant), "tenant:");
1687        }
1688        if let NautobotObjectKind::VirtualMachine = kind
1689            && let Some(cluster) = self.nested_field(record, &["cluster", "slug"])
1690        {
1691            self.push_group(&mut groups, Some(&cluster), "cluster:");
1692        }
1693        if self.tags_as_groups
1694            && let Some(tags) = record.get("tags").and_then(|value| value.as_array())
1695        {
1696            for tag in tags {
1697                if let Some(slug) = Self::tag_slug(tag) {
1698                    self.push_group(&mut groups, Some(slug.as_str()), "tag:");
1699                }
1700            }
1701        }
1702        groups
1703    }
1704
1705    fn push_group(&self, groups: &mut Vec<String>, value: Option<&str>, prefix: &str) {
1706        if let Some(val) = value {
1707            let entry = format!("{prefix}{val}");
1708            if !groups.contains(&entry) {
1709                groups.push(entry);
1710            }
1711        }
1712    }
1713
1714    fn string_field(&self, record: &serde_json::Value, key: &str) -> Option<String> {
1715        record.get(key)?.as_str().map(|value| value.to_string())
1716    }
1717
1718    fn nested_field(&self, record: &serde_json::Value, path: &[&str]) -> Option<String> {
1719        let mut cursor = record;
1720        for segment in path.iter().take(path.len().saturating_sub(1)) {
1721            cursor = cursor.get(*segment)?;
1722        }
1723        cursor
1724            .get(*path.last()?)
1725            .and_then(|value| value.as_str().map(|s| s.to_string()))
1726    }
1727
1728    fn primary_ip_field(&self, record: &serde_json::Value, field: &str) -> Option<String> {
1729        let address = record.get(field)?.get("address")?.as_str()?;
1730        Some(address.split('/').next().unwrap_or(address).to_string())
1731    }
1732
1733    fn primary_ip(&self, record: &serde_json::Value) -> Option<String> {
1734        self.primary_ip_field(record, "primary_ip4")
1735            .or_else(|| self.primary_ip_field(record, "primary_ip"))
1736            .or_else(|| self.primary_ip_field(record, "primary_ip6"))
1737    }
1738
1739    fn platform_slug(&self, record: &serde_json::Value) -> Option<String> {
1740        self.nested_field(record, &["platform", "slug"])
1741            .or_else(|| self.nested_field(record, &["platform", "network_driver"]))
1742    }
1743
1744    fn tag_slug(value: &serde_json::Value) -> Option<String> {
1745        value
1746            .get("slug")
1747            .and_then(|field| field.as_str())
1748            .map(|slug| slug.to_string())
1749            .or_else(|| {
1750                value
1751                    .get("name")
1752                    .and_then(|field| field.as_str())
1753                    .map(|name| name.to_string())
1754            })
1755            .or_else(|| {
1756                value
1757                    .get("display")
1758                    .and_then(|field| field.as_str())
1759                    .map(|display| display.to_string())
1760            })
1761            .or_else(|| {
1762                value
1763                    .get("natural_slug")
1764                    .and_then(|field| field.as_str())
1765                    .map(|slug| slug.to_string())
1766            })
1767    }
1768}
1769
1770impl InventorySource for NautobotInventorySource {
1771    fn load(&self) -> Result<Inventory, InventoryError> {
1772        let mut inventory = Inventory::default();
1773        for device in self.fetch_collection(NAUTOBOT_DEVICES_ENDPOINT, &self.device_filters)? {
1774            let host = self.build_device_host(&device)?;
1775            inventory.hosts.insert(host.name.clone(), host);
1776        }
1777        if self.include_virtual_machines {
1778            for vm in self.fetch_collection(NAUTOBOT_VMS_ENDPOINT, &self.vm_filters)? {
1779                let host = self.build_vm_host(&vm)?;
1780                inventory.hosts.insert(host.name.clone(), host);
1781            }
1782        }
1783        Ok(inventory)
1784    }
1785
1786    fn name(&self) -> &str {
1787        self.base_url.as_str()
1788    }
1789}
1790
1791#[derive(Deserialize)]
1792struct NautobotListResponse {
1793    next: Option<String>,
1794    results: Vec<serde_json::Value>,
1795}
1796
1797#[derive(Clone, Copy)]
1798enum NautobotObjectKind {
1799    Device,
1800    VirtualMachine,
1801}
1802
1803impl NautobotObjectKind {
1804    fn as_str(&self) -> &'static str {
1805        match self {
1806            NautobotObjectKind::Device => "device",
1807            NautobotObjectKind::VirtualMachine => "virtual_machine",
1808        }
1809    }
1810}
1811
1812/// Configuration consumed by the NetBox inventory source.
1813pub struct NetboxInventoryConfig {
1814    pub base_url: String,
1815    pub token: String,
1816    pub verify_tls: bool,
1817    pub timeout: Duration,
1818    pub include_virtual_machines: bool,
1819    pub flatten_custom_fields: bool,
1820    pub tags_as_groups: bool,
1821    pub sites_as_groups: bool,
1822    pub roles_as_groups: bool,
1823    pub tenants_as_groups: bool,
1824    pub use_platform_slug: bool,
1825    pub use_platform_napalm_driver: bool,
1826    pub page_size: u32,
1827    pub device_filters: Vec<(String, String)>,
1828    pub virtual_machine_filters: Vec<(String, String)>,
1829    pub group_file: Option<PathBuf>,
1830    pub defaults_file: Option<PathBuf>,
1831}
1832
1833/// Inventory source backed by NetBox's REST API.
1834pub struct NetboxInventorySource {
1835    client: reqwest::blocking::Client,
1836    base_url: Url,
1837    auth_header: String,
1838    include_virtual_machines: bool,
1839    flatten_custom_fields: bool,
1840    tags_as_groups: bool,
1841    sites_as_groups: bool,
1842    roles_as_groups: bool,
1843    tenants_as_groups: bool,
1844    use_platform_slug: bool,
1845    use_platform_napalm_driver: bool,
1846    page_size: u32,
1847    device_filters: Vec<(String, String)>,
1848    vm_filters: Vec<(String, String)>,
1849    group_file: Option<PathBuf>,
1850    defaults_file: Option<PathBuf>,
1851}
1852
1853impl NetboxInventorySource {
1854    /// Creates a source with the provided NetBox connection details.
1855    pub fn new(config: NetboxInventoryConfig) -> Result<Self, InventoryError> {
1856        let mut builder = reqwest::blocking::Client::builder()
1857            .timeout(config.timeout)
1858            .user_agent("kore/netbox");
1859        if !config.verify_tls {
1860            builder = builder.danger_accept_invalid_certs(true);
1861        }
1862        let client = builder.build()?;
1863        let base_url = Url::parse(&config.base_url)
1864            .map_err(|err| InventoryError::Parse(format!("invalid NetBox url: {err}")))?;
1865        Ok(Self {
1866            client,
1867            base_url,
1868            auth_header: Self::format_auth_header(&config.token),
1869            include_virtual_machines: config.include_virtual_machines,
1870            flatten_custom_fields: config.flatten_custom_fields,
1871            tags_as_groups: config.tags_as_groups,
1872            sites_as_groups: config.sites_as_groups,
1873            roles_as_groups: config.roles_as_groups,
1874            tenants_as_groups: config.tenants_as_groups,
1875            use_platform_slug: config.use_platform_slug,
1876            use_platform_napalm_driver: config.use_platform_napalm_driver,
1877            page_size: config.page_size.max(1),
1878            device_filters: config.device_filters,
1879            vm_filters: config.virtual_machine_filters,
1880            group_file: config.group_file,
1881            defaults_file: config.defaults_file,
1882        })
1883    }
1884
1885    fn fetch_collection(
1886        &self,
1887        endpoint: &str,
1888        filters: &[(String, String)],
1889    ) -> Result<Vec<serde_json::Value>, InventoryError> {
1890        let mut cursor = Some(self.base_url.join(endpoint).map_err(|err| {
1891            InventoryError::Parse(format!("failed to construct NetBox URL: {err}"))
1892        })?);
1893        if let Some(current) = cursor.as_mut() {
1894            {
1895                let mut pairs = current.query_pairs_mut();
1896                pairs.append_pair("limit", &self.page_size.to_string());
1897                pairs.append_pair("offset", "0");
1898                for (key, value) in filters {
1899                    pairs.append_pair(key, value);
1900                }
1901            }
1902        }
1903        let mut results = Vec::new();
1904        while let Some(current) = cursor.take() {
1905            let payload = self.get_page(current)?;
1906            results.extend(payload.results.into_iter());
1907            cursor = match payload.next {
1908                Some(next) => Some(self.parse_next_url(&next)?),
1909                None => None,
1910            };
1911        }
1912        Ok(results)
1913    }
1914
1915    fn get_page(&self, url: Url) -> Result<NetboxListResponse, InventoryError> {
1916        let response = self
1917            .client
1918            .get(url.clone())
1919            .header("Authorization", self.auth_header.clone())
1920            .header("Accept", "application/json")
1921            .send()?
1922            .error_for_status()?;
1923        Ok(response.json()?)
1924    }
1925
1926    fn format_auth_header(token: &str) -> String {
1927        let trimmed = token.trim();
1928        if trimmed.starts_with("Bearer ") {
1929            trimmed.to_string()
1930        } else if trimmed.starts_with("nbt_") {
1931            format!("Bearer {trimmed}")
1932        } else {
1933            format!("Token {trimmed}")
1934        }
1935    }
1936
1937    fn parse_next_url(&self, raw: &str) -> Result<Url, InventoryError> {
1938        match Url::parse(raw) {
1939            Ok(url) => Ok(url),
1940            Err(_) => self.base_url.join(raw).map_err(|err| {
1941                InventoryError::Parse(format!("invalid NetBox pagination URL: {err}"))
1942            }),
1943        }
1944    }
1945
1946    fn fetch_platform_drivers(&self) -> Result<HashMap<String, String>, InventoryError> {
1947        let mut lookup = HashMap::new();
1948        for platform in self.fetch_collection(NETBOX_PLATFORMS_ENDPOINT, &[])? {
1949            if let (Some(slug), Some(driver)) = (
1950                platform.get("slug").and_then(|v| v.as_str()),
1951                platform.get("napalm_driver").and_then(|v| v.as_str()),
1952            ) {
1953                lookup.insert(slug.to_string(), driver.to_string());
1954            }
1955        }
1956        Ok(lookup)
1957    }
1958
1959    fn build_device_host(
1960        &self,
1961        record: &serde_json::Value,
1962        platform_lookup: Option<&HashMap<String, String>>,
1963    ) -> Result<Host, InventoryError> {
1964        self.build_host(record, NetboxObjectKind::Device, platform_lookup)
1965    }
1966
1967    fn build_vm_host(
1968        &self,
1969        record: &serde_json::Value,
1970        platform_lookup: Option<&HashMap<String, String>>,
1971    ) -> Result<Host, InventoryError> {
1972        self.build_host(record, NetboxObjectKind::VirtualMachine, platform_lookup)
1973    }
1974
1975    fn build_host(
1976        &self,
1977        record: &serde_json::Value,
1978        kind: NetboxObjectKind,
1979        platform_lookup: Option<&HashMap<String, String>>,
1980    ) -> Result<Host, InventoryError> {
1981        let name = self
1982            .string_field(record, "name")
1983            .or_else(|| self.string_field(record, "display"))
1984            .or_else(|| record.get("id").map(|id| id.to_string()))
1985            .ok_or_else(|| InventoryError::Parse("netbox record missing name".into()))?;
1986        let hostname = self
1987            .primary_ip(record)
1988            .or_else(|| self.string_field(record, "name"))
1989            .unwrap_or_else(|| name.clone());
1990        let mut host = Host {
1991            name: name.clone(),
1992            hostname: Some(hostname),
1993            platform: self.platform_value(record, platform_lookup),
1994            ..Host::default()
1995        };
1996        host.groups = self.derive_groups(record, kind);
1997        host.data = self.build_data(record, kind);
1998        Ok(host)
1999    }
2000
2001    fn build_data(&self, record: &serde_json::Value, kind: NetboxObjectKind) -> Variables {
2002        let mut data = Variables::new();
2003        data.insert("netbox".into(), record.clone());
2004        data.insert("kind".into(), serde_json::json!(kind.as_str()));
2005        if let Some(status) = self.nested_field(record, &["status", "value"]) {
2006            data.insert("status".into(), serde_json::json!(status));
2007        }
2008        if let Some(serial) = self.string_field(record, "serial") {
2009            data.insert("serial".into(), serde_json::json!(serial));
2010        }
2011        if let Some(asset) = self.string_field(record, "asset_tag") {
2012            data.insert("asset_tag".into(), serde_json::json!(asset));
2013        }
2014        if let Some(role) = self
2015            .nested_field(record, &["device_role", "slug"])
2016            .or_else(|| self.nested_field(record, &["role", "slug"]))
2017        {
2018            data.insert("role".into(), serde_json::json!(role));
2019        }
2020        if let Some(site) = self.nested_field(record, &["site", "slug"]) {
2021            data.insert("site".into(), serde_json::json!(site));
2022        }
2023        if let Some(tenant) = self.nested_field(record, &["tenant", "slug"]) {
2024            data.insert("tenant".into(), serde_json::json!(tenant));
2025        }
2026        if let Some(cluster) = self.nested_field(record, &["cluster", "slug"]) {
2027            data.insert("cluster".into(), serde_json::json!(cluster));
2028        }
2029        if let Some(primary) = self.primary_ip_field(record, "primary_ip") {
2030            data.insert("primary_ip".into(), serde_json::json!(primary));
2031        }
2032        if let Some(primary) = self.primary_ip_field(record, "primary_ip4") {
2033            data.insert("primary_ip4".into(), serde_json::json!(primary));
2034        }
2035        if let Some(primary) = self.primary_ip_field(record, "primary_ip6") {
2036            data.insert("primary_ip6".into(), serde_json::json!(primary));
2037        }
2038        if let Some(tags) = record
2039            .get("tags")
2040            .and_then(|value| value.as_array())
2041            .map(|list| {
2042                list.iter()
2043                    .filter_map(Self::tag_slug)
2044                    .map(serde_json::Value::String)
2045                    .collect::<Vec<_>>()
2046            })
2047        {
2048            data.insert("tags".into(), serde_json::Value::Array(tags));
2049        }
2050        if let Some(cf) = record.get("custom_fields") {
2051            if self.flatten_custom_fields {
2052                if let Some(map) = cf.as_object() {
2053                    for (key, value) in map {
2054                        data.insert(key.clone(), value.clone());
2055                    }
2056                }
2057            } else {
2058                data.insert("custom_fields".into(), cf.clone());
2059            }
2060        }
2061        data
2062    }
2063
2064    fn derive_groups(&self, record: &serde_json::Value, kind: NetboxObjectKind) -> Vec<String> {
2065        let mut groups = Vec::new();
2066        self.push_group(&mut groups, Some(kind.as_str()), "kind:");
2067        if self.roles_as_groups
2068            && let Some(role) = self
2069                .nested_field(record, &["device_role", "slug"])
2070                .or_else(|| self.nested_field(record, &["role", "slug"]))
2071        {
2072            self.push_group(&mut groups, Some(&role), "role:");
2073        }
2074        if self.sites_as_groups
2075            && let Some(site) = self.nested_field(record, &["site", "slug"])
2076        {
2077            self.push_group(&mut groups, Some(&site), "site:");
2078        }
2079        if self.tenants_as_groups
2080            && let Some(tenant) = self.nested_field(record, &["tenant", "slug"])
2081        {
2082            self.push_group(&mut groups, Some(&tenant), "tenant:");
2083        }
2084        if matches!(kind, NetboxObjectKind::VirtualMachine)
2085            && let Some(cluster) = self.nested_field(record, &["cluster", "slug"])
2086        {
2087            self.push_group(&mut groups, Some(&cluster), "cluster:");
2088        }
2089        if let Some(platform) = self.nested_field(record, &["platform", "slug"]) {
2090            self.push_group(&mut groups, Some(&platform), "platform:");
2091        }
2092        if let Some(device_type) = self.nested_field(record, &["device_type", "slug"]) {
2093            self.push_group(&mut groups, Some(&device_type), "device_type:");
2094        }
2095        if let Some(manufacturer) =
2096            self.nested_field(record, &["device_type", "manufacturer", "slug"])
2097        {
2098            self.push_group(&mut groups, Some(&manufacturer), "manufacturer:");
2099        }
2100        if self.tags_as_groups
2101            && let Some(tags) = record.get("tags").and_then(|value| value.as_array())
2102        {
2103            for tag in tags {
2104                if let Some(slug) = Self::tag_slug(tag) {
2105                    self.push_group(&mut groups, Some(&slug), "tag:");
2106                }
2107            }
2108        }
2109        groups
2110    }
2111
2112    fn push_group(&self, groups: &mut Vec<String>, value: Option<&str>, prefix: &str) {
2113        if let Some(val) = value {
2114            let entry = format!("{prefix}{val}");
2115            if !groups.contains(&entry) {
2116                groups.push(entry);
2117            }
2118        }
2119    }
2120
2121    fn string_field(&self, record: &serde_json::Value, key: &str) -> Option<String> {
2122        record.get(key)?.as_str().map(|value| value.to_string())
2123    }
2124
2125    fn nested_field(&self, record: &serde_json::Value, path: &[&str]) -> Option<String> {
2126        let mut cursor = record;
2127        for segment in path.iter().take(path.len().saturating_sub(1)) {
2128            cursor = cursor.get(*segment)?;
2129        }
2130        cursor
2131            .get(*path.last()?)
2132            .and_then(|value| value.as_str().map(|s| s.to_string()))
2133    }
2134
2135    fn primary_ip_field(&self, record: &serde_json::Value, field: &str) -> Option<String> {
2136        let address = record.get(field)?.get("address")?.as_str()?;
2137        Some(address.split('/').next().unwrap_or(address).to_string())
2138    }
2139
2140    fn primary_ip(&self, record: &serde_json::Value) -> Option<String> {
2141        self.primary_ip_field(record, "primary_ip4")
2142            .or_else(|| self.primary_ip_field(record, "primary_ip"))
2143            .or_else(|| self.primary_ip_field(record, "primary_ip6"))
2144    }
2145
2146    fn platform_value(
2147        &self,
2148        record: &serde_json::Value,
2149        platform_lookup: Option<&HashMap<String, String>>,
2150    ) -> Option<String> {
2151        let platform = record.get("platform")?;
2152        match platform {
2153            serde_json::Value::String(value) => Some(value.clone()),
2154            serde_json::Value::Object(map) => {
2155                if self.use_platform_napalm_driver
2156                    && let Some(slug) = map.get("slug").and_then(|v| v.as_str())
2157                    && let Some(lookup) = platform_lookup
2158                    && let Some(driver) = lookup.get(slug)
2159                {
2160                    return Some(driver.clone());
2161                }
2162                if self.use_platform_slug {
2163                    map.get("slug")
2164                        .and_then(|value| value.as_str())
2165                        .map(|s| s.to_string())
2166                        .or_else(|| {
2167                            map.get("name")
2168                                .and_then(|value| value.as_str())
2169                                .map(|s| s.to_string())
2170                        })
2171                } else {
2172                    map.get("name")
2173                        .and_then(|value| value.as_str())
2174                        .map(|s| s.to_string())
2175                }
2176            }
2177            _ => None,
2178        }
2179    }
2180
2181    fn tag_slug(value: &serde_json::Value) -> Option<String> {
2182        value
2183            .get("slug")
2184            .and_then(|field| field.as_str())
2185            .map(|slug| slug.to_string())
2186            .or_else(|| {
2187                value
2188                    .get("name")
2189                    .and_then(|field| field.as_str())
2190                    .map(|name| name.to_string())
2191            })
2192            .or_else(|| {
2193                value
2194                    .get("display")
2195                    .and_then(|field| field.as_str())
2196                    .map(|display| display.to_string())
2197            })
2198            .or_else(|| {
2199                value
2200                    .get("natural_slug")
2201                    .and_then(|field| field.as_str())
2202                    .map(|slug| slug.to_string())
2203            })
2204    }
2205
2206    fn load_overlays(&self) -> Result<Option<Inventory>, InventoryError> {
2207        let mut overlays = CompositeFileInventorySource::new();
2208        let mut has_files = false;
2209        if let Some(path) = &self.group_file
2210            && path.exists()
2211        {
2212            overlays = overlays.groups(path.clone())?;
2213            has_files = true;
2214        }
2215        if let Some(path) = &self.defaults_file
2216            && path.exists()
2217        {
2218            overlays = overlays.defaults(path.clone())?;
2219            has_files = true;
2220        }
2221        if has_files {
2222            overlays.load().map(Some)
2223        } else {
2224            Ok(None)
2225        }
2226    }
2227}
2228
2229impl InventorySource for NetboxInventorySource {
2230    fn load(&self) -> Result<Inventory, InventoryError> {
2231        let platform_lookup = if self.use_platform_napalm_driver {
2232            Some(self.fetch_platform_drivers()?)
2233        } else {
2234            None
2235        };
2236        let mut inventory = Inventory::default();
2237        for device in self.fetch_collection(NETBOX_DEVICES_ENDPOINT, &self.device_filters)? {
2238            let host = self.build_device_host(&device, platform_lookup.as_ref())?;
2239            inventory.hosts.insert(host.name.clone(), host);
2240        }
2241        if self.include_virtual_machines {
2242            for vm in self.fetch_collection(NETBOX_VMS_ENDPOINT, &self.vm_filters)? {
2243                let host = self.build_vm_host(&vm, platform_lookup.as_ref())?;
2244                inventory.hosts.insert(host.name.clone(), host);
2245            }
2246        }
2247        if let Some(overlays) = self.load_overlays()? {
2248            inventory.merge(overlays);
2249        }
2250        Ok(inventory)
2251    }
2252
2253    fn name(&self) -> &str {
2254        self.base_url.as_str()
2255    }
2256}
2257
2258#[derive(Deserialize)]
2259struct NetboxListResponse {
2260    next: Option<String>,
2261    results: Vec<serde_json::Value>,
2262}
2263
2264#[derive(Clone, Copy, PartialEq, Eq)]
2265enum NetboxObjectKind {
2266    Device,
2267    VirtualMachine,
2268}
2269
2270impl NetboxObjectKind {
2271    fn as_str(&self) -> &'static str {
2272        match self {
2273            NetboxObjectKind::Device => "device",
2274            NetboxObjectKind::VirtualMachine => "virtual_machine",
2275        }
2276    }
2277}
2278
2279#[cfg(test)]
2280mod tests {
2281    use super::*;
2282    use serde_json::json;
2283    use std::{fs, io::Write, path::Path, time::Duration};
2284    use tempfile::{NamedTempFile, tempdir};
2285
2286    #[test]
2287    fn merges_host_data() {
2288        let mut base = Inventory::default();
2289        base.hosts.insert(
2290            "r1".into(),
2291            Host {
2292                name: "r1".into(),
2293                hostname: Some("10.0.0.1".into()),
2294                port: Some(22),
2295                ..Host::default()
2296            },
2297        );
2298
2299        let mut override_inv = Inventory::default();
2300        override_inv.hosts.insert(
2301            "r1".into(),
2302            Host {
2303                name: "r1".into(),
2304                platform: Some("ios".into()),
2305                port: Some(2222),
2306                ..Host::default()
2307            },
2308        );
2309
2310        base.merge(override_inv);
2311
2312        let host = base.host("r1").unwrap();
2313        assert_eq!(host.hostname.as_deref(), Some("10.0.0.1"));
2314        assert_eq!(host.platform.as_deref(), Some("ios"));
2315        assert_eq!(host.port, Some(2222));
2316    }
2317
2318    #[test]
2319    fn merges_group_and_default_credentials() {
2320        let mut inventory = Inventory::default();
2321        inventory.groups.insert(
2322            "core".into(),
2323            Group {
2324                name: "core".into(),
2325                credentials: Some(Credentials {
2326                    username: Some("group-user".into()),
2327                    password: Some(Secret::new("group-pass")),
2328                    ..Credentials::default()
2329                }),
2330                ..Group::default()
2331            },
2332        );
2333
2334        let mut override_inv = Inventory::default();
2335        override_inv.defaults.credentials = Some(Credentials {
2336            username: Some("default-user".into()),
2337            password: Some(Secret::new("default-pass")),
2338            private_key: Some(Secret::new("key")),
2339            enable_secret: None,
2340        });
2341        override_inv.groups.insert(
2342            "core".into(),
2343            Group {
2344                name: "core".into(),
2345                credentials: Some(Credentials {
2346                    username: Some("override-user".into()),
2347                    ..Credentials::default()
2348                }),
2349                ..Group::default()
2350            },
2351        );
2352
2353        inventory.merge(override_inv);
2354
2355        let group = inventory.groups.get("core").unwrap();
2356        assert_eq!(
2357            group.credentials.as_ref().unwrap().username.as_deref(),
2358            Some("override-user")
2359        );
2360        assert_eq!(
2361            group
2362                .credentials
2363                .as_ref()
2364                .unwrap()
2365                .password
2366                .as_ref()
2367                .unwrap()
2368                .as_str(),
2369            Some("group-pass")
2370        );
2371        assert_eq!(
2372            inventory
2373                .defaults
2374                .credentials
2375                .as_ref()
2376                .unwrap()
2377                .private_key
2378                .as_ref()
2379                .unwrap()
2380                .as_str(),
2381            Some("key")
2382        );
2383    }
2384
2385    #[test]
2386    fn merges_connection_parameters() {
2387        let mut inventory = Inventory::default();
2388        inventory.defaults.connections.insert(
2389            "ssh".into(),
2390            TransportSettings {
2391                transport: "ssh".into(),
2392                params: [("port".into(), json!(22))].into_iter().collect(),
2393            },
2394        );
2395
2396        let mut override_inv = Inventory::default();
2397        override_inv.defaults.connections.insert(
2398            "ssh".into(),
2399            TransportSettings {
2400                transport: "".into(),
2401                params: [("timeout".into(), json!(30))].into_iter().collect(),
2402            },
2403        );
2404
2405        inventory.merge(override_inv);
2406
2407        let conn = inventory.defaults.connections.get("ssh").unwrap();
2408        assert_eq!(conn.transport, "ssh");
2409        assert_eq!(conn.params.get("port").unwrap(), &json!(22));
2410        assert_eq!(conn.params.get("timeout").unwrap(), &json!(30));
2411    }
2412
2413    #[test]
2414    fn transport_settings_params_to_struct() {
2415        #[derive(Deserialize, PartialEq, Debug)]
2416        struct Config {
2417            username: String,
2418            retries: Option<u8>,
2419        }
2420
2421        let mut params = Variables::default();
2422        params.insert("username".into(), json!("automation"));
2423        params.insert("retries".into(), json!(3));
2424
2425        let settings = TransportSettings {
2426            transport: "ssh".into(),
2427            params,
2428        };
2429
2430        let config: Config = settings.params_as().unwrap();
2431        assert_eq!(
2432            config,
2433            Config {
2434                username: "automation".into(),
2435                retries: Some(3)
2436            }
2437        );
2438    }
2439
2440    #[test]
2441    fn builder_combines_sources() {
2442        let mut first = Inventory::default();
2443        first.hosts.insert(
2444            "r1".into(),
2445            Host {
2446                name: "r1".into(),
2447                hostname: Some("10.0.0.1".into()),
2448                ..Host::default()
2449            },
2450        );
2451
2452        let mut second = Inventory::default();
2453        second.hosts.insert(
2454            "r2".into(),
2455            Host {
2456                name: "r2".into(),
2457                hostname: Some("10.0.0.2".into()),
2458                ..Host::default()
2459            },
2460        );
2461
2462        let built = InventoryBuilder::new()
2463            .with_source(StaticInventorySource::new("first", first))
2464            .with_source(StaticInventorySource::new("second", second))
2465            .build()
2466            .unwrap();
2467
2468        assert!(built.host("r1").is_some());
2469        assert!(built.host("r2").is_some());
2470    }
2471
2472    #[test]
2473    fn host_inherits_group_and_defaults_data() {
2474        let mut hosts_inv = Inventory::default();
2475        hosts_inv.hosts.insert(
2476            "r1".into(),
2477            Host {
2478                name: "r1".into(),
2479                groups: vec!["core".into()],
2480                ..Host::default()
2481            },
2482        );
2483
2484        let mut groups_inv = Inventory::default();
2485        groups_inv.groups.insert(
2486            "core".into(),
2487            Group {
2488                name: "core".into(),
2489                data: {
2490                    let mut data = Variables::default();
2491                    data.insert("tier".into(), json!("core"));
2492                    data
2493                },
2494                ..Group::default()
2495            },
2496        );
2497
2498        let mut defaults_inv = Inventory::default();
2499        defaults_inv
2500            .defaults
2501            .data
2502            .insert("owner".into(), json!("netops"));
2503
2504        let built = InventoryBuilder::new()
2505            .with_source(StaticInventorySource::new("hosts", hosts_inv))
2506            .with_source(StaticInventorySource::new("groups", groups_inv))
2507            .with_source(StaticInventorySource::new("defaults", defaults_inv))
2508            .build()
2509            .unwrap();
2510
2511        let host = built.host("r1").expect("host");
2512        assert_eq!(host.data.get("tier").unwrap(), "core");
2513        assert_eq!(host.data.get("owner").unwrap(), "netops");
2514    }
2515
2516    #[test]
2517    fn io_errors_note_path() {
2518        let err = io_error_with_path(
2519            std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
2520            Path::new("/tmp/demo.yaml"),
2521        );
2522        assert!(
2523            err.to_string().contains("/tmp/demo.yaml"),
2524            "io error should mention path: {}",
2525            err
2526        );
2527    }
2528
2529    #[test]
2530    fn file_loader_reads_yaml() {
2531        let mut file = NamedTempFile::new().unwrap();
2532        let yaml = r#"
2533hosts:
2534  r1:
2535    name: r1
2536    hostname: 10.0.0.5
2537defaults:
2538  data:
2539    env: lab
2540"#;
2541        file.write_all(yaml.as_bytes()).unwrap();
2542
2543        let source = FileInventorySource::new(file.path(), InventoryFormat::Yaml);
2544        let inventory = source.load().unwrap();
2545
2546        assert_eq!(
2547            inventory.host("r1").unwrap().hostname.as_deref(),
2548            Some("10.0.0.5")
2549        );
2550        assert_eq!(inventory.defaults.data.get("env").unwrap(), "lab");
2551    }
2552
2553    #[test]
2554    fn file_loader_reads_sequence_hosts() {
2555        let mut file = NamedTempFile::new().unwrap();
2556        let yaml = r#"
2557hosts:
2558  - name: core-rt
2559    hostname: 10.0.0.6
2560    port: 2222
2561"#;
2562        file.write_all(yaml.as_bytes()).unwrap();
2563
2564        let source = FileInventorySource::new(file.path(), InventoryFormat::Yaml);
2565        let inventory = source.load().unwrap();
2566        let host = inventory.host("core-rt").unwrap();
2567        assert_eq!(host.hostname.as_deref(), Some("10.0.0.6"));
2568        assert_eq!(host.port, Some(2222));
2569    }
2570
2571    #[test]
2572    fn composite_file_source_merges_parts() {
2573        let dir = tempdir().unwrap();
2574        let hosts_path = dir.path().join("hosts.yaml");
2575        let groups_path = dir.path().join("groups.yaml");
2576        let defaults_path = dir.path().join("defaults.yaml");
2577
2578        fs::write(
2579            &hosts_path,
2580            r#"
2581r1:
2582  hostname: 10.0.0.8
2583  groups: ["core"]
2584"#,
2585        )
2586        .unwrap();
2587
2588        fs::write(
2589            &groups_path,
2590            r#"
2591core:
2592  data:
2593    role: spine
2594"#,
2595        )
2596        .unwrap();
2597
2598        fs::write(
2599            &defaults_path,
2600            r#"
2601data:
2602  owner: neteng
2603"#,
2604        )
2605        .unwrap();
2606
2607        let source = CompositeFileInventorySource::new()
2608            .hosts(&hosts_path)
2609            .unwrap()
2610            .groups(&groups_path)
2611            .unwrap()
2612            .defaults(&defaults_path)
2613            .unwrap();
2614
2615        let inventory = source.load().unwrap();
2616
2617        assert_eq!(
2618            inventory.host("r1").unwrap().hostname.as_deref(),
2619            Some("10.0.0.8")
2620        );
2621        assert_eq!(
2622            inventory
2623                .groups
2624                .get("core")
2625                .unwrap()
2626                .data
2627                .get("role")
2628                .unwrap(),
2629            "spine"
2630        );
2631        assert_eq!(inventory.defaults.data.get("owner").unwrap(), "neteng");
2632    }
2633
2634    #[test]
2635    fn http_source_parses_payload() {
2636        let inventory = parse_inventory_body(
2637            InventoryFormat::Json,
2638            r#"{
2639                    "hosts": {
2640                        "r1": {
2641                            "name": "r1",
2642                            "hostname": "10.0.0.9"
2643                        }
2644                    }
2645                }"#,
2646        )
2647        .unwrap();
2648
2649        assert_eq!(
2650            inventory.host("r1").unwrap().hostname.as_deref(),
2651            Some("10.0.0.9")
2652        );
2653    }
2654
2655    #[test]
2656    fn inventory_format_parses_labels() {
2657        assert!(matches!(
2658            InventoryFormat::from_label("YAML"),
2659            Some(InventoryFormat::Yaml)
2660        ));
2661        assert!(matches!(
2662            InventoryFormat::from_label("json"),
2663            Some(InventoryFormat::Json)
2664        ));
2665        assert!(InventoryFormat::from_label("bogus").is_none());
2666    }
2667
2668    #[test]
2669    fn secret_deserializes_from_string() {
2670        #[derive(Deserialize)]
2671        struct Wrapper {
2672            password: Secret,
2673        }
2674        let parsed: Wrapper = serde_yaml::from_str("password: hunter2").unwrap();
2675        assert_eq!(parsed.password.as_str(), Some("hunter2"));
2676        assert!(parsed.password.reference.is_none());
2677    }
2678
2679    #[test]
2680    fn secret_supports_reference_fields() {
2681        let yaml = r#"
2682password:
2683  ref: netops/password
2684  provider: vault
2685  optional: true
2686"#;
2687        #[derive(Deserialize)]
2688        struct Wrapper {
2689            password: Secret,
2690        }
2691        let parsed: Wrapper = serde_yaml::from_str(yaml).unwrap();
2692        let reference = parsed.password.reference.as_ref().unwrap();
2693        assert_eq!(reference.key, "netops/password");
2694        assert_eq!(reference.provider.as_deref(), Some("vault"));
2695        assert!(reference.optional);
2696        assert!(parsed.password.as_str().is_none());
2697    }
2698
2699    #[test]
2700    fn nautobot_device_host_merges_expected_fields() {
2701        let source = NautobotInventorySource::new(NautobotInventoryConfig {
2702            base_url: "https://example/api/".into(),
2703            token: "abc123".into(),
2704            verify_tls: true,
2705            timeout: Duration::from_secs(1),
2706            include_virtual_machines: true,
2707            flatten_custom_fields: true,
2708            tags_as_groups: true,
2709            sites_as_groups: true,
2710            roles_as_groups: true,
2711            tenants_as_groups: true,
2712            page_size: 50,
2713            device_filters: Vec::new(),
2714            virtual_machine_filters: Vec::new(),
2715        })
2716        .unwrap();
2717        let record = serde_json::json!({
2718            "id": 1,
2719            "name": "core1-rt",
2720            "primary_ip4": { "address": "10.0.0.1/32" },
2721            "platform": { "slug": "iosxe" },
2722            "device_role": { "slug": "core" },
2723            "site": { "slug": "lab" },
2724            "tenant": { "slug": "test" },
2725            "status": { "value": "active" },
2726            "tags": [{ "slug": "edge" }],
2727            "custom_fields": { "region": "us-east" }
2728        });
2729        let host = source.build_device_host(&record).unwrap();
2730        assert_eq!(host.name, "core1-rt");
2731        assert_eq!(host.hostname.as_deref(), Some("10.0.0.1"));
2732        assert_eq!(host.platform.as_deref(), Some("iosxe"));
2733        assert!(host.groups.contains(&"role:core".into()));
2734        assert!(host.groups.contains(&"site:lab".into()));
2735        assert!(host.groups.contains(&"tag:edge".into()));
2736        assert!(host.groups.contains(&"tenant:test".into()));
2737        assert_eq!(
2738            host.data.get("region").and_then(|v| v.as_str()),
2739            Some("us-east")
2740        );
2741        assert_eq!(
2742            host.data
2743                .get("nautobot")
2744                .and_then(|v| v.get("name"))
2745                .and_then(|v| v.as_str()),
2746            Some("core1-rt")
2747        );
2748    }
2749
2750    #[test]
2751    fn nautobot_vm_host_sets_cluster_groups_and_primary_ip() {
2752        let source = NautobotInventorySource::new(NautobotInventoryConfig {
2753            base_url: "https://example/api/".into(),
2754            token: "xyz".into(),
2755            verify_tls: true,
2756            timeout: Duration::from_secs(1),
2757            include_virtual_machines: true,
2758            flatten_custom_fields: true,
2759            tags_as_groups: false,
2760            sites_as_groups: true,
2761            roles_as_groups: true,
2762            tenants_as_groups: true,
2763            page_size: 50,
2764            device_filters: Vec::new(),
2765            virtual_machine_filters: Vec::new(),
2766        })
2767        .unwrap();
2768        let record = serde_json::json!({
2769            "id": 2,
2770            "name": "dc1-vm1",
2771            "primary_ip": { "address": "192.0.2.10/32" },
2772            "platform": { "slug": "linux" },
2773            "role": { "slug": "app" },
2774            "cluster": { "slug": "compute" },
2775            "tenant": { "slug": "tenant1" },
2776            "status": { "value": "active" },
2777            "custom_fields": { "tier": "app" }
2778        });
2779        let host = source.build_vm_host(&record).unwrap();
2780        assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2781        assert!(host.groups.contains(&"role:app".into()));
2782        assert!(host.groups.contains(&"cluster:compute".into()));
2783        assert!(host.groups.contains(&"kind:virtual_machine".into()));
2784        assert_eq!(host.data.get("tier").and_then(|v| v.as_str()), Some("app"));
2785    }
2786
2787    #[test]
2788    fn netbox_device_host_sets_expected_fields() {
2789        let source = NetboxInventorySource::new(NetboxInventoryConfig {
2790            base_url: "https://netbox/api/".into(),
2791            token: "abc123".into(),
2792            verify_tls: true,
2793            timeout: Duration::from_secs(1),
2794            include_virtual_machines: true,
2795            flatten_custom_fields: true,
2796            tags_as_groups: true,
2797            sites_as_groups: true,
2798            roles_as_groups: true,
2799            tenants_as_groups: true,
2800            use_platform_slug: true,
2801            use_platform_napalm_driver: false,
2802            page_size: 50,
2803            device_filters: Vec::new(),
2804            virtual_machine_filters: Vec::new(),
2805            group_file: None,
2806            defaults_file: None,
2807        })
2808        .unwrap();
2809        let record = serde_json::json!({
2810            "id": 101,
2811            "name": "dc1-edge-1",
2812            "primary_ip": { "address": "10.10.10.10/32" },
2813            "platform": { "slug": "iosxe", "name": "IOS-XE" },
2814            "device_role": { "slug": "edge" },
2815            "site": { "slug": "lab" },
2816            "tenant": { "slug": "prod" },
2817            "device_type": {
2818                "slug": "c9300",
2819                "manufacturer": { "slug": "cisco" }
2820            },
2821            "tags": [{ "slug": "core" }],
2822            "custom_fields": { "region": "us-east" }
2823        });
2824        let host = source.build_device_host(&record, None).unwrap();
2825        assert_eq!(host.name, "dc1-edge-1");
2826        assert_eq!(host.hostname.as_deref(), Some("10.10.10.10"));
2827        assert!(host.groups.contains(&"site:lab".into()));
2828        assert!(host.groups.contains(&"role:edge".into()));
2829        assert!(host.groups.contains(&"manufacturer:cisco".into()));
2830        assert!(host.groups.contains(&"tag:core".into()));
2831        assert_eq!(
2832            host.data.get("region").and_then(|value| value.as_str()),
2833            Some("us-east")
2834        );
2835        assert_eq!(
2836            host.data
2837                .get("netbox")
2838                .and_then(|value| value.get("name"))
2839                .and_then(|value| value.as_str()),
2840            Some("dc1-edge-1")
2841        );
2842    }
2843
2844    #[test]
2845    fn netbox_vm_host_sets_cluster_group() {
2846        let source = NetboxInventorySource::new(NetboxInventoryConfig {
2847            base_url: "https://netbox/api/".into(),
2848            token: "xyz".into(),
2849            verify_tls: true,
2850            timeout: Duration::from_secs(1),
2851            include_virtual_machines: true,
2852            flatten_custom_fields: false,
2853            tags_as_groups: false,
2854            sites_as_groups: true,
2855            roles_as_groups: true,
2856            tenants_as_groups: false,
2857            use_platform_slug: true,
2858            use_platform_napalm_driver: false,
2859            page_size: 50,
2860            device_filters: Vec::new(),
2861            virtual_machine_filters: Vec::new(),
2862            group_file: None,
2863            defaults_file: None,
2864        })
2865        .unwrap();
2866        let record = serde_json::json!({
2867            "id": 202,
2868            "name": "app-vm1",
2869            "primary_ip4": { "address": "192.0.2.10/32" },
2870            "role": { "slug": "app" },
2871            "cluster": { "slug": "compute" },
2872            "custom_fields": {}
2873        });
2874        let host = source.build_vm_host(&record, None).unwrap();
2875        assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2876        assert!(host.groups.contains(&"role:app".into()));
2877        assert!(host.groups.contains(&"cluster:compute".into()));
2878        assert!(host.groups.contains(&"kind:virtual_machine".into()));
2879    }
2880
2881    #[test]
2882    fn netbox_authorization_header_supports_bearer_tokens() {
2883        assert_eq!(
2884            NetboxInventorySource::format_auth_header("foo"),
2885            "Token foo"
2886        );
2887        assert_eq!(
2888            NetboxInventorySource::format_auth_header("nbt_abc123"),
2889            "Bearer nbt_abc123"
2890        );
2891        assert_eq!(
2892            NetboxInventorySource::format_auth_header("Bearer nbt_token"),
2893            "Bearer nbt_token"
2894        );
2895    }
2896}