Skip to main content

newgit_core/
resource.rs

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/// A lifecycle unit that re-establishes per-branch state that can't travel
10/// as content. Parsed from `.newgit/resources/<name>.toml`; the name comes
11/// from the filename.
12#[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    /// `sha256:<hex12>` of the definition file contents.
26    pub definition_rev: String,
27}
28
29/// Who owns the concrete instance and what cleanup may touch. Operational,
30/// not a security label.
31#[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    /// Whether tearing down one branch instance may touch the concrete
43    /// resource. `project` is shared by the project's instances and `user`
44    /// is shared beyond it, so per-branch teardown must leave both alone —
45    /// this is the conservative half of the ownership table, and the reason
46    /// a pnpm store survives `newgit remove`.
47    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    /// Signal sent by `stop` for a long-running sibling `start`.
84    #[serde(default)]
85    pub signal: Option<String>,
86    /// Names to read out of the command's stdout and merge into this
87    /// resource's binding exports — how a resource that mints an external
88    /// handle (a preview id, a tunnel URL) publishes it. See
89    /// [`parse_captures`] for the accepted output shapes.
90    #[serde(default)]
91    pub captures: Vec<String>,
92}
93
94/// How a resource tears its concrete instance down. Ownership decides
95/// whether the hook may run at all; this decides what running it means.
96#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
97pub struct CleanupSpec {
98    /// May use `{{state_ref}}` (from the instance's latest checkpoint) and
99    /// `{{exports.<name>}}` (from the binding).
100    pub command: Option<String>,
101}
102
103/// How a resource captures branch-local state at checkpoint time.
104#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
105pub struct CheckpointSpec {
106    pub mode: CheckpointMode,
107    /// `hash`: identity files whose content hash is the captured state.
108    #[serde(default)]
109    pub paths: Vec<Utf8PathBuf>,
110    /// `command`: emits the state; trimmed stdout becomes the state ref.
111    #[serde(default)]
112    pub command: Option<String>,
113    /// `command`: deposit `{{snapshot.path}}` into this tracker's lane.
114    #[serde(default)]
115    pub into_tracker: Option<String>,
116    /// `external`: template for the opaque ref (may use `{{exports.<name>}}`).
117    #[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/// How a resource re-establishes checkpointed state during undo.
131#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
132pub struct RestoreSpec {
133    pub mode: RestoreMode,
134    /// `command`: may use `{{state_ref}}`.
135    #[serde(default)]
136    pub command: Option<String>,
137    /// `recompute`: the action to re-run (default `prepare`).
138    #[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    /// The action a `recompute` restore re-runs.
153    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    /// The action `stop` signals, resolved: explicit `signal`, default TERM.
215    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
295/// Read an action's declared `captures` out of its stdout.
296///
297/// Two shapes are accepted, because both are what a real command already
298/// emits: stdout whose first non-whitespace character is `{` is parsed as a
299/// flat JSON object (`cloudctl ... --json`), and anything else is read as
300/// `KEY=VALUE` lines (`echo PREVIEW_ID=pv_9`). Only declared names are
301/// taken, JSON scalars are stringified, and a name the command did not emit
302/// is simply absent rather than an error — a resource may legitimately
303/// publish a handle only on some runs.
304pub 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
335/// JSON scalars render as themselves; containers have no obvious env-var
336/// spelling, so they are skipped rather than guessed at.
337fn 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
346/// Order resources so dependencies come before dependents. Dependencies may
347/// name trackers (which only need to exist) or other resources.
348pub 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            // Caller verified membership; only reachable for dependencies.
373            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        // Non-string scalars stringify; unparseable output captures nothing.
457        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}