1use std::collections::{BTreeMap, BTreeSet};
2
3use camino::{Utf8Path, Utf8PathBuf};
4use serde::Deserialize;
5use sha2::{Digest, Sha256};
6
7use crate::error::{NewgitError, Result};
8
9#[derive(Debug, Clone, PartialEq)]
13pub struct ResourceDefinition {
14 pub name: String,
15 pub kind: String,
16 pub ownership: Ownership,
17 pub depends_on: Vec<String>,
18 pub identity: Option<IdentitySpec>,
19 pub ports: BTreeMap<String, PortRequest>,
20 pub exports: BTreeMap<String, String>,
21 pub actions: BTreeMap<String, ActionSpec>,
22 pub checkpoint: Option<CheckpointSpec>,
23 pub restore: Option<RestoreSpec>,
24 pub cleanup: Option<CleanupSpec>,
25 pub definition_rev: String,
27}
28
29#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, serde::Serialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum Ownership {
34 Branch,
35 Workspace,
36 Project,
37 User,
38 External,
39}
40
41impl Ownership {
42 pub fn per_branch_teardown_may_touch(self) -> bool {
48 match self {
49 Self::Branch | Self::Workspace | Self::External => true,
50 Self::Project | Self::User => false,
51 }
52 }
53
54 pub fn label(self) -> &'static str {
55 match self {
56 Self::Branch => "branch",
57 Self::Workspace => "workspace",
58 Self::Project => "project",
59 Self::User => "user",
60 Self::External => "external",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
66pub struct IdentitySpec {
67 pub paths: Vec<Utf8PathBuf>,
68}
69
70#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
71pub struct PortRequest {
72 pub start: u16,
73 #[serde(default)]
74 pub env: Option<String>,
75}
76
77#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
78pub struct ActionSpec {
79 #[serde(default)]
80 pub command: Option<String>,
81 #[serde(default)]
82 pub long_running: bool,
83 #[serde(default)]
85 pub signal: Option<String>,
86 #[serde(default)]
91 pub captures: Vec<String>,
92}
93
94#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
97pub struct CleanupSpec {
98 pub command: Option<String>,
101}
102
103#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
105pub struct CheckpointSpec {
106 pub mode: CheckpointMode,
107 #[serde(default)]
109 pub paths: Vec<Utf8PathBuf>,
110 #[serde(default)]
112 pub command: Option<String>,
113 #[serde(default)]
115 pub into_tracker: Option<String>,
116 #[serde(default)]
118 pub state_ref: Option<String>,
119}
120
121#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "kebab-case")]
123pub enum CheckpointMode {
124 None,
125 Hash,
126 Command,
127 External,
128}
129
130#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
132pub struct RestoreSpec {
133 pub mode: RestoreMode,
134 #[serde(default)]
136 pub command: Option<String>,
137 #[serde(default)]
139 pub action: Option<String>,
140}
141
142#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "kebab-case")]
144pub enum RestoreMode {
145 None,
146 Command,
147 Recompute,
148 External,
149}
150
151impl RestoreSpec {
152 pub fn recompute_action(&self) -> &str {
154 self.action.as_deref().unwrap_or("prepare")
155 }
156}
157
158#[derive(Debug, Deserialize)]
159struct ResourceDefinitionFile {
160 kind: String,
161 ownership: Ownership,
162 #[serde(default)]
163 depends_on: Vec<String>,
164 #[serde(default)]
165 identity: Option<IdentitySpec>,
166 #[serde(default)]
167 ports: BTreeMap<String, PortRequest>,
168 #[serde(default)]
169 exports: BTreeMap<String, String>,
170 #[serde(default)]
171 actions: BTreeMap<String, ActionSpec>,
172 #[serde(default)]
173 checkpoint: Option<CheckpointSpec>,
174 #[serde(default)]
175 restore: Option<RestoreSpec>,
176 #[serde(default)]
177 cleanup: Option<CleanupSpec>,
178}
179
180impl ResourceDefinition {
181 pub fn from_file(name: &str, path: &Utf8Path) -> Result<Self> {
182 let contents =
183 std::fs::read_to_string(path).map_err(|source| NewgitError::io(path, source))?;
184 let file: ResourceDefinitionFile =
185 toml::from_str(&contents).map_err(|source| NewgitError::TomlRead {
186 path: path.to_path_buf(),
187 source,
188 })?;
189
190 let digest = Sha256::digest(contents.as_bytes());
191 let hex: String = digest[..6]
192 .iter()
193 .map(|byte| format!("{byte:02x}"))
194 .collect();
195
196 let definition = Self {
197 name: name.to_owned(),
198 kind: file.kind,
199 ownership: file.ownership,
200 depends_on: file.depends_on,
201 identity: file.identity,
202 ports: file.ports,
203 exports: file.exports,
204 actions: file.actions,
205 checkpoint: file.checkpoint,
206 restore: file.restore,
207 cleanup: file.cleanup,
208 definition_rev: format!("sha256:{hex}"),
209 };
210 definition.validate()?;
211 Ok(definition)
212 }
213
214 pub fn stop_signal(&self) -> String {
216 self.actions
217 .get("stop")
218 .and_then(|action| action.signal.clone())
219 .unwrap_or_else(|| "term".to_owned())
220 }
221
222 pub fn has_long_running_action(&self) -> bool {
223 self.actions.values().any(|action| action.long_running)
224 }
225
226 fn validate(&self) -> Result<()> {
227 if let Some(checkpoint) = &self.checkpoint {
228 match checkpoint.mode {
229 CheckpointMode::None => {}
230 CheckpointMode::Hash => {
231 if checkpoint.paths.is_empty() {
232 return Err(
233 self.invalid("checkpoint mode `hash` requires `paths`".to_owned())
234 );
235 }
236 }
237 CheckpointMode::Command => {
238 if checkpoint.command.is_none() {
239 return Err(
240 self.invalid("checkpoint mode `command` requires `command`".to_owned())
241 );
242 }
243 }
244 CheckpointMode::External => {
245 if checkpoint.state_ref.is_none() {
246 return Err(self.invalid(
247 "checkpoint mode `external` requires `state_ref`".to_owned(),
248 ));
249 }
250 }
251 }
252 }
253 if let Some(restore) = &self.restore {
254 match restore.mode {
255 RestoreMode::Command if restore.command.is_none() => {
256 return Err(
257 self.invalid("restore mode `command` requires `command`".to_owned())
258 );
259 }
260 RestoreMode::Recompute
261 if !self.actions.contains_key(restore.recompute_action()) =>
262 {
263 return Err(self.invalid(format!(
264 "restore mode `recompute` re-runs action `{}`, which is not defined",
265 restore.recompute_action()
266 )));
267 }
268 _ => {}
269 }
270 }
271 for (action_name, action) in &self.actions {
272 let is_signal_only = action.command.is_none() && action.signal.is_some();
273 if action.command.is_none() && !is_signal_only {
274 return Err(self.invalid(format!(
275 "action `{action_name}` has neither a command nor a signal"
276 )));
277 }
278 if action.long_running && action.command.is_none() {
279 return Err(self.invalid(format!(
280 "action `{action_name}` is long_running but has no command"
281 )));
282 }
283 }
284 Ok(())
285 }
286
287 fn invalid(&self, reason: String) -> NewgitError {
288 NewgitError::InvalidDefinition {
289 tracker: self.name.clone(),
290 reason,
291 }
292 }
293}
294
295pub fn parse_captures(stdout: &str, wanted: &[String]) -> BTreeMap<String, String> {
305 if wanted.is_empty() {
306 return BTreeMap::new();
307 }
308 let trimmed = stdout.trim_start();
309
310 let mut found: BTreeMap<String, String> = BTreeMap::new();
311 if trimmed.starts_with('{') {
312 if let Ok(serde_json::Value::Object(object)) =
313 serde_json::from_str::<serde_json::Value>(trimmed)
314 {
315 for (key, value) in object {
316 if let Some(text) = json_scalar(&value) {
317 found.insert(key, text);
318 }
319 }
320 }
321 } else {
322 for line in stdout.lines() {
323 if let Some((key, value)) = line.split_once('=') {
324 found.insert(key.trim().to_owned(), value.trim().to_owned());
325 }
326 }
327 }
328
329 wanted
330 .iter()
331 .filter_map(|name| found.remove_entry(name))
332 .collect()
333}
334
335fn json_scalar(value: &serde_json::Value) -> Option<String> {
338 match value {
339 serde_json::Value::String(text) => Some(text.clone()),
340 serde_json::Value::Number(number) => Some(number.to_string()),
341 serde_json::Value::Bool(flag) => Some(flag.to_string()),
342 _ => None,
343 }
344}
345
346pub fn topological_order(
349 resources: &[ResourceDefinition],
350 tracker_names: &BTreeSet<String>,
351) -> Result<Vec<String>> {
352 let mut ordered = Vec::new();
353 let mut state: BTreeMap<&str, Visit> = BTreeMap::new();
354
355 fn visit<'a>(
356 name: &'a str,
357 resources: &'a [ResourceDefinition],
358 tracker_names: &BTreeSet<String>,
359 state: &mut BTreeMap<&'a str, Visit>,
360 ordered: &mut Vec<String>,
361 stack: &mut Vec<String>,
362 ) -> Result<()> {
363 match state.get(name) {
364 Some(Visit::Done) => return Ok(()),
365 Some(Visit::InProgress) => {
366 stack.push(name.to_owned());
367 return Err(NewgitError::DependencyCycle(stack.clone()));
368 }
369 None => {}
370 }
371 let Some(resource) = resources.iter().find(|r| r.name == name) else {
372 return Ok(());
374 };
375 state.insert(&resource.name, Visit::InProgress);
376 stack.push(name.to_owned());
377 for dependency in &resource.depends_on {
378 if tracker_names.contains(dependency) {
379 continue;
380 }
381 if !resources.iter().any(|r| &r.name == dependency) {
382 return Err(NewgitError::MissingDependency {
383 resource: resource.name.clone(),
384 dependency: dependency.clone(),
385 });
386 }
387 visit(dependency, resources, tracker_names, state, ordered, stack)?;
388 }
389 stack.pop();
390 state.insert(&resource.name, Visit::Done);
391 ordered.push(resource.name.clone());
392 Ok(())
393 }
394
395 #[derive(Clone, Copy)]
396 enum Visit {
397 InProgress,
398 Done,
399 }
400
401 for resource in resources {
402 visit(
403 &resource.name,
404 resources,
405 tracker_names,
406 &mut state,
407 &mut ordered,
408 &mut Vec::new(),
409 )?;
410 }
411 Ok(ordered)
412}
413
414#[cfg(test)]
415mod tests {
416 use std::collections::{BTreeMap, BTreeSet};
417
418 use super::*;
419
420 fn resource(name: &str, deps: &[&str]) -> ResourceDefinition {
421 ResourceDefinition {
422 name: name.to_owned(),
423 kind: "command".to_owned(),
424 ownership: Ownership::Branch,
425 depends_on: deps.iter().map(ToString::to_string).collect(),
426 identity: None,
427 ports: BTreeMap::new(),
428 exports: BTreeMap::new(),
429 actions: BTreeMap::new(),
430 checkpoint: None,
431 restore: None,
432 cleanup: None,
433 definition_rev: "sha256:000000000000".to_owned(),
434 }
435 }
436
437 #[test]
438 fn captures_read_json_objects_and_key_value_lines() {
439 let wanted = ["PREVIEW_ID".to_owned(), "PREVIEW_URL".to_owned()];
440
441 let json = parse_captures(
442 r#"{"PREVIEW_ID": "pv_9", "PREVIEW_URL": "https://pv9.example", "extra": 1}"#,
443 &wanted,
444 );
445 assert_eq!(json["PREVIEW_ID"], "pv_9");
446 assert_eq!(json["PREVIEW_URL"], "https://pv9.example");
447 assert_eq!(json.len(), 2, "undeclared keys are not captured");
448
449 let lines = parse_captures("noise\nPREVIEW_ID=pv_9\n", &wanted);
450 assert_eq!(lines["PREVIEW_ID"], "pv_9");
451 assert!(
452 !lines.contains_key("PREVIEW_URL"),
453 "a name the command did not emit is absent, not empty"
454 );
455
456 assert_eq!(
458 parse_captures(r#"{"PORT": 5432}"#, &["PORT".to_owned()])["PORT"],
459 "5432"
460 );
461 assert!(parse_captures("{not json", &wanted).is_empty());
462 assert!(parse_captures("PREVIEW_ID=pv_9", &[]).is_empty());
463 }
464
465 #[test]
466 fn orders_dependencies_first_and_detects_cycles() {
467 let trackers = BTreeSet::from(["runtime-env".to_owned()]);
468 let resources = vec![
469 resource("app", &["deps", "runtime-env"]),
470 resource("deps", &[]),
471 ];
472 let order = topological_order(&resources, &trackers).expect("order");
473 assert_eq!(order, vec!["deps".to_owned(), "app".to_owned()]);
474
475 let cyclic = vec![resource("a", &["b"]), resource("b", &["a"])];
476 assert!(matches!(
477 topological_order(&cyclic, &trackers),
478 Err(NewgitError::DependencyCycle(_))
479 ));
480
481 let missing = vec![resource("app", &["nope"])];
482 assert!(matches!(
483 topological_order(&missing, &trackers),
484 Err(NewgitError::MissingDependency { .. })
485 ));
486 }
487}