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}