Skip to main content

netgauze_yang_push/cache/
storage.rs

1// Copyright (C) 2025-present The NetGauze Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//    http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
12// implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! YANG Library Cache Module
17//!
18//! This module provides functionality for caching and managing YANG library
19//! references and their associated schemas for YANG-Push collectors.
20//!
21//! # Overview
22//!
23//! The YANG Library Cache is designed to:
24//! - Store and retrieve YANG library references indexed by content ID
25//! - Associate YANG libraries with subscription information (peer address,
26//!   Target filters, module sets)
27//! - Persist YANG libraries and schemas to disk for durability
28//! - Load cached YANG libraries from disk on startup
29//! - Support multiple subscriptions referencing the same YANG library.
30//!
31//! # Key Components
32//!
33//! - [`YangLibraryCache`]: The main cache structure that manages YANG library
34//!   references, providing both in-memory indexing and persistent disk storage.
35//!
36//! - [`YangLibraryReference`]: A reference to a YANG Library stored on disk,
37//!   containing the content ID, file path, and search directory for YANG
38//!   modules.
39//!
40//! - [`SubscriptionInfo`]: Metadata about a YANG Push subscription, including
41//!   peer address, content ID, Target filter, and associated YANG modules.
42//!
43//! # Storage Layout
44//!
45//! The cache persists data to disk in a structured directory hierarchy:
46//!
47//! ```text
48//! <cache_root_path>/
49//!   ├── <content-id-1>/
50//!   │   ├── yang-lib.xml           # Serialized YANG library
51//!   │   ├── subscriptions-info.json # Subscriptions metadata
52//!   │   └── modules/               # YANG module files
53//!   │       ├── module1@revision.yang
54//!   │       └── module2@revision.yang
55//!   └── <content-id-2>/
56//!       └── ...
57//! ```
58//!
59//! # Error Handling
60//!
61//! The module defines [`YangLibraryCacheError`] to handle various failure
62//! modes:
63//! - Missing or invalid YANG library paths
64//! - Duplicate content IDs
65//! - XML parsing errors
66//! - Schema loading errors
67//! - I/O and serialization errors
68//!
69//! # Example Usage
70//!
71//! ```rust,ignore
72//! use std::path::PathBuf;
73//!
74//! // Create a new cache
75//! let mut cache = YangLibraryCache::new(PathBuf::from("/path/to/schemas"));
76//!
77//! // Or load existing cache from disk
78//! let mut cache = YangLibraryCache::from_disk(PathBuf::from("/path/to/schemas"))?;
79//!
80//! // Store a YANG library with subscription info, or append the subscription info to the existing one
81//! // if the YANG library already exists and is identical.
82//! let yang_lib_ref = cache.put_yang_library(subscription_info, yang_lib, schemas)?;
83//!
84//! // Retrieve by subscription info
85//! if let Some(yang_lib_ref) = cache.get_by_subscription_info(&subscription_info) {
86//!     let schemas = yang_lib_ref.load_schemas()?;
87//! }
88//!
89//! // Retrieve by content ID
90//! if let Some(yang_lib_ref) = cache.get_by_content_id(&content_id) {
91//!     let yang_library = yang_lib_ref.yang_library()?;
92//! }
93//! ```
94
95use crate::ContentId;
96use chrono::{DateTime, Utc};
97use netgauze_netconf_proto::xml_utils::{XmlDeserialize, XmlSerialize, XmlWriter};
98use netgauze_netconf_proto::yang_push::identities::{Encoding, Transport};
99use netgauze_netconf_proto::yang_push::subscription::{UpdateTrigger, YangPushModuleVersion};
100use netgauze_netconf_proto::yang_push::types::SubscriptionId;
101use netgauze_netconf_proto::yanglib::{SchemaLoadingError, YangLibrary};
102use netgauze_udp_notif_pkt::notification::Target;
103use quick_xml::NsReader;
104use rustc_hash::FxHashMap;
105use serde::{Deserialize, Serialize};
106use std::collections::{HashMap, HashSet};
107use std::io::Write;
108use std::net::{IpAddr, SocketAddr};
109use std::path::{Path, PathBuf};
110use std::sync::Arc;
111use tracing::{debug, info, trace, warn};
112
113/// Need to add this at the end of yang library to make it work with libyang.
114/// This is a temporary fix, hopefully new versions of libyang will fix this
115/// issue.
116const MODULE_STATE: &str = r#"
117<modules-state xmlns="urn:ietf:params:xml:ns:yang:ietf-yang-library">
118    <module-set-id>ALL</module-set-id>
119</modules-state>
120"#;
121
122// default file names for yang library file
123const YANG_LIBRARY_FILE_NAME: &str = "yang-lib.xml";
124
125// default file names for the subscription info file
126const SUBSCRIPTIONS_INFO_FILE_NAME: &str = "subscriptions-info.json";
127
128/// Error types for YANG Library Cache operations.
129///
130/// This enum captures all the ways that YANG library cache operations can fail,
131/// including file system errors, parsing errors, and validation errors.
132#[derive(Debug, strum_macros::Display)]
133pub enum YangLibraryCacheError {
134    #[strum(to_string = "yang library is not found at path '{0:?}'")]
135    YangLibraryPathNotFound(PathBuf),
136
137    #[strum(to_string = "yang library at path '{0:?}' is not a file")]
138    YangLibraryPathIsNotValid(PathBuf),
139
140    #[strum(to_string = "yang library search path '{0:?}' is not found")]
141    YangLibrarySearchPathNotFound(PathBuf),
142
143    #[strum(to_string = "yang library search path '{0:?}' is not a directory")]
144    YangLibrarySearchPathIsNotValid(PathBuf),
145
146    #[strum(to_string = "duplicate yang library at path '{0:?}', check content id to be unique")]
147    DuplicateYangLibrary(PathBuf),
148
149    #[strum(to_string = "xml parsing error while parsing the yang library: {0}")]
150    ParsingError(netgauze_netconf_proto::xml_utils::ParsingError),
151
152    #[strum(to_string = "schema files loading error: {0}")]
153    SchemaLoadingError(SchemaLoadingError),
154
155    #[strum(to_string = "io error: {0}")]
156    IoError(std::io::Error),
157
158    #[strum(to_string = "quick-xml error: {0}")]
159    QuickXmlError(quick_xml::Error),
160
161    #[strum(to_string = "serde json error: {0}")]
162    SerdeJsonError(serde_json::Error),
163
164    #[strum(to_string = "failed to connect to netconf server: {0}")]
165    NetConfClientError(netgauze_netconf_proto::client::NetConfSshClientError),
166}
167
168impl std::error::Error for YangLibraryCacheError {}
169
170impl From<netgauze_netconf_proto::xml_utils::ParsingError> for YangLibraryCacheError {
171    fn from(err: netgauze_netconf_proto::xml_utils::ParsingError) -> Self {
172        YangLibraryCacheError::ParsingError(err)
173    }
174}
175
176impl From<std::io::Error> for YangLibraryCacheError {
177    fn from(err: std::io::Error) -> Self {
178        YangLibraryCacheError::IoError(err)
179    }
180}
181
182impl From<quick_xml::Error> for YangLibraryCacheError {
183    fn from(err: quick_xml::Error) -> Self {
184        YangLibraryCacheError::QuickXmlError(err)
185    }
186}
187
188impl From<serde_json::Error> for YangLibraryCacheError {
189    fn from(err: serde_json::Error) -> Self {
190        YangLibraryCacheError::SerdeJsonError(err)
191    }
192}
193
194impl From<SchemaLoadingError> for YangLibraryCacheError {
195    fn from(err: SchemaLoadingError) -> Self {
196        YangLibraryCacheError::SchemaLoadingError(err)
197    }
198}
199
200impl From<netgauze_netconf_proto::client::NetConfSshClientError> for YangLibraryCacheError {
201    fn from(err: netgauze_netconf_proto::client::NetConfSshClientError) -> Self {
202        YangLibraryCacheError::NetConfClientError(err)
203    }
204}
205
206impl From<tokio::time::error::Elapsed> for YangLibraryCacheError {
207    fn from(_err: tokio::time::error::Elapsed) -> Self {
208        YangLibraryCacheError::IoError(std::io::Error::new(
209            std::io::ErrorKind::TimedOut,
210            "timeout while waiting for yang library cache lock",
211        ))
212    }
213}
214
215/// A reference to a YANG Library stored on disk.
216///
217/// This structure provides a lightweight handle to a YANG library without
218/// loading all the module contents into memory. It stores the essential
219/// information needed to locate and load the YANG library and its associated
220/// modules on demand.
221///
222/// # Multi-Subscription Support
223///
224/// A single `YangLibraryReference` can be associated with multiple
225/// subscriptions from different peers, as long as they all use the same YANG
226/// library (content-id).
227///
228/// - [`subscriptions_info`](Self::subscriptions_info): Retrieve all associated
229///   subscriptions
230/// - [`append_subscription_info`](Self::append_subscription_info): Add another
231///   subscription
232/// - [`save_to_disk`](Self::save_to_disk): Persist YANG library and
233///   subscriptions
234///
235/// # Components
236///
237/// - **Content ID**: A unique identifier for the YANG library, extracted from
238///   the library itself during construction to ensure consistency.
239///
240/// - **YANG Library Path**: The filesystem path to the serialized YANG library
241///   XML file (`yang-lib.xml`).
242///
243/// - **YANG Library Reference Directory**: The directory containing the
244///   `yang-lib.xml`, `subscriptions-info.json` and `modules` directory
245///   containing YANG modules referenced by the library. Modules are loaded from
246///   `{yang_lib_ref_path}/modules ` in this directory when
247///   [`load_schemas`](Self::load_schemas) is called.
248///
249/// # Lazy Loading
250///
251/// The reference is designed for lazy loading - it validates that the paths
252/// exist and extracts the content ID during construction, but does not load
253/// the full YANG library or module contents until explicitly requested via
254/// [`yang_library`](Self::yang_library) or
255/// [`load_schemas`](Self::load_schemas).
256///
257/// # Example
258///
259/// ```rust,ignore
260/// use std::path::PathBuf;
261///
262/// // Create a reference to an existing YANG library on disk
263/// let yang_lib_ref = YangLibraryReference::new(
264///     PathBuf::from("/schemas/content-id-123/yang-lib.xml"),
265///     PathBuf::from("/schemas/content-id-123"),
266/// )?;
267///
268/// // Access the content ID (available immediately)
269/// let content_id = yang_lib_ref.content_id();
270///
271/// // Load the full YANG library when needed
272/// let yang_library = yang_lib_ref.yang_library()?;
273///
274/// // Load all module schemas from disk
275/// let schemas = yang_lib_ref.load_schemas()?;
276/// ```
277///
278/// # Errors
279///
280/// Construction will fail if:
281/// - The YANG library path does not exist or is not a file
282/// - The search directory does not exist or is not a directory
283/// - The YANG library XML cannot be parsed
284#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
285pub struct YangLibraryReference {
286    content_id: ContentId,
287    yang_library_path: PathBuf,
288    yang_lib_ref_path: PathBuf,
289}
290
291impl YangLibraryReference {
292    /// Load a YANG Library Reference from the given YANG Library file path
293    /// and root directory for the YANG Library reference.
294    ///
295    /// Note: the content ID is read from the YANG Library file to ensure its
296    /// correctness.
297    ///
298    /// This does not load the YANG modules themselves, only the reference to
299    /// them.
300    pub fn load_from_disk(
301        yang_library_path: PathBuf,
302        yang_lib_ref_path: PathBuf,
303    ) -> Result<Self, YangLibraryCacheError> {
304        if !yang_library_path.exists() {
305            warn!(
306                yang_library_path = %yang_library_path.display(),
307                "YANG library not found at path"
308            );
309            return Err(YangLibraryCacheError::YangLibraryPathNotFound(
310                yang_library_path,
311            ));
312        }
313        if !yang_library_path.is_file() {
314            warn!(
315                yang_library_path = %yang_library_path.display(),
316                "YANG library path is not a file"
317            );
318            return Err(YangLibraryCacheError::YangLibraryPathIsNotValid(
319                yang_library_path,
320            ));
321        }
322        if !yang_lib_ref_path.exists() {
323            warn!(
324                 yang_lib_ref_path=%yang_lib_ref_path.display(),
325                "YANG library reference path not found"
326            );
327            return Err(YangLibraryCacheError::YangLibrarySearchPathNotFound(
328                yang_lib_ref_path,
329            ));
330        }
331        if !yang_lib_ref_path.is_dir() {
332            warn!(
333                yang_lib_ref_path=%yang_lib_ref_path.display(),
334                "YANG library reference path is not a directory"
335            );
336            return Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(
337                yang_lib_ref_path,
338            ));
339        }
340        debug!(
341            yang_library_path=%yang_library_path.display(),
342            yang_lib_ref_path= %yang_lib_ref_path.display(),
343            "loading YANG library reference",
344        );
345        let subscriptions_info_path = yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
346        let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path()).map_err(|error| {
347            warn!(
348                subscriptions_info_path=%subscriptions_info_path.display(), "failed to open subscriptions info file");
349            std::io::Error::other(format!(
350                "failed to open subscriptions info file {}: {error}",
351                subscriptions_info_path.display()
352            ))
353        })?;
354        let subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(subscriptions_info_file).map_err(|error| {
355           warn!(subscriptions_info_path=%subscriptions_info_path.display(), error=%error, "failed to parse subscriptions info file");
356            YangLibraryCacheError::SerdeJsonError(error)
357        })?;
358        let yang_library = Self::load_yang_library(&yang_library_path)?;
359        let content_id = yang_library.content_id().into();
360        info!(
361            yang_library_path=%yang_library_path.display(),
362            yang_lib_ref_path=%yang_lib_ref_path.display(),
363            content_id,
364            subscriptions_count=subscriptions_info.len(),
365            "loaded YANG library reference",
366        );
367        Ok(Self {
368            content_id,
369            yang_library_path,
370            yang_lib_ref_path,
371        })
372    }
373
374    /// Create and save a YANG Library Reference to disk.
375    ///
376    /// This will create the necessary directory structure and files to store
377    /// the YANG library, subscription info, and YANG modules.
378    ///
379    /// If the YANG Library already exists on disk and differs from the provided
380    /// YANG Library, an error is returned to prevent overwriting.
381    ///
382    /// If the YANG Library already exists and is identical, the existing
383    /// reference is returned and the subscription info is extended with the new
384    /// subscriptions.
385    pub fn save_to_disk(
386        yang_lib_ref_path: PathBuf,
387        yang_library: &YangLibrary,
388        schemas: HashMap<Box<str>, Box<str>>,
389        subscriptions_info: &[SubscriptionInfo],
390    ) -> Result<Self, YangLibraryCacheError> {
391        if !yang_lib_ref_path.exists() {
392            std::fs::create_dir_all(&yang_lib_ref_path).map_err(|error| {
393                warn!(
394                    yang_lib_ref_path=%yang_lib_ref_path.display(),
395                    content_id=yang_library.content_id(),
396                    "failed to create YANG Library Reference dir");
397                std::io::Error::other(format!(
398                    "failed to create YANG Library Reference {} dir {}: {error}",
399                    yang_library.content_id(),
400                    yang_lib_ref_path.display()
401                ))
402            })?
403        }
404        if !yang_lib_ref_path.is_dir() {
405            warn!(
406                yang_lib_ref_path=%yang_lib_ref_path.display(),
407                "YANG library reference path is not a directory"
408            );
409            return Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(
410                yang_lib_ref_path,
411            ));
412        }
413        let yang_library_path = yang_lib_ref_path.join(YANG_LIBRARY_FILE_NAME);
414        if yang_library_path.exists() {
415            if !yang_library_path.is_file() {
416                warn!(
417                    yang_library_path = %yang_library_path.display(),
418                    "YANG library path is not a file"
419                );
420                return Err(YangLibraryCacheError::YangLibraryPathIsNotValid(
421                    yang_library_path,
422                ));
423            }
424            let loaded_yang_lib = Self::load_yang_library(yang_library_path.as_path())?;
425            if &loaded_yang_lib != yang_library {
426                warn!(
427                    yang_library_path = %yang_library_path.display(),
428                    "Different YANG library found, cannot override"
429                );
430                return Err(YangLibraryCacheError::DuplicateYangLibrary(
431                    yang_library_path.clone(),
432                ));
433            }
434        } else {
435            let file = std::fs::File::create(&yang_library_path)?;
436            let writer = std::io::BufWriter::new(file);
437            let quick_xml_writer = quick_xml::writer::Writer::new_with_indent(writer, 32, 2);
438            let mut xml_writer = XmlWriter::new(quick_xml_writer);
439            yang_library.xml_serialize(&mut xml_writer)?;
440            let mut inner = xml_writer.into_inner();
441            inner.write_all(MODULE_STATE.as_bytes())?;
442            inner.flush()?;
443        }
444
445        let subscriptions_info_path = yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
446        let subscriptions_info = if subscriptions_info_path.is_file() {
447            debug!(
448                subscriptions_info_path=%subscriptions_info_path.display(),
449                content_id=yang_library.content_id(),
450                "subscriptions info file already exists, extending it with new subscriptions"
451            );
452            let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path())
453                .map_err(|error| {
454                    tracing::error!(
455                    subscriptions_info_path=%subscriptions_info_path.display(),
456                    error=%error,
457                    "failed to open subscriptions info file");
458                    std::io::Error::other(format!(
459                        "failed to open subscriptions info file {}: {error}",
460                        subscriptions_info_path.display()
461                    ))
462                })?;
463            let mut read_subscriptions_info: HashSet<SubscriptionInfo> =
464                serde_json::from_reader(subscriptions_info_file).map_err(|error| {
465                    tracing::error!(
466                        subscriptions_info_path=%subscriptions_info_path.display(),
467                        error=%error,
468                        "failed to parse subscriptions info file"
469                    );
470                    error
471                })?;
472            read_subscriptions_info.extend(subscriptions_info.iter().cloned());
473            &read_subscriptions_info
474                .into_iter()
475                .collect::<Vec<SubscriptionInfo>>()
476        } else {
477            subscriptions_info
478        };
479
480        let subscriptions_info_file = std::fs::File::create(subscriptions_info_path.as_path())
481            .map_err(|error| {
482                warn!(
483                subscriptions_info_path=%subscriptions_info_path.display(),
484                error=%error,
485                "failed to open subscriptions info file");
486                std::io::Error::other(format!(
487                    "failed to open subscriptions info file {}: {error}",
488                    subscriptions_info_path.display()
489                ))
490            })?;
491        serde_json::to_writer_pretty(subscriptions_info_file, subscriptions_info)?;
492
493        let modules_path = yang_lib_ref_path.join("modules");
494        std::fs::create_dir_all(&modules_path)?;
495        for (name, schema) in &schemas {
496            let mut revision = None;
497            if let Some(module) = yang_library.find_module(name.as_ref()) {
498                revision = module.revision();
499            } else if let Some(import_only_modules) = yang_library.find_import_module(name.as_ref())
500            {
501                if let Some(import_only_module) = import_only_modules.into_iter().next() {
502                    revision = import_only_module.revision();
503                }
504            } else if let Some(submodule) = yang_library.find_submodule(name.as_ref()) {
505                revision = submodule.revision();
506            }
507            let filename = if let Some(revision) = revision {
508                format!("{name}@{revision}.yang")
509            } else {
510                name.to_string()
511            };
512            let yang_module_path = modules_path.join(&filename);
513            std::fs::write(&yang_module_path, schema.as_ref())?;
514            debug!(yang_module_path = %yang_module_path.display(), "saved yang module to disk");
515        }
516        info!(
517            content_id=yang_library.content_id(),
518            modules_count = schemas.len(),
519            modules_path = %modules_path.display(),
520            "saved YANG modules to disk"
521        );
522
523        Ok(Self {
524            content_id: yang_library.content_id().into(),
525            yang_library_path,
526            yang_lib_ref_path,
527        })
528    }
529
530    // Load the YANG Library from the given path.
531    fn load_yang_library(yang_library_path: &Path) -> Result<YangLibrary, YangLibraryCacheError> {
532        let reader = NsReader::from_file(yang_library_path)
533            .map_err(|error| {
534                warn!(yang_library_path=%yang_library_path.display(), error=%error, "failed to open yang library file");
535                YangLibraryCacheError::ParsingError(error.into())
536            })?;
537        trace!(yang_library_path=%yang_library_path.display(), "parsing yang library from disk");
538        let mut xml_reader = netgauze_netconf_proto::xml_utils::XmlParser::new(reader)?;
539        let yang_library = YangLibrary::xml_deserialize(&mut xml_reader)?;
540        Ok(yang_library)
541    }
542    /// Get the content ID of the YANG Library.
543    pub const fn content_id(&self) -> &ContentId {
544        &self.content_id
545    }
546
547    /// Get the path to the YANG Library file.
548    pub fn yang_library_path(&self) -> &Path {
549        self.yang_library_path.as_path()
550    }
551
552    /// Load the YANG Library from disk.
553    pub fn yang_library(&self) -> Result<YangLibrary, YangLibraryCacheError> {
554        Self::load_yang_library(self.yang_library_path.as_path())
555    }
556
557    /// Get the path to the directory containing the YANG Library file.
558    pub fn yang_lib_ref_path(&self) -> &Path {
559        self.yang_lib_ref_path.as_path()
560    }
561
562    /// Get the path to the search directory containing the YANG modules.
563    pub fn search_dir(&self) -> PathBuf {
564        self.yang_lib_ref_path().join("modules")
565    }
566
567    ///Load the YANG schemas defined in the library from disk.
568    pub fn load_schemas(&self) -> Result<HashMap<Box<str>, Box<str>>, YangLibraryCacheError> {
569        let yang_library = Self::load_yang_library(self.yang_library_path.as_path())?;
570        let search_dir = self.search_dir();
571        info!(
572            search_dir = %search_dir.display(),
573            "loading yang schemas from search directory",
574        );
575
576        match yang_library.load_schemas_from_search_path(search_dir.as_path()) {
577            Ok(schemas) => {
578                debug!(
579                    schemas_count = schemas.len(),
580                    "loaded yang schemas successfully"
581                );
582                Ok(schemas)
583            }
584            Err(err) => {
585                warn!(
586                    search_dir = %search_dir.display(),
587                    error = %err,
588                    "failed to load yang schemas from search directory"
589                );
590                Err(err.into())
591            }
592        }
593    }
594
595    pub fn subscription_info_path(&self) -> PathBuf {
596        self.yang_lib_ref_path.join(SUBSCRIPTIONS_INFO_FILE_NAME)
597    }
598
599    pub fn subscriptions_info(&self) -> Result<Vec<SubscriptionInfo>, YangLibraryCacheError> {
600        let subscriptions_info_path = self.subscription_info_path();
601        debug!(
602            subscriptions_info_path = %subscriptions_info_path.display(),
603            content_id=self.content_id,
604            "loading subscription info from disk"
605        );
606        let subscriptions_info_file = std::fs::File::open(subscriptions_info_path.as_path())
607            .map_err(|error| {
608                std::io::Error::other(format!(
609                    "failed to read subscriptions info from {}: {error}",
610                    subscriptions_info_path.display()
611                ))
612            })?;
613        let subscriptions_info = serde_json::from_reader(subscriptions_info_file)?;
614        Ok(subscriptions_info)
615    }
616
617    /// Appends a subscription information entry to the persisted list.
618    ///
619    /// If the subscription info already exists, this operation is idempotent
620    /// (no duplicate entry is created). Uses file locking to prevent concurrent
621    /// modification issues and atomic writes for crash safety.
622    ///
623    /// # Errors
624    ///
625    /// Returns an error if:
626    /// - The subscription info file cannot be accessed
627    /// - JSON parsing/serialization fails
628    /// - File system operations fail
629    pub fn append_subscription_info(
630        &self,
631        subscription_info: SubscriptionInfo,
632    ) -> Result<(), YangLibraryCacheError> {
633        let subscriptions_info_path = self.subscription_info_path();
634
635        let file = std::fs::OpenOptions::new()
636            .read(true)
637            .write(true)
638            .create(true)
639            .truncate(false)
640            .open(&subscriptions_info_path)?;
641
642        file.lock().map_err(|e| {
643            YangLibraryCacheError::IoError(std::io::Error::other(format!(
644                "failed to acquire lock on {}: {e}",
645                subscriptions_info_path.display()
646            )))
647        })?;
648
649        // Ensure unlock on all paths
650        let result = (|| {
651            let mut subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(&file)?;
652
653            if subscriptions_info
654                .iter()
655                .any(|info| info == &subscription_info)
656            {
657                return Ok(());
658            }
659
660            subscriptions_info.push(subscription_info);
661
662            // Atomic write via a temp file
663            let temp_path = subscriptions_info_path.with_extension(".tmp");
664            let temp_file = std::fs::File::create(&temp_path)?;
665            serde_json::to_writer_pretty(temp_file, &subscriptions_info)?;
666            std::fs::rename(temp_path, &subscriptions_info_path)?;
667
668            Ok(())
669        })();
670
671        file.unlock()?;
672        result
673    }
674
675    /// Remove a subscription info from the YANG library reference.
676    ///
677    /// If the subscription info is found and removed, it is returned.
678    /// If not found, None is returned.
679    pub fn remove_subscription_info(
680        &self,
681        subscription_info: &SubscriptionInfo,
682    ) -> Result<Option<SubscriptionInfo>, YangLibraryCacheError> {
683        let subscriptions_info_path = self.subscription_info_path();
684
685        let file = std::fs::OpenOptions::new()
686            .read(true)
687            .write(true)
688            .open(&subscriptions_info_path)?;
689
690        file.lock().map_err(|e| {
691            YangLibraryCacheError::IoError(std::io::Error::other(format!(
692                "failed to acquire lock on {}: {e}",
693                subscriptions_info_path.display()
694            )))
695        })?;
696
697        let result = (|| {
698            let mut subscriptions_info: Vec<SubscriptionInfo> = serde_json::from_reader(&file)?;
699
700            let original_len = subscriptions_info.len();
701            subscriptions_info.retain(|info| info != subscription_info);
702
703            if subscriptions_info.len() == original_len {
704                return Ok(None);
705            }
706
707            // Atomic write
708            let temp_path = subscriptions_info_path.with_extension(".tmp");
709            let temp_file = std::fs::File::create(&temp_path)?;
710            serde_json::to_writer_pretty(temp_file, &subscriptions_info)?;
711            std::fs::rename(temp_path, &subscriptions_info_path)?;
712
713            Ok(Some(subscription_info.clone()))
714        })();
715
716        file.unlock()?;
717        result
718    }
719}
720
721/// Metadata about a YANG Push subscription.
722///
723/// This structure captures the essential information about a YANG Push
724/// subscription, used to uniquely identify and associate subscriptions with
725/// their corresponding YANG library references in the cache.
726///
727/// # Fields
728///
729/// - **collector**: The socket address (IP and port) of the YANG-Push collector
730///   that received the subscription.
731///
732/// - **interface**: The network interface name (if available) on which the
733///   subscription was received.
734///
735/// - **peer**: The socket address (IP and port) of the remote device that
736///   established the subscription. This identifies which network element sent
737///   the subscription-started notification.
738///
739/// - **content_id**: The YANG Library content identifier associated with this
740///   subscription. This links the subscription to a specific version of the
741///   device's YANG schema set.
742///
743/// - **target**: The [Target] filter expression that defines what data the
744///   subscription monitors. This determines which parts of the YANG data tree
745///   are included in notifications.
746///
747/// - **models**: A list of YANG module names that are relevant to this
748///   subscription. These modules define the schema for the subscribed data.
749///
750/// # Identity and Equality
751///
752/// `SubscriptionInfo` implements `Eq` and `Hash`, allowing it to be used as
753/// a key in hash maps. Two subscription infos are considered equal if all
754/// their fields match exactly.
755///
756/// # Serialization
757///
758/// This structure supports JSON serialization via serde, enabling persistent
759/// storage of subscription metadata to disk alongside the YANG library files.
760///
761/// # Example
762///
763/// ```rust,ignore
764/// use std::net::SocketAddr;
765///
766/// let subscription_info = SubscriptionInfo::new(
767///     "192.168.1.100:830".parse().unwrap(),
768///     1,
769///     ContentId::from("2024-01-15-content-id"),
770///     Target::new_datastore(
771///         "ds:operational".to_string(),
772///         either::Right("/ietf-interfaces:interfaces/ietf-interfaces/statistics".to_string()),
773///     ),
774///     vec!["ietf-interfaces".to_string(), "ietf-ip".to_string()],
775/// );
776///
777/// // Access subscription details
778/// println!("Peer: {}", subscription_info.peer());
779/// println!("Content ID: {}", subscription_info.content_id());
780/// println!("Subscription Target: {}", subscription_info.target());
781/// println!("Models: {:?}", subscription_info.models());
782/// ```
783#[derive(Clone, Debug, Eq, serde::Serialize, serde::Deserialize)]
784pub struct SubscriptionInfo {
785    pub collector: SocketAddr,
786    pub interface: Option<String>,
787    pub peer: SocketAddr,
788    pub id: SubscriptionId,
789    pub target: Target,
790
791    #[serde(skip_serializing_if = "Option::is_none")]
792    pub stop_time: Option<DateTime<Utc>>,
793
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub transport: Option<Transport>,
796
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub encoding: Option<Encoding>,
799
800    #[serde(skip_serializing_if = "Option::is_none")]
801    pub purpose: Option<Box<str>>,
802
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub update_trigger: Option<UpdateTrigger>,
805
806    pub models: Box<[YangPushModuleVersion]>,
807
808    pub content_id: ContentId,
809}
810
811impl std::hash::Hash for SubscriptionInfo {
812    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
813        self.peer.ip().hash(state);
814        self.id.hash(state);
815        self.target.hash(state);
816        self.stop_time.hash(state);
817        self.transport.hash(state);
818        self.encoding.hash(state);
819        self.purpose.hash(state);
820        self.update_trigger.hash(state);
821        self.models.hash(state);
822        self.content_id.hash(state);
823    }
824}
825
826impl PartialEq for SubscriptionInfo {
827    fn eq(&self, other: &Self) -> bool {
828        self.peer.ip() == other.peer.ip()
829            && self.id == other.id
830            && self.target == other.target
831            && self.stop_time == other.stop_time
832            && self.transport == other.transport
833            && self.encoding == other.encoding
834            && self.purpose == other.purpose
835            && self.update_trigger == other.update_trigger
836            && self.models == other.models
837            && self.content_id == other.content_id
838    }
839}
840
841impl SubscriptionInfo {
842    #[allow(clippy::too_many_arguments)]
843    pub fn new(
844        collector: SocketAddr,
845        interface: Option<String>,
846        peer: SocketAddr,
847        id: SubscriptionId,
848        target: Target,
849        stop_time: Option<DateTime<Utc>>,
850        transport: Option<Transport>,
851        encoding: Option<Encoding>,
852        purpose: Option<Box<str>>,
853        update_trigger: Option<UpdateTrigger>,
854        models: Box<[YangPushModuleVersion]>,
855        content_id: ContentId,
856    ) -> Self {
857        Self {
858            collector,
859            interface,
860            peer,
861            id,
862            target,
863            stop_time,
864            transport,
865            encoding,
866            purpose,
867            update_trigger,
868            models,
869            content_id,
870        }
871    }
872
873    /// Create an empty subscription info placeholder.
874    /// This can be used when no subscription info is available.
875    /// Or to indicate that no yang library is associated with the subscription.
876    pub fn new_empty(
877        collector: SocketAddr,
878        interface: Option<String>,
879        peer: SocketAddr,
880        id: SubscriptionId,
881    ) -> Self {
882        Self {
883            collector,
884            interface,
885            peer,
886            id,
887            target: Target::new_datastore("EMPTY".into(), either::Right("EMPTY".into())),
888            stop_time: None,
889            transport: None,
890            encoding: None,
891            purpose: None,
892            models: Box::new([]),
893            update_trigger: None,
894            content_id: "EMPTY".to_string(),
895        }
896    }
897
898    pub fn is_empty(&self) -> bool {
899        self.content_id == "EMPTY"
900    }
901
902    /// The peer address of the device who sent the subscription started
903    /// message.
904    pub const fn peer(&self) -> SocketAddr {
905        self.peer
906    }
907
908    /// The collector address of the subscription.
909    pub const fn collector(&self) -> SocketAddr {
910        self.collector
911    }
912
913    /// The interface name if available.
914    pub fn interface(&self) -> Option<String> {
915        self.interface.clone()
916    }
917
918    /// The subscription ID associated with the subscription. This is a unique
919    /// identifier for the subscription within each peer
920    pub const fn id(&self) -> SubscriptionId {
921        self.id
922    }
923
924    pub const fn stop_time(&self) -> Option<DateTime<Utc>> {
925        self.stop_time
926    }
927
928    pub const fn transport(&self) -> Option<Transport> {
929        self.transport
930    }
931
932    pub const fn encoding(&self) -> Option<Encoding> {
933        self.encoding
934    }
935
936    pub fn purpose(&self) -> Option<&str> {
937        self.purpose.as_deref()
938    }
939
940    /// The YANG Library content ID associated with the subscription by the
941    /// device.
942    pub const fn content_id(&self) -> &ContentId {
943        &self.content_id
944    }
945
946    /// The [Target] associated with the subscription.
947    pub const fn target(&self) -> &Target {
948        &self.target
949    }
950
951    pub const fn update_trigger(&self) -> Option<&UpdateTrigger> {
952        self.update_trigger.as_ref()
953    }
954
955    /// The list of YANG modules associated with the subscription.
956    pub fn models(&self) -> &[YangPushModuleVersion] {
957        &self.models
958    }
959}
960
961impl std::fmt::Display for SubscriptionInfo {
962    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
963        write!(
964            f,
965            "SubscriptionInfo {{ peer: {}, content_id: {}, target: {}, models: {:?} }}",
966            self.peer, self.content_id, self.target, self.models
967        )
968    }
969}
970
971/// Cache for YANG library references used by a YANG-Push collector.
972///
973/// `YangLibraryCache` provides efficient lookup of YANG library references
974/// through two indexing strategies:
975///
976/// - **Content ID index**: Direct O(1) lookup when the content identifier is
977///   known
978/// - **Subscription info index**: Lookup by the full subscription context
979///   (peer, XPath, modules)
980///
981/// In addition, it maintains a mapping of subscription IDs per peer IP address
982/// to facilitate lookups based on subscription ID to get the associated
983/// [SubscriptionInfo]. This is useful when processing that has the subscription
984/// started message read in the past and only received the period notifications.
985///
986/// The cache supports both in-memory creation and restoration from persistent
987/// disk storage. See the [module documentation](self) for the disk storage
988/// layout and detailed usage examples.
989pub struct YangLibraryCache {
990    cache_by_content_id: FxHashMap<ContentId, Arc<YangLibraryReference>>,
991    cache_by_subscription_info: FxHashMap<SubscriptionInfo, Arc<YangLibraryReference>>,
992    cache_by_subscription_id: FxHashMap<IpAddr, FxHashMap<SubscriptionId, SubscriptionInfo>>,
993    cache_root_path: PathBuf,
994}
995
996impl YangLibraryCache {
997    /// Create a new, empty YANG Library Cache.
998    pub fn new(cache_root_path: PathBuf) -> Self {
999        Self {
1000            cache_by_content_id: FxHashMap::default(),
1001            cache_by_subscription_info: FxHashMap::default(),
1002            cache_by_subscription_id: FxHashMap::default(),
1003            cache_root_path,
1004        }
1005    }
1006
1007    /// Load an existing YANG Library Cache from disk.
1008    /// The cache is populated by reading all YANG library entries
1009    /// stored under the given root path.
1010    ///
1011    /// The YANG modules are loaded for each YANG Library reference to verify
1012    /// they are correctly stored.
1013    pub fn from_disk(cache_root_path: PathBuf) -> Result<Self, YangLibraryCacheError> {
1014        debug!(
1015            cache_root_path = %cache_root_path.display(),
1016            "loading YANG Library references cache",
1017        );
1018        let mut cache_by_content_id = FxHashMap::default();
1019        let mut cache_by_subscription_info = FxHashMap::default();
1020        let mut cache_by_subscription_id: FxHashMap<
1021            IpAddr,
1022            FxHashMap<SubscriptionId, SubscriptionInfo>,
1023        > = FxHashMap::default();
1024        let read_dir = std::fs::read_dir(cache_root_path.as_path()).map_err(|error| {
1025            std::io::Error::other(format!(
1026                "failed to read cached root path {}: {error}",
1027                cache_root_path.display()
1028            ))
1029        })?;
1030        for dir in read_dir {
1031            let entry = dir.map_err(|error| {
1032                std::io::Error::other(format!(
1033                    "failed to read cached entry in root path {}: {error}",
1034                    cache_root_path.display()
1035                ))
1036            })?;
1037            let path = entry.path();
1038            if !path.is_dir() {
1039                continue;
1040            }
1041            let yang_lib_path = path.join(YANG_LIBRARY_FILE_NAME);
1042            let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, path.clone())?;
1043            let yang_lib_ref = Arc::new(yang_lib_ref);
1044            // Loading the schemas to verify they are correctly stored
1045            _ = yang_lib_ref.load_schemas()?;
1046            let content_id = yang_lib_ref.content_id().clone();
1047            let subscriptions_info = yang_lib_ref.subscriptions_info()?;
1048            info!(
1049                yang_library_path = %path.display(),
1050                subscriptions_info = ?subscriptions_info,
1051                "loaded yang library reference ",
1052            );
1053            cache_by_content_id.insert(content_id, Arc::clone(&yang_lib_ref));
1054            for subscription_info in subscriptions_info {
1055                cache_by_subscription_id
1056                    .entry(subscription_info.peer().ip())
1057                    .or_default()
1058                    .insert(subscription_info.id(), subscription_info.clone());
1059                cache_by_subscription_info.insert(subscription_info, Arc::clone(&yang_lib_ref));
1060            }
1061        }
1062        info!(
1063            total_entries = %cache_by_content_id.len(),
1064            "loaded YANG Library references cache",
1065        );
1066        Ok(Self {
1067            cache_by_content_id,
1068            cache_by_subscription_info,
1069            cache_by_subscription_id,
1070            cache_root_path,
1071        })
1072    }
1073
1074    /// Store a YANG Library along with its associated Subscription Info
1075    /// and schemas into the cache and persist them to disk.
1076    ///
1077    /// If the YANG Library already exists in the cache and is identical,
1078    /// the existing reference is returned and the subscription info is
1079    /// appended.
1080    pub fn put_yang_library(
1081        &mut self,
1082        subscription_info: SubscriptionInfo,
1083        yang_lib: YangLibrary,
1084        schemas: HashMap<Box<str>, Box<str>>,
1085    ) -> Result<Arc<YangLibraryReference>, YangLibraryCacheError> {
1086        let content_id = yang_lib.content_id();
1087        // Check if already cached
1088        if let Some(existing_ref) = self.cache_by_content_id.get(content_id) {
1089            if !self
1090                .cache_by_subscription_info
1091                .contains_key(&subscription_info)
1092            {
1093                existing_ref.append_subscription_info(subscription_info.clone())?;
1094                self.cache_by_subscription_info
1095                    .insert(subscription_info.clone(), Arc::clone(existing_ref));
1096                self.cache_by_subscription_id
1097                    .entry(subscription_info.peer().ip())
1098                    .or_default()
1099                    .insert(subscription_info.id(), subscription_info);
1100            }
1101            return Ok(Arc::clone(existing_ref));
1102        }
1103        // New entry - save to disk
1104        let entry_path = self.cache_root_path.join(yang_lib.content_id());
1105        let yang_lib_ref = YangLibraryReference::save_to_disk(
1106            entry_path,
1107            &yang_lib,
1108            schemas,
1109            std::slice::from_ref(&subscription_info),
1110        )?;
1111        let content_id = yang_lib_ref.content_id().clone();
1112        let yang_lib_ref = Arc::new(yang_lib_ref);
1113        self.cache_by_subscription_info
1114            .insert(subscription_info.clone(), Arc::clone(&yang_lib_ref));
1115        self.cache_by_content_id
1116            .insert(content_id, Arc::clone(&yang_lib_ref));
1117        self.cache_by_subscription_id
1118            .entry(subscription_info.peer().ip())
1119            .or_default()
1120            .insert(subscription_info.id(), subscription_info);
1121        Ok(yang_lib_ref)
1122    }
1123
1124    pub fn remove_by_content_id(
1125        &mut self,
1126        content_id: &ContentId,
1127    ) -> Result<(), YangLibraryCacheError> {
1128        info!(content_id, "removing yang library for content from cache");
1129        if let Some(yang_lib_ref) = self.cache_by_content_id.remove(content_id) {
1130            self.cache_by_subscription_info
1131                .retain(|_, v| v.content_id() != content_id);
1132            self.cache_by_subscription_id.retain(|_, sub_map| {
1133                sub_map.retain(|_, sub_info| sub_info.content_id() != content_id);
1134                !sub_map.is_empty()
1135            });
1136            std::fs::remove_file(yang_lib_ref.yang_library_path())?;
1137            std::fs::remove_dir_all(yang_lib_ref.yang_lib_ref_path())?;
1138            info!(
1139                content_id,
1140                "removed yang library with content id from cache"
1141            );
1142        } else {
1143            info!(
1144                content_id,
1145                "no yang library reference found for content id in cache"
1146            );
1147        }
1148        Ok(())
1149    }
1150
1151    pub fn remove_by_subscription_info(
1152        &mut self,
1153        subscription_info: &SubscriptionInfo,
1154    ) -> Result<Option<SubscriptionInfo>, YangLibraryCacheError> {
1155        // Step 1: Remove from subscription_info -> content_id mapping
1156        let yang_lib_ref = match self.cache_by_subscription_info.remove(subscription_info) {
1157            Some(yang_lib_ref) => yang_lib_ref,
1158            None => return Ok(None),
1159        };
1160
1161        // Step 2: Remove from (peer_ip, sub_id) -> SubscriptionInfo mapping
1162        if let Some(sub_map) = self
1163            .cache_by_subscription_id
1164            .get_mut(&subscription_info.peer().ip())
1165        {
1166            sub_map.remove(&subscription_info.id());
1167            if sub_map.is_empty() {
1168                self.cache_by_subscription_id
1169                    .remove(&subscription_info.peer().ip());
1170            }
1171        }
1172
1173        // Step 3: Check if any other subscriptions are still using this content_id
1174        let content_id_still_in_use = self
1175            .cache_by_subscription_info
1176            .values()
1177            .any(|lib_ref| lib_ref.content_id() == yang_lib_ref.content_id());
1178
1179        // Step 4: Only remove YangLibraryReference and delete files if no subscriptions
1180        // remain
1181        if !content_id_still_in_use {
1182            if let Some(yang_lib_ref) = self.cache_by_content_id.remove(yang_lib_ref.content_id()) {
1183                // Remove subscription from the reference's internal list
1184                yang_lib_ref.remove_subscription_info(subscription_info)?;
1185
1186                // Delete the directory and all its contents from the filesystem
1187                let content_dir = self.cache_root_path.join(yang_lib_ref.content_id());
1188                if content_dir.exists() {
1189                    std::fs::remove_dir_all(&content_dir).map_err(|error| {
1190                        YangLibraryCacheError::IoError(std::io::Error::other(format!(
1191                            "Failed to remove directory {}: {error}",
1192                            content_dir.display(),
1193                        )))
1194                    })?;
1195                }
1196            }
1197        } else {
1198            // Step 5: Content is still in use - just remove this subscription from the
1199            // reference
1200            if let Some(yang_lib_ref) = self.cache_by_content_id.get_mut(yang_lib_ref.content_id())
1201            {
1202                yang_lib_ref.remove_subscription_info(subscription_info)?;
1203            }
1204        }
1205        Ok(Some(subscription_info.clone()))
1206    }
1207
1208    pub fn get_by_subscription_info(
1209        &self,
1210        subscription_info: &SubscriptionInfo,
1211    ) -> Option<Arc<YangLibraryReference>> {
1212        let result = self
1213            .cache_by_subscription_info
1214            .get(subscription_info)
1215            .map(Arc::clone);
1216        debug!(
1217            peer=%subscription_info.peer(),
1218            subscription_id=subscription_info.id(),
1219            router_content_id=subscription_info.content_id(),
1220            target=%subscription_info.target(),
1221            hit = result.is_some(),
1222            "cache lookup by subscription info"
1223        );
1224        result
1225    }
1226
1227    pub fn get_by_content_id(
1228        &mut self,
1229        content_id: &ContentId,
1230    ) -> Option<Arc<YangLibraryReference>> {
1231        let result = self.cache_by_content_id.get(content_id).map(Arc::clone);
1232        debug!(
1233            content_id,
1234            hit = result.is_some(),
1235            "cache lookup by content id"
1236        );
1237        result
1238    }
1239
1240    pub fn get_by_subscription_id(
1241        &self,
1242        peer_ip: IpAddr,
1243        subscription_id: SubscriptionId,
1244    ) -> Option<(SubscriptionInfo, Option<Arc<YangLibraryReference>>)> {
1245        let result = self
1246            .cache_by_subscription_id
1247            .get(&peer_ip)
1248            .and_then(|x| x.get(&subscription_id))
1249            .cloned()
1250            .map(|subscription_info| {
1251                let yang_lib_ref = self
1252                    .cache_by_subscription_info
1253                    .get(&subscription_info)
1254                    .map(Arc::clone);
1255                (subscription_info, yang_lib_ref)
1256            });
1257        debug!(
1258            peer_ip=%peer_ip,
1259            subscription_id,
1260            hit = result.is_some(),
1261            "cache lookup by subscription id"
1262        );
1263        result
1264    }
1265}
1266
1267#[cfg(test)]
1268mod tests {
1269    use super::*;
1270    use netgauze_netconf_proto::yang_push::types::CentiSeconds;
1271    use std::net::{IpAddr, Ipv4Addr};
1272    use tempfile::TempDir;
1273
1274    fn create_test_subscription_info(content_id: &str) -> SubscriptionInfo {
1275        SubscriptionInfo::new(
1276            SocketAddr::from(([192, 168, 1, 100], 10000)),
1277            None,
1278            SocketAddr::from(([192, 168, 1, 101], 830)),
1279            1,
1280            Target::new_datastore(
1281                "ds:operational".into(),
1282                either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1283            ),
1284            None,
1285            Some(Transport::UDPNotif),
1286            Some(Encoding::Json),
1287            Some("test subscription".into()),
1288            Some(UpdateTrigger::OnChange {
1289                dampening_period: None,
1290                sync_on_start: Some(true),
1291                excluded_change: None,
1292            }),
1293            Box::new([
1294                YangPushModuleVersion::new("ietf-interfaces".into(), Some("2018-02-20".into()), None),
1295                YangPushModuleVersion::new("ietf-ip".into(), None, None),
1296            ]),
1297            ContentId::from(content_id.to_string()),
1298        )
1299    }
1300
1301    fn create_minimal_yang_library_xml(content_id: &str) -> String {
1302        format!(
1303            r#"<?xml version="1.0" encoding="UTF-8"?>
1304<yang-library xmlns="urn:ietf:params:xml:ns:yang:ietf-yang-library">
1305  <module-set>
1306    <name>test-module-set</name>
1307    <module>
1308      <name>test-module</name>
1309      <revision>2025-12-12</revision>
1310      <namespace>urn:test:test-module</namespace>
1311    </module>
1312  </module-set>
1313  <schema>
1314    <name>test-schema</name>
1315    <module-set>test-module-set</module-set>
1316  </schema>
1317  <datastore>
1318    <name xmlns:ds="urn:ietf:params:xml:ns:yang:ietf-datastores">ds:running</name>
1319    <schema>test-schema</schema>
1320  </datastore>
1321  <content-id>{content_id}</content-id>
1322</yang-library>"#
1323        )
1324    }
1325
1326    fn create_test_yang_module() -> &'static str {
1327        r#"module test-module {
1328  namespace "urn:test:test-module";
1329  prefix tm;
1330
1331  revision 2025-12-12;
1332
1333  container test {
1334    leaf name {
1335      type string;
1336    }
1337  }
1338}"#
1339    }
1340
1341    fn setup_yang_library_on_disk(
1342        temp_dir: &TempDir,
1343        content_id: &str,
1344        subscriptions_info: &[SubscriptionInfo],
1345    ) -> Result<PathBuf, YangLibraryCacheError> {
1346        let entry_path = temp_dir.path().join(content_id);
1347        std::fs::create_dir_all(&entry_path)?;
1348
1349        // Write yang library file
1350        let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1351        let yang_lib_xml = create_minimal_yang_library_xml(content_id);
1352        std::fs::write(&yang_lib_path, yang_lib_xml)?;
1353
1354        // Write subscription info
1355        let subscriptions_info_path = entry_path.join(SUBSCRIPTIONS_INFO_FILE_NAME);
1356        let subscriptions_info_file = std::fs::File::create(&subscriptions_info_path)?;
1357        serde_json::to_writer_pretty(subscriptions_info_file, subscriptions_info)?;
1358
1359        // Create the modules directory with a test module
1360        let modules_path = entry_path.join("modules");
1361        std::fs::create_dir_all(&modules_path)?;
1362        let module_path = modules_path.join("test-module@2025-12-12.yang");
1363        debug!("creating test module at {module_path:?}");
1364        std::fs::write(&module_path, create_test_yang_module())?;
1365
1366        Ok(entry_path)
1367    }
1368
1369    #[test]
1370    #[tracing_test::traced_test]
1371    fn test_yang_library_cache_new() {
1372        let temp_dir = TempDir::new().unwrap();
1373        let cache = YangLibraryCache::new(temp_dir.path().to_path_buf());
1374
1375        assert!(cache.cache_by_content_id.is_empty());
1376        assert!(cache.cache_by_subscription_info.is_empty());
1377        assert_eq!(cache.cache_root_path, temp_dir.path());
1378    }
1379
1380    #[test]
1381    #[tracing_test::traced_test]
1382    fn test_yang_library_reference_new_file_not_found() {
1383        let temp_dir = TempDir::new().unwrap();
1384        let yang_lib_path = temp_dir.path().join("nonexistent.xml");
1385        let search_dir = temp_dir.path().to_path_buf();
1386
1387        let result = YangLibraryReference::load_from_disk(yang_lib_path.clone(), search_dir);
1388        assert!(matches!(
1389            result,
1390            Err(YangLibraryCacheError::YangLibraryPathNotFound(_))
1391        ));
1392    }
1393
1394    #[test]
1395    #[tracing_test::traced_test]
1396    fn test_yang_library_reference_new_path_is_directory() {
1397        let temp_dir = TempDir::new().unwrap();
1398        let yang_lib_path = temp_dir.path().to_path_buf();
1399        let search_dir = temp_dir.path().to_path_buf();
1400
1401        let result = YangLibraryReference::load_from_disk(yang_lib_path.clone(), search_dir);
1402        assert!(matches!(
1403            result,
1404            Err(YangLibraryCacheError::YangLibraryPathIsNotValid(_))
1405        ));
1406    }
1407
1408    #[test]
1409    #[tracing_test::traced_test]
1410    fn test_yang_library_reference_new_search_dir_not_found() {
1411        let temp_dir = TempDir::new().unwrap();
1412        let yang_lib_path = temp_dir.path().join("yang-lib.xml");
1413        std::fs::write(&yang_lib_path, create_minimal_yang_library_xml("test-id")).unwrap();
1414        let search_dir = temp_dir.path().join("nonexistent");
1415
1416        let result = YangLibraryReference::load_from_disk(yang_lib_path, search_dir.clone());
1417        assert!(matches!(
1418            result,
1419            Err(YangLibraryCacheError::YangLibrarySearchPathNotFound(_))
1420        ));
1421    }
1422
1423    #[test]
1424    #[tracing_test::traced_test]
1425    fn test_yang_library_reference_new_search_dir_is_file() {
1426        let temp_dir = TempDir::new().unwrap();
1427        let yang_lib_path = temp_dir.path().join("yang-lib.xml");
1428        std::fs::write(&yang_lib_path, create_minimal_yang_library_xml("test-id")).unwrap();
1429        let search_dir = temp_dir.path().join("file.txt");
1430        std::fs::write(&search_dir, "test").unwrap();
1431
1432        let result = YangLibraryReference::load_from_disk(yang_lib_path, search_dir.clone());
1433        assert!(matches!(
1434            result,
1435            Err(YangLibraryCacheError::YangLibrarySearchPathIsNotValid(_))
1436        ));
1437    }
1438
1439    #[test]
1440    #[tracing_test::traced_test]
1441    fn test_yang_library_reference_new_success() {
1442        let temp_dir = TempDir::new().unwrap();
1443        let content_id = "test-content-id-123";
1444        let subscription_info = create_test_subscription_info(content_id);
1445        let entry_path = setup_yang_library_on_disk(
1446            &temp_dir,
1447            content_id,
1448            std::slice::from_ref(&subscription_info),
1449        )
1450        .unwrap();
1451        let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1452
1453        let result =
1454            YangLibraryReference::load_from_disk(yang_lib_path.clone(), entry_path.clone());
1455        assert!(result.is_ok());
1456
1457        let yang_lib_ref = result.unwrap();
1458        assert_eq!(yang_lib_ref.content_id(), content_id);
1459        assert_eq!(yang_lib_ref.yang_library_path(), yang_lib_path);
1460        assert_eq!(yang_lib_ref.yang_lib_ref_path(), entry_path);
1461    }
1462
1463    #[test]
1464    #[tracing_test::traced_test]
1465    fn test_yang_library_reference_yang_library() {
1466        let temp_dir = TempDir::new().unwrap();
1467        let content_id = "test-content-id-456";
1468        let subscription_info = create_test_subscription_info(content_id);
1469        let entry_path = setup_yang_library_on_disk(
1470            &temp_dir,
1471            content_id,
1472            std::slice::from_ref(&subscription_info),
1473        )
1474        .unwrap();
1475        let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1476
1477        let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, entry_path).unwrap();
1478        let yang_lib = yang_lib_ref.yang_library().unwrap();
1479
1480        assert_eq!(yang_lib.content_id(), content_id);
1481    }
1482
1483    #[test]
1484    #[tracing_test::traced_test]
1485    fn test_subscription_info_new() {
1486        let collector = SocketAddr::from(([192, 168, 1, 100], 10000));
1487        let peer = SocketAddr::from(([192, 168, 1, 101], 12345));
1488        let content_id = ContentId::from("content-123");
1489        let target = Target::new_datastore(
1490            "ds:operational".into(),
1491            either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1492        );
1493
1494        let models = Box::new([
1495            YangPushModuleVersion::new("model1".into(), None, None),
1496            YangPushModuleVersion::new("model2".into(), None, None),
1497        ]);
1498        let info = SubscriptionInfo::new(
1499            collector,
1500            None,
1501            peer,
1502            1,
1503            Target::new_datastore(
1504                "ds:operational".into(),
1505                either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1506            ),
1507            None,
1508            Some(Transport::UDPNotif),
1509            Some(Encoding::Json),
1510            Some("test subscription".into()),
1511            Some(UpdateTrigger::OnChange {
1512                dampening_period: None,
1513                sync_on_start: Some(true),
1514                excluded_change: None,
1515            }),
1516            models.clone(),
1517            content_id.clone(),
1518        );
1519
1520        assert_eq!(info.peer(), peer);
1521        assert_eq!(info.content_id(), &content_id);
1522        assert_eq!(info.target(), &target);
1523        assert_eq!(info.models(), models.as_slice());
1524    }
1525
1526    #[test]
1527    #[tracing_test::traced_test]
1528    fn test_subscription_info_display() {
1529        let info = create_test_subscription_info("test-id");
1530        let display = format!("{info}");
1531
1532        assert!(display.contains("192.168.1.101:830"));
1533        assert!(display.contains("test-id"));
1534        assert!(display.contains("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics"));
1535        assert!(display.contains("ietf-interfaces"));
1536    }
1537
1538    #[test]
1539    #[tracing_test::traced_test]
1540    fn test_subscription_info_serialization() {
1541        let info = create_test_subscription_info("serialize-test");
1542
1543        let json = serde_json::to_string(&info).unwrap();
1544        let deserialized: SubscriptionInfo = serde_json::from_str(&json).unwrap();
1545
1546        assert_eq!(info, deserialized);
1547    }
1548
1549    #[test]
1550    #[tracing_test::traced_test]
1551    fn test_yang_library_cache_from_disk_empty() {
1552        let temp_dir = TempDir::new().unwrap();
1553
1554        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1555
1556        assert!(cache.cache_by_content_id.is_empty());
1557        assert!(cache.cache_by_subscription_info.is_empty());
1558    }
1559
1560    #[test]
1561    #[tracing_test::traced_test]
1562    fn test_yang_library_cache_from_disk_with_entries() {
1563        let temp_dir = TempDir::new().unwrap();
1564        let content_id = "disk-test-id";
1565        let subscription_info = create_test_subscription_info(content_id);
1566        setup_yang_library_on_disk(
1567            &temp_dir,
1568            content_id,
1569            std::slice::from_ref(&subscription_info),
1570        )
1571        .unwrap();
1572
1573        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1574
1575        assert_eq!(cache.cache_by_content_id.len(), 1);
1576        assert_eq!(cache.cache_by_subscription_info.len(), 1);
1577
1578        let yang_lib_ref = cache
1579            .cache_by_content_id
1580            .get(&ContentId::from(content_id.to_string()))
1581            .unwrap();
1582        assert_eq!(yang_lib_ref.content_id(), content_id);
1583    }
1584
1585    #[test]
1586    #[tracing_test::traced_test]
1587    fn test_yang_library_cache_get_by_content_id() {
1588        let temp_dir = TempDir::new().unwrap();
1589        let content_id = "get-by-id-test";
1590
1591        let subscription_info = create_test_subscription_info(content_id);
1592        setup_yang_library_on_disk(
1593            &temp_dir,
1594            content_id,
1595            std::slice::from_ref(&subscription_info),
1596        )
1597        .unwrap();
1598
1599        let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1600
1601        let result = cache.get_by_content_id(&ContentId::from(content_id.to_string()));
1602        assert!(result.is_some());
1603        assert_eq!(result.unwrap().content_id(), content_id);
1604
1605        let not_found = cache.get_by_content_id(&ContentId::from("nonexistent".to_string()));
1606        assert!(not_found.is_none());
1607    }
1608
1609    #[test]
1610    #[tracing_test::traced_test]
1611    fn test_yang_library_cache_get_by_subscription_info() {
1612        let temp_dir = TempDir::new().unwrap();
1613        let content_id = "get-by-sub-test";
1614
1615        let subscription_info = create_test_subscription_info(content_id);
1616        setup_yang_library_on_disk(
1617            &temp_dir,
1618            content_id,
1619            std::slice::from_ref(&subscription_info),
1620        )
1621        .unwrap();
1622
1623        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1624
1625        let subscription_info = create_test_subscription_info(content_id);
1626        let result = cache.get_by_subscription_info(&subscription_info);
1627        assert!(result.is_some());
1628        assert_eq!(result.unwrap().content_id(), content_id);
1629
1630        let other_info = create_test_subscription_info("other-id");
1631        let not_found = cache.get_by_subscription_info(&other_info);
1632        assert!(not_found.is_none());
1633    }
1634
1635    #[test]
1636    #[tracing_test::traced_test]
1637    fn test_yang_library_cache_get_by_subscription_id() {
1638        let temp_dir = TempDir::new().unwrap();
1639        let content_id = "get-by-sub-id-test";
1640
1641        let subscription_info = create_test_subscription_info(content_id);
1642        setup_yang_library_on_disk(
1643            &temp_dir,
1644            content_id,
1645            std::slice::from_ref(&subscription_info),
1646        )
1647        .unwrap();
1648
1649        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1650
1651        let subscription_info = create_test_subscription_info(content_id);
1652        let peer_ip = subscription_info.peer().ip();
1653        let subscription_id = subscription_info.id();
1654
1655        // Test successful lookup
1656        let result = cache.get_by_subscription_id(peer_ip, subscription_id);
1657        assert!(result.is_some());
1658        let (retrieved_info, yang_lib_ref) = result.unwrap();
1659        assert_eq!(retrieved_info, subscription_info);
1660        assert!(yang_lib_ref.is_some());
1661        assert_eq!(yang_lib_ref.unwrap().content_id(), content_id);
1662
1663        // Test lookup with wrong subscription id
1664        let not_found = cache.get_by_subscription_id(peer_ip, 9999);
1665        assert!(not_found.is_none());
1666
1667        // Test lookup with wrong peer ip
1668        let wrong_ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
1669        let not_found = cache.get_by_subscription_id(wrong_ip, subscription_id);
1670        assert!(not_found.is_none());
1671    }
1672
1673    #[test]
1674    #[tracing_test::traced_test]
1675    fn test_yang_library_cache_remove_by_content_id() {
1676        let temp_dir = TempDir::new().unwrap();
1677        let content_id = "remove-by-id-test";
1678
1679        let subscription_info = create_test_subscription_info(content_id);
1680        setup_yang_library_on_disk(
1681            &temp_dir,
1682            content_id,
1683            std::slice::from_ref(&subscription_info),
1684        )
1685        .unwrap();
1686
1687        let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1688        assert_eq!(cache.cache_by_content_id.len(), 1);
1689
1690        let content_id_key = ContentId::from(content_id.to_string());
1691        cache.remove_by_content_id(&content_id_key).unwrap();
1692
1693        assert!(cache.cache_by_content_id.is_empty());
1694        assert!(cache.cache_by_subscription_info.is_empty());
1695    }
1696
1697    #[test]
1698    #[tracing_test::traced_test]
1699    fn test_yang_library_cache_remove_by_subscription_info() {
1700        let temp_dir = TempDir::new().unwrap();
1701        let content_id = "remove-by-sub-test";
1702
1703        let subscription_info = create_test_subscription_info(content_id);
1704        setup_yang_library_on_disk(
1705            &temp_dir,
1706            content_id,
1707            std::slice::from_ref(&subscription_info),
1708        )
1709        .unwrap();
1710
1711        let mut cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1712        assert_eq!(cache.cache_by_subscription_info.len(), 1);
1713
1714        let subscription_info = create_test_subscription_info(content_id);
1715        cache
1716            .remove_by_subscription_info(&subscription_info)
1717            .unwrap();
1718
1719        assert!(cache.cache_by_content_id.is_empty());
1720        assert!(cache.cache_by_subscription_info.is_empty());
1721    }
1722
1723    #[test]
1724    #[tracing_test::traced_test]
1725    fn test_yang_library_cache_remove_nonexistent() {
1726        let temp_dir = TempDir::new().unwrap();
1727        let mut cache = YangLibraryCache::new(temp_dir.path().to_path_buf());
1728
1729        // Should not error when removing non-existent entries
1730        let result = cache.remove_by_content_id(&ContentId::from("nonexistent".to_string()));
1731        assert!(result.is_ok());
1732
1733        let subscription_info = create_test_subscription_info("nonexistent");
1734        let result = cache.remove_by_subscription_info(&subscription_info);
1735        assert!(result.is_ok());
1736    }
1737
1738    #[test]
1739    #[tracing_test::traced_test]
1740    fn test_yang_library_cache_from_disk_skips_files() {
1741        let temp_dir = TempDir::new().unwrap();
1742
1743        // Create a file (not a directory) in the root path
1744        let file_path = temp_dir.path().join("some-file.txt");
1745        std::fs::write(&file_path, "this is a file, not a directory").unwrap();
1746
1747        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1748        assert!(cache.cache_by_content_id.is_empty());
1749    }
1750
1751    #[test]
1752    #[tracing_test::traced_test]
1753    fn test_yang_library_cache_multiple_entries() {
1754        let temp_dir = TempDir::new().unwrap();
1755
1756        let subscription_info1 = create_test_subscription_info("content_id");
1757        let subscription_info2 = SubscriptionInfo::new(
1758            SocketAddr::from(([192, 168, 1, 100], 10000)),
1759            None,
1760            SocketAddr::from(([192, 168, 1, 102], 830)),
1761            2,
1762            Target::new_datastore(
1763                "ds:operational".into(),
1764                either::Right("/ietf-routing:routing/routing-protocols".into()),
1765            ),
1766            None,
1767            Some(Transport::UDPNotif),
1768            Some(Encoding::Json),
1769            Some("test subscription".into()),
1770            Some(UpdateTrigger::OnChange {
1771                dampening_period: None,
1772                sync_on_start: Some(true),
1773                excluded_change: None,
1774            }),
1775            Box::new([YangPushModuleVersion::new(
1776                "ietf-routing".into(),
1777                None,
1778                None,
1779            )]),
1780            ContentId::from("content_id2".to_string()),
1781        );
1782        let subscription_info3 = SubscriptionInfo::new(
1783            SocketAddr::from(([192, 168, 1, 100], 10000)),
1784            None,
1785            SocketAddr::from(([192, 168, 1, 103], 830)),
1786            2,
1787            Target::new_datastore(
1788                "ds:operational".into(),
1789                either::Right("/ietf-routing:routing/routing-protocols".into()),
1790            ),
1791            None,
1792            Some(Transport::UDPNotif),
1793            Some(Encoding::Json),
1794            Some("test subscription".into()),
1795            Some(UpdateTrigger::Periodic {
1796                period: Some(CentiSeconds::new(300)),
1797                anchor_time: None,
1798            }),
1799            Box::new([YangPushModuleVersion::new(
1800                "ietf-routing".into(),
1801                None,
1802                None,
1803            )]),
1804            ContentId::from("content_id3".to_string()),
1805        );
1806        setup_yang_library_on_disk(
1807            &temp_dir,
1808            "content-id-1",
1809            std::slice::from_ref(&subscription_info1),
1810        )
1811        .unwrap();
1812        setup_yang_library_on_disk(
1813            &temp_dir,
1814            "content-id-2",
1815            std::slice::from_ref(&subscription_info2),
1816        )
1817        .unwrap();
1818        setup_yang_library_on_disk(
1819            &temp_dir,
1820            "content-id-3",
1821            std::slice::from_ref(&subscription_info3),
1822        )
1823        .unwrap();
1824
1825        let cache = YangLibraryCache::from_disk(temp_dir.path().to_path_buf()).unwrap();
1826
1827        assert_eq!(cache.cache_by_content_id.len(), 3);
1828        assert_eq!(cache.cache_by_subscription_info.len(), 3);
1829    }
1830
1831    #[test]
1832    #[tracing_test::traced_test]
1833    fn test_yang_library_reference_append_subscription_info() {
1834        let temp_dir = TempDir::new().unwrap();
1835        let content_id = "append-sub-test";
1836
1837        // Create initial subscription info
1838        let subscription_info1 = create_test_subscription_info(content_id);
1839        setup_yang_library_on_disk(
1840            &temp_dir,
1841            content_id,
1842            std::slice::from_ref(&subscription_info1),
1843        )
1844        .unwrap();
1845
1846        let entry_path = temp_dir.path().join(content_id);
1847        let yang_lib_path = entry_path.join(YANG_LIBRARY_FILE_NAME);
1848        let yang_lib_ref = YangLibraryReference::load_from_disk(yang_lib_path, entry_path).unwrap();
1849
1850        // Verify initial state
1851        let initial_subscriptions = yang_lib_ref.subscriptions_info().unwrap();
1852        assert_eq!(initial_subscriptions, vec![subscription_info1.clone()]);
1853
1854        // Test appending duplicate subscription info, should have no effect
1855        yang_lib_ref
1856            .append_subscription_info(subscription_info1.clone())
1857            .unwrap();
1858        let subscriptions_after_duplicate = yang_lib_ref.subscriptions_info().unwrap();
1859        assert_eq!(
1860            subscriptions_after_duplicate,
1861            vec![subscription_info1.clone()]
1862        );
1863
1864        // Create different subscription info with the same content_id
1865        let subscription_info2 = SubscriptionInfo::new(
1866            SocketAddr::from(([192, 168, 1, 100], 10000)),
1867            None,
1868            SocketAddr::from(([192, 168, 1, 102], 830)),
1869            2,
1870            Target::new_datastore(
1871                "ds:operational".into(),
1872                either::Right("/ietf-routing:routing/routing-protocols".into()),
1873            ),
1874            None,
1875            Some(Transport::UDPNotif),
1876            Some(Encoding::Json),
1877            Some("test subscription".into()),
1878            Some(UpdateTrigger::OnChange {
1879                dampening_period: None,
1880                sync_on_start: Some(true),
1881                excluded_change: None,
1882            }),
1883            Box::new([YangPushModuleVersion::new(
1884                "ietf-routing".into(),
1885                None,
1886                None,
1887            )]),
1888            ContentId::from(content_id.to_string()),
1889        );
1890
1891        // Test appending new subscription info
1892        yang_lib_ref
1893            .append_subscription_info(subscription_info2.clone())
1894            .unwrap();
1895        let subscriptions_after_new = yang_lib_ref.subscriptions_info().unwrap();
1896        assert_eq!(
1897            HashSet::from_iter(subscriptions_after_new.into_iter()),
1898            HashSet::from([subscription_info1.clone(), subscription_info2.clone()])
1899        );
1900
1901        // Verify persisted data on disk
1902        let reloaded_yang_lib_ref = YangLibraryReference::load_from_disk(
1903            yang_lib_ref.yang_library_path().to_path_buf(),
1904            yang_lib_ref.yang_lib_ref_path().to_path_buf(),
1905        )
1906        .unwrap();
1907        let persisted_subscriptions = reloaded_yang_lib_ref.subscriptions_info().unwrap();
1908        assert_eq!(
1909            HashSet::from_iter(persisted_subscriptions.into_iter()),
1910            HashSet::from([subscription_info1, subscription_info2])
1911        );
1912    }
1913
1914    #[test]
1915    #[tracing_test::traced_test]
1916    fn test_yang_library_cache_put_with_same_content_id_different_subscription() {
1917        let cache_dir = TempDir::new().unwrap();
1918        let content_id = "shared-content-id";
1919        let mut cache = YangLibraryCache::new(cache_dir.path().to_path_buf());
1920
1921        // Create first subscription info
1922        let subscription_info1 = SubscriptionInfo::new(
1923            SocketAddr::from(([192, 168, 1, 100], 10000)),
1924            None,
1925            SocketAddr::from(([192, 168, 1, 101], 830)),
1926            1,
1927            Target::new_datastore(
1928                "ds:operational".into(),
1929                either::Right("/ietf-interfaces:interfaces/ietf-interfaces:interface[ietf-interfaces:name='eth0']/statistics".into()),
1930            ),
1931            None,
1932            Some(Transport::UDPNotif),
1933            Some(Encoding::Json),
1934            Some("test subscription".into()),
1935            Some(UpdateTrigger::OnChange {
1936                dampening_period: None,
1937                sync_on_start: Some(true),
1938                excluded_change: None,
1939            }),
1940            Box::new([
1941                YangPushModuleVersion::new("ietf-interfaces".into(), Some("2018-02-20".into()), None),
1942                YangPushModuleVersion::new("ietf-ip".into(), None, None),
1943            ]),
1944            ContentId::from(content_id.to_string()),
1945        );
1946
1947        // Create YANG library and schemas
1948        let yang_lib_xml = create_minimal_yang_library_xml(content_id);
1949        let reader = NsReader::from_str(&yang_lib_xml);
1950        let mut xml_reader = netgauze_netconf_proto::xml_utils::XmlParser::new(reader).unwrap();
1951        let yang_library = YangLibrary::xml_deserialize(&mut xml_reader).unwrap();
1952
1953        let mut schemas = HashMap::new();
1954        schemas.insert(
1955            Box::from("test-module"),
1956            Box::from(create_test_yang_module()),
1957        );
1958
1959        // Put first subscription
1960        let yang_lib_ref1 = cache
1961            .put_yang_library(
1962                subscription_info1.clone(),
1963                yang_library.clone(),
1964                schemas.clone(),
1965            )
1966            .unwrap();
1967
1968        // Verify the first subscription is stored
1969        assert_eq!(cache.cache_by_content_id.len(), 1);
1970        assert_eq!(cache.cache_by_subscription_info.len(), 1);
1971        assert_eq!(cache.cache_by_subscription_id.len(), 1);
1972
1973        // Create second subscription info with different peer and target but same
1974        // content_id
1975        let subscription_info2 = SubscriptionInfo::new(
1976            SocketAddr::from(([192, 168, 1, 100], 10000)),
1977            None,
1978            SocketAddr::from(([192, 168, 1, 102], 830)),
1979            2,
1980            Target::new_datastore(
1981                "ds:operational".into(),
1982                either::Right("/ietf-routing:routing/routing-protocols".into()),
1983            ),
1984            None,
1985            Some(Transport::UDPNotif),
1986            Some(Encoding::Json),
1987            Some("test subscription".into()),
1988            Some(UpdateTrigger::OnChange {
1989                dampening_period: None,
1990                sync_on_start: Some(true),
1991                excluded_change: None,
1992            }),
1993            Box::new([YangPushModuleVersion::new(
1994                "ietf-routing".into(),
1995                None,
1996                None,
1997            )]),
1998            ContentId::from(content_id.to_string()),
1999        );
2000
2001        // Put a second subscription with the same YANG library
2002        let yang_lib_ref2 = cache
2003            .put_yang_library(
2004                subscription_info2.clone(),
2005                yang_library.clone(),
2006                schemas.clone(),
2007            )
2008            .unwrap();
2009
2010        // Verify both subscriptions share the same YANG library reference
2011        assert_eq!(yang_lib_ref1.content_id(), yang_lib_ref2.content_id());
2012        assert_eq!(cache.cache_by_content_id.len(), 1);
2013        assert_eq!(cache.cache_by_subscription_info.len(), 2);
2014        assert_eq!(cache.cache_by_subscription_id.len(), 2);
2015
2016        // Verify both subscriptions can be retrieved
2017        let retrieved1 = cache.get_by_subscription_info(&subscription_info1);
2018        assert!(retrieved1.is_some());
2019        assert_eq!(retrieved1.unwrap().content_id(), content_id);
2020
2021        let retrieved2 = cache.get_by_subscription_info(&subscription_info2);
2022        assert!(retrieved2.is_some());
2023        assert_eq!(retrieved2.unwrap().content_id(), content_id);
2024
2025        // Verify both subscription IDs can be retrieved
2026        let (sub1_info, sub1_ref) = cache
2027            .get_by_subscription_id(subscription_info1.peer().ip(), subscription_info1.id())
2028            .unwrap();
2029        assert_eq!(sub1_info, subscription_info1);
2030        assert!(sub1_ref.is_some());
2031
2032        let (sub2_info, sub2_ref) = cache
2033            .get_by_subscription_id(subscription_info2.peer().ip(), subscription_info2.id())
2034            .unwrap();
2035        assert_eq!(sub2_info, subscription_info2);
2036        assert!(sub2_ref.is_some());
2037
2038        // Verify both subscriptions are persisted in the reference
2039        let subscriptions = yang_lib_ref2.subscriptions_info().unwrap();
2040        assert_eq!(subscriptions.len(), 2);
2041        assert!(subscriptions.contains(&subscription_info1));
2042        assert!(subscriptions.contains(&subscription_info2));
2043    }
2044
2045    /// A device that reconnects from a different source port must still hit the
2046    /// cache: [`SubscriptionInfo`] identity intentionally ignores the peer's
2047    /// ephemeral port. See <https://github.com/NetGauze/NetGauze/issues/483>.
2048    #[test]
2049    fn test_subscription_info_identity_ignores_peer_port() {
2050        let info = create_test_subscription_info("content-id-1");
2051
2052        // Same subscription, device reconnected from a different source port.
2053        let mut reconnected = info.clone();
2054        reconnected.peer = SocketAddr::new(info.peer.ip(), info.peer.port() + 1);
2055
2056        assert_eq!(
2057            info, reconnected,
2058            "subscriptions differing only by peer port must compare equal"
2059        );
2060
2061        // `Hash` must agree with `Eq`, otherwise the map lookup still misses.
2062        let hash = |x: &SubscriptionInfo| {
2063            use std::hash::{Hash, Hasher};
2064            let mut h = std::collections::hash_map::DefaultHasher::new();
2065            x.hash(&mut h);
2066            h.finish()
2067        };
2068        assert_eq!(
2069            hash(&info),
2070            hash(&reconnected),
2071            "equal subscriptions must hash equally"
2072        );
2073
2074        // The lookup the issue is about: cached under the old port, found under
2075        // the new one.
2076        let mut cache = FxHashMap::default();
2077        cache.insert(info.clone(), "yang-library");
2078        assert_eq!(
2079            cache.get(&reconnected),
2080            Some(&"yang-library"),
2081            "cache lookup must hit after the peer reconnects from a new port"
2082        );
2083    }
2084
2085    /// The port is the *only* part of the peer that is ignored -- a different
2086    /// device must never share a cache entry.
2087    #[test]
2088    fn test_subscription_info_identity_still_distinguishes_peer_ip() {
2089        let info = create_test_subscription_info("content-id-1");
2090
2091        let mut other_device = info.clone();
2092        other_device.peer = SocketAddr::new(
2093            IpAddr::V4(Ipv4Addr::new(192, 168, 1, 200)),
2094            info.peer.port(),
2095        );
2096        assert_ne!(
2097            info, other_device,
2098            "subscriptions from different peer IPs must not compare equal"
2099        );
2100
2101        let mut other_id = info.clone();
2102        other_id.id = info.id + 1;
2103        assert_ne!(
2104            info, other_id,
2105            "different subscription IDs must not compare equal"
2106        );
2107
2108        let mut other_content = info.clone();
2109        other_content.content_id = ContentId::from("content-id-2".to_string());
2110        assert_ne!(
2111            info, other_content,
2112            "different content IDs must not compare equal"
2113        );
2114    }
2115}