use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use crate::error::{NewgitError, Result};
use crate::render::RenderSpec;
#[derive(Debug, Clone, PartialEq)]
pub struct ResourceDefinition {
pub name: String,
pub kind: String,
pub ownership: Ownership,
pub depends_on: Vec<String>,
pub identity: Option<IdentitySpec>,
pub ports: BTreeMap<String, PortRequest>,
pub exports: BTreeMap<String, String>,
pub render: Vec<RenderSpec>,
pub actions: BTreeMap<String, ActionSpec>,
pub checkpoint: Option<CheckpointSpec>,
pub restore: Option<RestoreSpec>,
pub cleanup: Option<CleanupSpec>,
pub definition_rev: String,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Ownership {
Branch,
Workspace,
Project,
User,
External,
}
impl Ownership {
pub fn per_branch_teardown_may_touch(self) -> bool {
match self {
Self::Branch | Self::Workspace | Self::External => true,
Self::Project | Self::User => false,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Branch => "branch",
Self::Workspace => "workspace",
Self::Project => "project",
Self::User => "user",
Self::External => "external",
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct IdentitySpec {
pub paths: Vec<Utf8PathBuf>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct PortRequest {
pub start: u16,
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ActionSpec {
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub long_running: bool,
#[serde(default)]
pub signal: Option<String>,
#[serde(default)]
pub captures: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CleanupSpec {
pub command: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CheckpointSpec {
pub mode: CheckpointMode,
#[serde(default)]
pub paths: Vec<Utf8PathBuf>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub into_tracker: Option<String>,
#[serde(default)]
pub state_ref: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CheckpointMode {
None,
Hash,
Command,
External,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct RestoreSpec {
pub mode: RestoreMode,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub action: Option<String>,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RestoreMode {
None,
Command,
Recompute,
External,
}
impl RestoreSpec {
pub fn recompute_action(&self) -> &str {
self.action.as_deref().unwrap_or("prepare")
}
}
#[derive(Debug, Deserialize)]
struct ResourceDefinitionFile {
kind: String,
ownership: Ownership,
#[serde(default)]
depends_on: Vec<String>,
#[serde(default)]
identity: Option<IdentitySpec>,
#[serde(default)]
ports: BTreeMap<String, PortRequest>,
#[serde(default)]
exports: BTreeMap<String, String>,
#[serde(default)]
render: Vec<RenderSpec>,
#[serde(default)]
actions: BTreeMap<String, ActionSpec>,
#[serde(default)]
checkpoint: Option<CheckpointSpec>,
#[serde(default)]
restore: Option<RestoreSpec>,
#[serde(default)]
cleanup: Option<CleanupSpec>,
}
impl ResourceDefinition {
pub fn from_file(name: &str, path: &Utf8Path) -> Result<Self> {
let contents =
std::fs::read_to_string(path).map_err(|source| NewgitError::io(path, source))?;
let file: ResourceDefinitionFile =
toml::from_str(&contents).map_err(|source| NewgitError::TomlRead {
path: path.to_path_buf(),
source,
})?;
let digest = Sha256::digest(contents.as_bytes());
let hex: String = digest[..6]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let definition = Self {
name: name.to_owned(),
kind: file.kind,
ownership: file.ownership,
depends_on: file.depends_on,
identity: file.identity,
ports: file.ports,
exports: file.exports,
render: file.render,
actions: file.actions,
checkpoint: file.checkpoint,
restore: file.restore,
cleanup: file.cleanup,
definition_rev: format!("sha256:{hex}"),
};
definition.validate()?;
Ok(definition)
}
pub fn stop_signal(&self) -> String {
self.actions
.get("stop")
.and_then(|action| action.signal.clone())
.unwrap_or_else(|| "term".to_owned())
}
pub fn has_long_running_action(&self) -> bool {
self.actions.values().any(|action| action.long_running)
}
fn validate(&self) -> Result<()> {
if let Some(checkpoint) = &self.checkpoint {
match checkpoint.mode {
CheckpointMode::None => {}
CheckpointMode::Hash => {
if checkpoint.paths.is_empty() {
return Err(
self.invalid("checkpoint mode `hash` requires `paths`".to_owned())
);
}
}
CheckpointMode::Command => {
if checkpoint.command.is_none() {
return Err(
self.invalid("checkpoint mode `command` requires `command`".to_owned())
);
}
}
CheckpointMode::External => {
if checkpoint.state_ref.is_none() {
return Err(self.invalid(
"checkpoint mode `external` requires `state_ref`".to_owned(),
));
}
}
}
}
if let Some(restore) = &self.restore {
match restore.mode {
RestoreMode::Command if restore.command.is_none() => {
return Err(
self.invalid("restore mode `command` requires `command`".to_owned())
);
}
RestoreMode::Recompute
if !self.actions.contains_key(restore.recompute_action()) =>
{
return Err(self.invalid(format!(
"restore mode `recompute` re-runs action `{}`, which is not defined",
restore.recompute_action()
)));
}
_ => {}
}
}
for (action_name, action) in &self.actions {
let is_signal_only = action.command.is_none() && action.signal.is_some();
if action.command.is_none() && !is_signal_only {
return Err(self.invalid(format!(
"action `{action_name}` has neither a command nor a signal"
)));
}
if action.long_running && action.command.is_none() {
return Err(self.invalid(format!(
"action `{action_name}` is long_running but has no command"
)));
}
}
for spec in &self.render {
if spec.replace.is_empty() {
return Err(self.invalid(format!(
"render into `{}` declares no replacements",
spec.path
)));
}
if spec.path.is_absolute()
|| spec
.path
.components()
.any(|part| part.as_str() == ".." || part.as_str() == ".newgit")
{
return Err(self.invalid(format!(
"render path `{}` must be workspace-relative and outside `.newgit/`",
spec.path
)));
}
for replacement in &spec.replace {
if replacement.find.is_empty() {
return Err(
self.invalid(format!("render into `{}` has an empty `find`", spec.path))
);
}
if replacement.count == 0 {
return Err(self.invalid(format!(
"render into `{}` declares `count = 0` for `{}`; a replacement that \
matches nothing is a definition that does nothing",
spec.path, replacement.find
)));
}
}
}
Ok(())
}
fn invalid(&self, reason: String) -> NewgitError {
NewgitError::InvalidDefinition {
tracker: self.name.clone(),
reason,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Captures {
pub found: BTreeMap<String, String>,
pub missing: Vec<String>,
}
pub fn parse_captures(stdout: &str, wanted: &[String]) -> Captures {
if wanted.is_empty() {
return Captures::default();
}
let trimmed = stdout.trim_start();
let mut seen: BTreeMap<String, String> = BTreeMap::new();
if trimmed.starts_with('{') {
if let Ok(serde_json::Value::Object(object)) =
serde_json::from_str::<serde_json::Value>(trimmed)
{
for (key, value) in object {
if let Some(text) = json_scalar(&value) {
seen.insert(key, text);
}
}
}
} else {
for line in stdout.lines() {
if let Some((key, value)) = line.split_once('=') {
seen.insert(key.trim().to_owned(), value.trim().to_owned());
}
}
}
let mut captures = Captures::default();
for name in wanted {
match seen.remove_entry(name) {
Some((key, value)) => {
captures.found.insert(key, value);
}
None => captures.missing.push(name.clone()),
}
}
captures
}
fn json_scalar(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(text) => Some(text.clone()),
serde_json::Value::Number(number) => Some(number.to_string()),
serde_json::Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GraphProblem {
MissingDependency {
resource: String,
dependency: String,
},
Cycle(Vec<String>),
}
impl GraphProblem {
pub fn into_error(self) -> NewgitError {
match self {
Self::MissingDependency {
resource,
dependency,
} => NewgitError::MissingDependency {
resource,
dependency,
},
Self::Cycle(stack) => NewgitError::DependencyCycle(stack),
}
}
}
impl fmt::Display for GraphProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.clone().into_error())
}
}
pub fn resolve_order(
resources: &[ResourceDefinition],
tracker_names: &BTreeSet<String>,
) -> (Vec<String>, Vec<GraphProblem>) {
let mut ordered = Vec::new();
let mut problems = Vec::new();
let mut state: BTreeMap<&str, Visit> = BTreeMap::new();
fn visit<'a>(
name: &'a str,
resources: &'a [ResourceDefinition],
tracker_names: &BTreeSet<String>,
state: &mut BTreeMap<&'a str, Visit>,
ordered: &mut Vec<String>,
problems: &mut Vec<GraphProblem>,
stack: &mut Vec<String>,
) {
match state.get(name) {
Some(Visit::Done) => return,
Some(Visit::InProgress) => {
let mut cycle = stack.clone();
cycle.push(name.to_owned());
problems.push(GraphProblem::Cycle(cycle));
return;
}
None => {}
}
let Some(resource) = resources.iter().find(|r| r.name == name) else {
return;
};
state.insert(&resource.name, Visit::InProgress);
stack.push(name.to_owned());
for dependency in &resource.depends_on {
if tracker_names.contains(dependency) {
continue;
}
if !resources.iter().any(|r| &r.name == dependency) {
problems.push(GraphProblem::MissingDependency {
resource: resource.name.clone(),
dependency: dependency.clone(),
});
continue;
}
visit(
dependency,
resources,
tracker_names,
state,
ordered,
problems,
stack,
);
}
stack.pop();
state.insert(&resource.name, Visit::Done);
ordered.push(resource.name.clone());
}
#[derive(Clone, Copy)]
enum Visit {
InProgress,
Done,
}
for resource in resources {
visit(
&resource.name,
resources,
tracker_names,
&mut state,
&mut ordered,
&mut problems,
&mut Vec::new(),
);
}
(ordered, problems)
}
pub fn topological_order(
resources: &[ResourceDefinition],
tracker_names: &BTreeSet<String>,
) -> Result<Vec<String>> {
let (ordered, problems) = resolve_order(resources, tracker_names);
match problems.into_iter().next() {
Some(problem) => Err(problem.into_error()),
None => Ok(ordered),
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
fn resource(name: &str, deps: &[&str]) -> ResourceDefinition {
ResourceDefinition {
name: name.to_owned(),
kind: "command".to_owned(),
ownership: Ownership::Branch,
depends_on: deps.iter().map(ToString::to_string).collect(),
identity: None,
ports: BTreeMap::new(),
exports: BTreeMap::new(),
render: Vec::new(),
actions: BTreeMap::new(),
checkpoint: None,
restore: None,
cleanup: None,
definition_rev: "sha256:000000000000".to_owned(),
}
}
#[test]
fn captures_read_json_objects_and_key_value_lines() {
let wanted = ["PREVIEW_ID".to_owned(), "PREVIEW_URL".to_owned()];
let json = parse_captures(
r#"{"PREVIEW_ID": "pv_9", "PREVIEW_URL": "https://pv9.example", "extra": 1}"#,
&wanted,
);
assert_eq!(json.found["PREVIEW_ID"], "pv_9");
assert_eq!(json.found["PREVIEW_URL"], "https://pv9.example");
assert_eq!(json.found.len(), 2, "undeclared keys are not captured");
assert!(json.missing.is_empty());
let lines = parse_captures("noise\nPREVIEW_ID=pv_9\n", &wanted);
assert_eq!(lines.found["PREVIEW_ID"], "pv_9");
assert!(
!lines.found.contains_key("PREVIEW_URL"),
"a name the command did not emit is absent, not empty"
);
assert_eq!(
lines.missing,
vec!["PREVIEW_URL".to_owned()],
"and it is reported, not silently dropped"
);
assert_eq!(
parse_captures(r#"{"PORT": 5432}"#, &["PORT".to_owned()]).found["PORT"],
"5432"
);
let unparseable = parse_captures("{not json", &wanted);
assert!(unparseable.found.is_empty());
assert_eq!(
unparseable.missing, wanted,
"every declared name is missing"
);
let undeclared = parse_captures("PREVIEW_ID=pv_9", &[]);
assert!(undeclared.found.is_empty() && undeclared.missing.is_empty());
}
#[test]
fn orders_dependencies_first_and_detects_cycles() {
let trackers = BTreeSet::from(["runtime-env".to_owned()]);
let resources = vec![
resource("app", &["deps", "runtime-env"]),
resource("deps", &[]),
];
let order = topological_order(&resources, &trackers).expect("order");
assert_eq!(order, vec!["deps".to_owned(), "app".to_owned()]);
let cyclic = vec![resource("a", &["b"]), resource("b", &["a"])];
assert!(matches!(
topological_order(&cyclic, &trackers),
Err(NewgitError::DependencyCycle(_))
));
let missing = vec![resource("app", &["nope"])];
assert!(matches!(
topological_order(&missing, &trackers),
Err(NewgitError::MissingDependency { .. })
));
}
}