mod markdown;
mod prepare;
mod run;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use super::ui::{Mode, PickAction, PickItem, Picker, PickerKind};
use super::{ClientId, Server};
use crate::layout::PaneId;
use crate::pane::PaneSpec;
use crate::paths;
use crate::protocol::Placement;
use crate::runbooks::{self, Block, Body, On, Runbook, Source, Steps};
use crate::servers;
#[expect(
clippy::large_enum_variant,
reason = "a run has a handful of targets: a box around the server would only add an indirection"
)]
#[derive(Debug, Clone)]
pub enum Target {
Local,
Pane(PaneId),
Server(servers::Server),
}
#[derive(Clone, Copy)]
pub(in crate::server) struct Asker {
client: Option<ClientId>,
reply: Option<ClientId>,
}
pub struct Draft {
id: u32,
runbook: Runbook,
pub steps: Steps,
pub pane: Option<PaneId>,
pub targets: Option<Vec<Target>>,
values: Vec<(String, String)>,
trusted: bool,
choices: Option<Vec<String>>,
reviewed: Option<Vec<String>>,
}
impl Draft {
fn new(runbook: Runbook, steps: Steps, pane: Option<PaneId>, targets: Option<Vec<Target>>) -> Draft {
let trusted = runbooks::trusted(&runbook);
Draft { id: 0, runbook, steps, pane, targets, values: Vec::new(), trusted, choices: None, reviewed: None }
}
fn shown_values(&self) -> Vec<String> {
self.values
.iter()
.map(|(k, v)| {
let param = self.steps.params.iter().find(|p| &p.name == k);
format!("{k}={}", runbooks::shown_value(param, v))
})
.collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemState {
Waiting,
Running,
Stopping(Instant),
Ok,
Failed(Option<i32>),
TimedOut,
Skipped,
}
impl ItemState {
fn failed(self) -> bool {
matches!(self, ItemState::Failed(_) | ItemState::TimedOut)
}
fn pending(self) -> bool {
matches!(self, ItemState::Waiting | ItemState::Running | ItemState::Stopping(_))
}
}
pub struct RunItem {
pub label: String,
pub pane: PaneId,
pub state: ItemState,
started: Option<Instant>,
process: Option<crate::pane::ProcessHold>,
killed: Option<Instant>,
}
pub struct StrayGroup {
process: crate::pane::ProcessHold,
since: Instant,
killed: Option<Instant>,
}
pub struct Run {
pub id: u32,
pub name: String,
tab: u32,
pub items: Vec<RunItem>,
pub rolling: usize,
pub stop_on_failure: bool,
timeout: Option<Duration>,
pub log: PathBuf,
aborted: bool,
}
pub struct MdSession {
runbook: Runbook,
pub title: String,
blocks: Vec<Block>,
pub target: PaneId,
sent: HashSet<usize>,
approved: Option<crate::server::contexts::Production>,
remote_ok: bool,
}
pub(super) const PLAN_LIFETIME: Duration = Duration::from_secs(600);
pub(super) fn first_line(code: &str) -> String {
crate::text::first_line(code, 80)
}
pub(super) fn list_choices(cmd: &str, cwd: Option<&Path>, env: &[(String, String)]) -> Result<Vec<String>> {
let mut c = Command::new("sh");
c.arg("-c").arg(cmd).envs(env.iter().cloned());
if let Some(d) = cwd {
if !d.is_dir() {
bail!("{} does not exist any more: cannot list the choices there", d.display());
}
c.current_dir(d);
}
let out = crate::exec::run(&mut c, Duration::from_secs(5), 256 << 10)?;
if out.timed_out {
bail!("{cmd:?} took more than 5 s");
}
if !out.success() {
bail!("{cmd:?} failed: {}", out.stderr_text().trim());
}
Ok(out.stdout_text().lines().map(str::trim).filter(|l| !l.is_empty()).take(100).map(String::from).collect())
}
impl Server {
pub(super) fn runbooks_key(&self) -> String {
self.keymap.hint(crate::keymap::Action::Runbooks).unwrap_or_else(|| "the Runbooks command".into())
}
pub(super) fn runbooks_action(&mut self, client: ClientId) -> Mode {
if let Some(s) = self.md_sessions.get(&client)
&& self.panes.contains_key(&s.target)
&& s.sent.len() < s.blocks.len()
{
let next = (0..s.blocks.len()).find(|i| !s.sent.contains(i)).unwrap_or(0);
return Mode::Picker(self.md_picker(client, next, true));
}
self.runbook_list(client)
}
pub(super) fn wait_for<T: Send + 'static>(
&mut self,
client: ClientId,
kind: PickerKind,
title: &str,
work: impl FnOnce() -> T + Send + 'static,
then: impl FnOnce(&mut Server, T) -> Mode + 'static,
) -> Mode {
let id = self.alloc_id();
let waiting = move |server: &Server| {
server.clients.get(&client).is_some_and(|c| matches!(&c.mode, Mode::Picker(p) if p.loading == Some(id)))
};
let started = self.background(work, move |server, answer| {
let Some(Mode::Picker(asked)) = server.clients.get(&client).map(|c| &c.mode).filter(|_| waiting(server))
else {
return;
};
let typed = (asked.query.clone(), asked.cursor);
let mut next = then(server, answer);
if let Mode::Picker(p) = &mut next
&& p.kind == kind
&& !typed.0.is_empty()
{
(p.query, p.cursor) = typed;
p.selected = 0;
p.refilter();
}
if waiting(server)
&& let Some(c) = server.clients.get_mut(&client)
{
c.mode = next;
c.dirty = true;
}
server.mark_dirty_client(client);
});
if !started {
self.toast(client, "kraal could not start reading: try again");
return Mode::Normal;
}
let mut picker = Picker::new(kind, title, Vec::new());
picker.loading = Some(id);
Mode::Picker(picker)
}
pub(super) fn runbook_list(&mut self, client: ClientId) -> Mode {
let focus = self.client_focus(client);
let repo = focus.and_then(|p| self.pane_git(p)).map(|g| g.root.clone());
self.wait_for(
client,
PickerKind::Runbook,
"Runbooks",
move || runbooks::all(repo.as_deref()),
move |server, (list, errors)| server.runbook_picker(client, &list, &errors),
)
}
fn runbook_picker(&mut self, client: ClientId, list: &[Runbook], errors: &[String]) -> Mode {
if let Some(e) = errors.first() {
self.toast(client, e.clone());
}
if list.is_empty() {
if errors.is_empty() {
let dir = paths::shorten_home(&runbooks::user_dir());
self.toast(client, format!("no runbooks yet: `kraal runbook new <name>` creates one in {dir}"));
}
return Mode::Normal;
}
let items = list
.iter()
.map(|r| {
let mut detail = vec![r.description()];
match &r.body {
Body::Markdown { .. } => detail.push("document".into()),
Body::Steps(s) if s.on == On::Server => detail.push("on servers".into()),
Body::Steps(s) if s.on == On::Pane => detail.push("where the pane is".into()),
Body::Steps(_) => {}
}
if let Source::Repo(root) = &r.source {
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
detail.push(format!("repository {name}"));
}
detail.retain(|d| !d.is_empty());
PickItem::new(r.name.clone(), detail.join(" ยท "), PickAction::Runbook(r.path.clone()))
})
.collect();
Mode::Picker(Picker::new(PickerKind::Runbook, "Runbooks", items))
}
pub(super) fn pick_runbook(&mut self, client: ClientId, path: &Path) -> Mode {
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let path = path.to_path_buf();
self.wait_for(
client,
PickerKind::Runbook,
&format!("Opening {name}"),
move || runbooks::load(&path, runbooks::source_of(&path)),
move |server, loaded| match loaded {
Ok(r) => server.open_runbook(client, r),
Err(e) => {
server.toast(client, format!("{e:#}"));
Mode::Normal
}
},
)
}
fn open_runbook(&mut self, client: ClientId, r: Runbook) -> Mode {
let pane = self.client_focus(client);
match &r.body {
Body::Steps(s) => {
let steps = (**s).clone();
let mut draft = Draft::new(r, steps, pane, None);
draft.id = self.alloc_id();
self.drafts.insert(client, draft);
self.advance_draft(client)
}
Body::Markdown { .. } => self.open_markdown(client, r),
}
}
pub(super) fn edit_runbook(&mut self, client: ClientId, path: &Path) {
let Some(ws) = self.clients.get(&client).map(|c| c.workspace) else { return };
let spec =
PaneSpec { command: crate::edit::editor_argv(path, None), close_on_exit: true, ..Default::default() };
let name = path.file_name().map(|n| n.to_string_lossy().into_owned());
if let Err(e) = self.spawn_pane_in(ws, None, spec, Placement::Floating, name) {
self.toast(client, format!("{e:#}"));
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn choices_are_listed_where_asked_or_not_at_all() {
let gone = std::env::temp_dir().join(format!("kraal-choices-gone-{}", std::process::id()));
let refused = super::list_choices("pwd", Some(&gone), &[]).unwrap_err().to_string();
assert!(refused.contains("does not exist"), "{refused}");
let here = std::env::temp_dir().canonicalize().unwrap();
let listed = super::list_choices("pwd -P", Some(&here), &[]).unwrap();
assert_eq!(listed, vec![here.display().to_string()]);
}
}