Skip to main content

genja_core/
state.rs

1//! Runtime state management for Genja execution.
2//!
3//! This module provides thread-safe state tracking for the Genja automation framework,
4//! managing the lifecycle and status of hosts, connections, and task executions throughout
5//! a playbook run. The state system uses concurrent data structures to enable safe access
6//! from multiple threads while maintaining consistency across all tracked entities.
7//!
8//! # Overview
9//!
10//! The state module centers around the [`State`] structure, which maintains three primary
11//! categories of runtime information:
12//!
13//! 1. **Host Status** - Tracks whether hosts are in scope (available for operations) or
14//!    have been marked as failed and should be excluded from further operations.
15//!
16//! 2. **Connection State** - Records the status of connection attempts for each host/plugin
17//!    combination, including the number of attempts made, current connection status, and
18//!    error information from failed attempts.
19//!
20//! 3. **Task Execution State** - Maintains the execution status of tasks on individual hosts,
21//!    tracking whether tasks are pending, running, succeeded, failed, or skipped, along with
22//!    attempt counts and error details.
23//!
24//! All state tracking is designed to be thread-safe, allowing concurrent access from multiple
25//! execution threads without requiring external synchronization.
26//!
27//! # Core Types
28//!
29//! ## State Management
30//!
31//! - [`State`] - The main state container that tracks all runtime information using concurrent
32//!   hash maps. Provides methods for querying and updating host status, connection state, and
33//!   task execution state.
34//!
35//! ## Host Status
36//!
37//! - [`HostStatus`] - Indicates whether a host is in scope or has been marked as failed.
38//!   Hosts default to `InScope` and can be transitioned to `Failed` when errors occur.
39//!
40//! ## Connection Tracking
41//!
42//! - [`ConnectionAttemptState`] - Tracks the state of connection attempts for a specific
43//!   host/plugin pair, including status, attempt count, and error information.
44//!
45//! - [`ConnectionStatus`] - Represents the current status of a connection attempt, from
46//!   initial connection through success, retry, or failure states.
47//!
48//! - [`ConnectionFailureKind`] - Categorizes connection failures into specific types
49//!   (timeout, authentication, DNS, etc.) for targeted error handling.
50//!
51//! ## Task Execution Tracking
52//!
53//! - [`TaskAttemptState`] - Tracks the state of task execution attempts for a specific
54//!   host/task pair, including status, attempt count, and error information.
55//!
56//! - [`TaskExecutionKey`] - A composite key that uniquely identifies a task execution
57//!   by combining hostname and task name.
58//!
59//! - [`TaskStatus`] - Represents the current status of a task execution, from pending
60//!   through running, succeeded, failed, or skipped states.
61//!
62//! - [`TaskFailureKind`] - Categorizes task execution failures into specific types
63//!   (command failed, parse failed, validation failed, etc.) for targeted error handling.
64//!
65//! # Usage Patterns
66//!
67//! ## Basic State Creation
68//!
69//! ```rust
70//! use genja_core::state::State;
71//!
72//! let state = State::new();
73//! ```
74//!
75//! ## Host Status Management
76//!
77//! ```rust
78//! # use genja_core::state::{State, HostStatus};
79//! # let state = State::new();
80//! // Check if a host is in scope (defaults to true)
81//! assert!(state.is_in_scope("router1"));
82//!
83//! // Mark a host as failed
84//! state.mark_failed("router1");
85//! assert_eq!(state.host_status("router1"), Some(HostStatus::Failed));
86//!
87//! // Restore a host to in-scope status
88//! state.mark_in_scope("router1");
89//! assert_eq!(state.host_status("router1"), Some(HostStatus::InScope));
90//! ```
91//!
92//! ## Connection State Tracking
93//!
94//! ```rust
95//! # use genja_core::state::{State, ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
96//! # let state = State::new();
97//! // Begin a connection attempt
98//! state.begin_connection_attempt("router1", "ssh");
99//!
100//! // Mark the connection as successful
101//! state.mark_connection_connected("router1", "ssh");
102//!
103//! // Or mark it for retry with an error message
104//! state.mark_connection_retry_pending("router1", "ssh", "connection timed out");
105//!
106//! // Or mark it as permanently failed
107//! state.mark_connection_failed(
108//!     "router1",
109//!     "ssh",
110//!     ConnectionFailureKind::Timeout,
111//!     "connection timed out after 30 seconds"
112//! );
113//!
114//! // Query the current connection state
115//! if let Some(conn_state) = state.connection_state("router1", "ssh") {
116//!     println!("Connection status: {:?}", conn_state.status);
117//!     println!("Attempts made: {}", conn_state.attempts);
118//!     if let Some(error) = conn_state.last_error {
119//!         println!("Last error: {}", error);
120//!     }
121//! }
122//! ```
123//!
124//! ## Task Execution State Tracking
125//!
126//! ```rust
127//! # use genja_core::state::{State, TaskAttemptState, TaskStatus, TaskFailureKind};
128//! # let state = State::new();
129//! // Record a successful task execution
130//! let task_state = TaskAttemptState::new(TaskStatus::Succeeded)
131//!     .with_attempts(1);
132//! state.set_task_state("router1", "show_version", task_state);
133//!
134//! // Record a failed task execution with error details
135//! let failed_state = TaskAttemptState::new(
136//!     TaskStatus::Failed(TaskFailureKind::ParseFailed)
137//! )
138//! .with_attempts(2)
139//! .with_last_error("failed to parse command output");
140//! state.set_task_state("router1", "configure_interface", failed_state);
141//!
142//! // Query task execution state
143//! if let Some(task_state) = state.task_state("router1", "show_version") {
144//!     println!("Task status: {:?}", task_state.status);
145//!     println!("Attempts made: {}", task_state.attempts);
146//! }
147//! ```
148//!
149//! ## Using Keys for Efficient Lookups
150//!
151//! When you need to perform multiple operations on the same host/plugin or host/task
152//! combination, using the `_key` variants of methods can be more efficient:
153//!
154//! ```rust
155//! # use genja_core::state::State;
156//! # use genja_core::inventory::ConnectionKey;
157//! # let state = State::new();
158//! // Create a key once
159//! let key = ConnectionKey::new("router1", "ssh");
160//!
161//! // Use it for multiple operations
162//! state.begin_connection_attempt_key(key.clone());
163//! state.mark_connection_connected_key(key.clone());
164//!
165//! // Query using the same key
166//! if let Some(conn_state) = state.connection_state_key(&key) {
167//!     println!("Connection established after {} attempts", conn_state.attempts);
168//! }
169//! ```
170//!
171//! # Thread Safety
172//!
173//! All state tracking structures use `DashMap` internally, which provides concurrent
174//! access without requiring external locks. This allows multiple threads to safely
175//! update and query state simultaneously:
176//!
177//! ```rust
178//! # use genja_core::state::State;
179//! # use std::sync::Arc;
180//! # use std::thread;
181//! let state = Arc::new(State::new());
182//!
183//! let mut handles = vec![];
184//! for i in 0..4 {
185//!     let state = Arc::clone(&state);
186//!     handles.push(thread::spawn(move || {
187//!         let host = format!("router{}", i);
188//!         state.begin_connection_attempt(&host, "ssh");
189//!         state.mark_connection_connected(&host, "ssh");
190//!     }));
191//! }
192//!
193//! for handle in handles {
194//!     handle.join().unwrap();
195//! }
196//! ```
197//!
198//! # Design Considerations
199//!
200//! ## Builder Pattern
201//!
202//! State structures like [`ConnectionAttemptState`] and [`TaskAttemptState`] use the
203//! builder pattern for convenient construction and method chaining:
204//!
205//! ```rust
206//! # use genja_core::state::{ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
207//! let state = ConnectionAttemptState::new(
208//!     ConnectionStatus::Failed(ConnectionFailureKind::Timeout)
209//! )
210//! .with_attempts(3)
211//! .with_last_error("connection timed out after 30 seconds");
212//! ```
213//!
214//! ## Attempt Counting
215//!
216//! Connection and task attempt counters are preserved across state transitions,
217//! allowing you to track the total number of attempts made even after a connection
218//! succeeds or a task completes:
219//!
220//! ```rust
221//! # use genja_core::state::State;
222//! # let state = State::new();
223//! state.begin_connection_attempt("router1", "ssh"); // attempts = 1
224//! state.begin_connection_attempt("router1", "ssh"); // attempts = 2
225//! state.mark_connection_connected("router1", "ssh"); // attempts still = 2
226//! ```
227//!
228//! ## Error Tracking
229//!
230//! Error messages from failed attempts are preserved in the state, allowing for
231//! detailed diagnostics and logging:
232//!
233//! ```rust
234//! # use genja_core::state::{State, ConnectionFailureKind};
235//! # let state = State::new();
236//! state.begin_connection_attempt("router1", "ssh");
237//! state.mark_connection_failed(
238//!     "router1",
239//!     "ssh",
240//!     ConnectionFailureKind::Auth,
241//!     "authentication failed: invalid credentials"
242//! );
243//!
244//! if let Some(conn_state) = state.connection_state("router1", "ssh") {
245//!     if let Some(error) = conn_state.last_error {
246//!         eprintln!("Connection failed: {}", error);
247//!     }
248//! }
249//! ```
250use crate::{inventory::ConnectionKey, types::NatString};
251use dashmap::DashMap;
252use log::warn;
253
254/// Per-host execution state for the current Genja instance.
255///
256/// This structure maintains thread-safe state tracking for hosts, connections, and tasks
257/// within a Genja runtime. It uses concurrent hash maps ([`DashMap`]) to allow safe
258/// concurrent access from multiple threads without requiring external synchronization.
259///
260/// # State Categories
261///
262/// The state is organized into three primary categories:
263///
264/// * **Host Status** - Tracks whether hosts are in scope or have failed, allowing the
265///   runtime to exclude failed hosts from operations until they are explicitly restored.
266///
267/// * **Connection State** - Records connection attempt history for each host/plugin pair,
268///   including the current connection status, number of attempts, and any error messages
269///   from failed attempts.
270///
271/// * **Task State** - Tracks task execution state for each host/task pair, including
272///   execution status, attempt counts, and error information.
273///
274/// # Thread Safety
275///
276/// All state maps use [`DashMap`] internally, which provides lock-free concurrent access
277/// for most operations. This allows multiple threads to safely read and update state
278/// without explicit locking.
279///
280/// # Examples
281///
282/// ```
283/// # use genja_core::state::State;
284/// let state = State::new();
285///
286/// // Track host failures
287/// state.mark_failed("router1");
288/// assert!(!state.is_in_scope("router1"));
289///
290/// // Track connection attempts
291/// state.begin_connection_attempt("router2", "ssh");
292/// state.mark_connection_connected("router2", "ssh");
293///
294/// // Restore failed hosts
295/// state.mark_in_scope("router1");
296/// assert!(state.is_in_scope("router1"));
297/// ```
298#[derive(Debug, Default)]
299pub struct State {
300    host_status: DashMap<NatString, HostStatus>,
301    connection_state: DashMap<ConnectionKey, ConnectionAttemptState>,
302    task_state: DashMap<TaskExecutionKey, TaskAttemptState>,
303}
304
305impl State {
306    /// Create an empty state store.
307    pub fn new() -> Self {
308        Self {
309            host_status: DashMap::new(),
310            connection_state: DashMap::new(),
311            task_state: DashMap::new(),
312        }
313    }
314
315    /// Marks a host as failed and removes it from the active scope.
316    ///
317    /// When a host is marked as failed, it will be excluded from host views and
318    /// operations until it is explicitly restored using [`mark_in_scope`](Self::mark_in_scope)
319    /// or [`mark_in_scope_key`](Self::mark_in_scope_key).
320    ///
321    /// # Parameters
322    ///
323    /// * `name` - The hostname to mark as failed. Can be any type that converts into a `NatString`,
324    ///   such as `&str`, `String`, or `NatString`.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// # use genja_core::state::State;
330    /// let state = State::new();
331    /// state.mark_failed("router1");
332    /// assert!(!state.is_in_scope("router1"));
333    /// ```
334    pub fn mark_failed<K>(&self, name: K)
335    where
336        K: Into<NatString>,
337    {
338        let name = name.into();
339        warn!("host '{}' marked as failed", name);
340        self.host_status.insert(name, HostStatus::Failed);
341    }
342
343    /// Marks a host as back in scope and restores it to the active host view.
344    ///
345    /// When a host is marked as in scope, it will be included in host views and
346    /// operations. This is typically used to restore a host that was previously
347    /// marked as failed using [`mark_failed`](Self::mark_failed).
348    ///
349    /// # Parameters
350    ///
351    /// * `name` - The hostname to mark as in scope. Can be any type that converts into a `NatString`,
352    ///   such as `&str`, `String`, or `NatString`.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// # use genja_core::state::State;
358    /// let state = State::new();
359    /// state.mark_failed("router1");
360    /// state.mark_in_scope("router1");
361    /// assert!(state.is_in_scope("router1"));
362    /// ```
363    pub fn mark_in_scope<K>(&self, name: K)
364    where
365        K: Into<NatString>,
366    {
367        self.host_status.insert(name.into(), HostStatus::InScope);
368    }
369
370    /// Marks a host as back in scope using an existing `NatString` key.
371    ///
372    /// This is a more efficient variant of [`mark_in_scope`](Self::mark_in_scope) when you
373    /// already have a `NatString` reference, as it avoids an additional conversion.
374    ///
375    /// # Parameters
376    ///
377    /// * `key` - A reference to the `NatString` key representing the hostname to mark as in scope.
378    ///
379    /// # Examples
380    ///
381    /// ```
382    /// # use genja_core::state::State;
383    /// # use genja_core::types::NatString;
384    /// let state = State::new();
385    /// let host = NatString::from("router1");
386    /// state.mark_failed(host.clone());
387    /// state.mark_in_scope_key(&host);
388    /// assert!(state.is_in_scope_key(&host));
389    /// ```
390    pub fn mark_in_scope_key(&self, key: &NatString) {
391        self.host_status.insert(key.clone(), HostStatus::InScope);
392    }
393
394    /// Checks if a host is currently in scope and available for operations.
395    ///
396    /// A host is considered in scope unless it has been explicitly marked as failed
397    /// using [`mark_failed`](Self::mark_failed). Hosts that have never been tracked
398    /// are considered in scope by default.
399    ///
400    /// # Parameters
401    ///
402    /// * `name` - The hostname to check. Can be any type that converts into a `NatString`,
403    ///   such as `&str`, `String`, or `NatString`.
404    ///
405    /// # Returns
406    ///
407    /// Returns `true` if the host is in scope (either explicitly marked as in scope or
408    /// never tracked), `false` if the host has been marked as failed.
409    ///
410    /// # Examples
411    ///
412    /// ```
413    /// # use genja_core::state::State;
414    /// let state = State::new();
415    ///
416    /// // Untracked hosts are in scope by default
417    /// assert!(state.is_in_scope("router1"));
418    ///
419    /// // Failed hosts are not in scope
420    /// state.mark_failed("router1");
421    /// assert!(!state.is_in_scope("router1"));
422    ///
423    /// // Restored hosts are back in scope
424    /// state.mark_in_scope("router1");
425    /// assert!(state.is_in_scope("router1"));
426    /// ```
427    pub fn is_in_scope<K>(&self, name: K) -> bool
428    where
429        K: Into<NatString>,
430    {
431        let key = name.into();
432        self.is_in_scope_key(&key)
433    }
434
435    /// Returns the tracked host status for a host, if it has been explicitly set.
436    ///
437    /// This method retrieves the current status of a host if it has been tracked
438    /// (i.e., marked as failed or explicitly set as in scope). If the host has never
439    /// been tracked, this method returns `None`.
440    ///
441    /// # Parameters
442    ///
443    /// * `name` - The hostname to query. Can be any type that converts into a `NatString`,
444    ///   such as `&str`, `String`, or `NatString`.
445    ///
446    /// # Returns
447    ///
448    /// Returns `Some(HostStatus)` if the host has been explicitly tracked, or `None`
449    /// if the host has never been marked with a status. Note that returning `None`
450    /// does not mean the host is out of scope; untracked hosts are considered in
451    /// scope by default (see [`is_in_scope`](Self::is_in_scope)).
452    ///
453    /// # Examples
454    ///
455    /// ```
456    /// # use genja_core::state::{State, HostStatus};
457    /// let state = State::new();
458    ///
459    /// // Untracked hosts return None
460    /// assert_eq!(state.host_status("router1"), None);
461    ///
462    /// // Tracked hosts return their status
463    /// state.mark_failed("router1");
464    /// assert_eq!(state.host_status("router1"), Some(HostStatus::Failed));
465    ///
466    /// state.mark_in_scope("router1");
467    /// assert_eq!(state.host_status("router1"), Some(HostStatus::InScope));
468    /// ```
469    pub fn host_status<K>(&self, name: K) -> Option<HostStatus>
470    where
471        K: Into<NatString>,
472    {
473        let key = name.into();
474        self.host_status_key(&key)
475    }
476
477    /// Returns the tracked host status for a host using an existing `NatString` key.
478    ///
479    /// This is a more efficient variant of [`host_status`](Self::host_status) when you
480    /// already have a `NatString` reference, as it avoids an additional conversion.
481    ///
482    /// # Parameters
483    ///
484    /// * `key` - A reference to the `NatString` key representing the hostname to query.
485    ///
486    /// # Returns
487    ///
488    /// Returns `Some(HostStatus)` if the host has been explicitly tracked, or `None`
489    /// if the host has never been marked with a status. Note that returning `None`
490    /// does not mean the host is out of scope; untracked hosts are considered in
491    /// scope by default (see [`is_in_scope`](Self::is_in_scope)).
492    ///
493    /// # Examples
494    ///
495    /// ```
496    /// # use genja_core::state::{State, HostStatus};
497    /// # use genja_core::types::NatString;
498    /// let state = State::new();
499    /// let host = NatString::from("router1");
500    ///
501    /// // Untracked hosts return None
502    /// assert_eq!(state.host_status_key(&host), None);
503    ///
504    /// // Tracked hosts return their status
505    /// state.mark_failed(host.clone());
506    /// assert_eq!(state.host_status_key(&host), Some(HostStatus::Failed));
507    ///
508    /// state.mark_in_scope_key(&host);
509    /// assert_eq!(state.host_status_key(&host), Some(HostStatus::InScope));
510    /// ```
511    pub fn host_status_key(&self, key: &NatString) -> Option<HostStatus> {
512        self.host_status.get(key).map(|entry| *entry.value())
513    }
514
515    /// Checks if a host is currently in scope using an existing `NatString` key.
516    ///
517    /// This is a more efficient variant of [`is_in_scope`](Self::is_in_scope) when you
518    /// already have a `NatString` reference, as it avoids an additional conversion.
519    ///
520    /// A host is considered in scope unless it has been explicitly marked as failed
521    /// using [`mark_failed`](Self::mark_failed). Hosts that have never been tracked
522    /// are considered in scope by default.
523    ///
524    /// # Parameters
525    ///
526    /// * `key` - A reference to the `NatString` key representing the hostname to check.
527    ///
528    /// # Returns
529    ///
530    /// Returns `true` if the host is in scope (either explicitly marked as in scope or
531    /// never tracked), `false` if the host has been marked as failed.
532    ///
533    /// # Examples
534    ///
535    /// ```
536    /// # use genja_core::state::State;
537    /// # use genja_core::types::NatString;
538    /// let state = State::new();
539    /// let host = NatString::from("router1");
540    ///
541    /// // Untracked hosts are in scope by default
542    /// assert!(state.is_in_scope_key(&host));
543    ///
544    /// // Failed hosts are not in scope
545    /// state.mark_failed(host.clone());
546    /// assert!(!state.is_in_scope_key(&host));
547    ///
548    /// // Restored hosts are back in scope
549    /// state.mark_in_scope_key(&host);
550    /// assert!(state.is_in_scope_key(&host));
551    /// ```
552    pub fn is_in_scope_key(&self, key: &NatString) -> bool {
553        match self.host_status.get(key) {
554            Some(status) => *status.value() == HostStatus::InScope,
555            None => true,
556        }
557    }
558
559    /// Sets the connection attempt state for a specific host and plugin combination.
560    ///
561    /// This method records the current state of a connection attempt, including the
562    /// connection status, number of attempts made, and any error information. The state
563    /// is stored using a `ConnectionKey` composed of the host and plugin name.
564    ///
565    /// If the connection state indicates a failure, a warning will be logged with details
566    /// about the failure kind and any associated error message.
567    ///
568    /// # Parameters
569    ///
570    /// * `host` - The hostname for which to set the connection state. Can be any type that
571    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
572    ///
573    /// * `plugin_name` - The name of the connection plugin being used. Can be any type that
574    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
575    ///
576    /// * `state` - The `ConnectionAttemptState` to record, containing the connection status,
577    ///   attempt count, and optional error information.
578    ///
579    /// # Examples
580    ///
581    /// ```
582    /// # use genja_core::state::{State, ConnectionAttemptState, ConnectionStatus};
583    /// let state = State::new();
584    ///
585    /// // Record a successful connection
586    /// let connection_state = ConnectionAttemptState::new(ConnectionStatus::Connected)
587    ///     .with_attempts(1);
588    /// state.set_connection_state("router1", "ssh", connection_state);
589    ///
590    /// // Verify the state was recorded
591    /// assert_eq!(
592    ///     state.connection_state("router1", "ssh").map(|s| s.status),
593    ///     Some(ConnectionStatus::Connected)
594    /// );
595    /// ```
596    pub fn set_connection_state(
597        &self,
598        host: impl Into<String>,
599        plugin_name: impl Into<String>,
600        state: ConnectionAttemptState,
601    ) {
602        self.set_connection_state_key(ConnectionKey::new(host, plugin_name), state);
603    }
604
605    /// Sets the connection attempt state using an existing `ConnectionKey`.
606    ///
607    /// This is a more efficient variant of [`set_connection_state`](Self::set_connection_state)
608    /// when you already have a `ConnectionKey`, as it avoids constructing a new key from
609    /// separate host and plugin name components.
610    ///
611    /// If the connection state indicates a failure, a warning will be logged with details
612    /// about the failure kind and any associated error message.
613    ///
614    /// # Parameters
615    ///
616    /// * `key` - The `ConnectionKey` identifying the host and plugin combination for which
617    ///   to set the connection state.
618    ///
619    /// * `state` - The `ConnectionAttemptState` to record, containing the connection status,
620    ///   attempt count, and optional error information.
621    ///
622    /// # Examples
623    ///
624    /// ```
625    /// # use genja_core::state::{State, ConnectionAttemptState, ConnectionStatus};
626    /// # use genja_core::inventory::ConnectionKey;
627    /// let state = State::new();
628    /// let key = ConnectionKey::new("router1", "ssh");
629    ///
630    /// // Record a successful connection
631    /// let connection_state = ConnectionAttemptState::new(ConnectionStatus::Connected)
632    ///     .with_attempts(1);
633    /// state.set_connection_state_key(key.clone(), connection_state);
634    ///
635    /// // Verify the state was recorded
636    /// assert_eq!(
637    ///     state.connection_state_key(&key).map(|s| s.status),
638    ///     Some(ConnectionStatus::Connected)
639    /// );
640    /// ```
641    pub fn set_connection_state_key(&self, key: ConnectionKey, state: ConnectionAttemptState) {
642        if let ConnectionStatus::Failed(kind) = &state.status {
643            match &state.last_error {
644                Some(error) => warn!(
645                    "connection failed for host '{}' via plugin '{}' ({kind:?}): {error}",
646                    key.hostname, key.plugin_name
647                ),
648                None => warn!(
649                    "connection failed for host '{}' via plugin '{}' ({kind:?})",
650                    key.hostname, key.plugin_name
651                ),
652            }
653        }
654        self.connection_state.insert(key, state);
655    }
656
657    /// Retrieves the current connection attempt state for a specific host and plugin combination.
658    ///
659    /// This method looks up the connection state using the provided host and plugin name,
660    /// returning the current state if it exists. The state includes information about the
661    /// connection status, number of attempts made, and any error from the last failed attempt.
662    ///
663    /// # Parameters
664    ///
665    /// * `host` - The hostname for which to retrieve the connection state.
666    ///
667    /// * `plugin_name` - The name of the connection plugin being used.
668    ///
669    /// # Returns
670    ///
671    /// Returns `Some(ConnectionAttemptState)` if a connection state has been recorded for
672    /// the given host and plugin combination, or `None` if no connection attempts have been
673    /// tracked for this combination.
674    ///
675    /// # Examples
676    ///
677    /// ```
678    /// # use genja_core::state::{State, ConnectionAttemptState, ConnectionStatus};
679    /// let state = State::new();
680    ///
681    /// // No state recorded yet
682    /// assert_eq!(state.connection_state("router1", "ssh"), None);
683    ///
684    /// // After recording a connection attempt
685    /// state.begin_connection_attempt("router1", "ssh");
686    /// let connection_state = state.connection_state("router1", "ssh");
687    /// assert!(connection_state.is_some());
688    /// assert_eq!(connection_state.unwrap().status, ConnectionStatus::Connecting);
689    /// ```
690    pub fn connection_state(
691        &self,
692        host: &str,
693        plugin_name: &str,
694    ) -> Option<ConnectionAttemptState> {
695        let key = ConnectionKey::new(host, plugin_name);
696        self.connection_state
697            .get(&key)
698            .map(|entry| entry.value().clone())
699    }
700
701    /// Retrieves the current connection attempt state using an existing `ConnectionKey`.
702    ///
703    /// This is a more efficient variant of [`connection_state`](Self::connection_state) when you
704    /// already have a `ConnectionKey`, as it avoids constructing a new key from separate host
705    /// and plugin name components.
706    ///
707    /// # Parameters
708    ///
709    /// * `key` - A reference to the `ConnectionKey` identifying the host and plugin combination
710    ///   for which to retrieve the connection state.
711    ///
712    /// # Returns
713    ///
714    /// Returns `Some(ConnectionAttemptState)` if a connection state has been recorded for
715    /// the given key, or `None` if no connection attempts have been tracked for this
716    /// host and plugin combination.
717    ///
718    /// # Examples
719    ///
720    /// ```
721    /// # use genja_core::state::{State, ConnectionAttemptState, ConnectionStatus};
722    /// # use genja_core::inventory::ConnectionKey;
723    /// let state = State::new();
724    /// let key = ConnectionKey::new("router1", "ssh");
725    ///
726    /// // No state recorded yet
727    /// assert_eq!(state.connection_state_key(&key), None);
728    ///
729    /// // After recording a connection attempt
730    /// state.begin_connection_attempt_key(key.clone());
731    /// let connection_state = state.connection_state_key(&key);
732    /// assert!(connection_state.is_some());
733    /// assert_eq!(connection_state.unwrap().status, ConnectionStatus::Connecting);
734    /// ```
735    pub fn connection_state_key(&self, key: &ConnectionKey) -> Option<ConnectionAttemptState> {
736        self.connection_state
737            .get(key)
738            .map(|entry| entry.value().clone())
739    }
740
741    /// Records the start of a connection attempt and increments the attempt counter.
742    ///
743    /// This method marks the beginning of a new connection attempt for a specific host and
744    /// plugin combination. It automatically increments the attempt counter, tracking how many
745    /// times a connection has been attempted for this host/plugin pair. The connection status
746    /// is set to `ConnectionStatus::Connecting`.
747    ///
748    /// If this is the first connection attempt for the given host and plugin, the attempt
749    /// counter starts at 1. For subsequent attempts, the counter is incremented from its
750    /// previous value.
751    ///
752    /// # Parameters
753    ///
754    /// * `host` - The hostname for which to record the connection attempt. Can be any type that
755    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
756    ///
757    /// * `plugin_name` - The name of the connection plugin being used for the attempt. Can be
758    ///   any type that converts into a `String`, such as `&str`, `String`, or other string-like types.
759    ///
760    /// # Examples
761    ///
762    /// ```
763    /// # use genja_core::state::{State, ConnectionStatus};
764    /// let state = State::new();
765    ///
766    /// // First connection attempt
767    /// state.begin_connection_attempt("router1", "ssh");
768    /// let connection_state = state.connection_state("router1", "ssh").unwrap();
769    /// assert_eq!(connection_state.status, ConnectionStatus::Connecting);
770    /// assert_eq!(connection_state.attempts, 1);
771    ///
772    /// // Second connection attempt
773    /// state.begin_connection_attempt("router1", "ssh");
774    /// let connection_state = state.connection_state("router1", "ssh").unwrap();
775    /// assert_eq!(connection_state.attempts, 2);
776    /// ```
777    pub fn begin_connection_attempt(
778        &self,
779        host: impl Into<String>,
780        plugin_name: impl Into<String>,
781    ) {
782        self.begin_connection_attempt_key(ConnectionKey::new(host, plugin_name));
783    }
784
785    /// Records the start of a connection attempt using an existing `ConnectionKey` and increments the attempt counter.
786    ///
787    /// This is a more efficient variant of [`begin_connection_attempt`](Self::begin_connection_attempt)
788    /// when you already have a `ConnectionKey`, as it avoids constructing a new key from separate
789    /// host and plugin name components.
790    ///
791    /// This method marks the beginning of a new connection attempt for the specified connection key.
792    /// It automatically increments the attempt counter, tracking how many times a connection has been
793    /// attempted for this host/plugin pair. The connection status is set to `ConnectionStatus::Connecting`.
794    ///
795    /// If this is the first connection attempt for the given key, the attempt counter starts at 1.
796    /// For subsequent attempts, the counter is incremented from its previous value.
797    ///
798    /// # Parameters
799    ///
800    /// * `key` - The `ConnectionKey` identifying the host and plugin combination for which to record
801    ///   the connection attempt.
802    ///
803    /// # Examples
804    ///
805    /// ```
806    /// # use genja_core::state::{State, ConnectionStatus};
807    /// # use genja_core::inventory::ConnectionKey;
808    /// let state = State::new();
809    /// let key = ConnectionKey::new("router1", "ssh");
810    ///
811    /// // First connection attempt
812    /// state.begin_connection_attempt_key(key.clone());
813    /// let connection_state = state.connection_state_key(&key).unwrap();
814    /// assert_eq!(connection_state.status, ConnectionStatus::Connecting);
815    /// assert_eq!(connection_state.attempts, 1);
816    ///
817    /// // Second connection attempt
818    /// state.begin_connection_attempt_key(key.clone());
819    /// let connection_state = state.connection_state_key(&key).unwrap();
820    /// assert_eq!(connection_state.attempts, 2);
821    /// ```
822    pub fn begin_connection_attempt_key(&self, key: ConnectionKey) {
823        let attempts = self
824            .connection_state_key(&key)
825            .map(|state| state.attempts + 1)
826            .unwrap_or(1);
827
828        self.set_connection_state_key(
829            key,
830            ConnectionAttemptState::new(ConnectionStatus::Connecting).with_attempts(attempts),
831        );
832    }
833
834    /// Marks a connection as successfully established while preserving the attempt count.
835    ///
836    /// This method updates the connection state to `ConnectionStatus::Connected`, indicating
837    /// that a connection has been successfully established for the specified host and plugin
838    /// combination. The attempt counter is preserved from any previous connection attempts,
839    /// allowing you to track how many attempts were needed before the connection succeeded.
840    ///
841    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
842    ///
843    /// # Parameters
844    ///
845    /// * `host` - The hostname for which to mark the connection as connected. Can be any type
846    ///   that converts into a `String`, such as `&str`, `String`, or other string-like types.
847    ///
848    /// * `plugin_name` - The name of the connection plugin that successfully established the
849    ///   connection. Can be any type that converts into a `String`, such as `&str`, `String`,
850    ///   or other string-like types.
851    ///
852    /// # Examples
853    ///
854    /// ```
855    /// # use genja_core::state::{State, ConnectionStatus};
856    /// let state = State::new();
857    ///
858    /// // Record connection attempts and then mark as connected
859    /// state.begin_connection_attempt("router1", "ssh");
860    /// state.begin_connection_attempt("router1", "ssh");
861    /// state.mark_connection_connected("router1", "ssh");
862    ///
863    /// // Verify the connection is marked as connected with preserved attempt count
864    /// let connection_state = state.connection_state("router1", "ssh").unwrap();
865    /// assert_eq!(connection_state.status, ConnectionStatus::Connected);
866    /// assert_eq!(connection_state.attempts, 2);
867    /// ```
868    pub fn mark_connection_connected(
869        &self,
870        host: impl Into<String>,
871        plugin_name: impl Into<String>,
872    ) {
873        self.mark_connection_connected_key(ConnectionKey::new(host, plugin_name));
874    }
875
876    /// Marks a connection as successfully established using an existing `ConnectionKey` while preserving the attempt count.
877    ///
878    /// This is a more efficient variant of [`mark_connection_connected`](Self::mark_connection_connected)
879    /// when you already have a `ConnectionKey`, as it avoids constructing a new key from separate
880    /// host and plugin name components.
881    ///
882    /// This method updates the connection state to `ConnectionStatus::Connected`, indicating
883    /// that a connection has been successfully established. The attempt counter is preserved from
884    /// any previous connection attempts, allowing you to track how many attempts were needed
885    /// before the connection succeeded.
886    ///
887    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
888    ///
889    /// # Parameters
890    ///
891    /// * `key` - The `ConnectionKey` identifying the host and plugin combination for which to
892    ///   mark the connection as connected.
893    ///
894    /// # Examples
895    ///
896    /// ```
897    /// # use genja_core::state::{State, ConnectionStatus};
898    /// # use genja_core::inventory::ConnectionKey;
899    /// let state = State::new();
900    /// let key = ConnectionKey::new("router1", "ssh");
901    ///
902    /// // Record connection attempts and then mark as connected
903    /// state.begin_connection_attempt_key(key.clone());
904    /// state.begin_connection_attempt_key(key.clone());
905    /// state.mark_connection_connected_key(key.clone());
906    ///
907    /// // Verify the connection is marked as connected with preserved attempt count
908    /// let connection_state = state.connection_state_key(&key).unwrap();
909    /// assert_eq!(connection_state.status, ConnectionStatus::Connected);
910    /// assert_eq!(connection_state.attempts, 2);
911    /// ```
912    pub fn mark_connection_connected_key(&self, key: ConnectionKey) {
913        let attempts = self
914            .connection_state_key(&key)
915            .map(|state| state.attempts)
916            .unwrap_or(0);
917
918        self.set_connection_state_key(
919            key,
920            ConnectionAttemptState::new(ConnectionStatus::Connected).with_attempts(attempts),
921        );
922    }
923
924    /// Marks a connection as pending retry while preserving the attempt count and recording the error.
925    ///
926    /// This method updates the connection state to `ConnectionStatus::RetryPending`, indicating
927    /// that a connection attempt has failed but will be retried. The attempt counter is preserved
928    /// from any previous connection attempts, and the provided error message is stored for
929    /// diagnostic purposes.
930    ///
931    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
932    ///
933    /// # Parameters
934    ///
935    /// * `host` - The hostname for which to mark the connection as pending retry. Can be any type
936    ///   that converts into a `String`, such as `&str`, `String`, or other string-like types.
937    ///
938    /// * `plugin_name` - The name of the connection plugin for which the retry is pending. Can be
939    ///   any type that converts into a `String`, such as `&str`, `String`, or other string-like types.
940    ///
941    /// * `last_error` - The error message from the failed connection attempt that triggered the
942    ///   retry. Can be any type that converts into a `String`, such as `&str`, `String`, or other
943    ///   string-like types.
944    ///
945    /// # Examples
946    ///
947    /// ```
948    /// # use genja_core::state::{State, ConnectionStatus};
949    /// let state = State::new();
950    ///
951    /// // Record a connection attempt and mark as pending retry
952    /// state.begin_connection_attempt("router1", "ssh");
953    /// state.mark_connection_retry_pending("router1", "ssh", "connection timed out");
954    ///
955    /// // Verify the connection is marked as retry pending with error
956    /// let connection_state = state.connection_state("router1", "ssh").unwrap();
957    /// assert_eq!(connection_state.status, ConnectionStatus::RetryPending);
958    /// assert_eq!(connection_state.attempts, 1);
959    /// assert_eq!(connection_state.last_error, Some("connection timed out".to_string()));
960    /// ```
961    pub fn mark_connection_retry_pending(
962        &self,
963        host: impl Into<String>,
964        plugin_name: impl Into<String>,
965        last_error: impl Into<String>,
966    ) {
967        self.mark_connection_retry_pending_key(ConnectionKey::new(host, plugin_name), last_error);
968    }
969
970    /// Marks a connection as pending retry using an existing `ConnectionKey` while preserving the attempt count and recording the error.
971    ///
972    /// This is a more efficient variant of [`mark_connection_retry_pending`](Self::mark_connection_retry_pending)
973    /// when you already have a `ConnectionKey`, as it avoids constructing a new key from separate
974    /// host and plugin name components.
975    ///
976    /// This method updates the connection state to `ConnectionStatus::RetryPending`, indicating
977    /// that a connection attempt has failed but will be retried. The attempt counter is preserved
978    /// from any previous connection attempts, and the provided error message is stored for
979    /// diagnostic purposes.
980    ///
981    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
982    ///
983    /// # Parameters
984    ///
985    /// * `key` - The `ConnectionKey` identifying the host and plugin combination for which to
986    ///   mark the connection as pending retry.
987    ///
988    /// * `last_error` - The error message from the failed connection attempt that triggered the
989    ///   retry. Can be any type that converts into a `String`, such as `&str`, `String`, or other
990    ///   string-like types.
991    ///
992    /// # Examples
993    ///
994    /// ```
995    /// # use genja_core::state::{State, ConnectionStatus};
996    /// # use genja_core::inventory::ConnectionKey;
997    /// let state = State::new();
998    /// let key = ConnectionKey::new("router1", "ssh");
999    ///
1000    /// // Record a connection attempt and mark as pending retry
1001    /// state.begin_connection_attempt_key(key.clone());
1002    /// state.mark_connection_retry_pending_key(key.clone(), "connection timed out");
1003    ///
1004    /// // Verify the connection is marked as retry pending with error
1005    /// let connection_state = state.connection_state_key(&key).unwrap();
1006    /// assert_eq!(connection_state.status, ConnectionStatus::RetryPending);
1007    /// assert_eq!(connection_state.attempts, 1);
1008    /// assert_eq!(connection_state.last_error, Some("connection timed out".to_string()));
1009    /// ```
1010    pub fn mark_connection_retry_pending_key(
1011        &self,
1012        key: ConnectionKey,
1013        last_error: impl Into<String>,
1014    ) {
1015        let attempts = self
1016            .connection_state_key(&key)
1017            .map(|state| state.attempts)
1018            .unwrap_or(0);
1019
1020        self.set_connection_state_key(
1021            key,
1022            ConnectionAttemptState::new(ConnectionStatus::RetryPending)
1023                .with_attempts(attempts)
1024                .with_last_error(last_error),
1025        );
1026    }
1027
1028    /// Marks a connection as terminally failed while preserving the attempt count and recording the error.
1029    ///
1030    /// This method updates the connection state to `ConnectionStatus::Failed` with the specified
1031    /// failure kind, indicating that a connection attempt has failed and will not be retried. The
1032    /// attempt counter is preserved from any previous connection attempts, and the provided error
1033    /// message is stored for diagnostic purposes.
1034    ///
1035    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
1036    ///
1037    /// # Parameters
1038    ///
1039    /// * `host` - The hostname for which to mark the connection as failed. Can be any type that
1040    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1041    ///
1042    /// * `plugin_name` - The name of the connection plugin for which the connection failed. Can be
1043    ///   any type that converts into a `String`, such as `&str`, `String`, or other string-like types.
1044    ///
1045    /// * `kind` - The `ConnectionFailureKind` classifying the type of failure (e.g., timeout,
1046    ///   authentication failure, DNS error).
1047    ///
1048    /// * `last_error` - The error message from the failed connection attempt. Can be any type that
1049    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1050    ///
1051    /// # Examples
1052    ///
1053    /// ```
1054    /// # use genja_core::state::{State, ConnectionStatus, ConnectionFailureKind};
1055    /// let state = State::new();
1056    ///
1057    /// // Record connection attempts and mark as failed
1058    /// state.begin_connection_attempt("router1", "ssh");
1059    /// state.begin_connection_attempt("router1", "ssh");
1060    /// state.mark_connection_failed(
1061    ///     "router1",
1062    ///     "ssh",
1063    ///     ConnectionFailureKind::Timeout,
1064    ///     "connection timed out after 30 seconds"
1065    /// );
1066    ///
1067    /// // Verify the connection is marked as failed with error details
1068    /// let connection_state = state.connection_state("router1", "ssh").unwrap();
1069    /// assert_eq!(connection_state.status, ConnectionStatus::Failed(ConnectionFailureKind::Timeout));
1070    /// assert_eq!(connection_state.attempts, 2);
1071    /// assert_eq!(connection_state.last_error, Some("connection timed out after 30 seconds".to_string()));
1072    /// ```
1073    pub fn mark_connection_failed(
1074        &self,
1075        host: impl Into<String>,
1076        plugin_name: impl Into<String>,
1077        kind: ConnectionFailureKind,
1078        last_error: impl Into<String>,
1079    ) {
1080        self.mark_connection_failed_key(ConnectionKey::new(host, plugin_name), kind, last_error);
1081    }
1082
1083    /// Marks a connection as terminally failed using an existing `ConnectionKey` while preserving the attempt count and recording the error.
1084    ///
1085    /// This is a more efficient variant of [`mark_connection_failed`](Self::mark_connection_failed)
1086    /// when you already have a `ConnectionKey`, as it avoids constructing a new key from separate
1087    /// host and plugin name components.
1088    ///
1089    /// This method updates the connection state to `ConnectionStatus::Failed` with the specified
1090    /// failure kind, indicating that a connection attempt has failed and will not be retried. The
1091    /// attempt counter is preserved from any previous connection attempts, and the provided error
1092    /// message is stored for diagnostic purposes.
1093    ///
1094    /// If no previous connection attempts were recorded, the attempt count will be set to 0.
1095    ///
1096    /// # Parameters
1097    ///
1098    /// * `key` - The `ConnectionKey` identifying the host and plugin combination for which to
1099    ///   mark the connection as failed.
1100    ///
1101    /// * `kind` - The `ConnectionFailureKind` classifying the type of failure (e.g., timeout,
1102    ///   authentication failure, DNS error).
1103    ///
1104    /// * `last_error` - The error message from the failed connection attempt. Can be any type that
1105    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1106    ///
1107    /// # Examples
1108    ///
1109    /// ```
1110    /// # use genja_core::state::{State, ConnectionStatus, ConnectionFailureKind};
1111    /// # use genja_core::inventory::ConnectionKey;
1112    /// let state = State::new();
1113    /// let key = ConnectionKey::new("router1", "ssh");
1114    ///
1115    /// // Record connection attempts and mark as failed
1116    /// state.begin_connection_attempt_key(key.clone());
1117    /// state.begin_connection_attempt_key(key.clone());
1118    /// state.mark_connection_failed_key(
1119    ///     key.clone(),
1120    ///     ConnectionFailureKind::Timeout,
1121    ///     "connection timed out after 30 seconds"
1122    /// );
1123    ///
1124    /// // Verify the connection is marked as failed with error details
1125    /// let connection_state = state.connection_state_key(&key).unwrap();
1126    /// assert_eq!(connection_state.status, ConnectionStatus::Failed(ConnectionFailureKind::Timeout));
1127    /// assert_eq!(connection_state.attempts, 2);
1128    /// assert_eq!(connection_state.last_error, Some("connection timed out after 30 seconds".to_string()));
1129    /// ```
1130    pub fn mark_connection_failed_key(
1131        &self,
1132        key: ConnectionKey,
1133        kind: ConnectionFailureKind,
1134        last_error: impl Into<String>,
1135    ) {
1136        let attempts = self
1137            .connection_state_key(&key)
1138            .map(|state| state.attempts)
1139            .unwrap_or(0);
1140
1141        self.set_connection_state_key(
1142            key,
1143            ConnectionAttemptState::new(ConnectionStatus::Failed(kind))
1144                .with_attempts(attempts)
1145                .with_last_error(last_error),
1146        );
1147    }
1148
1149    /// Sets the task execution state for a specific host and task combination.
1150    ///
1151    /// This method records the current state of a task execution, including the
1152    /// task status, number of attempts made, and any error information. The state
1153    /// is stored using a `TaskExecutionKey` composed of the host and task name.
1154    ///
1155    /// If the task state indicates a failure, a warning will be logged with details
1156    /// about the failure kind and any associated error message.
1157    ///
1158    /// # Parameters
1159    ///
1160    /// * `host` - The hostname for which to set the task state. Can be any type that
1161    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1162    ///
1163    /// * `task_name` - The name of the task being executed. Can be any type that
1164    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1165    ///
1166    /// * `state` - The `TaskAttemptState` to record, containing the task status,
1167    ///   attempt count, and optional error information.
1168    ///
1169    /// # Examples
1170    ///
1171    /// ```
1172    /// # use genja_core::state::{State, TaskAttemptState, TaskStatus};
1173    /// let state = State::new();
1174    ///
1175    /// // Record a successful task execution
1176    /// let task_state = TaskAttemptState::new(TaskStatus::Succeeded)
1177    ///     .with_attempts(1);
1178    /// state.set_task_state("router1", "show_version", task_state);
1179    ///
1180    /// // Verify the state was recorded
1181    /// assert_eq!(
1182    ///     state.task_state("router1", "show_version").map(|s| s.status),
1183    ///     Some(TaskStatus::Succeeded)
1184    /// );
1185    /// ```
1186    pub fn set_task_state(
1187        &self,
1188        host: impl Into<String>,
1189        task_name: impl Into<String>,
1190        state: TaskAttemptState,
1191    ) {
1192        self.set_task_state_key(TaskExecutionKey::new(host, task_name), state);
1193    }
1194
1195    /// Sets the task execution state using an existing `TaskExecutionKey`.
1196    ///
1197    /// This is a more efficient variant of [`set_task_state`](Self::set_task_state)
1198    /// when you already have a `TaskExecutionKey`, as it avoids constructing a new key from
1199    /// separate host and task name components.
1200    ///
1201    /// If the task state indicates a failure, a warning will be logged with details
1202    /// about the failure kind and any associated error message.
1203    ///
1204    /// # Parameters
1205    ///
1206    /// * `key` - The `TaskExecutionKey` identifying the host and task combination for which
1207    ///   to set the task state.
1208    ///
1209    /// * `state` - The `TaskAttemptState` to record, containing the task status,
1210    ///   attempt count, and optional error information.
1211    ///
1212    /// # Examples
1213    ///
1214    /// ```
1215    /// # use genja_core::state::{State, TaskAttemptState, TaskStatus, TaskExecutionKey};
1216    /// let state = State::new();
1217    /// let key = TaskExecutionKey::new("router1", "show_version");
1218    ///
1219    /// // Record a successful task execution
1220    /// let task_state = TaskAttemptState::new(TaskStatus::Succeeded)
1221    ///     .with_attempts(1);
1222    /// state.set_task_state_key(key.clone(), task_state);
1223    ///
1224    /// // Verify the state was recorded
1225    /// assert_eq!(
1226    ///     state.task_state_key(&key).map(|s| s.status),
1227    ///     Some(TaskStatus::Succeeded)
1228    /// );
1229    /// ```
1230    pub fn set_task_state_key(&self, key: TaskExecutionKey, state: TaskAttemptState) {
1231        if let TaskStatus::Failed(kind) = &state.status {
1232            match &state.last_error {
1233                Some(error) => warn!(
1234                    "task failed for host '{}' in task '{}' ({kind:?}): {error}",
1235                    key.host, key.task_name
1236                ),
1237                None => warn!(
1238                    "task failed for host '{}' in task '{}' ({kind:?})",
1239                    key.host, key.task_name
1240                ),
1241            }
1242        }
1243        self.task_state.insert(key, state);
1244    }
1245
1246    /// Retrieves the current task execution state for a specific host and task combination.
1247    ///
1248    /// This method looks up the task state using the provided host and task name,
1249    /// returning the current state if it exists. The state includes information about the
1250    /// task status, number of attempts made, and any error from the last failed attempt.
1251    ///
1252    /// # Parameters
1253    ///
1254    /// * `host` - The hostname for which to retrieve the task state.
1255    ///
1256    /// * `task_name` - The name of the task being queried.
1257    ///
1258    /// # Returns
1259    ///
1260    /// Returns `Some(TaskAttemptState)` if a task state has been recorded for
1261    /// the given host and task combination, or `None` if no task execution has been
1262    /// tracked for this combination.
1263    ///
1264    /// # Examples
1265    ///
1266    /// ```
1267    /// # use genja_core::state::{State, TaskAttemptState, TaskStatus};
1268    /// let state = State::new();
1269    ///
1270    /// // No state recorded yet
1271    /// assert_eq!(state.task_state("router1", "show_version"), None);
1272    ///
1273    /// // After recording a task execution
1274    /// let task_state = TaskAttemptState::new(TaskStatus::Running).with_attempts(1);
1275    /// state.set_task_state("router1", "show_version", task_state.clone());
1276    /// assert_eq!(state.task_state("router1", "show_version"), Some(task_state));
1277    /// ```
1278    pub fn task_state(&self, host: &str, task_name: &str) -> Option<TaskAttemptState> {
1279        let key = TaskExecutionKey::new(host, task_name);
1280        self.task_state.get(&key).map(|entry| entry.value().clone())
1281    }
1282
1283    /// Retrieves the current task execution state using an existing `TaskExecutionKey`.
1284    ///
1285    /// This is a more efficient variant of [`task_state`](Self::task_state) when you
1286    /// already have a `TaskExecutionKey`, as it avoids constructing a new key from separate
1287    /// host and task name components.
1288    ///
1289    /// # Parameters
1290    ///
1291    /// * `key` - A reference to the `TaskExecutionKey` identifying the host and task combination
1292    ///   for which to retrieve the task state.
1293    ///
1294    /// # Returns
1295    ///
1296    /// Returns `Some(TaskAttemptState)` if a task state has been recorded for
1297    /// the given key, or `None` if no task execution has been tracked for this
1298    /// host and task combination.
1299    ///
1300    /// # Examples
1301    ///
1302    /// ```
1303    /// # use genja_core::state::{State, TaskAttemptState, TaskStatus, TaskExecutionKey};
1304    /// let state = State::new();
1305    /// let key = TaskExecutionKey::new("router1", "show_version");
1306    ///
1307    /// // No state recorded yet
1308    /// assert_eq!(state.task_state_key(&key), None);
1309    ///
1310    /// // After recording a task execution
1311    /// let task_state = TaskAttemptState::new(TaskStatus::Running).with_attempts(1);
1312    /// state.set_task_state_key(key.clone(), task_state.clone());
1313    /// assert_eq!(state.task_state_key(&key), Some(task_state));
1314    /// ```
1315    pub fn task_state_key(&self, key: &TaskExecutionKey) -> Option<TaskAttemptState> {
1316        self.task_state.get(key).map(|entry| entry.value().clone())
1317    }
1318}
1319
1320/// Represents the operational status of a host within the Genja runtime.
1321///
1322/// This enum tracks whether a host is currently available for operations or has
1323/// been marked as failed. Hosts can transition between these states using the
1324/// [`State::mark_failed`] and [`State::mark_in_scope`] methods.
1325///
1326/// # Variants
1327///
1328/// * `InScope` - The host is available and can participate in operations. This is
1329///   the default state for hosts that have not been explicitly marked as failed.
1330///
1331/// * `Failed` - The host has been marked as failed and will be excluded from
1332///   operations until it is explicitly restored to the `InScope` state.
1333///
1334/// # Examples
1335///
1336/// ```
1337/// # use genja_core::state::{State, HostStatus};
1338/// let state = State::new();
1339///
1340/// // Check the status of a host
1341/// assert_eq!(state.host_status("router1"), None); // Untracked hosts return None
1342///
1343/// // Mark a host as failed
1344/// state.mark_failed("router1");
1345/// assert_eq!(state.host_status("router1"), Some(HostStatus::Failed));
1346///
1347/// // Restore the host to in-scope status
1348/// state.mark_in_scope("router1");
1349/// assert_eq!(state.host_status("router1"), Some(HostStatus::InScope));
1350/// ```
1351#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1352pub enum HostStatus {
1353    #[default]
1354    InScope,
1355    Failed,
1356}
1357
1358/// Tracks the state of connection attempts for a specific host and plugin combination.
1359///
1360/// This structure maintains comprehensive information about connection attempts, including
1361/// the current connection status, the number of attempts that have been made, and any error
1362/// message from the most recent failed attempt. It is used by the [`State`] structure to
1363/// track connection history and current state for each host/plugin pair.
1364///
1365/// # Fields
1366///
1367/// * `status` - The current [`ConnectionStatus`] indicating whether the connection is in
1368///   progress, successfully established, pending retry, or has failed. This field provides
1369///   high-level information about the connection state.
1370///
1371/// * `attempts` - The total number of connection attempts that have been made for this
1372///   host/plugin combination. This counter is incremented each time a connection attempt
1373///   begins and is preserved across status changes, allowing you to track how many attempts
1374///   were needed before a connection succeeded or ultimately failed.
1375///
1376/// * `last_error` - An optional error message from the most recent failed connection attempt.
1377///   This field is `None` if no error has occurred or if the connection is currently in
1378///   progress. When present, it contains diagnostic information about why the last connection
1379///   attempt failed, which can be useful for troubleshooting and logging.
1380///
1381/// # Examples
1382///
1383/// ```
1384/// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
1385/// // Create a new connection state indicating a successful connection
1386/// let state = ConnectionAttemptState::new(ConnectionStatus::Connected)
1387///     .with_attempts(2);
1388///
1389/// assert_eq!(state.status, ConnectionStatus::Connected);
1390/// assert_eq!(state.attempts, 2);
1391/// assert_eq!(state.last_error, None);
1392///
1393/// // Create a connection state with a failure and error message
1394/// let failed_state = ConnectionAttemptState::new(
1395///     ConnectionStatus::Failed(ConnectionFailureKind::Timeout)
1396/// )
1397/// .with_attempts(3)
1398/// .with_last_error("connection timed out after 30 seconds");
1399///
1400/// assert_eq!(failed_state.attempts, 3);
1401/// assert_eq!(
1402///     failed_state.last_error,
1403///     Some("connection timed out after 30 seconds".to_string())
1404/// );
1405/// ```
1406#[derive(Debug, Clone, PartialEq, Eq)]
1407pub struct ConnectionAttemptState {
1408    pub status: ConnectionStatus,
1409    pub attempts: usize,
1410    pub last_error: Option<String>,
1411}
1412
1413impl ConnectionAttemptState {
1414    /// Creates a new `ConnectionAttemptState` with the specified connection status.
1415    ///
1416    /// This constructor initializes a connection attempt state with the given status,
1417    /// setting the attempt counter to 0 and leaving the error field empty. This is
1418    /// typically used when first recording a connection attempt or when transitioning
1419    /// to a new connection state.
1420    ///
1421    /// # Parameters
1422    ///
1423    /// * `status` - The initial [`ConnectionStatus`] for this connection attempt state.
1424    ///   This indicates whether the connection is in progress, successfully established,
1425    ///   pending retry, or has failed.
1426    ///
1427    /// # Returns
1428    ///
1429    /// Returns a new `ConnectionAttemptState` instance with the specified status,
1430    /// zero attempts, and no error message.
1431    ///
1432    /// # Examples
1433    ///
1434    /// ```
1435    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus};
1436    /// // Create a state for a new connection attempt
1437    /// let state = ConnectionAttemptState::new(ConnectionStatus::Connecting);
1438    /// assert_eq!(state.status, ConnectionStatus::Connecting);
1439    /// assert_eq!(state.attempts, 0);
1440    /// assert_eq!(state.last_error, None);
1441    /// ```
1442    pub fn new(status: ConnectionStatus) -> Self {
1443        Self {
1444            status,
1445            attempts: 0,
1446            last_error: None,
1447        }
1448    }
1449
1450    /// Sets the number of connection attempts for this connection state.
1451    ///
1452    /// This builder method allows you to specify how many connection attempts have been
1453    /// made for a particular host/plugin combination. The attempt count is typically used
1454    /// to track retry behavior and can be helpful for implementing exponential backoff
1455    /// strategies or determining when to give up on a connection.
1456    ///
1457    /// This method consumes `self` and returns a new instance with the updated attempt
1458    /// count, following the builder pattern for convenient method chaining.
1459    ///
1460    /// # Parameters
1461    ///
1462    /// * `attempts` - The number of connection attempts to record. This should represent
1463    ///   the total number of attempts made, not just the increment. A value of 0 indicates
1464    ///   no attempts have been made yet, while higher values indicate multiple retry attempts.
1465    ///
1466    /// # Returns
1467    ///
1468    /// Returns `self` with the `attempts` field updated to the specified value, allowing
1469    /// for method chaining with other builder methods like [`with_last_error`](Self::with_last_error).
1470    ///
1471    /// # Examples
1472    ///
1473    /// ```
1474    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus};
1475    /// // Create a connection state with 3 attempts
1476    /// let state = ConnectionAttemptState::new(ConnectionStatus::Connecting)
1477    ///     .with_attempts(3);
1478    ///
1479    /// assert_eq!(state.attempts, 3);
1480    /// ```
1481    ///
1482    /// ```
1483    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
1484    /// // Chain multiple builder methods together
1485    /// let state = ConnectionAttemptState::new(
1486    ///     ConnectionStatus::Failed(ConnectionFailureKind::Timeout)
1487    /// )
1488    /// .with_attempts(5)
1489    /// .with_last_error("connection timed out after 30 seconds");
1490    ///
1491    /// assert_eq!(state.attempts, 5);
1492    /// assert_eq!(state.last_error, Some("connection timed out after 30 seconds".to_string()));
1493    /// ```
1494    pub fn with_attempts(mut self, attempts: usize) -> Self {
1495        self.attempts = attempts;
1496        self
1497    }
1498
1499    /// Sets the error message from the most recent failed connection attempt.
1500    ///
1501    /// This builder method allows you to record diagnostic information about why a
1502    /// connection attempt failed. The error message is typically set when marking a
1503    /// connection as retry pending or failed, and can be used for logging, debugging,
1504    /// or displaying error information to users.
1505    ///
1506    /// This method consumes `self` and returns a new instance with the error message
1507    /// set, following the builder pattern for convenient method chaining.
1508    ///
1509    /// # Parameters
1510    ///
1511    /// * `last_error` - The error message to record. Can be any type that converts into
1512    ///   a `String`, such as `&str`, `String`, error types that implement `Display`, or
1513    ///   other string-like types. The error message should provide meaningful diagnostic
1514    ///   information about what went wrong during the connection attempt.
1515    ///
1516    /// # Returns
1517    ///
1518    /// Returns `self` with the `last_error` field set to `Some(error_message)`, allowing
1519    /// for method chaining with other builder methods like [`with_attempts`](Self::with_attempts).
1520    ///
1521    /// # Examples
1522    ///
1523    /// ```
1524    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
1525    /// // Create a connection state with an error message
1526    /// let state = ConnectionAttemptState::new(
1527    ///     ConnectionStatus::Failed(ConnectionFailureKind::Timeout)
1528    /// )
1529    /// .with_last_error("connection timed out after 30 seconds");
1530    ///
1531    /// assert_eq!(
1532    ///     state.last_error,
1533    ///     Some("connection timed out after 30 seconds".to_string())
1534    /// );
1535    /// ```
1536    ///
1537    /// ```
1538    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus};
1539    /// // Chain multiple builder methods together
1540    /// let state = ConnectionAttemptState::new(ConnectionStatus::RetryPending)
1541    ///     .with_attempts(2)
1542    ///     .with_last_error("authentication failed: invalid credentials");
1543    ///
1544    /// assert_eq!(state.attempts, 2);
1545    /// assert_eq!(
1546    ///     state.last_error,
1547    ///     Some("authentication failed: invalid credentials".to_string())
1548    /// );
1549    /// ```
1550    ///
1551    /// ```
1552    /// # use genja_core::state::{ConnectionAttemptState, ConnectionStatus, ConnectionFailureKind};
1553    /// // Use with String type
1554    /// let error_msg = String::from("network unreachable");
1555    /// let state = ConnectionAttemptState::new(
1556    ///     ConnectionStatus::Failed(ConnectionFailureKind::Transport)
1557    /// )
1558    /// .with_last_error(error_msg);
1559    ///
1560    /// assert_eq!(state.last_error, Some("network unreachable".to_string()));
1561    /// ```
1562    pub fn with_last_error(mut self, last_error: impl Into<String>) -> Self {
1563        self.last_error = Some(last_error.into());
1564        self
1565    }
1566}
1567
1568/// Represents the current status of a connection attempt for a host/plugin pair.
1569///
1570/// This enum tracks the lifecycle of a connection attempt, from the initial state
1571/// through various stages of connection establishment, including success, retry,
1572/// and failure states. It is used within [`ConnectionAttemptState`] to provide
1573/// high-level information about the current state of a connection.
1574///
1575/// # Variants
1576///
1577/// * `NeverTried` - No connection attempt has been made yet for this host/plugin
1578///   combination. This is the initial state before any connection activity.
1579///
1580/// * `Connecting` - A connection attempt is currently in progress. This state is
1581///   set when [`State::begin_connection_attempt`] is called and indicates that
1582///   the connection plugin is actively attempting to establish a connection.
1583///
1584/// * `Connected` - The connection has been successfully established. This state
1585///   is set when [`State::mark_connection_connected`] is called and indicates
1586///   that the connection is ready for use.
1587///
1588/// * `RetryPending` - A connection attempt has failed but will be retried. This
1589///   state is set when [`State::mark_connection_retry_pending`] is called and
1590///   indicates that the connection will be attempted again after a delay or
1591///   under different conditions.
1592///
1593/// * `Failed(ConnectionFailureKind)` - The connection attempt has failed and will
1594///   not be retried. This state is set when [`State::mark_connection_failed`] is
1595///   called and includes a [`ConnectionFailureKind`] that classifies the type of
1596///   failure that occurred (e.g., timeout, authentication failure, DNS error).
1597///
1598/// # Examples
1599///
1600/// ```
1601/// # use genja_core::state::{ConnectionStatus, ConnectionFailureKind};
1602/// // Check different connection states
1603/// let connecting = ConnectionStatus::Connecting;
1604/// let connected = ConnectionStatus::Connected;
1605/// let failed = ConnectionStatus::Failed(ConnectionFailureKind::Timeout);
1606///
1607/// assert_eq!(connecting, ConnectionStatus::Connecting);
1608/// assert_eq!(connected, ConnectionStatus::Connected);
1609/// assert!(matches!(failed, ConnectionStatus::Failed(_)));
1610/// ```
1611#[derive(Debug, Clone, PartialEq, Eq)]
1612pub enum ConnectionStatus {
1613    NeverTried,
1614    Connecting,
1615    Connected,
1616    RetryPending,
1617    Failed(ConnectionFailureKind),
1618}
1619
1620/// Classifies the type of failure that occurred during a connection attempt.
1621///
1622/// This enum categorizes connection failures into distinct types, allowing for
1623/// more targeted error handling, retry logic, and diagnostic reporting. Each
1624/// variant represents a different category of connection failure that may require
1625/// different handling strategies.
1626///
1627/// # Variants
1628///
1629/// * `Timeout` - The connection attempt exceeded the configured timeout period.
1630///   This typically indicates network latency issues, an unresponsive host, or
1631///   firewall rules blocking the connection.
1632///
1633/// * `Refused` - The connection was actively refused by the remote host. This
1634///   usually means the host is reachable but the service is not running or is
1635///   not accepting connections on the specified port.
1636///
1637/// * `Auth` - Authentication failed during the connection attempt. This indicates
1638///   that the credentials provided were invalid, expired, or insufficient for
1639///   establishing the connection.
1640///
1641/// * `Dns` - DNS resolution failed for the hostname. This means the hostname
1642///   could not be resolved to an IP address, possibly due to DNS server issues
1643///   or an invalid hostname.
1644///
1645/// * `Transport` - A transport-layer error occurred during the connection attempt.
1646///   This includes network unreachable errors, connection reset by peer, and
1647///   other low-level network failures.
1648///
1649/// * `Unknown` - The failure type could not be determined or does not fit into
1650///   any of the other categories. This is used as a fallback for unexpected or
1651///   unclassified errors.
1652///
1653/// # Examples
1654///
1655/// ```
1656/// # use genja_core::state::{ConnectionFailureKind, ConnectionStatus};
1657/// // Create different failure kinds
1658/// let timeout = ConnectionFailureKind::Timeout;
1659/// let auth = ConnectionFailureKind::Auth;
1660/// let dns = ConnectionFailureKind::Dns;
1661///
1662/// // Use in connection status
1663/// let failed_status = ConnectionStatus::Failed(ConnectionFailureKind::Timeout);
1664/// assert!(matches!(failed_status, ConnectionStatus::Failed(_)));
1665/// ```
1666#[derive(Debug, Clone, PartialEq, Eq)]
1667pub enum ConnectionFailureKind {
1668    Timeout,
1669    Refused,
1670    Auth,
1671    Dns,
1672    Transport,
1673    Unknown,
1674}
1675
1676/// A unique identifier for tracking task execution state for a specific host and task combination.
1677///
1678/// This structure serves as a composite key used by the [`State`] structure to track the
1679/// execution state of tasks on individual hosts. Each `TaskExecutionKey` uniquely identifies
1680/// a task execution by combining the hostname with the task name, allowing the state tracking
1681/// system to maintain separate execution histories for different task/host pairs.
1682///
1683/// The key is used internally by methods like [`State::set_task_state`], [`State::task_state`],
1684/// and their `_key` variants to store and retrieve [`TaskAttemptState`] information.
1685///
1686/// # Fields
1687///
1688/// * `host` - A [`NatString`] representing the hostname on which the task is being executed.
1689///   Using `NatString` provides efficient string handling with natural sorting capabilities,
1690///   which can be useful when displaying or organizing task execution results by hostname.
1691///
1692/// * `task_name` - The name of the task being executed. This is a standard `String` that
1693///   identifies the specific task or operation being performed on the host. Task names should
1694///   be unique within a playbook or execution context to ensure proper state tracking.
1695///
1696/// # Examples
1697///
1698/// ```
1699/// # use genja_core::state::TaskExecutionKey;
1700/// # use genja_core::types::NatString;
1701/// // Create a task execution key for a specific host and task
1702/// let key = TaskExecutionKey::new("router1", "show_version");
1703/// assert_eq!(key.host, NatString::from("router1"));
1704/// assert_eq!(key.task_name, "show_version");
1705///
1706/// // Keys can be used to track task state
1707/// # use genja_core::state::{State, TaskAttemptState, TaskStatus};
1708/// let state = State::new();
1709/// let task_state = TaskAttemptState::new(TaskStatus::Running).with_attempts(1);
1710/// state.set_task_state_key(key.clone(), task_state.clone());
1711/// assert_eq!(state.task_state_key(&key), Some(task_state));
1712/// ```
1713///
1714/// ```
1715/// # use genja_core::state::TaskExecutionKey;
1716/// // Keys with the same host and task name are equal
1717/// let key1 = TaskExecutionKey::new("router1", "show_version");
1718/// let key2 = TaskExecutionKey::new("router1", "show_version");
1719/// assert_eq!(key1, key2);
1720///
1721/// // Keys with different hosts or task names are not equal
1722/// let key3 = TaskExecutionKey::new("router2", "show_version");
1723/// assert_ne!(key1, key3);
1724/// ```
1725#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1726pub struct TaskExecutionKey {
1727    pub host: NatString,
1728    pub task_name: String,
1729}
1730
1731impl TaskExecutionKey {
1732    /// Creates a new `TaskExecutionKey` from a host and task name.
1733    ///
1734    /// This constructor method creates a unique identifier for tracking task execution state
1735    /// by combining a hostname with a task name. The resulting key can be used with methods
1736    /// like [`State::set_task_state_key`] and [`State::task_state_key`] to store and retrieve
1737    /// task execution information.
1738    ///
1739    /// The host parameter is converted into a [`NatString`] for efficient string handling with
1740    /// natural sorting capabilities, while the task name is stored as a standard `String`.
1741    ///
1742    /// # Parameters
1743    ///
1744    /// * `host` - The hostname for which the task is being executed. Can be any type that
1745    ///   converts into a `String`, such as `&str`, `String`, or other string-like types.
1746    ///   This will be stored as a `NatString` in the resulting key.
1747    ///
1748    /// * `task_name` - The name of the task being executed. Can be any type that converts
1749    ///   into a `String`, such as `&str`, `String`, or other string-like types. Task names
1750    ///   should be unique within a playbook or execution context to ensure proper state tracking.
1751    ///
1752    /// # Returns
1753    ///
1754    /// Returns a new `TaskExecutionKey` instance with the specified host and task name,
1755    /// ready to be used for tracking task execution state.
1756    ///
1757    /// # Examples
1758    ///
1759    /// ```
1760    /// # use genja_core::state::TaskExecutionKey;
1761    /// # use genja_core::types::NatString;
1762    /// // Create a key using string slices
1763    /// let key = TaskExecutionKey::new("router1", "show_version");
1764    /// assert_eq!(key.host, NatString::from("router1"));
1765    /// assert_eq!(key.task_name, "show_version");
1766    /// ```
1767    ///
1768    /// ```
1769    /// # use genja_core::state::TaskExecutionKey;
1770    /// // Create a key using owned Strings
1771    /// let host = String::from("router2");
1772    /// let task = String::from("configure_interface");
1773    /// let key = TaskExecutionKey::new(host, task);
1774    /// assert_eq!(key.task_name, "configure_interface");
1775    /// ```
1776    pub fn new(host: impl Into<String>, task_name: impl Into<String>) -> Self {
1777        Self {
1778            host: NatString::new(host.into()),
1779            task_name: task_name.into(),
1780        }
1781    }
1782}
1783
1784/// Tracks the state of task execution attempts for a specific host and task combination.
1785///
1786/// This structure maintains comprehensive information about task execution attempts, including
1787/// the current task status, the number of attempts that have been made, and any error message
1788/// from the most recent failed attempt. It is used by the [`State`] structure to track task
1789/// execution history and current state for each host/task pair.
1790///
1791/// # Fields
1792///
1793/// * `status` - The current [`TaskStatus`] indicating whether the task is pending, running,
1794///   succeeded, pending retry, failed, or skipped. This field provides high-level information
1795///   about the task execution state.
1796///
1797/// * `attempts` - The total number of task execution attempts that have been made for this
1798///   host/task combination. This counter tracks how many times the task has been attempted
1799///   and is preserved across status changes, allowing you to track how many attempts were
1800///   needed before the task succeeded or ultimately failed.
1801///
1802/// * `last_error` - An optional error message from the most recent failed task execution attempt.
1803///   This field is `None` if no error has occurred or if the task is currently running or has
1804///   succeeded. When present, it contains diagnostic information about why the last task
1805///   execution attempt failed, which can be useful for troubleshooting and logging.
1806///
1807/// # Examples
1808///
1809/// ```
1810/// # use genja_core::state::{TaskAttemptState, TaskStatus, TaskFailureKind};
1811/// // Create a new task state indicating a successful execution
1812/// let state = TaskAttemptState::new(TaskStatus::Succeeded)
1813///     .with_attempts(1);
1814///
1815/// assert_eq!(state.status, TaskStatus::Succeeded);
1816/// assert_eq!(state.attempts, 1);
1817/// assert_eq!(state.last_error, None);
1818///
1819/// // Create a task state with a failure and error message
1820/// let failed_state = TaskAttemptState::new(
1821///     TaskStatus::Failed(TaskFailureKind::ParseFailed)
1822/// )
1823/// .with_attempts(2)
1824/// .with_last_error("failed to parse command output");
1825///
1826/// assert_eq!(failed_state.attempts, 2);
1827/// assert_eq!(
1828///     failed_state.last_error,
1829///     Some("failed to parse command output".to_string())
1830/// );
1831/// ```
1832#[derive(Debug, Clone, PartialEq, Eq)]
1833pub struct TaskAttemptState {
1834    pub status: TaskStatus,
1835    pub attempts: usize,
1836    pub last_error: Option<String>,
1837}
1838
1839impl TaskAttemptState {
1840    /// Creates a new `TaskAttemptState` with the specified task status.
1841    ///
1842    /// This constructor initializes a task attempt state with the given status,
1843    /// setting the attempt counter to 0 and leaving the error field empty. This is
1844    /// typically used when first recording a task execution or when transitioning
1845    /// to a new task state.
1846    ///
1847    /// # Parameters
1848    ///
1849    /// * `status` - The initial [`TaskStatus`] for this task attempt state.
1850    ///   This indicates whether the task is pending, running, succeeded, pending retry,
1851    ///   failed, or skipped.
1852    ///
1853    /// # Returns
1854    ///
1855    /// Returns a new `TaskAttemptState` instance with the specified status,
1856    /// zero attempts, and no error message.
1857    ///
1858    /// # Examples
1859    ///
1860    /// ```
1861    /// # use genja_core::state::{TaskAttemptState, TaskStatus};
1862    /// // Create a state for a new task execution
1863    /// let state = TaskAttemptState::new(TaskStatus::Running);
1864    /// assert_eq!(state.status, TaskStatus::Running);
1865    /// assert_eq!(state.attempts, 0);
1866    /// assert_eq!(state.last_error, None);
1867    /// ```
1868    pub fn new(status: TaskStatus) -> Self {
1869        Self {
1870            status,
1871            attempts: 0,
1872            last_error: None,
1873        }
1874    }
1875
1876    /// Sets the number of task execution attempts for this task state.
1877    ///
1878    /// This builder method allows you to specify how many task execution attempts have been
1879    /// made for a particular host/task combination. The attempt count is typically used
1880    /// to track retry behavior and can be helpful for implementing retry strategies or
1881    /// determining when to give up on a task execution.
1882    ///
1883    /// This method consumes `self` and returns a new instance with the updated attempt
1884    /// count, following the builder pattern for convenient method chaining.
1885    ///
1886    /// # Parameters
1887    ///
1888    /// * `attempts` - The number of task execution attempts to record. This should represent
1889    ///   the total number of attempts made, not just the increment. A value of 0 indicates
1890    ///   no attempts have been made yet, while higher values indicate multiple retry attempts.
1891    ///
1892    /// # Returns
1893    ///
1894    /// Returns `self` with the `attempts` field updated to the specified value, allowing
1895    /// for method chaining with other builder methods like [`with_last_error`](Self::with_last_error).
1896    ///
1897    /// # Examples
1898    ///
1899    /// ```
1900    /// # use genja_core::state::{TaskAttemptState, TaskStatus};
1901    /// // Create a task state with 2 attempts
1902    /// let state = TaskAttemptState::new(TaskStatus::Running)
1903    ///     .with_attempts(2);
1904    ///
1905    /// assert_eq!(state.attempts, 2);
1906    /// ```
1907    ///
1908    /// ```
1909    /// # use genja_core::state::{TaskAttemptState, TaskStatus, TaskFailureKind};
1910    /// // Chain multiple builder methods together
1911    /// let state = TaskAttemptState::new(
1912    ///     TaskStatus::Failed(TaskFailureKind::ParseFailed)
1913    /// )
1914    /// .with_attempts(3)
1915    /// .with_last_error("failed to parse command output");
1916    ///
1917    /// assert_eq!(state.attempts, 3);
1918    /// assert_eq!(state.last_error, Some("failed to parse command output".to_string()));
1919    /// ```
1920    pub fn with_attempts(mut self, attempts: usize) -> Self {
1921        self.attempts = attempts;
1922        self
1923    }
1924
1925    /// Sets the error message from the most recent failed task execution attempt.
1926    ///
1927    /// This builder method allows you to record diagnostic information about why a
1928    /// task execution attempt failed. The error message is typically set when marking a
1929    /// task as retry pending or failed, and can be used for logging, debugging,
1930    /// or displaying error information to users.
1931    ///
1932    /// This method consumes `self` and returns a new instance with the error message
1933    /// set, following the builder pattern for convenient method chaining.
1934    ///
1935    /// # Parameters
1936    ///
1937    /// * `last_error` - The error message to record. Can be any type that converts into
1938    ///   a `String`, such as `&str`, `String`, error types that implement `Display`, or
1939    ///   other string-like types. The error message should provide meaningful diagnostic
1940    ///   information about what went wrong during the task execution attempt.
1941    ///
1942    /// # Returns
1943    ///
1944    /// Returns `self` with the `last_error` field set to `Some(error_message)`, allowing
1945    /// for method chaining with other builder methods like [`with_attempts`](Self::with_attempts).
1946    ///
1947    /// # Examples
1948    ///
1949    /// ```
1950    /// # use genja_core::state::{TaskAttemptState, TaskStatus, TaskFailureKind};
1951    /// // Create a task state with an error message
1952    /// let state = TaskAttemptState::new(
1953    ///     TaskStatus::Failed(TaskFailureKind::ParseFailed)
1954    /// )
1955    /// .with_last_error("failed to parse show version output");
1956    ///
1957    /// assert_eq!(
1958    ///     state.last_error,
1959    ///     Some("failed to parse show version output".to_string())
1960    /// );
1961    /// ```
1962    ///
1963    /// ```
1964    /// # use genja_core::state::{TaskAttemptState, TaskStatus};
1965    /// // Chain multiple builder methods together
1966    /// let state = TaskAttemptState::new(TaskStatus::RetryPending)
1967    ///     .with_attempts(1)
1968    ///     .with_last_error("command execution timed out");
1969    ///
1970    /// assert_eq!(state.attempts, 1);
1971    /// assert_eq!(
1972    ///     state.last_error,
1973    ///     Some("command execution timed out".to_string())
1974    /// );
1975    /// ```
1976    ///
1977    /// ```
1978    /// # use genja_core::state::{TaskAttemptState, TaskStatus, TaskFailureKind};
1979    /// // Use with String type
1980    /// let error_msg = String::from("validation failed: invalid interface name");
1981    /// let state = TaskAttemptState::new(
1982    ///     TaskStatus::Failed(TaskFailureKind::ValidationFailed)
1983    /// )
1984    /// .with_last_error(error_msg);
1985    ///
1986    /// assert_eq!(state.last_error, Some("validation failed: invalid interface name".to_string()));
1987    /// ```
1988    pub fn with_last_error(mut self, last_error: impl Into<String>) -> Self {
1989        self.last_error = Some(last_error.into());
1990        self
1991    }
1992}
1993
1994/// Represents the current status of a task execution attempt for a host/task pair.
1995///
1996/// This enum tracks the lifecycle of a task execution, from the initial pending state
1997/// through various stages of execution, including success, retry, and failure states.
1998/// It is used within [`TaskAttemptState`] to provide high-level information about the
1999/// current state of a task execution.
2000///
2001/// # Variants
2002///
2003/// * `Pending` - The task has been scheduled but has not yet started execution. This is
2004///   the initial state before any task execution activity begins.
2005///
2006/// * `Running` - The task is currently being executed. This state indicates that the task
2007///   execution is actively in progress.
2008///
2009/// * `Succeeded` - The task has completed successfully. This state indicates that the task
2010///   execution finished without errors and achieved its intended outcome.
2011///
2012/// * `RetryPending` - A task execution attempt has failed but will be retried. This state
2013///   indicates that the task will be attempted again after a delay or under different
2014///   conditions.
2015///
2016/// * `Failed(TaskFailureKind)` - The task execution has failed and will not be retried.
2017///   This state includes a [`TaskFailureKind`] that classifies the type of failure that
2018///   occurred (e.g., command failed, parse failed, validation failed, timeout).
2019///
2020/// * `Skipped` - The task was skipped and not executed. This typically occurs when task
2021///   conditions are not met or when the task is explicitly configured to be skipped.
2022///
2023/// # Examples
2024///
2025/// ```
2026/// # use genja_core::state::{TaskStatus, TaskFailureKind};
2027/// // Check different task states
2028/// let pending = TaskStatus::Pending;
2029/// let running = TaskStatus::Running;
2030/// let succeeded = TaskStatus::Succeeded;
2031/// let failed = TaskStatus::Failed(TaskFailureKind::ParseFailed);
2032///
2033/// assert_eq!(pending, TaskStatus::Pending);
2034/// assert_eq!(running, TaskStatus::Running);
2035/// assert_eq!(succeeded, TaskStatus::Succeeded);
2036/// assert!(matches!(failed, TaskStatus::Failed(_)));
2037/// ```
2038#[derive(Debug, Clone, PartialEq, Eq)]
2039pub enum TaskStatus {
2040    Pending,
2041    Running,
2042    Succeeded,
2043    RetryPending,
2044    Failed(TaskFailureKind),
2045    Skipped,
2046}
2047
2048/// Classifies the type of failure that occurred during a task execution attempt.
2049///
2050/// This enum categorizes task execution failures into distinct types, allowing for
2051/// more targeted error handling, retry logic, and diagnostic reporting. Each variant
2052/// represents a different category of task failure that may require different handling
2053/// strategies.
2054///
2055/// # Variants
2056///
2057/// * `CommandFailed` - The command or operation executed by the task failed. This
2058///   typically indicates that the command returned a non-zero exit code or encountered
2059///   an error during execution.
2060///
2061/// * `ParseFailed` - Parsing of the task output or result failed. This indicates that
2062///   the task executed successfully but the output could not be parsed into the expected
2063///   format or structure.
2064///
2065/// * `ValidationFailed` - Validation of the task result or output failed. This indicates
2066///   that the task executed and was parsed successfully, but the result did not meet
2067///   the expected validation criteria.
2068///
2069/// * `Timeout` - The task execution exceeded the configured timeout period. This
2070///   typically indicates that the task took too long to complete or became unresponsive.
2071///
2072/// * `DependencyFailed` - A dependency required by the task failed or was not available.
2073///   This indicates that the task could not execute because a prerequisite task or
2074///   resource was not in the expected state.
2075///
2076/// * `Unknown` - The failure type could not be determined or does not fit into any of
2077///   the other categories. This is used as a fallback for unexpected or unclassified
2078///   errors.
2079///
2080/// # Examples
2081///
2082/// ```
2083/// # use genja_core::state::{TaskFailureKind, TaskStatus};
2084/// // Create different failure kinds
2085/// let command_failed = TaskFailureKind::CommandFailed;
2086/// let parse_failed = TaskFailureKind::ParseFailed;
2087/// let timeout = TaskFailureKind::Timeout;
2088///
2089/// // Use in task status
2090/// let failed_status = TaskStatus::Failed(TaskFailureKind::ParseFailed);
2091/// assert!(matches!(failed_status, TaskStatus::Failed(_)));
2092/// ```
2093#[derive(Debug, Clone, PartialEq, Eq)]
2094pub enum TaskFailureKind {
2095    CommandFailed,
2096    ParseFailed,
2097    ValidationFailed,
2098    Timeout,
2099    DependencyFailed,
2100    Unknown,
2101}
2102#[cfg(test)]
2103mod tests {
2104    use super::*;
2105    use std::{sync::Arc, thread};
2106
2107    #[test]
2108    fn host_scope_defaults_to_in_scope() {
2109        let state = State::new();
2110
2111        assert!(state.is_in_scope("router1"));
2112    }
2113
2114    #[test]
2115    fn host_scope_can_be_marked_failed_and_restored() {
2116        let state = State::new();
2117
2118        state.mark_failed("router1");
2119        assert!(!state.is_in_scope("router1"));
2120        assert_eq!(state.host_status("router1"), Some(HostStatus::Failed));
2121
2122        state.mark_in_scope("router1");
2123        assert!(state.is_in_scope("router1"));
2124        assert_eq!(state.host_status("router1"), Some(HostStatus::InScope));
2125    }
2126
2127    #[test]
2128    fn host_scope_accepts_natstring_inputs() {
2129        let state = State::new();
2130        let host = NatString::from("router1");
2131
2132        state.mark_failed(host.clone());
2133
2134        assert_eq!(state.host_status(&host), Some(HostStatus::Failed));
2135        assert!(!state.is_in_scope(host));
2136    }
2137
2138    // #[test]
2139    // fn host_statuses_and_mark_in_scope_key_reflect_latest_scope() {
2140    //     let state = State::new();
2141    //     let host = NatString::from("router1");
2142
2143    //     state.mark_failed(host.clone());
2144    //     assert_eq!(
2145    //         state.host_statuses().get(&host).map(|entry| *entry.value()),
2146    //         Some(HostStatus::Failed)
2147    //     );
2148
2149    //     state.mark_in_scope_key(&host);
2150    //     assert_eq!(
2151    //         state.host_statuses().get(&host).map(|entry| *entry.value()),
2152    //         Some(HostStatus::InScope)
2153    //     );
2154    // }
2155
2156    #[test]
2157    fn stores_connection_state_by_host_and_plugin() {
2158        let state = State::new();
2159        let connection_state =
2160            ConnectionAttemptState::new(ConnectionStatus::Failed(ConnectionFailureKind::Timeout))
2161                .with_attempts(3)
2162                .with_last_error("timed out");
2163
2164        state.set_connection_state("router1", "ssh", connection_state.clone());
2165
2166        assert_eq!(
2167            state.connection_state("router1", "ssh"),
2168            Some(connection_state)
2169        );
2170    }
2171
2172    #[test]
2173    fn stores_connection_state_by_key() {
2174        let state = State::new();
2175        let key = ConnectionKey::new("router1", "ssh");
2176        let connection_state =
2177            ConnectionAttemptState::new(ConnectionStatus::Connected).with_attempts(2);
2178
2179        state.set_connection_state_key(key.clone(), connection_state.clone());
2180
2181        assert_eq!(state.connection_state_key(&key), Some(connection_state));
2182    }
2183
2184    // #[test]
2185    // fn connection_states_accessor_exposes_stored_entries() {
2186    //     let state = State::new();
2187    //     let key = ConnectionKey::new("router1", "ssh");
2188    //     let connection_state = ConnectionAttemptState::new(ConnectionStatus::RetryPending)
2189    //         .with_attempts(1)
2190    //         .with_last_error("timed out");
2191
2192    //     state.set_connection_state_key(key.clone(), connection_state.clone());
2193
2194    //     assert_eq!(
2195    //         state
2196    //             .connection_states()
2197    //             .get(&key)
2198    //             .map(|entry| entry.value().clone()),
2199    //         Some(connection_state)
2200    //     );
2201    // }
2202
2203    #[test]
2204    fn begin_connection_attempt_sets_connecting_and_increments_attempts() {
2205        let state = State::new();
2206        let key = ConnectionKey::new("router1", "ssh");
2207
2208        state.begin_connection_attempt_key(key.clone());
2209        assert_eq!(
2210            state.connection_state_key(&key),
2211            Some(ConnectionAttemptState::new(ConnectionStatus::Connecting).with_attempts(1))
2212        );
2213
2214        state.begin_connection_attempt_key(key.clone());
2215        assert_eq!(
2216            state.connection_state_key(&key),
2217            Some(ConnectionAttemptState::new(ConnectionStatus::Connecting).with_attempts(2))
2218        );
2219    }
2220
2221    #[test]
2222    fn mark_connection_connected_preserves_attempt_count() {
2223        let state = State::new();
2224        let key = ConnectionKey::new("router1", "ssh");
2225
2226        state.begin_connection_attempt_key(key.clone());
2227        state.begin_connection_attempt_key(key.clone());
2228        state.mark_connection_connected_key(key.clone());
2229
2230        assert_eq!(
2231            state.connection_state_key(&key),
2232            Some(ConnectionAttemptState::new(ConnectionStatus::Connected).with_attempts(2))
2233        );
2234    }
2235
2236    #[test]
2237    fn mark_connection_retry_pending_preserves_attempts_and_error() {
2238        let state = State::new();
2239        let key = ConnectionKey::new("router1", "ssh");
2240
2241        state.begin_connection_attempt_key(key.clone());
2242        state.mark_connection_retry_pending_key(key.clone(), "timed out");
2243
2244        assert_eq!(
2245            state.connection_state_key(&key),
2246            Some(
2247                ConnectionAttemptState::new(ConnectionStatus::RetryPending)
2248                    .with_attempts(1)
2249                    .with_last_error("timed out")
2250            )
2251        );
2252    }
2253
2254    #[test]
2255    fn mark_connection_failed_preserves_attempts_and_sets_failed_status() {
2256        let state = State::new();
2257        let key = ConnectionKey::new("router1", "ssh");
2258
2259        state.begin_connection_attempt_key(key.clone());
2260        state.begin_connection_attempt_key(key.clone());
2261        state.mark_connection_failed_key(key.clone(), ConnectionFailureKind::Timeout, "timed out");
2262
2263        assert_eq!(
2264            state.connection_state_key(&key),
2265            Some(
2266                ConnectionAttemptState::new(ConnectionStatus::Failed(
2267                    ConnectionFailureKind::Timeout,
2268                ))
2269                .with_attempts(2)
2270                .with_last_error("timed out")
2271            )
2272        );
2273    }
2274
2275    #[test]
2276    fn mark_connection_connected_without_prior_attempts_uses_zero_attempts() {
2277        let state = State::new();
2278        let key = ConnectionKey::new("router1", "ssh");
2279
2280        state.mark_connection_connected_key(key.clone());
2281
2282        assert_eq!(
2283            state.connection_state_key(&key),
2284            Some(ConnectionAttemptState::new(ConnectionStatus::Connected).with_attempts(0))
2285        );
2286    }
2287
2288    #[test]
2289    fn mark_connection_retry_pending_without_prior_attempts_uses_zero_attempts() {
2290        let state = State::new();
2291        let key = ConnectionKey::new("router1", "ssh");
2292
2293        state.mark_connection_retry_pending_key(key.clone(), "timed out");
2294
2295        assert_eq!(
2296            state.connection_state_key(&key),
2297            Some(
2298                ConnectionAttemptState::new(ConnectionStatus::RetryPending)
2299                    .with_attempts(0)
2300                    .with_last_error("timed out")
2301            )
2302        );
2303    }
2304
2305    #[test]
2306    fn mark_connection_failed_without_prior_attempts_uses_zero_attempts() {
2307        let state = State::new();
2308        let key = ConnectionKey::new("router1", "ssh");
2309
2310        state.mark_connection_failed_key(key.clone(), ConnectionFailureKind::Timeout, "timed out");
2311
2312        assert_eq!(
2313            state.connection_state_key(&key),
2314            Some(
2315                ConnectionAttemptState::new(ConnectionStatus::Failed(
2316                    ConnectionFailureKind::Timeout,
2317                ))
2318                .with_attempts(0)
2319                .with_last_error("timed out")
2320            )
2321        );
2322    }
2323
2324    #[test]
2325    fn begin_connection_attempt_wrapper_increments_attempts() {
2326        let state = State::new();
2327
2328        state.begin_connection_attempt("router1", "ssh");
2329        state.begin_connection_attempt("router1", "ssh");
2330
2331        assert_eq!(
2332            state.connection_state("router1", "ssh"),
2333            Some(ConnectionAttemptState::new(ConnectionStatus::Connecting).with_attempts(2))
2334        );
2335    }
2336
2337    #[test]
2338    fn mark_connection_connected_wrapper_preserves_attempt_count() {
2339        let state = State::new();
2340
2341        state.begin_connection_attempt("router1", "ssh");
2342        state.begin_connection_attempt("router1", "ssh");
2343        state.mark_connection_connected("router1", "ssh");
2344
2345        assert_eq!(
2346            state.connection_state("router1", "ssh"),
2347            Some(ConnectionAttemptState::new(ConnectionStatus::Connected).with_attempts(2))
2348        );
2349    }
2350
2351    #[test]
2352    fn mark_connection_retry_pending_wrapper_preserves_attempts_and_error() {
2353        let state = State::new();
2354
2355        state.begin_connection_attempt("router1", "ssh");
2356        state.mark_connection_retry_pending("router1", "ssh", "timed out");
2357
2358        assert_eq!(
2359            state.connection_state("router1", "ssh"),
2360            Some(
2361                ConnectionAttemptState::new(ConnectionStatus::RetryPending)
2362                    .with_attempts(1)
2363                    .with_last_error("timed out")
2364            )
2365        );
2366    }
2367
2368    #[test]
2369    fn mark_connection_failed_wrapper_preserves_attempts_and_error() {
2370        let state = State::new();
2371
2372        state.begin_connection_attempt("router1", "ssh");
2373        state.begin_connection_attempt("router1", "ssh");
2374        state.mark_connection_failed(
2375            "router1",
2376            "ssh",
2377            ConnectionFailureKind::Timeout,
2378            "timed out",
2379        );
2380
2381        assert_eq!(
2382            state.connection_state("router1", "ssh"),
2383            Some(
2384                ConnectionAttemptState::new(ConnectionStatus::Failed(
2385                    ConnectionFailureKind::Timeout,
2386                ))
2387                .with_attempts(2)
2388                .with_last_error("timed out")
2389            )
2390        );
2391    }
2392
2393    #[test]
2394    fn stores_task_state_by_host_and_task() {
2395        let state = State::new();
2396        let task_state = TaskAttemptState::new(TaskStatus::Failed(TaskFailureKind::ParseFailed))
2397            .with_attempts(1)
2398            .with_last_error("failed to parse show version output");
2399
2400        state.set_task_state("router1", "show_version", task_state.clone());
2401
2402        assert_eq!(
2403            state.task_state("router1", "show_version"),
2404            Some(task_state)
2405        );
2406    }
2407
2408    #[test]
2409    fn stores_task_state_by_key() {
2410        let state = State::new();
2411        let key = TaskExecutionKey::new("router1", "show_version");
2412        let task_state = TaskAttemptState::new(TaskStatus::Succeeded).with_attempts(1);
2413
2414        state.set_task_state_key(key.clone(), task_state.clone());
2415
2416        assert_eq!(state.task_state_key(&key), Some(task_state));
2417    }
2418
2419    // #[test]
2420    // fn task_states_accessor_exposes_stored_entries() {
2421    //     let state = State::new();
2422    //     let key = TaskExecutionKey::new("router1", "show_version");
2423    //     let task_state = TaskAttemptState::new(TaskStatus::Running).with_attempts(1);
2424
2425    //     state.set_task_state_key(key.clone(), task_state.clone());
2426
2427    //     assert_eq!(
2428    //         state.task_states().get(&key).map(|entry| entry.value().clone()),
2429    //         Some(task_state)
2430    //     );
2431    // }
2432
2433    #[test]
2434    fn supports_concurrent_updates_across_maps() {
2435        const THREADS: usize = 8;
2436        const ATTEMPTS_PER_THREAD: usize = 200;
2437
2438        let state = Arc::new(State::new());
2439        let mut handles = Vec::with_capacity(THREADS);
2440
2441        for i in 0..THREADS {
2442            let state = Arc::clone(&state);
2443            handles.push(thread::spawn(move || {
2444                let host = format!("router{i}");
2445
2446                for _ in 0..ATTEMPTS_PER_THREAD {
2447                    state.begin_connection_attempt(host.clone(), "ssh");
2448                }
2449
2450                state.mark_failed(host.clone());
2451                state.mark_in_scope(host.clone());
2452                state.mark_connection_connected(host.clone(), "ssh");
2453                state.set_task_state(
2454                    host.clone(),
2455                    "show_version",
2456                    TaskAttemptState::new(TaskStatus::Succeeded).with_attempts(1),
2457                );
2458            }));
2459        }
2460
2461        for handle in handles {
2462            handle.join().expect("worker thread panicked");
2463        }
2464
2465        for i in 0..THREADS {
2466            let host = format!("router{i}");
2467            let connection = state
2468                .connection_state(&host, "ssh")
2469                .expect("missing connection state");
2470
2471            assert_eq!(connection.status, ConnectionStatus::Connected);
2472            assert_eq!(connection.attempts, ATTEMPTS_PER_THREAD);
2473            assert!(state.is_in_scope(host.as_str()));
2474            assert_eq!(
2475                state.task_state(&host, "show_version"),
2476                Some(TaskAttemptState::new(TaskStatus::Succeeded).with_attempts(1))
2477            );
2478        }
2479    }
2480}