Skip to main content

consortium_crate/
task.rs

1//! Task orchestration.
2//!
3//! Rust implementation of `ClusterShell.Task`.
4//!
5//! A Task manages a coordinated collection of independent parallel Worker
6//! objects, providing a high-level API for executing commands locally or
7//! across remote nodes and collecting their results.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use consortium::task::Task;
13//!
14//! let mut task = Task::new();
15//! task.shell("hostname", None, None);
16//! task.run(None).unwrap();
17//!
18//! for (rc, nodes) in task.iter_retcodes(None) {
19//!     println!("rc={}: {:?}", rc, nodes);
20//! }
21//! ```
22
23use std::collections::{BTreeMap, HashMap, HashSet};
24use std::path::PathBuf;
25use std::time::Duration;
26
27use crate::defaults::{config_paths, Defaults};
28use crate::msg_tree::{MsgTree, MsgTreeMode};
29use crate::node_set::NodeSet;
30use crate::propagation::PropagationTreeRouter;
31use crate::topology::TopologyParser;
32use crate::worker::exec::ExecWorker;
33use crate::worker::{EventHandler, Worker, WorkerError, WorkerState};
34
35/// Error types for the task module.
36#[derive(Debug, thiserror::Error)]
37pub enum TaskError {
38    /// A worker error occurred.
39    #[error("worker error: {0}")]
40    Worker(#[from] WorkerError),
41    /// Task timed out.
42    #[error("task timeout")]
43    Timeout,
44    /// Task is already running.
45    #[error("task is already running")]
46    AlreadyRunning,
47    /// MsgTree not enabled for the requested stream.
48    #[error("{0}_msgtree not set")]
49    MsgTreeDisabled(String),
50    /// Topology error.
51    #[error("topology error: {0}")]
52    Topology(String),
53    /// General task error.
54    #[error("task error: {0}")]
55    General(String),
56}
57
58/// Result type alias for task operations.
59pub type Result<T> = std::result::Result<T, TaskError>;
60
61/// Identifies a source of output: a (worker_id, node_name) pair.
62///
63/// In the Python version, the source is `(worker_instance, node_string)`.
64/// Here we use a numeric worker ID for ownership safety.
65pub type Source = (usize, String);
66
67/// Current state of the task.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum TaskState {
70    /// Task is idle — workers may be queued but not running.
71    Idle,
72    /// Task is currently running its workers.
73    Running,
74    /// Task has completed.
75    Done,
76    /// Task was aborted.
77    Aborted,
78}
79
80/// Configuration for the task's default behavior.
81///
82/// Maps to ClusterShell's `_task_default` dictionary.
83#[derive(Debug, Clone)]
84pub struct TaskDefaults {
85    /// Whether to enable stderr separation (default: false).
86    pub stderr: bool,
87    /// Whether to enable stdin (default: true).
88    pub stdin: bool,
89    /// Whether to record stdout into a MsgTree (default: true).
90    pub stdout_msgtree: bool,
91    /// Whether to record stderr into a MsgTree (default: true).
92    pub stderr_msgtree: bool,
93    /// Auto tree mode (use tree topology when available).
94    pub auto_tree: bool,
95}
96
97impl Default for TaskDefaults {
98    fn default() -> Self {
99        Self {
100            stderr: false,
101            stdin: true,
102            stdout_msgtree: true,
103            stderr_msgtree: true,
104            auto_tree: false,
105        }
106    }
107}
108
109impl TaskDefaults {
110    /// Create task defaults from a Defaults config.
111    pub fn from_defaults(defaults: &Defaults) -> Self {
112        Self {
113            stderr: defaults.stderr(),
114            stdin: defaults.stdin(),
115            stdout_msgtree: true,
116            stderr_msgtree: true,
117            auto_tree: false,
118        }
119    }
120}
121
122/// Runtime info for the task.
123///
124/// Maps to ClusterShell's `_task_info` dictionary.
125#[derive(Debug, Clone)]
126pub struct TaskInfo {
127    /// Enable debug messages.
128    pub debug: bool,
129    /// Maximum fanout (concurrent workers).
130    pub fanout: usize,
131    /// Grooming delay for message traffic shaping (seconds).
132    pub grooming_delay: f64,
133    /// Connection timeout (seconds).
134    pub connect_timeout: f64,
135    /// Command timeout (seconds, 0 = unlimited).
136    pub command_timeout: f64,
137}
138
139impl Default for TaskInfo {
140    fn default() -> Self {
141        Self {
142            debug: false,
143            fanout: 64,
144            grooming_delay: 0.5,
145            connect_timeout: 10.0,
146            command_timeout: 0.0,
147        }
148    }
149}
150
151impl TaskInfo {
152    /// Create task info from a Defaults config.
153    pub fn from_defaults(defaults: &Defaults) -> Self {
154        Self {
155            debug: false,
156            fanout: defaults.fanout() as usize,
157            grooming_delay: 0.5,
158            connect_timeout: defaults.connect_timeout(),
159            command_timeout: defaults.command_timeout(),
160            ..Default::default()
161        }
162    }
163}
164
165/// Topology configuration file paths.
166fn topology_configs() -> Vec<PathBuf> {
167    config_paths("topology.conf")
168}
169
170/// Get the short hostname (hostname up to the first dot).
171fn get_short_hostname() -> String {
172    hostname::get()
173        .map(|h: std::ffi::OsString| {
174            h.to_string_lossy()
175                .to_string()
176                .split('.')
177                .next()
178                .unwrap_or("localhost")
179                .to_string()
180        })
181        .unwrap_or_else(|_| "localhost".to_string())
182}
183
184/// A managed worker entry inside a Task.
185struct ManagedWorker {
186    /// The worker instance.
187    worker: Box<dyn Worker>,
188    /// Whether this worker updates task-level return codes.
189    update_task_rc: bool,
190    /// Whether this worker auto-closes when all other workers finish.
191    autoclose: bool,
192}
193
194/// An event-gathering handler that records output into the Task's result stores.
195///
196/// This handler is installed on workers when they are scheduled. It forwards
197/// data to the task's internal MsgTrees and retcode maps via channels or
198/// direct callback.
199struct TaskGatheringHandler {
200    /// Worker ID that this handler is associated with.
201    #[allow(dead_code)]
202    worker_id: usize,
203    /// Collected stdout lines: node -> Vec<lines>.
204    stdout: HashMap<String, Vec<Vec<u8>>>,
205    /// Collected stderr lines: node -> Vec<lines>.
206    stderr: HashMap<String, Vec<Vec<u8>>>,
207    /// Return codes: node -> rc.
208    retcodes: HashMap<String, i32>,
209    /// Timed-out nodes.
210    timeouts: HashSet<String>,
211    /// User-provided handler to chain to, if any.
212    user_handler: Option<Box<dyn EventHandler>>,
213}
214
215impl TaskGatheringHandler {
216    fn new(worker_id: usize, user_handler: Option<Box<dyn EventHandler>>) -> Self {
217        Self {
218            worker_id,
219            stdout: HashMap::new(),
220            stderr: HashMap::new(),
221            retcodes: HashMap::new(),
222            timeouts: HashSet::new(),
223            user_handler,
224        }
225    }
226}
227
228impl EventHandler for TaskGatheringHandler {
229    fn on_start(&mut self, worker: &dyn Worker) {
230        if let Some(ref mut h) = self.user_handler {
231            h.on_start(worker);
232        }
233    }
234
235    fn on_read(&mut self, node: &str, fd: std::os::unix::io::RawFd, msg: &[u8]) {
236        // fd 1 = stdout, fd 2 = stderr
237        let map = if fd == 2 {
238            &mut self.stderr
239        } else {
240            &mut self.stdout
241        };
242        map.entry(node.to_string()).or_default().push(msg.to_vec());
243
244        if let Some(ref mut h) = self.user_handler {
245            h.on_read(node, fd, msg);
246        }
247    }
248
249    fn on_close(&mut self, node: &str, rc: i32) {
250        self.retcodes.insert(node.to_string(), rc);
251
252        if let Some(ref mut h) = self.user_handler {
253            h.on_close(node, rc);
254        }
255    }
256
257    fn on_timeout(&mut self, node: &str) {
258        self.timeouts.insert(node.to_string());
259
260        if let Some(ref mut h) = self.user_handler {
261            h.on_timeout(node);
262        }
263    }
264
265    fn on_error(&mut self, node: &str, error: &WorkerError) {
266        if let Some(ref mut h) = self.user_handler {
267            h.on_error(node, error);
268        }
269    }
270
271    fn take_buffers(
272        &mut self,
273    ) -> (
274        HashMap<String, Vec<Vec<u8>>>,
275        HashMap<String, Vec<Vec<u8>>>,
276        HashSet<String>,
277    ) {
278        (
279            std::mem::take(&mut self.stdout),
280            std::mem::take(&mut self.stderr),
281            std::mem::take(&mut self.timeouts),
282        )
283    }
284}
285
286/// A Task manages a set of workers and their results.
287///
288/// The Task is the primary orchestrator in consortium. It schedules workers
289/// for execution, runs them (respecting fanout limits), and collects their
290/// output, return codes, and timeout information.
291///
292/// # Design Differences from Python
293///
294/// The Python Task is heavily thread-based with a thread-local singleton
295/// pattern (`task_self()`). The Rust version is single-threaded and owned,
296/// using explicit lifetimes rather than thread-local storage. Multi-threaded
297/// usage should wrap `Task` in `Arc<Mutex<Task>>` if needed.
298pub struct Task {
299    /// Task state.
300    state: TaskState,
301    /// Task-level defaults.
302    defaults: TaskDefaults,
303    /// Task-level runtime info.
304    info: TaskInfo,
305    /// Global task timeout (set via resume/run).
306    timeout: Option<Duration>,
307
308    // -- Worker management --
309    /// Next worker ID to assign.
310    next_worker_id: usize,
311    /// Managed workers (worker_id -> ManagedWorker).
312    workers: Vec<(usize, ManagedWorker)>,
313
314    // -- Result stores --
315    /// MsgTree for stdout, keyed by (worker_id, node).
316    stdout_tree: Option<MsgTree<Source>>,
317    /// MsgTree for stderr, keyed by (worker_id, node).
318    stderr_tree: Option<MsgTree<Source>>,
319    /// Return codes by source: (worker_id, node) -> rc.
320    d_source_rc: HashMap<Source, i32>,
321    /// Sources by return code: rc -> set of sources.
322    d_rc_sources: BTreeMap<i32, HashSet<Source>>,
323    /// Maximum return code seen.
324    max_rc: Option<i32>,
325    /// Timed-out sources.
326    timeout_sources: HashSet<Source>,
327
328    // -- Topology / Tree mode --
329    /// Loaded topology parser (owns the tree).
330    topology_parser: Option<TopologyParser>,
331    /// Propagation tree router (shared across tree workers).
332    router: Option<PropagationTreeRouter>,
333    /// Gateway channels: gateway_name -> worker_id.
334    #[allow(dead_code)]
335    gateways: HashMap<String, usize>,
336}
337
338impl Task {
339    /// Create a new Task with default settings.
340    pub fn new() -> Self {
341        let defaults = TaskDefaults::default();
342        let info = TaskInfo::default();
343        Self::with_config(defaults, info)
344    }
345
346    /// Create a new Task from a Defaults configuration.
347    pub fn from_defaults(defaults: &Defaults) -> Self {
348        let task_defaults = TaskDefaults::from_defaults(defaults);
349        let task_info = TaskInfo::from_defaults(defaults);
350        Self::with_config(task_defaults, task_info)
351    }
352
353    /// Create a new Task with explicit configuration.
354    pub fn with_config(defaults: TaskDefaults, info: TaskInfo) -> Self {
355        let stdout_tree = if defaults.stdout_msgtree {
356            Some(MsgTree::new(MsgTreeMode::Shift))
357        } else {
358            None
359        };
360        let stderr_tree = if defaults.stderr_msgtree {
361            Some(MsgTree::new(MsgTreeMode::Shift))
362        } else {
363            None
364        };
365
366        Self {
367            state: TaskState::Idle,
368            defaults,
369            info,
370            timeout: None,
371            next_worker_id: 0,
372            workers: Vec::new(),
373            stdout_tree,
374            stderr_tree,
375            d_source_rc: HashMap::new(),
376            d_rc_sources: BTreeMap::new(),
377            max_rc: None,
378            timeout_sources: HashSet::new(),
379            topology_parser: None,
380            router: None,
381            gateways: HashMap::new(),
382        }
383    }
384
385    // -----------------------------------------------------------------------
386    // Defaults & Info
387    // -----------------------------------------------------------------------
388
389    /// Get task defaults.
390    pub fn defaults(&self) -> &TaskDefaults {
391        &self.defaults
392    }
393
394    /// Get mutable task defaults.
395    pub fn defaults_mut(&mut self) -> &mut TaskDefaults {
396        &mut self.defaults
397    }
398
399    /// Get task info.
400    pub fn info(&self) -> &TaskInfo {
401        &self.info
402    }
403
404    /// Get mutable task info.
405    pub fn info_mut(&mut self) -> &mut TaskInfo {
406        &mut self.info
407    }
408
409    /// Set the fanout (maximum concurrent workers/processes).
410    pub fn set_fanout(&mut self, fanout: usize) {
411        self.info.fanout = fanout;
412    }
413
414    /// Get the current fanout.
415    pub fn fanout(&self) -> usize {
416        self.info.fanout
417    }
418
419    // -----------------------------------------------------------------------
420    // Topology
421    // -----------------------------------------------------------------------
422
423    /// Load a propagation topology from a topology configuration file.
424    ///
425    /// On success, stores the parser (which caches the tree internally).
426    pub fn load_topology(&mut self, path: &str) -> Result<()> {
427        let hostname = get_short_hostname();
428        let mut parser = TopologyParser::new();
429        parser
430            .load(path)
431            .map_err(|e| TaskError::Topology(format!("failed to load topology: {}", e)))?;
432        // Validate that we can build the tree for our hostname
433        parser
434            .tree(&hostname)
435            .map_err(|e| TaskError::Topology(format!("failed to build tree: {}", e)))?;
436        self.topology_parser = Some(parser);
437        Ok(())
438    }
439
440    /// Try to auto-load topology from default config paths.
441    fn try_load_default_topology(&mut self) -> bool {
442        if self.topology_parser.is_some() {
443            return true;
444        }
445        let configs = topology_configs();
446        for path in configs.iter().rev() {
447            if path.exists() {
448                if let Ok(()) = self.load_topology(&path.to_string_lossy()) {
449                    return true;
450                }
451            }
452        }
453        false
454    }
455
456    /// Check if tree mode is enabled by default.
457    pub fn default_tree_is_enabled(&mut self) -> bool {
458        if self.topology_parser.is_none() {
459            self.try_load_default_topology();
460        }
461        self.topology_parser.is_some() && self.defaults.auto_tree
462    }
463
464    /// Get or create the default PropagationTreeRouter.
465    pub fn default_router(&mut self) -> Result<&PropagationTreeRouter> {
466        if self.router.is_none() {
467            let hostname = get_short_hostname();
468            let parser = self
469                .topology_parser
470                .as_mut()
471                .ok_or_else(|| TaskError::Topology("no topology loaded".to_string()))?;
472            let topology = parser
473                .tree(&hostname)
474                .map_err(|e| TaskError::Topology(format!("failed to build tree: {}", e)))?;
475            let root_ns = topology
476                .root()
477                .ok_or_else(|| TaskError::Topology("topology has no root".to_string()))?;
478            let root_str = root_ns.nodeset.to_string();
479            let fanout = self.info.fanout;
480            let router = PropagationTreeRouter::new(&root_str, topology, fanout)
481                .map_err(|e| TaskError::Topology(format!("router creation failed: {}", e)))?;
482            self.router = Some(router);
483        }
484        Ok(self.router.as_ref().unwrap())
485    }
486
487    /// Check if a topology is loaded.
488    pub fn has_topology(&self) -> bool {
489        self.topology_parser.is_some()
490    }
491
492    // -----------------------------------------------------------------------
493    // Worker scheduling
494    // -----------------------------------------------------------------------
495
496    /// Schedule a local shell command for execution.
497    ///
498    /// If `nodes` is None, executes locally on a single node.
499    /// If `nodes` is Some, executes the command on each node in the set.
500    ///
501    /// Returns the worker ID assigned to this worker.
502    pub fn shell(
503        &mut self,
504        command: &str,
505        nodes: Option<&str>,
506        handler: Option<Box<dyn EventHandler>>,
507    ) -> usize {
508        self.shell_with_opts(command, nodes, handler, None, false)
509    }
510
511    /// Schedule a shell command with full options.
512    ///
513    /// Returns the worker ID.
514    pub fn shell_with_opts(
515        &mut self,
516        command: &str,
517        nodes: Option<&str>,
518        handler: Option<Box<dyn EventHandler>>,
519        timeout: Option<Duration>,
520        autoclose: bool,
521    ) -> usize {
522        let node_list: Vec<String> = match nodes {
523            Some(ns) => {
524                // Parse as NodeSet for expansion
525                match NodeSet::parse(ns) {
526                    Ok(nodeset) => nodeset.iter().collect::<Vec<String>>(),
527                    Err(_) => {
528                        // Fall back to treating it as a single node
529                        vec![ns.to_string()]
530                    }
531                }
532            }
533            None => {
534                // Local execution — use localhost
535                vec!["localhost".to_string()]
536            }
537        };
538
539        let fanout = self.info.fanout;
540        let worker_timeout = timeout.or_else(|| {
541            if self.info.command_timeout > 0.0 {
542                Some(Duration::from_secs_f64(self.info.command_timeout))
543            } else {
544                None
545            }
546        });
547
548        let mut worker = ExecWorker::new(node_list, command.to_string(), fanout, worker_timeout);
549        worker = worker.with_stderr(self.defaults.stderr);
550
551        self.schedule(Box::new(worker), handler, autoclose)
552    }
553
554    /// Schedule a pre-built worker for execution.
555    ///
556    /// The worker will be started when `resume()` or `run()` is called.
557    /// Returns the worker ID.
558    pub fn schedule(
559        &mut self,
560        worker: Box<dyn Worker>,
561        handler: Option<Box<dyn EventHandler>>,
562        autoclose: bool,
563    ) -> usize {
564        let worker_id = self.next_worker_id;
565        self.next_worker_id += 1;
566
567        // Wrap with a gathering handler
568        let gathering = TaskGatheringHandler::new(worker_id, handler);
569        let mut managed_worker = ManagedWorker {
570            worker,
571            update_task_rc: true,
572            autoclose,
573        };
574        managed_worker.worker.set_handler(Box::new(gathering));
575
576        self.workers.push((worker_id, managed_worker));
577        worker_id
578    }
579
580    // -----------------------------------------------------------------------
581    // Execution
582    // -----------------------------------------------------------------------
583
584    /// Run the task: start all scheduled workers and block until they complete.
585    ///
586    /// Optionally schedule a shell command first (like Python's `task.run(cmd, ...)`).
587    ///
588    /// Returns Ok(()) on success, or the first error encountered.
589    pub fn run(&mut self, timeout: Option<Duration>) -> Result<()> {
590        self.timeout = timeout;
591        self.resume()
592    }
593
594    /// Run with a command: convenience for `shell(cmd) + run()`.
595    pub fn run_command(
596        &mut self,
597        command: &str,
598        nodes: Option<&str>,
599        handler: Option<Box<dyn EventHandler>>,
600        timeout: Option<Duration>,
601    ) -> Result<usize> {
602        let wid = self.shell(command, nodes, handler);
603        self.timeout = timeout;
604        self.resume()?;
605        Ok(wid)
606    }
607
608    /// Resume task execution — start all pending workers and run until done.
609    pub fn resume(&mut self) -> Result<()> {
610        if self.state == TaskState::Running {
611            return Err(TaskError::AlreadyRunning);
612        }
613
614        self.reset();
615        self.state = TaskState::Running;
616
617        // Start all pending workers, respecting fanout
618        let mut _running_count = 0;
619        let mut errors: Vec<TaskError> = Vec::new();
620
621        // Start workers
622        for (_wid, mw) in &mut self.workers {
623            if mw.worker.state() == WorkerState::Pending {
624                match mw.worker.start() {
625                    Ok(()) => _running_count += 1,
626                    Err(e) => errors.push(TaskError::Worker(e)),
627                }
628            }
629        }
630
631        // Poll workers until all are done
632        let start = std::time::Instant::now();
633        loop {
634            let mut any_running = false;
635            for (_wid, mw) in &mut self.workers {
636                if !mw.autoclose && !mw.worker.is_done() {
637                    // Drive the worker: read from its fds and check for exits
638                    let fds = mw.worker.read_fds();
639                    for fd in &fds {
640                        let _ = mw.worker.handle_read(*fd);
641                    }
642                    if !mw.worker.is_done() {
643                        any_running = true;
644                    }
645                }
646            }
647            if !any_running {
648                break;
649            }
650
651            // Check task-level timeout
652            if let Some(timeout) = self.timeout {
653                if start.elapsed() >= timeout {
654                    // Abort all running workers
655                    for (_wid, mw) in &mut self.workers {
656                        if !mw.worker.is_done() {
657                            mw.worker.abort(true);
658                        }
659                    }
660                    self.state = TaskState::Done;
661                    return Err(TaskError::Timeout);
662                }
663            }
664
665            // Small sleep to avoid busy-wait
666            std::thread::sleep(Duration::from_millis(10));
667        }
668
669        // Collect results from all workers
670        self.collect_results();
671
672        self.state = TaskState::Done;
673
674        if let Some(err) = errors.into_iter().next() {
675            Err(err)
676        } else {
677            Ok(())
678        }
679    }
680
681    /// Abort the task, stopping all running workers.
682    pub fn abort(&mut self, kill: bool) {
683        for (_wid, mw) in &mut self.workers {
684            if !mw.worker.is_done() {
685                mw.worker.abort(kill);
686            }
687        }
688        self.state = TaskState::Aborted;
689    }
690
691    /// Check if the task is currently running.
692    pub fn running(&self) -> bool {
693        self.state == TaskState::Running
694    }
695
696    /// Get the current task state.
697    pub fn state(&self) -> TaskState {
698        self.state
699    }
700
701    // -----------------------------------------------------------------------
702    // Result collection (internal)
703    // -----------------------------------------------------------------------
704
705    /// Reset result buffers for a new run.
706    fn reset(&mut self) {
707        if let Some(ref mut tree) = self.stdout_tree {
708            tree.clear();
709        }
710        if let Some(ref mut tree) = self.stderr_tree {
711            tree.clear();
712        }
713        self.d_source_rc.clear();
714        self.d_rc_sources.clear();
715        self.max_rc = None;
716        self.timeout_sources.clear();
717    }
718
719    /// Collect results from all workers' gathering handlers into the task stores.
720    fn collect_results(&mut self) {
721        for (wid, mw) in &mut self.workers {
722            let worker_id = *wid;
723
724            // Collect return codes from the worker's retcodes map
725            if mw.update_task_rc {
726                for (node, &rc) in mw.worker.retcodes() {
727                    let source: Source = (worker_id, node.clone());
728                    self.d_source_rc.insert(source.clone(), rc);
729                    self.d_rc_sources.entry(rc).or_default().insert(source);
730
731                    if self.max_rc.map_or(true, |max| rc > max) {
732                        self.max_rc = Some(rc);
733                    }
734                }
735            }
736
737            // Extract buffered stdout/stderr/timeouts from the gathering handler.
738            // take_handler() removes the handler from the worker, then we call
739            // take_buffers() to drain the collected data into our MsgTrees.
740            if let Some(mut handler) = mw.worker.take_handler() {
741                let (stdout, stderr, timeouts) = handler.take_buffers();
742
743                // Populate stdout MsgTree
744                if let Some(ref mut tree) = self.stdout_tree {
745                    for (node, chunks) in stdout {
746                        let source: Source = (worker_id, node);
747                        for chunk in chunks {
748                            tree.add(source.clone(), chunk);
749                        }
750                    }
751                }
752
753                // Populate stderr MsgTree
754                if let Some(ref mut tree) = self.stderr_tree {
755                    for (node, chunks) in stderr {
756                        let source: Source = (worker_id, node);
757                        for chunk in chunks {
758                            tree.add(source.clone(), chunk);
759                        }
760                    }
761                }
762
763                // Record timed-out nodes
764                for node in timeouts {
765                    self.timeout_sources.insert((worker_id, node));
766                }
767            }
768        }
769    }
770
771    // -----------------------------------------------------------------------
772    // Result accessors
773    // -----------------------------------------------------------------------
774
775    /// Get the maximum return code from the last run, or None if:
776    /// - All commands timed out.
777    /// - No command-based worker was executed.
778    pub fn max_retcode(&self) -> Option<i32> {
779        self.max_rc
780    }
781
782    /// Get the return code for a specific node.
783    ///
784    /// When the node is associated with multiple workers, returns the max
785    /// return code from those workers. Returns None if not found.
786    pub fn node_retcode(&self, node: &str) -> Option<i32> {
787        let mut max_rc: Option<i32> = None;
788        for ((_, n), &rc) in &self.d_source_rc {
789            if n == node {
790                max_rc = Some(max_rc.map_or(rc, |m: i32| m.max(rc)));
791            }
792        }
793        max_rc
794    }
795
796    /// Iterate over return codes and their associated node lists.
797    ///
798    /// Returns an iterator of `(rc, Vec<node_name>)` tuples.
799    /// If `match_keys` is Some, only includes matching nodes.
800    pub fn iter_retcodes(&self, match_keys: Option<&HashSet<String>>) -> Vec<(i32, Vec<String>)> {
801        let mut result = Vec::new();
802        for (&rc, sources) in &self.d_rc_sources {
803            let nodes: Vec<String> = sources
804                .iter()
805                .filter(|(_, n)| match_keys.map_or(true, |keys| keys.contains(n)))
806                .map(|(_, n)| n.clone())
807                .collect();
808            if !nodes.is_empty() {
809                result.push((rc, nodes));
810            }
811        }
812        result
813    }
814
815    /// Iterate over return codes for a specific worker.
816    pub fn iter_retcodes_by_worker(
817        &self,
818        worker_id: usize,
819        match_keys: Option<&HashSet<String>>,
820    ) -> Vec<(i32, Vec<String>)> {
821        let mut result = Vec::new();
822        for (&rc, sources) in &self.d_rc_sources {
823            let nodes: Vec<String> = sources
824                .iter()
825                .filter(|(wid, n)| {
826                    *wid == worker_id && match_keys.map_or(true, |keys| keys.contains(n))
827                })
828                .map(|(_, n)| n.clone())
829                .collect();
830            if !nodes.is_empty() {
831                result.push((rc, nodes));
832            }
833        }
834        result
835    }
836
837    /// Get the number of timed-out nodes.
838    pub fn num_timeout(&self) -> usize {
839        self.timeout_sources.len()
840    }
841
842    /// Iterate over timed-out nodes.
843    pub fn iter_keys_timeout(&self) -> impl Iterator<Item = &str> {
844        self.timeout_sources.iter().map(|(_, n)| n.as_str())
845    }
846
847    /// Get the number of timed-out nodes for a specific worker.
848    pub fn num_timeout_by_worker(&self, worker_id: usize) -> usize {
849        self.timeout_sources
850            .iter()
851            .filter(|(wid, _)| *wid == worker_id)
852            .count()
853    }
854
855    /// Iterate over timed-out nodes for a specific worker.
856    pub fn iter_keys_timeout_by_worker(&self, worker_id: usize) -> impl Iterator<Item = &str> {
857        self.timeout_sources
858            .iter()
859            .filter(move |(wid, _)| *wid == worker_id)
860            .map(|(_, n)| n.as_str())
861    }
862
863    // -----------------------------------------------------------------------
864    // Buffer accessors (MsgTree-based)
865    // -----------------------------------------------------------------------
866
867    /// Get the stdout buffer for a specific node across all workers.
868    ///
869    /// Returns a concatenation of all stdout messages for that node.
870    pub fn node_buffer(&self, node: &str) -> Result<Vec<u8>> {
871        let tree = self
872            .stdout_tree
873            .as_ref()
874            .ok_or_else(|| TaskError::MsgTreeDisabled("stdout".to_string()))?;
875        let mut buf = Vec::new();
876        for key in tree.keys() {
877            if key.1 == node {
878                if let Some(elem) = tree.get(key) {
879                    let msg = elem.message();
880                    if !buf.is_empty() {
881                        buf.push(b'\n');
882                    }
883                    buf.extend_from_slice(&msg);
884                }
885            }
886        }
887        Ok(buf)
888    }
889
890    /// Get the stderr buffer for a specific node.
891    pub fn node_error(&self, node: &str) -> Result<Vec<u8>> {
892        let tree = self
893            .stderr_tree
894            .as_ref()
895            .ok_or_else(|| TaskError::MsgTreeDisabled("stderr".to_string()))?;
896        let mut buf = Vec::new();
897        for key in tree.keys() {
898            if key.1 == node {
899                if let Some(elem) = tree.get(key) {
900                    let msg = elem.message();
901                    if !buf.is_empty() {
902                        buf.push(b'\n');
903                    }
904                    buf.extend_from_slice(&msg);
905                }
906            }
907        }
908        Ok(buf)
909    }
910
911    /// Flush all stdout buffers.
912    pub fn flush_buffers(&mut self) {
913        if let Some(ref mut tree) = self.stdout_tree {
914            tree.clear();
915        }
916    }
917
918    /// Flush all stderr buffers.
919    pub fn flush_errors(&mut self) {
920        if let Some(ref mut tree) = self.stderr_tree {
921            tree.clear();
922        }
923    }
924
925    // -----------------------------------------------------------------------
926    // Worker accessors
927    // -----------------------------------------------------------------------
928
929    /// Get the number of scheduled workers.
930    pub fn num_workers(&self) -> usize {
931        self.workers.len()
932    }
933
934    /// Get a reference to a worker by ID.
935    pub fn worker(&self, worker_id: usize) -> Option<&dyn Worker> {
936        self.workers
937            .iter()
938            .find(|(wid, _)| *wid == worker_id)
939            .map(|(_, mw)| mw.worker.as_ref())
940    }
941
942    /// Get the return codes for a specific worker.
943    pub fn worker_retcodes(&self, worker_id: usize) -> Option<&HashMap<String, i32>> {
944        self.workers
945            .iter()
946            .find(|(wid, _)| *wid == worker_id)
947            .map(|(_, mw)| mw.worker.retcodes())
948    }
949}
950
951impl Default for Task {
952    fn default() -> Self {
953        Self::new()
954    }
955}
956
957// ============================================================================
958// Tests
959// ============================================================================
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
965    use std::sync::Arc;
966
967    // -- TaskState tests --
968
969    #[test]
970    fn test_task_state_variants() {
971        assert_eq!(TaskState::Idle, TaskState::Idle);
972        assert_eq!(TaskState::Running, TaskState::Running);
973        assert_eq!(TaskState::Done, TaskState::Done);
974        assert_eq!(TaskState::Aborted, TaskState::Aborted);
975        assert_ne!(TaskState::Idle, TaskState::Running);
976    }
977
978    #[test]
979    fn test_task_state_debug() {
980        assert_eq!(format!("{:?}", TaskState::Idle), "Idle");
981        assert_eq!(format!("{:?}", TaskState::Running), "Running");
982        assert_eq!(format!("{:?}", TaskState::Done), "Done");
983        assert_eq!(format!("{:?}", TaskState::Aborted), "Aborted");
984    }
985
986    #[test]
987    fn test_task_state_clone_copy() {
988        let s = TaskState::Running;
989        let s2 = s;
990        assert_eq!(s, s2);
991    }
992
993    // -- TaskError tests --
994
995    #[test]
996    fn test_task_error_display() {
997        let e = TaskError::Timeout;
998        assert_eq!(e.to_string(), "task timeout");
999
1000        let e = TaskError::AlreadyRunning;
1001        assert_eq!(e.to_string(), "task is already running");
1002
1003        let e = TaskError::MsgTreeDisabled("stdout".to_string());
1004        assert_eq!(e.to_string(), "stdout_msgtree not set");
1005
1006        let e = TaskError::General("test error".to_string());
1007        assert_eq!(e.to_string(), "task error: test error");
1008
1009        let e = TaskError::Topology("no topology".to_string());
1010        assert_eq!(e.to_string(), "topology error: no topology");
1011    }
1012
1013    #[test]
1014    fn test_task_error_from_worker_error() {
1015        let we = WorkerError::Timeout;
1016        let te: TaskError = we.into();
1017        assert!(matches!(te, TaskError::Worker(WorkerError::Timeout)));
1018    }
1019
1020    // -- TaskDefaults tests --
1021
1022    #[test]
1023    fn test_task_defaults_default() {
1024        let d = TaskDefaults::default();
1025        assert!(!d.stderr);
1026        assert!(d.stdin);
1027        assert!(d.stdout_msgtree);
1028        assert!(d.stderr_msgtree);
1029        assert!(!d.auto_tree);
1030    }
1031
1032    #[test]
1033    fn test_task_defaults_from_defaults() {
1034        let defaults = Defaults::new();
1035        let d = TaskDefaults::from_defaults(&defaults);
1036        assert!(!d.stderr);
1037        assert!(d.stdin);
1038    }
1039
1040    // -- TaskInfo tests --
1041
1042    #[test]
1043    fn test_task_info_default() {
1044        let info = TaskInfo::default();
1045        assert!(!info.debug);
1046        assert_eq!(info.fanout, 64);
1047        assert_eq!(info.grooming_delay, 0.5);
1048        assert_eq!(info.connect_timeout, 10.0);
1049        assert_eq!(info.command_timeout, 0.0);
1050    }
1051
1052    #[test]
1053    fn test_task_info_from_defaults() {
1054        let defaults = Defaults::new();
1055        let info = TaskInfo::from_defaults(&defaults);
1056        assert_eq!(info.fanout, defaults.fanout() as usize);
1057        assert_eq!(info.connect_timeout, defaults.connect_timeout());
1058        assert_eq!(info.command_timeout, defaults.command_timeout());
1059    }
1060
1061    // -- Task construction tests --
1062
1063    #[test]
1064    fn test_task_new() {
1065        let task = Task::new();
1066        assert_eq!(task.state(), TaskState::Idle);
1067        assert_eq!(task.num_workers(), 0);
1068        assert_eq!(task.max_retcode(), None);
1069        assert_eq!(task.num_timeout(), 0);
1070        assert!(!task.running());
1071    }
1072
1073    #[test]
1074    fn test_task_default() {
1075        let task = Task::default();
1076        assert_eq!(task.state(), TaskState::Idle);
1077    }
1078
1079    #[test]
1080    fn test_task_from_defaults() {
1081        let defaults = Defaults::new();
1082        let task = Task::from_defaults(&defaults);
1083        assert_eq!(task.state(), TaskState::Idle);
1084        assert_eq!(task.fanout(), defaults.fanout() as usize);
1085    }
1086
1087    #[test]
1088    fn test_task_with_config() {
1089        let mut td = TaskDefaults::default();
1090        td.stderr = true;
1091        td.stdout_msgtree = false;
1092
1093        let mut ti = TaskInfo::default();
1094        ti.fanout = 32;
1095
1096        let task = Task::with_config(td, ti);
1097        assert!(task.defaults().stderr);
1098        assert!(!task.defaults().stdout_msgtree);
1099        assert_eq!(task.fanout(), 32);
1100        // stdout_msgtree disabled → no stdout tree
1101        assert!(task.stdout_tree.is_none());
1102        assert!(task.stderr_tree.is_some());
1103    }
1104
1105    // -- Fanout tests --
1106
1107    #[test]
1108    fn test_task_set_fanout() {
1109        let mut task = Task::new();
1110        assert_eq!(task.fanout(), 64);
1111        task.set_fanout(128);
1112        assert_eq!(task.fanout(), 128);
1113    }
1114
1115    // -- Worker scheduling tests --
1116
1117    #[test]
1118    fn test_task_shell_local() {
1119        let mut task = Task::new();
1120        let wid = task.shell("echo hello", None, None);
1121        assert_eq!(wid, 0);
1122        assert_eq!(task.num_workers(), 1);
1123    }
1124
1125    #[test]
1126    fn test_task_shell_remote() {
1127        let mut task = Task::new();
1128        let wid = task.shell("hostname", Some("node[1-3]"), None);
1129        assert_eq!(wid, 0);
1130        assert_eq!(task.num_workers(), 1);
1131    }
1132
1133    #[test]
1134    fn test_task_shell_multiple() {
1135        let mut task = Task::new();
1136        let wid1 = task.shell("echo 1", None, None);
1137        let wid2 = task.shell("echo 2", None, None);
1138        assert_eq!(wid1, 0);
1139        assert_eq!(wid2, 1);
1140        assert_eq!(task.num_workers(), 2);
1141    }
1142
1143    #[test]
1144    fn test_task_shell_with_opts() {
1145        let mut task = Task::new();
1146        let wid = task.shell_with_opts(
1147            "echo test",
1148            Some("node[1-5]"),
1149            None,
1150            Some(Duration::from_secs(30)),
1151            true,
1152        );
1153        assert_eq!(wid, 0);
1154        assert_eq!(task.num_workers(), 1);
1155    }
1156
1157    // -- Execution tests --
1158
1159    #[test]
1160    fn test_task_run_local_command() {
1161        let mut task = Task::new();
1162        task.shell("echo hello", None, None);
1163        let result = task.run(None);
1164        assert!(result.is_ok());
1165        assert_eq!(task.state(), TaskState::Done);
1166    }
1167
1168    #[test]
1169    fn test_task_run_returns_retcode() {
1170        let mut task = Task::new();
1171        task.shell("true", None, None);
1172        task.run(None).unwrap();
1173        assert_eq!(task.max_retcode(), Some(0));
1174    }
1175
1176    #[test]
1177    fn test_task_run_failing_command() {
1178        let mut task = Task::new();
1179        task.shell("false", None, None);
1180        task.run(None).unwrap();
1181        assert_eq!(task.max_retcode(), Some(1));
1182    }
1183
1184    #[test]
1185    fn test_task_run_mixed_retcodes() {
1186        let mut task = Task::new();
1187        task.shell("true", None, None);
1188        task.shell("exit 42", None, None);
1189        task.run(None).unwrap();
1190        assert_eq!(task.max_retcode(), Some(42));
1191    }
1192
1193    #[test]
1194    fn test_task_run_command_convenience() {
1195        let mut task = Task::new();
1196        let wid = task.run_command("echo test", None, None, None).unwrap();
1197        assert_eq!(wid, 0);
1198        assert_eq!(task.max_retcode(), Some(0));
1199    }
1200
1201    #[test]
1202    fn test_task_run_multiple_nodes() {
1203        let mut task = Task::new();
1204        task.shell("echo hi", Some("node[1-3]"), None);
1205        task.run(None).unwrap();
1206        // Each "node" runs locally via ExecWorker (echo hi runs 3 times)
1207        assert_eq!(task.max_retcode(), Some(0));
1208
1209        let retcodes = task.iter_retcodes(None);
1210        // All should be rc=0
1211        for (rc, nodes) in &retcodes {
1212            assert_eq!(*rc, 0);
1213        }
1214    }
1215
1216    // -- Retcode accessor tests --
1217
1218    #[test]
1219    fn test_task_node_retcode() {
1220        let mut task = Task::new();
1221        task.shell("true", None, None);
1222        task.run(None).unwrap();
1223        // The node is "localhost" for local commands
1224        assert_eq!(task.node_retcode("localhost"), Some(0));
1225        assert_eq!(task.node_retcode("nonexistent"), None);
1226    }
1227
1228    #[test]
1229    fn test_task_iter_retcodes_with_filter() {
1230        let mut task = Task::new();
1231        task.shell("echo hi", Some("node[1-3]"), None);
1232        task.run(None).unwrap();
1233
1234        let mut filter = HashSet::new();
1235        filter.insert("node1".to_string());
1236        let retcodes = task.iter_retcodes(Some(&filter));
1237        for (_rc, nodes) in &retcodes {
1238            for n in nodes {
1239                assert_eq!(n, "node1");
1240            }
1241        }
1242    }
1243
1244    #[test]
1245    fn test_task_iter_retcodes_by_worker() {
1246        let mut task = Task::new();
1247        let wid1 = task.shell("true", None, None);
1248        let wid2 = task.shell("exit 7", None, None);
1249        task.run(None).unwrap();
1250
1251        let rc1 = task.iter_retcodes_by_worker(wid1, None);
1252        assert_eq!(rc1.len(), 1);
1253        assert_eq!(rc1[0].0, 0);
1254
1255        let rc2 = task.iter_retcodes_by_worker(wid2, None);
1256        assert_eq!(rc2.len(), 1);
1257        assert_eq!(rc2[0].0, 7);
1258    }
1259
1260    // -- Abort tests --
1261
1262    #[test]
1263    fn test_task_abort() {
1264        let mut task = Task::new();
1265        task.shell("sleep 100", None, None);
1266        // Don't run, just abort
1267        task.abort(true);
1268        assert_eq!(task.state(), TaskState::Aborted);
1269    }
1270
1271    // -- Timeout tests --
1272
1273    #[test]
1274    fn test_task_run_with_timeout() {
1275        let mut task = Task::new();
1276        task.shell("sleep 100", None, None);
1277        let result = task.run(Some(Duration::from_millis(100)));
1278        assert!(result.is_err());
1279        match result.unwrap_err() {
1280            TaskError::Timeout => {}
1281            other => panic!("expected Timeout, got: {:?}", other),
1282        }
1283    }
1284
1285    // -- Event handler chaining tests --
1286
1287    #[test]
1288    fn test_task_with_custom_handler() {
1289        let started = Arc::new(AtomicBool::new(false));
1290        let closed = Arc::new(AtomicBool::new(false));
1291        let started_clone = started.clone();
1292        let closed_clone = closed.clone();
1293
1294        struct TestHandler {
1295            started: Arc<AtomicBool>,
1296            closed: Arc<AtomicBool>,
1297        }
1298
1299        impl EventHandler for TestHandler {
1300            fn on_start(&mut self, _worker: &dyn Worker) {
1301                self.started.store(true, Ordering::SeqCst);
1302            }
1303            fn on_close(&mut self, _node: &str, _rc: i32) {
1304                self.closed.store(true, Ordering::SeqCst);
1305            }
1306        }
1307
1308        let handler = TestHandler {
1309            started: started_clone,
1310            closed: closed_clone,
1311        };
1312
1313        let mut task = Task::new();
1314        task.shell("echo hello", None, Some(Box::new(handler)));
1315        task.run(None).unwrap();
1316
1317        assert!(started.load(Ordering::SeqCst), "on_start should be called");
1318        assert!(closed.load(Ordering::SeqCst), "on_close should be called");
1319    }
1320
1321    // -- Reset tests --
1322
1323    #[test]
1324    fn test_task_reset_between_runs() {
1325        let mut task = Task::new();
1326        task.shell("exit 5", None, None);
1327        task.run(None).unwrap();
1328        assert_eq!(task.max_retcode(), Some(5));
1329
1330        // Schedule new work and run again
1331        task.shell("exit 3", None, None);
1332        // Reset state to allow re-run
1333        task.state = TaskState::Idle;
1334        task.run(None).unwrap();
1335        // Both workers are in the list. The first (exit 5) is Done and
1336        // won't re-run, but collect_results() still picks up its stored
1337        // retcodes. So max_rc is max(5, 3) = 5.
1338        assert_eq!(task.max_retcode(), Some(5));
1339    }
1340
1341    // -- Topology tests --
1342
1343    #[test]
1344    fn test_task_topology_none_by_default() {
1345        let task = Task::new();
1346        assert!(!task.has_topology());
1347    }
1348
1349    #[test]
1350    fn test_task_default_tree_disabled() {
1351        let mut task = Task::new();
1352        assert!(!task.default_tree_is_enabled());
1353    }
1354
1355    // -- Buffer accessor tests --
1356
1357    #[test]
1358    fn test_task_node_buffer_empty() {
1359        let task = Task::new();
1360        let buf = task.node_buffer("somenode").unwrap();
1361        assert!(buf.is_empty());
1362    }
1363
1364    #[test]
1365    fn test_task_node_error_empty() {
1366        let task = Task::new();
1367        let buf = task.node_error("somenode").unwrap();
1368        assert!(buf.is_empty());
1369    }
1370
1371    #[test]
1372    fn test_task_node_buffer_disabled() {
1373        let mut td = TaskDefaults::default();
1374        td.stdout_msgtree = false;
1375        let task = Task::with_config(td, TaskInfo::default());
1376        let result = task.node_buffer("somenode");
1377        assert!(result.is_err());
1378    }
1379
1380    #[test]
1381    fn test_task_flush_buffers() {
1382        let mut task = Task::new();
1383        task.flush_buffers();
1384        task.flush_errors();
1385        // Should not panic
1386    }
1387
1388    // -- Worker accessor tests --
1389
1390    #[test]
1391    fn test_task_worker_accessor() {
1392        let mut task = Task::new();
1393        let wid = task.shell("echo test", None, None);
1394        assert!(task.worker(wid).is_some());
1395        assert!(task.worker(999).is_none());
1396    }
1397
1398    #[test]
1399    fn test_task_worker_retcodes_accessor() {
1400        let mut task = Task::new();
1401        let wid = task.shell("echo test", None, None);
1402        task.run(None).unwrap();
1403        let retcodes = task.worker_retcodes(wid);
1404        assert!(retcodes.is_some());
1405        assert_eq!(*retcodes.unwrap().get("localhost").unwrap(), 0);
1406    }
1407
1408    // -- Info/Defaults mutability tests --
1409
1410    #[test]
1411    fn test_task_defaults_mut() {
1412        let mut task = Task::new();
1413        assert!(!task.defaults().stderr);
1414        task.defaults_mut().stderr = true;
1415        assert!(task.defaults().stderr);
1416    }
1417
1418    #[test]
1419    fn test_task_info_mut() {
1420        let mut task = Task::new();
1421        assert_eq!(task.info().fanout, 64);
1422        task.info_mut().fanout = 256;
1423        assert_eq!(task.info().fanout, 256);
1424    }
1425
1426    // -- Hostname helper --
1427
1428    #[test]
1429    fn test_get_short_hostname() {
1430        let hostname = get_short_hostname();
1431        assert!(!hostname.is_empty());
1432        assert!(!hostname.contains('.'));
1433    }
1434
1435    // -- Gathering handler tests --
1436
1437    #[test]
1438    fn test_gathering_handler_collects_retcodes() {
1439        let mut handler = TaskGatheringHandler::new(0, None);
1440        handler.on_close("node1", 0);
1441        handler.on_close("node2", 1);
1442        assert_eq!(handler.retcodes.len(), 2);
1443        assert_eq!(handler.retcodes["node1"], 0);
1444        assert_eq!(handler.retcodes["node2"], 1);
1445    }
1446
1447    #[test]
1448    fn test_gathering_handler_collects_stdout() {
1449        let mut handler = TaskGatheringHandler::new(0, None);
1450        handler.on_read("node1", 1, b"hello");
1451        handler.on_read("node1", 1, b"world");
1452        handler.on_read("node2", 1, b"hi");
1453        assert_eq!(handler.stdout["node1"].len(), 2);
1454        assert_eq!(handler.stdout["node2"].len(), 1);
1455    }
1456
1457    #[test]
1458    fn test_gathering_handler_collects_stderr() {
1459        let mut handler = TaskGatheringHandler::new(0, None);
1460        handler.on_read("node1", 2, b"error msg");
1461        assert_eq!(handler.stderr["node1"].len(), 1);
1462        assert!(handler.stdout.is_empty());
1463    }
1464
1465    #[test]
1466    fn test_gathering_handler_collects_timeouts() {
1467        let mut handler = TaskGatheringHandler::new(0, None);
1468        handler.on_timeout("node1");
1469        handler.on_timeout("node2");
1470        assert_eq!(handler.timeouts.len(), 2);
1471        assert!(handler.timeouts.contains("node1"));
1472    }
1473
1474    #[test]
1475    fn test_gathering_handler_chains_user_handler() {
1476        let called = Arc::new(AtomicUsize::new(0));
1477        let called_clone = called.clone();
1478
1479        struct CountHandler(Arc<AtomicUsize>);
1480        impl EventHandler for CountHandler {
1481            fn on_close(&mut self, _node: &str, _rc: i32) {
1482                self.0.fetch_add(1, Ordering::SeqCst);
1483            }
1484        }
1485
1486        let user_handler = Box::new(CountHandler(called_clone));
1487        let mut handler = TaskGatheringHandler::new(0, Some(user_handler));
1488        handler.on_close("node1", 0);
1489        handler.on_close("node2", 0);
1490
1491        assert_eq!(called.load(Ordering::SeqCst), 2);
1492        assert_eq!(handler.retcodes.len(), 2);
1493    }
1494}