Skip to main content

newgit_core/
resource.rs

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/// A lifecycle unit that re-establishes per-branch state that can't travel
12/// as content. Parsed from `.newgit/resources/<name>.toml`; the name comes
13/// from the filename.
14#[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    /// Files this resource substitutes per-instance values into, before
24    /// `prepare`. See [`crate::render`].
25    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    /// `sha256:<hex12>` of the definition file contents.
31    pub definition_rev: String,
32}
33
34/// Who owns the concrete instance and what cleanup may touch. Operational,
35/// not a security label.
36#[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    /// Whether tearing down one branch instance may touch the concrete
48    /// resource. `project` is shared by the project's instances and `user`
49    /// is shared beyond it, so per-branch teardown must leave both alone —
50    /// this is the conservative half of the ownership table, and the reason
51    /// a pnpm store survives `newgit remove`.
52    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    /// Signal sent by `stop` for a long-running sibling `start`.
89    #[serde(default)]
90    pub signal: Option<String>,
91    /// Names to read out of the command's stdout and merge into this
92    /// resource's binding exports — how a resource that mints an external
93    /// handle (a preview id, a tunnel URL) publishes it. See
94    /// [`parse_captures`] for the accepted output shapes.
95    #[serde(default)]
96    pub captures: Vec<String>,
97}
98
99/// How a resource tears its concrete instance down. Ownership decides
100/// whether the hook may run at all; this decides what running it means.
101#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
102pub struct CleanupSpec {
103    /// May use `{{state_ref}}` (from the instance's latest checkpoint) and
104    /// `{{exports.<name>}}` (from the binding).
105    pub command: Option<String>,
106}
107
108/// How a resource captures branch-local state at checkpoint time.
109#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
110pub struct CheckpointSpec {
111    pub mode: CheckpointMode,
112    /// `hash`: identity files whose content hash is the captured state.
113    #[serde(default)]
114    pub paths: Vec<Utf8PathBuf>,
115    /// `command`: emits the state; trimmed stdout becomes the state ref.
116    #[serde(default)]
117    pub command: Option<String>,
118    /// `command`: deposit `{{snapshot.path}}` into this tracker's lane.
119    #[serde(default)]
120    pub into_tracker: Option<String>,
121    /// `external`: template for the opaque ref (may use `{{exports.<name>}}`).
122    #[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/// How a resource re-establishes checkpointed state during undo.
136#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
137pub struct RestoreSpec {
138    pub mode: RestoreMode,
139    /// `command`: may use `{{state_ref}}`.
140    #[serde(default)]
141    pub command: Option<String>,
142    /// `recompute`: the action to re-run (default `prepare`).
143    #[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    /// The action a `recompute` restore re-runs.
158    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    /// The action `stop` signals, resolved: explicit `signal`, default TERM.
223    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            // A render target is workspace-relative. v1 does not render into
300            // user-level or system config: the skip-worktree and
301            // reverse-on-capture story only holds inside a workspace.
302            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/// What an action's stdout yielded against the names it declared.
340#[derive(Debug, Clone, Default, PartialEq, Eq)]
341pub struct Captures {
342    pub found: BTreeMap<String, String>,
343    /// Declared names that stdout did not contain. Reported rather than
344    /// silently dropped: a capture that never appears is almost always a bug
345    /// in the command — most often noisy output on stdout, which belongs to
346    /// newgit when `captures` is set — and the resource is otherwise marked
347    /// ready with an empty handle nobody notices until something 401s.
348    pub missing: Vec<String>,
349}
350
351/// Read an action's declared `captures` out of its stdout.
352///
353/// Two shapes are accepted, because both are what a real command already
354/// emits: stdout whose first non-whitespace character is `{` is parsed as a
355/// flat JSON object (`cloudctl ... --json`), and anything else is read as
356/// `KEY=VALUE` lines (`echo PREVIEW_ID=pv_9`). Only declared names are
357/// taken and JSON scalars are stringified. A name the command did not emit
358/// is not an error — a resource may legitimately publish a handle only on
359/// some runs — but it is always reported in `missing`.
360pub 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
397/// JSON scalars render as themselves; containers have no obvious env-var
398/// spelling, so they are skipped rather than guessed at.
399fn 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/// A dependency graph that does not hold together. Reported rather than
409/// raised, because the commands that build the graph are the ones most likely
410/// to run while it is still incomplete.
411#[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    /// The error a graph-acting command raises when it meets this problem.
422    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
442/// Order resources so dependencies come before dependents. Dependencies may
443/// name trackers (which only need to exist) or other resources.
444///
445/// Never fails: an unresolvable dependency is skipped and reported, so a
446/// half-built graph still loads. Commands that act on the graph must check the
447/// reported problems first; commands that build it may proceed and warn.
448pub 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                // Report the back-edge and stop descending; the resource is
469                // already on the stack and will still be ordered by its caller.
470                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            // Caller verified membership; only reachable for dependencies.
479            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
529/// [`resolve_order`] for callers that require a whole graph.
530pub 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        // Non-string scalars stringify; unparseable output captures nothing.
591        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        // Nothing declared means nothing wanted, so nothing is missing either.
603        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}