1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use camino::{Utf8Path, Utf8PathBuf};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7
8use crate::error::{NewgitError, Result};
9use crate::render::RenderSpec;
10
11#[derive(Debug, Clone, PartialEq)]
15pub struct ResourceDefinition {
16 pub name: String,
17 pub kind: String,
18 pub ownership: Ownership,
19 pub depends_on: Vec<String>,
20 pub identity: Option<IdentitySpec>,
21 pub ports: BTreeMap<String, PortRequest>,
22 pub exports: BTreeMap<String, String>,
23 pub render: Vec<RenderSpec>,
26 pub actions: BTreeMap<String, ActionSpec>,
27 pub checkpoint: Option<CheckpointSpec>,
28 pub restore: Option<RestoreSpec>,
29 pub cleanup: Option<CleanupSpec>,
30 pub definition_rev: String,
32}
33
34#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, serde::Serialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum Ownership {
39 Branch,
40 Workspace,
41 Project,
42 User,
43 External,
44}
45
46impl Ownership {
47 pub fn per_branch_teardown_may_touch(self) -> bool {
53 match self {
54 Self::Branch | Self::Workspace | Self::External => true,
55 Self::Project | Self::User => false,
56 }
57 }
58
59 pub fn label(self) -> &'static str {
60 match self {
61 Self::Branch => "branch",
62 Self::Workspace => "workspace",
63 Self::Project => "project",
64 Self::User => "user",
65 Self::External => "external",
66 }
67 }
68}
69
70#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
71pub struct IdentitySpec {
72 pub paths: Vec<Utf8PathBuf>,
73}
74
75#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
76pub struct PortRequest {
77 pub start: u16,
78 #[serde(default)]
79 pub env: Option<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
83pub struct ActionSpec {
84 #[serde(default)]
85 pub command: Option<String>,
86 #[serde(default)]
87 pub long_running: bool,
88 #[serde(default)]
90 pub signal: Option<String>,
91 #[serde(default)]
96 pub captures: Vec<String>,
97}
98
99#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
102pub struct CleanupSpec {
103 pub command: Option<String>,
106}
107
108#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
110pub struct CheckpointSpec {
111 pub mode: CheckpointMode,
112 #[serde(default)]
114 pub paths: Vec<Utf8PathBuf>,
115 #[serde(default)]
117 pub command: Option<String>,
118 #[serde(default)]
120 pub into_tracker: Option<String>,
121 #[serde(default)]
123 pub state_ref: Option<String>,
124}
125
126#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
127#[serde(rename_all = "kebab-case")]
128pub enum CheckpointMode {
129 None,
130 Hash,
131 Command,
132 External,
133}
134
135#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
137pub struct RestoreSpec {
138 pub mode: RestoreMode,
139 #[serde(default)]
141 pub command: Option<String>,
142 #[serde(default)]
144 pub action: Option<String>,
145}
146
147#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
148#[serde(rename_all = "kebab-case")]
149pub enum RestoreMode {
150 None,
151 Command,
152 Recompute,
153 External,
154}
155
156impl RestoreSpec {
157 pub fn recompute_action(&self) -> &str {
159 self.action.as_deref().unwrap_or("prepare")
160 }
161}
162
163#[derive(Debug, Deserialize)]
164struct ResourceDefinitionFile {
165 kind: String,
166 ownership: Ownership,
167 #[serde(default)]
168 depends_on: Vec<String>,
169 #[serde(default)]
170 identity: Option<IdentitySpec>,
171 #[serde(default)]
172 ports: BTreeMap<String, PortRequest>,
173 #[serde(default)]
174 exports: BTreeMap<String, String>,
175 #[serde(default)]
176 render: Vec<RenderSpec>,
177 #[serde(default)]
178 actions: BTreeMap<String, ActionSpec>,
179 #[serde(default)]
180 checkpoint: Option<CheckpointSpec>,
181 #[serde(default)]
182 restore: Option<RestoreSpec>,
183 #[serde(default)]
184 cleanup: Option<CleanupSpec>,
185}
186
187impl ResourceDefinition {
188 pub fn from_file(name: &str, path: &Utf8Path) -> Result<Self> {
189 let contents =
190 std::fs::read_to_string(path).map_err(|source| NewgitError::io(path, source))?;
191 let file: ResourceDefinitionFile =
192 toml::from_str(&contents).map_err(|source| NewgitError::TomlRead {
193 path: path.to_path_buf(),
194 source,
195 })?;
196
197 let digest = Sha256::digest(contents.as_bytes());
198 let hex: String = digest[..6]
199 .iter()
200 .map(|byte| format!("{byte:02x}"))
201 .collect();
202
203 let definition = Self {
204 name: name.to_owned(),
205 kind: file.kind,
206 ownership: file.ownership,
207 depends_on: file.depends_on,
208 identity: file.identity,
209 ports: file.ports,
210 exports: file.exports,
211 render: file.render,
212 actions: file.actions,
213 checkpoint: file.checkpoint,
214 restore: file.restore,
215 cleanup: file.cleanup,
216 definition_rev: format!("sha256:{hex}"),
217 };
218 definition.validate()?;
219 Ok(definition)
220 }
221
222 pub fn stop_signal(&self) -> String {
224 self.actions
225 .get("stop")
226 .and_then(|action| action.signal.clone())
227 .unwrap_or_else(|| "term".to_owned())
228 }
229
230 pub fn has_long_running_action(&self) -> bool {
231 self.actions.values().any(|action| action.long_running)
232 }
233
234 fn validate(&self) -> Result<()> {
235 if let Some(checkpoint) = &self.checkpoint {
236 match checkpoint.mode {
237 CheckpointMode::None => {}
238 CheckpointMode::Hash => {
239 if checkpoint.paths.is_empty() {
240 return Err(
241 self.invalid("checkpoint mode `hash` requires `paths`".to_owned())
242 );
243 }
244 }
245 CheckpointMode::Command => {
246 if checkpoint.command.is_none() {
247 return Err(
248 self.invalid("checkpoint mode `command` requires `command`".to_owned())
249 );
250 }
251 }
252 CheckpointMode::External => {
253 if checkpoint.state_ref.is_none() {
254 return Err(self.invalid(
255 "checkpoint mode `external` requires `state_ref`".to_owned(),
256 ));
257 }
258 }
259 }
260 }
261 if let Some(restore) = &self.restore {
262 match restore.mode {
263 RestoreMode::Command if restore.command.is_none() => {
264 return Err(
265 self.invalid("restore mode `command` requires `command`".to_owned())
266 );
267 }
268 RestoreMode::Recompute
269 if !self.actions.contains_key(restore.recompute_action()) =>
270 {
271 return Err(self.invalid(format!(
272 "restore mode `recompute` re-runs action `{}`, which is not defined",
273 restore.recompute_action()
274 )));
275 }
276 _ => {}
277 }
278 }
279 for (action_name, action) in &self.actions {
280 let is_signal_only = action.command.is_none() && action.signal.is_some();
281 if action.command.is_none() && !is_signal_only {
282 return Err(self.invalid(format!(
283 "action `{action_name}` has neither a command nor a signal"
284 )));
285 }
286 if action.long_running && action.command.is_none() {
287 return Err(self.invalid(format!(
288 "action `{action_name}` is long_running but has no command"
289 )));
290 }
291 }
292 for spec in &self.render {
293 if spec.replace.is_empty() {
294 return Err(self.invalid(format!(
295 "render into `{}` declares no replacements",
296 spec.path
297 )));
298 }
299 if spec.path.is_absolute()
303 || spec
304 .path
305 .components()
306 .any(|part| part.as_str() == ".." || part.as_str() == ".newgit")
307 {
308 return Err(self.invalid(format!(
309 "render path `{}` must be workspace-relative and outside `.newgit/`",
310 spec.path
311 )));
312 }
313 for replacement in &spec.replace {
314 if replacement.find.is_empty() {
315 return Err(
316 self.invalid(format!("render into `{}` has an empty `find`", spec.path))
317 );
318 }
319 if replacement.count == 0 {
320 return Err(self.invalid(format!(
321 "render into `{}` declares `count = 0` for `{}`; a replacement that \
322 matches nothing is a definition that does nothing",
323 spec.path, replacement.find
324 )));
325 }
326 }
327 }
328 Ok(())
329 }
330
331 fn invalid(&self, reason: String) -> NewgitError {
332 NewgitError::InvalidDefinition {
333 tracker: self.name.clone(),
334 reason,
335 }
336 }
337}
338
339#[derive(Debug, Clone, Default, PartialEq, Eq)]
341pub struct Captures {
342 pub found: BTreeMap<String, String>,
343 pub missing: Vec<String>,
349}
350
351pub fn parse_captures(stdout: &str, wanted: &[String]) -> Captures {
361 if wanted.is_empty() {
362 return Captures::default();
363 }
364 let trimmed = stdout.trim_start();
365
366 let mut seen: BTreeMap<String, String> = BTreeMap::new();
367 if trimmed.starts_with('{') {
368 if let Ok(serde_json::Value::Object(object)) =
369 serde_json::from_str::<serde_json::Value>(trimmed)
370 {
371 for (key, value) in object {
372 if let Some(text) = json_scalar(&value) {
373 seen.insert(key, text);
374 }
375 }
376 }
377 } else {
378 for line in stdout.lines() {
379 if let Some((key, value)) = line.split_once('=') {
380 seen.insert(key.trim().to_owned(), value.trim().to_owned());
381 }
382 }
383 }
384
385 let mut captures = Captures::default();
386 for name in wanted {
387 match seen.remove_entry(name) {
388 Some((key, value)) => {
389 captures.found.insert(key, value);
390 }
391 None => captures.missing.push(name.clone()),
392 }
393 }
394 captures
395}
396
397fn json_scalar(value: &serde_json::Value) -> Option<String> {
400 match value {
401 serde_json::Value::String(text) => Some(text.clone()),
402 serde_json::Value::Number(number) => Some(number.to_string()),
403 serde_json::Value::Bool(flag) => Some(flag.to_string()),
404 _ => None,
405 }
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
412pub enum GraphProblem {
413 MissingDependency {
414 resource: String,
415 dependency: String,
416 },
417 Cycle(Vec<String>),
418}
419
420impl GraphProblem {
421 pub fn into_error(self) -> NewgitError {
423 match self {
424 Self::MissingDependency {
425 resource,
426 dependency,
427 } => NewgitError::MissingDependency {
428 resource,
429 dependency,
430 },
431 Self::Cycle(stack) => NewgitError::DependencyCycle(stack),
432 }
433 }
434}
435
436impl fmt::Display for GraphProblem {
437 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438 write!(f, "{}", self.clone().into_error())
439 }
440}
441
442pub fn resolve_order(
449 resources: &[ResourceDefinition],
450 tracker_names: &BTreeSet<String>,
451) -> (Vec<String>, Vec<GraphProblem>) {
452 let mut ordered = Vec::new();
453 let mut problems = Vec::new();
454 let mut state: BTreeMap<&str, Visit> = BTreeMap::new();
455
456 fn visit<'a>(
457 name: &'a str,
458 resources: &'a [ResourceDefinition],
459 tracker_names: &BTreeSet<String>,
460 state: &mut BTreeMap<&'a str, Visit>,
461 ordered: &mut Vec<String>,
462 problems: &mut Vec<GraphProblem>,
463 stack: &mut Vec<String>,
464 ) {
465 match state.get(name) {
466 Some(Visit::Done) => return,
467 Some(Visit::InProgress) => {
468 let mut cycle = stack.clone();
471 cycle.push(name.to_owned());
472 problems.push(GraphProblem::Cycle(cycle));
473 return;
474 }
475 None => {}
476 }
477 let Some(resource) = resources.iter().find(|r| r.name == name) else {
478 return;
480 };
481 state.insert(&resource.name, Visit::InProgress);
482 stack.push(name.to_owned());
483 for dependency in &resource.depends_on {
484 if tracker_names.contains(dependency) {
485 continue;
486 }
487 if !resources.iter().any(|r| &r.name == dependency) {
488 problems.push(GraphProblem::MissingDependency {
489 resource: resource.name.clone(),
490 dependency: dependency.clone(),
491 });
492 continue;
493 }
494 visit(
495 dependency,
496 resources,
497 tracker_names,
498 state,
499 ordered,
500 problems,
501 stack,
502 );
503 }
504 stack.pop();
505 state.insert(&resource.name, Visit::Done);
506 ordered.push(resource.name.clone());
507 }
508
509 #[derive(Clone, Copy)]
510 enum Visit {
511 InProgress,
512 Done,
513 }
514
515 for resource in resources {
516 visit(
517 &resource.name,
518 resources,
519 tracker_names,
520 &mut state,
521 &mut ordered,
522 &mut problems,
523 &mut Vec::new(),
524 );
525 }
526 (ordered, problems)
527}
528
529pub fn topological_order(
531 resources: &[ResourceDefinition],
532 tracker_names: &BTreeSet<String>,
533) -> Result<Vec<String>> {
534 let (ordered, problems) = resolve_order(resources, tracker_names);
535 match problems.into_iter().next() {
536 Some(problem) => Err(problem.into_error()),
537 None => Ok(ordered),
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use std::collections::{BTreeMap, BTreeSet};
544
545 use super::*;
546
547 fn resource(name: &str, deps: &[&str]) -> ResourceDefinition {
548 ResourceDefinition {
549 name: name.to_owned(),
550 kind: "command".to_owned(),
551 ownership: Ownership::Branch,
552 depends_on: deps.iter().map(ToString::to_string).collect(),
553 identity: None,
554 ports: BTreeMap::new(),
555 exports: BTreeMap::new(),
556 render: Vec::new(),
557 actions: BTreeMap::new(),
558 checkpoint: None,
559 restore: None,
560 cleanup: None,
561 definition_rev: "sha256:000000000000".to_owned(),
562 }
563 }
564
565 #[test]
566 fn captures_read_json_objects_and_key_value_lines() {
567 let wanted = ["PREVIEW_ID".to_owned(), "PREVIEW_URL".to_owned()];
568
569 let json = parse_captures(
570 r#"{"PREVIEW_ID": "pv_9", "PREVIEW_URL": "https://pv9.example", "extra": 1}"#,
571 &wanted,
572 );
573 assert_eq!(json.found["PREVIEW_ID"], "pv_9");
574 assert_eq!(json.found["PREVIEW_URL"], "https://pv9.example");
575 assert_eq!(json.found.len(), 2, "undeclared keys are not captured");
576 assert!(json.missing.is_empty());
577
578 let lines = parse_captures("noise\nPREVIEW_ID=pv_9\n", &wanted);
579 assert_eq!(lines.found["PREVIEW_ID"], "pv_9");
580 assert!(
581 !lines.found.contains_key("PREVIEW_URL"),
582 "a name the command did not emit is absent, not empty"
583 );
584 assert_eq!(
585 lines.missing,
586 vec!["PREVIEW_URL".to_owned()],
587 "and it is reported, not silently dropped"
588 );
589
590 assert_eq!(
592 parse_captures(r#"{"PORT": 5432}"#, &["PORT".to_owned()]).found["PORT"],
593 "5432"
594 );
595 let unparseable = parse_captures("{not json", &wanted);
596 assert!(unparseable.found.is_empty());
597 assert_eq!(
598 unparseable.missing, wanted,
599 "every declared name is missing"
600 );
601
602 let undeclared = parse_captures("PREVIEW_ID=pv_9", &[]);
604 assert!(undeclared.found.is_empty() && undeclared.missing.is_empty());
605 }
606
607 #[test]
608 fn orders_dependencies_first_and_detects_cycles() {
609 let trackers = BTreeSet::from(["runtime-env".to_owned()]);
610 let resources = vec![
611 resource("app", &["deps", "runtime-env"]),
612 resource("deps", &[]),
613 ];
614 let order = topological_order(&resources, &trackers).expect("order");
615 assert_eq!(order, vec!["deps".to_owned(), "app".to_owned()]);
616
617 let cyclic = vec![resource("a", &["b"]), resource("b", &["a"])];
618 assert!(matches!(
619 topological_order(&cyclic, &trackers),
620 Err(NewgitError::DependencyCycle(_))
621 ));
622
623 let missing = vec![resource("app", &["nope"])];
624 assert!(matches!(
625 topological_order(&missing, &trackers),
626 Err(NewgitError::MissingDependency { .. })
627 ));
628 }
629}