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    Dependency {
69        depends_on: Vec<ArtifactName>,
70    },
71    Runtime {
72        command: String,
73        args: Vec<Argument>,
74    },
75}
76
77#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
78pub struct Relation {
79    pub source: ArtifactName,
80    pub target: ArtifactName,
81    #[serde(flatten)]
82    pub kind: Edge,
83}
84
85#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
86#[serde(tag = "kind", rename_all = "lowercase")]
87pub enum Edge {
88    Child,
89    Mount {
90        alias: crate::config::MountAlias,
91    },
92    Dependency {
93        eval_id: EvalId,
94        name: crate::config::ReferenceName,
95    },
96    Instruction {
97        eval_id: EvalId,
98        name: crate::config::ReferenceName,
99    },
100    Argv {
101        eval_id: EvalId,
102        index: usize,
103        name: crate::config::ReferenceName,
104        path: crate::scope::LogicalTarget,
105    },
106}
107
108pub fn extract(config: &RepoConfig) -> Structure {
109    let artifacts = config
110        .artifacts
111        .iter()
112        .map(|(name, artifact)| {
113            let fingerprint = match &artifact.fingerprint {
114                Some(Fingerprint::Graph) => Coverage::Graph,
115                None => Coverage::Disabled,
116                Some(Fingerprint::Artifactsum { files, ignore }) => Coverage::Artifactsum {
117                    files: files.clone(),
118                    ignore: ignore.clone(),
119                },
120                Some(Fingerprint::Script {
121                    command,
122                    args,
123                    files,
124                    ..
125                }) => Coverage::Script {
126                    command: command.clone(),
127                    args: args.clone(),
128                    files: files.clone(),
129                },
130            };
131            (
132                name.clone(),
133                Artifact {
134                    kind: artifact.kind,
135                    basis: artifact.basis,
136                    fingerprint,
137                },
138            )
139        })
140        .collect();
141    let evals = config
142        .evals
143        .iter()
144        .map(|eval| {
145            let declaration = &eval.declaration;
146            let criteria = match declaration.profile() {
147                Profile::Agent { .. } => Criteria::Agent,
148                Profile::Human {} => Criteria::Human,
149                Profile::Dependency { depends_on } => Criteria::Dependency {
150                    depends_on: depends_on.clone(),
151                },
152                Profile::Runtime { command, args, .. } => Criteria::Runtime {
153                    command: command.clone(),
154                    args: args.clone(),
155                },
156            };
157            (
158                eval.id.clone(),
159                Eval {
160                    target: eval.target.clone(),
161                    title: declaration.title.clone(),
162                    gate: declaration.gate,
163                    criteria,
164                    payload: declaration.payload().cloned(),
165                    pass_schema: declaration.pass_schema().cloned(),
166                    fail_schema: declaration.fail_schema().cloned(),
167                },
168            )
169        })
170        .collect();
171    let mut relations: Vec<_> = config
172        .relations
173        .iter()
174        .map(|relation| Relation {
175            source: relation.source.clone(),
176            target: relation.target.clone(),
177            kind: match &relation.kind {
178                RelationKind::Child { .. } => Edge::Child,
179                RelationKind::Mount { alias } => Edge::Mount {
180                    alias: alias.clone(),
181                },
182                RelationKind::Dependency { eval_id, name } => Edge::Dependency {
183                    eval_id: eval_id.clone(),
184                    name: name.clone(),
185                },
186                RelationKind::Instruction { eval_id, name } => Edge::Instruction {
187                    eval_id: eval_id.clone(),
188                    name: name.clone(),
189                },
190                RelationKind::Argument {
191                    eval_id,
192                    index,
193                    name,
194                    path,
195                } => Edge::Argv {
196                    eval_id: eval_id.clone(),
197                    index: *index,
198                    name: name.clone(),
199                    path: path.clone(),
200                },
201            },
202        })
203        .collect();
204    relations.sort();
205    Structure {
206        artifacts,
207        evals,
208        relations,
209    }
210}
211
212/// Reload declarations, not file contents: tools and end-of-review rechecks see current state.
213pub fn read(repo: &Path) -> Result<Structure, String> {
214    crate::config::read_workspace_declarations(repo)
215        .map(|config| extract(&config))
216        .map_err(|error| error.to_string())
217}
218
219impl Structure {
220    pub fn fingerprint(&self) -> crate::types::Fingerprint {
221        let mut value = serde_json::to_value(self).expect("graph structure is JSON");
222        value.sort_all_objects();
223        let mut digest = Sha256::new();
224        digest.update(FORMAT);
225        digest.update(serde_json::to_vec(&value).expect("graph structure is JSON"));
226        let hex: String = digest
227            .finalize()
228            .iter()
229            .map(|byte| format!("{byte:02x}"))
230            .collect();
231        format!("graph:{hex}")
232            .parse()
233            .expect("graph fingerprint is nonblank")
234    }
235
236    /// A flat, cycle-safe entity tree; arrows always run from input to consumer.
237    /// The criteria JSON exposes exactly the data hashed, not a separate graph projection.
238    pub fn tree(&self) -> String {
239        let mut text = format!("Artifact graph {}\n", self.fingerprint());
240        for (name, artifact) in &self.artifacts {
241            writeln!(text, "{name} [{:?}]", artifact.kind).expect("write to String");
242            writeln!(
243                text,
244                "  definition: {}",
245                serde_json::to_string(artifact).expect("Artifact is JSON")
246            )
247            .expect("write to String");
248            for (id, eval) in self.evals.iter().filter(|(_, eval)| &eval.target == name) {
249                writeln!(text, "  ├─ {id}: {}", eval.title).expect("write to String");
250                writeln!(
251                    text,
252                    "  │  criteria: {}",
253                    serde_json::to_string(eval).expect("Eval is JSON")
254                )
255                .expect("write to String");
256            }
257        }
258        for relation in &self.relations {
259            writeln!(
260                text,
261                "{} → {} {}",
262                relation.source,
263                relation.target,
264                serde_json::to_string(&relation.kind).expect("relation is JSON")
265            )
266            .expect("write to String");
267        }
268        text
269    }
270}
271
272/// The existing desktop application's detached launch path, with a current-project graph
273/// screen rather than a historical Run. No shell or second renderer is involved.
274pub fn launch(repo: &Path) -> Result<(), String> {
275    // Validate the same structure before handing it to the desktop viewer.
276    read(repo)?;
277    let program =
278        crate::platform::program::resolve(std::ffi::OsStr::new("artifactize-desktop"), repo)
279            .map_err(|error| format!("Cannot find artifactize-desktop: {error}"))?;
280    crate::process::launch_detached(
281        program.as_os_str(),
282        &[
283            "graph".into(),
284            "--repo".into(),
285            crate::platform::path_text(repo),
286        ],
287        repo,
288    )
289    .map_err(|error| error.to_string())
290}
291
292#[cfg(test)]
293mod tests;