Skip to main content

artifactize_core/
graph_structure.rs

1//! Name-based project structure shared by the graph fingerprint and both graph tools.
2//! Paths locate declarations, never identify graph entities. Extraction is flat, so Artifact
3//! cycles need no recursive traversal and the graph Artifact includes itself without recursion.
4use std::{collections::BTreeMap, fmt::Write, path::Path};
5
6use serde::Serialize;
7use serde_json::{Map, Value};
8use sha2::{Digest, Sha256};
9
10use crate::{
11    config::{ArtifactKind, EvalPayload, Fingerprint, Profile, RepoConfig},
12    scope::{Argument, RelationKind},
13    types::{ArtifactName, EvalId},
14};
15
16/// Domain separation: changing the extraction contract invalidates old graph fingerprints.
17const FORMAT: &[u8] = b"artifactize-graph-v1\n";
18
19#[derive(Debug, Serialize)]
20#[serde(rename_all = "camelCase")]
21pub struct Structure {
22    pub artifacts: BTreeMap<ArtifactName, Artifact>,
23    pub evals: BTreeMap<EvalId, Eval>,
24    pub relations: Vec<Relation>,
25}
26
27#[derive(Debug, Serialize)]
28pub struct Artifact {
29    pub kind: ArtifactKind,
30    pub basis: Option<bool>,
31    pub fingerprint: Coverage,
32}
33
34#[derive(Debug, Serialize)]
35#[serde(tag = "kind", rename_all = "lowercase")]
36pub enum Coverage {
37    Graph,
38    Disabled,
39    Artifactsum {
40        files: Vec<crate::config::LogicalPath>,
41        ignore: Vec<String>,
42    },
43    Script {
44        command: String,
45        args: Vec<Argument>,
46        files: Vec<crate::config::LogicalPath>,
47    },
48}
49
50#[derive(Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Eval {
53    pub target: ArtifactName,
54    pub title: String,
55    pub gate: Option<crate::config::Gate>,
56    pub criteria: Criteria,
57    pub payload: Option<EvalPayload>,
58    pub pass_schema: Option<Map<String, Value>>,
59    pub fail_schema: Option<Map<String, Value>>,
60}
61
62/// One explicit extraction per profile: execution options never enter this type.
63#[derive(Debug, Serialize)]
64#[serde(tag = "kind", rename_all = "lowercase")]
65pub enum Criteria {
66    Agent,
67    Human,
68    Runtime {
69        command: String,
70        args: Vec<Argument>,
71    },
72}
73
74#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
75pub struct Relation {
76    pub source: ArtifactName,
77    pub target: ArtifactName,
78    #[serde(flatten)]
79    pub kind: Edge,
80}
81
82#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
83#[serde(tag = "kind", rename_all = "lowercase")]
84pub enum Edge {
85    Child,
86    Instruction {
87        eval_id: EvalId,
88        name: crate::config::ReferenceName,
89    },
90    Argv {
91        eval_id: EvalId,
92        index: usize,
93        name: crate::config::ReferenceName,
94        path: crate::scope::LogicalTarget,
95    },
96}
97
98pub fn extract(config: &RepoConfig) -> Structure {
99    let artifacts = config
100        .artifacts
101        .iter()
102        .map(|(name, artifact)| {
103            let fingerprint = match &artifact.fingerprint {
104                Some(Fingerprint::Graph) => Coverage::Graph,
105                None => Coverage::Disabled,
106                Some(Fingerprint::Artifactsum { files, ignore }) => Coverage::Artifactsum {
107                    files: files.clone(),
108                    ignore: ignore.clone(),
109                },
110                Some(Fingerprint::Script {
111                    command,
112                    args,
113                    files,
114                    ..
115                }) => Coverage::Script {
116                    command: command.clone(),
117                    args: args.clone(),
118                    files: files.clone(),
119                },
120            };
121            (
122                name.clone(),
123                Artifact {
124                    kind: artifact.kind,
125                    basis: artifact.basis,
126                    fingerprint,
127                },
128            )
129        })
130        .collect();
131    let evals = config
132        .evals
133        .iter()
134        .map(|eval| {
135            let declaration = &eval.declaration;
136            let criteria = match declaration.profile() {
137                Profile::Agent { .. } => Criteria::Agent,
138                Profile::Human {} => Criteria::Human,
139                Profile::Runtime { command, args, .. } => Criteria::Runtime {
140                    command: command.clone(),
141                    args: args.clone(),
142                },
143            };
144            (
145                eval.id.clone(),
146                Eval {
147                    target: eval.target.clone(),
148                    title: declaration.title.clone(),
149                    gate: declaration.gate,
150                    criteria,
151                    payload: declaration.payload().cloned(),
152                    pass_schema: declaration.pass_schema().cloned(),
153                    fail_schema: declaration.fail_schema().cloned(),
154                },
155            )
156        })
157        .collect();
158    let mut relations: Vec<_> = config
159        .relations
160        .iter()
161        .map(|relation| Relation {
162            source: relation.source.clone(),
163            target: relation.target.clone(),
164            kind: match &relation.kind {
165                RelationKind::Child { .. } => Edge::Child,
166                RelationKind::Instruction { eval_id, name } => Edge::Instruction {
167                    eval_id: eval_id.clone(),
168                    name: name.clone(),
169                },
170                RelationKind::Argument {
171                    eval_id,
172                    index,
173                    name,
174                    path,
175                } => Edge::Argv {
176                    eval_id: eval_id.clone(),
177                    index: *index,
178                    name: name.clone(),
179                    path: path.clone(),
180                },
181            },
182        })
183        .collect();
184    relations.sort();
185    Structure {
186        artifacts,
187        evals,
188        relations,
189    }
190}
191
192/// Reload declarations, not file contents: tools and end-of-review rechecks see current state.
193pub fn read(repo: &Path) -> Result<Structure, String> {
194    crate::config::read_workspace_declarations(repo)
195        .map(|config| extract(&config))
196        .map_err(|error| error.to_string())
197}
198
199impl Structure {
200    pub fn fingerprint(&self) -> crate::types::Fingerprint {
201        let mut value = serde_json::to_value(self).expect("graph structure is JSON");
202        value.sort_all_objects();
203        let mut digest = Sha256::new();
204        digest.update(FORMAT);
205        digest.update(serde_json::to_vec(&value).expect("graph structure is JSON"));
206        let hex: String = digest
207            .finalize()
208            .iter()
209            .map(|byte| format!("{byte:02x}"))
210            .collect();
211        format!("graph:{hex}")
212            .parse()
213            .expect("graph fingerprint is nonblank")
214    }
215
216    /// A flat, cycle-safe entity tree; arrows always run from input to consumer.
217    /// The criteria JSON exposes exactly the data hashed, not a separate graph projection.
218    pub fn tree(&self) -> String {
219        let mut text = format!("Artifact graph {}\n", self.fingerprint());
220        for (name, artifact) in &self.artifacts {
221            writeln!(text, "{name} [{:?}]", artifact.kind).expect("write to String");
222            writeln!(
223                text,
224                "  definition: {}",
225                serde_json::to_string(artifact).expect("Artifact is JSON")
226            )
227            .expect("write to String");
228            for (id, eval) in self.evals.iter().filter(|(_, eval)| &eval.target == name) {
229                writeln!(text, "  ├─ {id}: {}", eval.title).expect("write to String");
230                writeln!(
231                    text,
232                    "  │  criteria: {}",
233                    serde_json::to_string(eval).expect("Eval is JSON")
234                )
235                .expect("write to String");
236            }
237        }
238        for relation in &self.relations {
239            writeln!(
240                text,
241                "{} → {} {}",
242                relation.source,
243                relation.target,
244                serde_json::to_string(&relation.kind).expect("relation is JSON")
245            )
246            .expect("write to String");
247        }
248        text
249    }
250}
251
252/// The existing desktop application's detached launch path, with a current-project graph
253/// screen rather than a historical Run. No shell or second renderer is involved.
254/// Open the graph viewer on `repo`; its output reaches `run`'s Tool log until it exits. A
255/// failure hands the run back to be ended.
256pub(crate) fn launch(
257    repo: &Path,
258    run: crate::tools::human::log::Run,
259) -> Result<(), (String, crate::tools::human::log::Run)> {
260    // Validate the same structure before handing it to the desktop viewer.
261    if let Err(error) = read(repo) {
262        return Err((error, run));
263    }
264    let program = match crate::platform::program::resolve(
265        std::ffi::OsStr::new("artifactize-desktop"),
266        repo,
267    ) {
268        Ok(program) => program,
269        Err(error) => return Err((format!("Cannot find artifactize-desktop: {error}"), run)),
270    };
271    crate::process::launch_watched(
272        program.as_os_str(),
273        &[
274            "graph".into(),
275            "--repo".into(),
276            crate::platform::path_text(repo),
277        ],
278        repo,
279        run,
280    )
281    .map_err(|(error, run)| (error.to_string(), run))
282}
283
284#[cfg(test)]
285mod tests;