1use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19use crate::harness_command::HarnessCommand;
20use crate::jobs_control::{harness_program, hermes_home, JobControlError, JobMutation};
21use crate::{HarnessHomes, HarnessId};
22
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
25#[serde(default)]
26pub struct JobNotepadRequest {
27 pub harness: String,
29 pub id: String,
31 pub key: Option<String>,
33 pub value: Option<String>,
35 pub profile: Option<String>,
37 pub homes: HarnessHomes,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct JobNotepadEntry {
44 pub key: String,
46 pub value: String,
48 pub updated_at: String,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct JobNotepad {
55 pub harness: String,
57 pub id: String,
59 pub entries: Vec<JobNotepadEntry>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub ran: Option<String>,
65}
66
67fn refuse_other_harnesses(request: &JobNotepadRequest) -> Result<(), JobControlError> {
68 if request.id.trim().is_empty() {
69 return Err(JobControlError::Invalid(
70 "a notepad call needs the job id".into(),
71 ));
72 }
73 if request.harness == HarnessId::HERMES {
74 return Ok(());
75 }
76 Err(JobControlError::Unsupported(format!(
77 "`{}` keeps no job notepad; the notepad is supported for: {}",
78 request.harness,
79 HarnessId::HERMES
80 )))
81}
82
83fn home(request: &JobNotepadRequest) -> PathBuf {
84 hermes_home(&JobMutation {
85 harness: request.harness.clone(),
86 profile: request.profile.clone(),
87 homes: request.homes.clone(),
88 ..JobMutation::default()
89 })
90}
91
92fn require_job(request: &JobNotepadRequest) -> Result<(), JobControlError> {
96 let found =
97 crate::jobs::get_job(&request.harness, &request.id, &request.homes).map_err(|error| {
98 JobControlError::Failed(format!("the job store could not be read: {error}"))
99 })?;
100 match found {
101 Some((job, _)) if job.profile == request.profile => Ok(()),
102 Some((job, _)) => Err(JobControlError::Invalid(format!(
103 "job `{}` belongs to {}; name that profile with `profile`",
104 request.id,
105 job.profile
106 .map(|p| format!("profile `{p}`"))
107 .unwrap_or_else(|| "the root home".into())
108 ))),
109 None => Err(JobControlError::Invalid(format!(
110 "{} has no job `{}`",
111 request.harness, request.id
112 ))),
113 }
114}
115
116pub fn read(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
118 refuse_other_harnesses(request)?;
119 require_job(request)?;
120 let store = home(request).join("cron/notepad.db");
121 let entries = if store.exists() {
122 read_store(&store, &request.id, request.key.as_deref())?
123 } else {
124 Vec::new()
125 };
126 Ok(JobNotepad {
127 harness: request.harness.clone(),
128 id: request.id.clone(),
129 entries,
130 ran: None,
131 })
132}
133
134fn read_store(
135 store: &std::path::Path,
136 id: &str,
137 key: Option<&str>,
138) -> Result<Vec<JobNotepadEntry>, JobControlError> {
139 let unreadable =
140 |error: rusqlite::Error| JobControlError::Failed(format!("{}: {error}", store.display()));
141 let connection = rusqlite::Connection::open_with_flags(
142 store,
143 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
144 )
145 .map_err(unreadable)?;
146 let mut statement = connection
147 .prepare(
148 "SELECT key, value, updated_at FROM cron_notepad \
149 WHERE job_id = ?1 AND (?2 IS NULL OR key = ?2) ORDER BY key",
150 )
151 .map_err(unreadable)?;
152 let rows = statement
153 .query_map(rusqlite::params![id, key], |row| {
154 Ok(JobNotepadEntry {
155 key: row.get(0)?,
156 value: row.get(1)?,
157 updated_at: row.get(2)?,
158 })
159 })
160 .map_err(unreadable)?;
161 rows.collect::<Result<Vec<_>, _>>().map_err(unreadable)
162}
163
164pub fn set(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
166 refuse_other_harnesses(request)?;
167 let (Some(key), Some(value)) = (request.key.as_deref(), request.value.as_deref()) else {
168 return Err(JobControlError::Invalid(
169 "`notepad set` needs a key and a value".into(),
170 ));
171 };
172 let ran = run(request, &["set", "--", key, value])?;
174 let read = read(request)?;
175 match read.entries.as_slice() {
176 [entry] if entry.value == value => Ok(JobNotepad {
177 ran: Some(ran),
178 ..read
179 }),
180 _ => Err(JobControlError::Failed(format!(
181 "`{ran}` exited 0 but the store does not hold `{key}` = the value sent afterwards"
182 ))),
183 }
184}
185
186pub fn delete(request: &JobNotepadRequest) -> Result<JobNotepad, JobControlError> {
188 refuse_other_harnesses(request)?;
189 let Some(key) = request.key.as_deref() else {
190 return Err(JobControlError::Invalid(
191 "`notepad delete` needs a key".into(),
192 ));
193 };
194 let ran = run(request, &["delete", "--", key])?;
195 let read = read(request)?;
196 if read.entries.is_empty() {
197 Ok(JobNotepad {
198 ran: Some(ran),
199 ..read
200 })
201 } else {
202 Err(JobControlError::Failed(format!(
203 "`{ran}` exited 0 but the store still holds `{key}`"
204 )))
205 }
206}
207
208fn run(request: &JobNotepadRequest, action: &[&str]) -> Result<String, JobControlError> {
209 require_job(request)?;
210 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
211 command.env("HERMES_HOME", home(request).to_string_lossy());
212 command.args(["cron", "notepad", request.id.as_str()]);
213 command.args(action.iter().copied());
214 let ran = command.narrate();
215 command.run().map_err(JobControlError::Failed)?;
216 Ok(ran)
217}