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