use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::harness_command::HarnessCommand;
use crate::jobs_control::{harness_program, hermes_home, JobControlError, JobMutation};
use crate::{HarnessHomes, HarnessId};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct JobNotepadRequest {
pub harness: String,
pub id: String,
pub key: Option<String>,
pub value: Option<String>,
pub profile: Option<String>,
pub homes: HarnessHomes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobNotepadEntry {
pub key: String,
pub value: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobNotepad {
pub harness: String,
pub id: String,
pub entries: Vec<JobNotepadEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ran: Option<String>,
}
fn refuse_other_harnesses(request: &JobNotepadRequest) -> Result<(), JobControlError> {
if request.id.trim().is_empty() {
return Err(JobControlError::Invalid(
"a notepad call needs the job id".into(),
));
}
if request.harness == HarnessId::HERMES {
return Ok(());
}
Err(JobControlError::Unsupported(format!(
"`{}` keeps no job notepad; the notepad is supported for: {}",
request.harness,
HarnessId::HERMES
)))
}
fn home(request: &JobNotepadRequest) -> PathBuf {
hermes_home(&JobMutation {
harness: request.harness.clone(),
profile: request.profile.clone(),
homes: request.homes.clone(),
..JobMutation::default()
})
}
fn require_job(request: &JobNotepadRequest) -> Result<(), JobControlError> {
let found =
crate::jobs::get_job(&request.harness, &request.id, &request.homes).map_err(|error| {
JobControlError::Failed(format!("the job store could not be read: {error}"))
})?;
match found {
Some((job, _)) if job.profile == request.profile => Ok(()),
Some((job, _)) => Err(JobControlError::Invalid(format!(
"job `{}` belongs to {}; name that profile with `profile`",
request.id,
job.profile
.map(|p| format!("profile `{p}`"))
.unwrap_or_else(|| "the root home".into())
))),
None => Err(JobControlError::Invalid(format!(
"{} has no job `{}`",
request.harness, request.id
))),
}
}
pub fn read(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
refuse_other_harnesses(request)?;
require_job(request)?;
let store = home(request).join("cron/notepad.db");
let entries = if store.exists() {
read_store(&store, &request.id, request.key.as_deref())?
} else {
Vec::new()
};
Ok(JobNotepad {
harness: request.harness.clone(),
id: request.id.clone(),
entries,
ran: None,
})
}
fn read_store(
store: &std::path::Path,
id: &str,
key: Option<&str>,
) -> Result<Vec<JobNotepadEntry>, JobControlError> {
let unreadable =
|error: rusqlite::Error| JobControlError::Failed(format!("{}: {error}", store.display()));
let connection = rusqlite::Connection::open_with_flags(
store,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(unreadable)?;
let mut statement = connection
.prepare(
"SELECT key, value, updated_at FROM cron_notepad \
WHERE job_id = ?1 AND (?2 IS NULL OR key = ?2) ORDER BY key",
)
.map_err(unreadable)?;
let rows = statement
.query_map(rusqlite::params![id, key], |row| {
Ok(JobNotepadEntry {
key: row.get(0)?,
value: row.get(1)?,
updated_at: row.get(2)?,
})
})
.map_err(unreadable)?;
rows.collect::<Result<Vec<_>, _>>().map_err(unreadable)
}
pub fn set(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
refuse_other_harnesses(request)?;
let (Some(key), Some(value)) = (request.key.as_deref(), request.value.as_deref()) else {
return Err(JobControlError::Invalid(
"`notepad set` needs a key and a value".into(),
));
};
let ran = run(request, &["set", "--", key, value])?;
let read = read(request)?;
match read.entries.as_slice() {
[entry] if entry.value == value => Ok(JobNotepad {
ran: Some(ran),
..read
}),
_ => Err(JobControlError::Failed(format!(
"`{ran}` exited 0 but the store does not hold `{key}` = the value sent afterwards"
))),
}
}
pub fn delete(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
refuse_other_harnesses(request)?;
let Some(key) = request.key.as_deref() else {
return Err(JobControlError::Invalid(
"`notepad delete` needs a key".into(),
));
};
let ran = run(request, &["delete", "--", key])?;
let read = read(request)?;
if read.entries.is_empty() {
Ok(JobNotepad {
ran: Some(ran),
..read
})
} else {
Err(JobControlError::Failed(format!(
"`{ran}` exited 0 but the store still holds `{key}`"
)))
}
}
fn run(request: &JobNotepadRequest, action: &[&str]) -> Result<String, JobControlError> {
require_job(request)?;
let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
command.env("HERMES_HOME", home(request).to_string_lossy());
command.args(["cron", "notepad", request.id.as_str()]);
command.args(action.iter().copied());
let ran = command.narrate();
command.run().map_err(JobControlError::Failed)?;
Ok(ran)
}