Skip to main content

artifactize_core/store/
snapshots.rs

1//! Saved review and validation snapshots, parsed once at the store boundary.
2//! Missing and null saved fields stay distinct; owner extension fields remain JSON.
3
4use crate::{
5    config::Field,
6    types::{ArtifactName, EvalId, Fingerprint, RequestStatus},
7};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::{collections::BTreeMap, path::PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct ChildIdentity {
15    pub pid: crate::process::ProcessId,
16    pub start_time: u64,
17}
18impl From<crate::process::ChildIdentity> for ChildIdentity {
19    fn from(child: crate::process::ChildIdentity) -> Self {
20        Self {
21            pid: child.pid,
22            start_time: child.start_time,
23        }
24    }
25}
26
27/// A dependency blocker is either an Artifact or a workspace-qualified Eval.
28/// Its saved representation remains the original untagged string.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(try_from = "String", into = "String")]
31pub enum Blocker {
32    Artifact(ArtifactName),
33    Eval(EvalId),
34}
35impl TryFrom<String> for Blocker {
36    type Error = String;
37    fn try_from(value: String) -> Result<Self, String> {
38        if value.contains('/') {
39            value.parse().map(Self::Eval)
40        } else {
41            value.parse().map(Self::Artifact)
42        }
43    }
44}
45impl From<Blocker> for String {
46    fn from(value: Blocker) -> String {
47        value.to_string()
48    }
49}
50impl std::fmt::Display for Blocker {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Artifact(id) => id.fmt(f),
54            Self::Eval(id) => id.fmt(f),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct HumanDefinition {
61    #[serde(
62        default,
63        skip_serializing_if = "Field::missing",
64        with = "super::definitions::path_field"
65    )]
66    pub repo: Field<PathBuf>,
67    pub eval: HumanEval,
68    #[serde(default, skip_serializing_if = "Field::missing")]
69    pub artifacts: Field<BTreeMap<ArtifactName, super::definitions::Artifact>>,
70    #[serde(flatten)]
71    pub extra: Map<String, Value>,
72}
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct HumanEval {
75    #[serde(default, skip_serializing_if = "Field::missing")]
76    pub id: Field<EvalId>,
77    #[serde(default, skip_serializing_if = "Field::missing")]
78    pub target: Field<ArtifactName>,
79    #[serde(default, skip_serializing_if = "Field::missing")]
80    pub references: Field<BTreeMap<crate::config::ReferenceName, ArtifactName>>,
81    #[serde(default, skip_serializing_if = "Field::missing")]
82    pub deps: Field<Vec<ArtifactName>>,
83    #[serde(default, skip_serializing_if = "Field::missing")]
84    pub declaration: Field<super::definitions::Declaration>,
85    #[serde(flatten)]
86    pub extra: Map<String, Value>,
87}
88impl PartialEq for HumanDefinition {
89    fn eq(&self, other: &Self) -> bool {
90        // Owner JSON schemas are structural values; compare their preserved saved forms.
91        serde_json::to_value(self).expect("Human definition is JSON")
92            == serde_json::to_value(other).expect("Human definition is JSON")
93    }
94}
95
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97#[serde(transparent)]
98pub struct Validation(pub Option<ValidationSnapshot>);
99impl Validation {
100    pub fn snapshot(&self) -> Option<&ValidationSnapshot> {
101        self.0.as_ref()
102    }
103}
104
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct ValidationSnapshot {
108    #[serde(default, skip_serializing_if = "Field::missing")]
109    pub selection: Field<super::definitions::SavedSelection>,
110    #[serde(default, skip_serializing_if = "Field::missing")]
111    pub recursive: Field<bool>,
112    #[serde(default, skip_serializing_if = "Field::missing")]
113    pub force: Field<bool>,
114    #[serde(default, skip_serializing_if = "Field::missing")]
115    pub ignore_gates: Field<bool>,
116    #[serde(default, skip_serializing_if = "Field::missing")]
117    pub selected_eval_ids: Field<Vec<EvalId>>,
118    #[serde(default, skip_serializing_if = "Field::missing")]
119    pub included_eval_ids: Field<Vec<EvalId>>,
120    #[serde(default, skip_serializing_if = "Field::missing")]
121    pub satisfied: Field<bool>,
122    #[serde(default, skip_serializing_if = "Field::missing")]
123    pub obligations: Field<Vec<ArtifactName>>,
124    #[serde(default, skip_serializing_if = "Field::missing")]
125    pub artifacts: Field<Vec<ArtifactValidation>>,
126    #[serde(default, skip_serializing_if = "Field::missing")]
127    pub evals: Field<Vec<EvalValidation>>,
128    #[serde(flatten)]
129    pub extra: Map<String, Value>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct ArtifactValidation {
134    pub id: ArtifactName,
135    #[serde(default, skip_serializing_if = "Field::missing")]
136    pub status: Field<crate::types::ArtifactValidationStatus>,
137    #[serde(default, skip_serializing_if = "Field::missing")]
138    pub passed: Field<u64>,
139    #[serde(default, skip_serializing_if = "Field::missing")]
140    pub total: Field<u64>,
141    #[serde(default, skip_serializing_if = "Field::missing")]
142    pub satisfied: Field<bool>,
143    #[serde(
144        default,
145        rename = "fingerprintKind",
146        skip_serializing_if = "Field::missing"
147    )]
148    pub fingerprint_kind: Field<crate::types::FingerprintKind>,
149    #[serde(default, skip_serializing_if = "Field::missing")]
150    pub fingerprint: Field<Fingerprint>,
151    #[serde(flatten)]
152    pub extra: Map<String, Value>,
153}
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct EvalValidation {
157    pub id: EvalId,
158    #[serde(default, skip_serializing_if = "Field::missing")]
159    pub status: Field<RequestStatus>,
160    #[serde(default, skip_serializing_if = "Field::missing")]
161    pub blocked_by: Field<Vec<Blocker>>,
162    #[serde(flatten)]
163    pub extra: Map<String, Value>,
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde_json::json;
170
171    #[test]
172    fn human_snapshot_preserves_partial_legacy_and_owner_extension_fields() {
173        for value in [
174            json!({"eval":{"declaration":{"failSchema":{"type":"object"}}}}),
175            json!({"repo":"saved/workspace","eval":{"id":"app/review","target":"app","references":{"spec":"spec"},"deps":["spec"],"declaration":{"payload":{"instruction":"Review.","owner":{"v":1}},"passSchema":{"required":["approved"]},"owner":[1,2]}},"artifacts":{"app":{"path":"app","views":{"humanTools":{}},"extension":true}},"extension":{"v":2}}),
176        ] {
177            let saved: HumanDefinition = serde_json::from_value(value.clone()).unwrap();
178            assert_eq!(serde_json::to_value(saved).unwrap(), value);
179        }
180        assert!(
181            serde_json::from_value::<HumanDefinition>(json!({"eval":{"id":"not-qualified"}}))
182                .is_err()
183        );
184        let child: ChildIdentity = serde_json::from_value(json!({"pid":7,"startTime":9})).unwrap();
185        assert_eq!(
186            serde_json::to_value(child).unwrap(),
187            json!({"pid":7,"startTime":9})
188        );
189    }
190    #[test]
191    fn validation_preserves_legacy_null_missing_and_extension_fields() {
192        for value in [
193            Value::Null,
194            json!({}),
195            json!({"satisfied":null,"extension":{"owner":true}}),
196            json!({"satisfied":false,"artifacts":[{"id":"app","status":"STALE","fingerprintKind":"script","fingerprint":"v1"}],"evals":[{"id":"app/test","status":"GREEN","blockedBy":["dep","dep/test"]}]}),
197        ] {
198            let typed: Validation = serde_json::from_value(value.clone()).unwrap();
199            assert_eq!(serde_json::to_value(typed).unwrap(), value);
200        }
201        assert!(serde_json::from_value::<Validation>(json!({"evals":[{"id":"bad"}]})).is_err());
202        assert!(serde_json::from_value::<Blocker>(json!("../escape")).is_err());
203        assert_eq!(
204            serde_json::to_value(Blocker::try_from("app/test".to_owned()).unwrap()).unwrap(),
205            json!("app/test")
206        );
207    }
208}