solti-exec 0.0.2

Solti SDK jobs execution crate.
Documentation
//! # Task: resolved subprocess configuration.
//!
//! [`SubprocessTaskConfig`] is the **fully resolved** config passed to the subprocess spawn loop.
//! It is produced by [`SubprocessRunner::build_task_config`] from a [`TaskSpec`](solti_model::TaskSpec) + [`BuildContext`](solti_runner::BuildContext).
//!
//! ## How it fits
//! ```text
//! TaskSpec (model)
//!//!     ├──► SubprocessRunner::build_task_config()
//!     │     ├──► resolve SubprocessMode → (command, args)
//!     │     ├──► merge_env(task_env, runner_env)
//!     │     └──► generate run_id
//!//!     └──► SubprocessTaskConfig (this struct)
//!           ├──► validate(): command not empty
//!           └──► passed to run_subprocess() for execution
//! ```
//!
//! ## Fields
//!
//! | Field              | Source                    | Description                          |
//! |--------------------|---------------------------|--------------------------------------|
//! | `run_id`           | generated by runner       | unique execution identifier          |
//! | `command`          | `SubprocessMode`          | OS command to exec                   |
//! | `args`             | `SubprocessMode`          | command-line arguments               |
//! | `env`              | `merge_env(task, runner)` | merged environment variables         |
//! | `cwd`              | `SubprocessSpec`          | working directory (`None` = inherit) |
//! | `fail_on_non_zero` | `SubprocessSpec`          | treat non-zero exit as failure       |

use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc};

use solti_model::Flag;

use crate::ExecError;

/// Subprocess configuration - fully resolved per-task parameters.
///
/// ## Also
///
/// - [`SubprocessRunner`](super::SubprocessRunner) produces this config in `build_task`.
/// - [`SubprocessBackendConfig`](super::SubprocessBackendConfig) runner-level settings applied at spawn.
#[derive(Debug, Clone)]
pub struct SubprocessTaskConfig {
    /// End-to-End log identifier.
    pub(crate) run_id: Arc<str>,
    /// Raw sequence number from run id generation (used for cgroup naming).
    pub(crate) seq: u64,
    /// Command to execute (e.g. `"ls"`, `"/usr/bin/python"`).
    pub(crate) command: String,
    /// Command-line arguments passed to the command.
    pub(crate) args: Vec<String>,
    /// Merged environment (runner overrides task). Ready for `Command::envs()`.
    pub(crate) env: BTreeMap<String, String>,
    /// Working directory for the subprocess.
    ///
    /// If `None`, the subprocess inherits the parent process working directory.
    pub(crate) cwd: Option<PathBuf>,
    /// Whether non-zero exit codes should be treated as task failures.
    pub(crate) fail_on_non_zero: Flag,
}

impl SubprocessTaskConfig {
    /// Validate the configuration before spawning a subprocess.
    ///
    /// Rules:
    /// - `command` is not empty or whitespace-only.
    pub fn validate(&self) -> Result<(), ExecError> {
        if self.command.trim().is_empty() {
            return Err(ExecError::InvalidSpec("Subprocess command is empty".into()));
        }
        Ok(())
    }
}

impl fmt::Display for SubprocessTaskConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "SubprocessTaskConfig(cmd='{}', args={}, env={}, cwd={:?}, fail_on_non_zero={})",
            self.command,
            self.args.len(),
            self.env.len(),
            self.cwd,
            self.fail_on_non_zero.is_enabled(),
        )
    }
}