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