Skip to main content

artifactize_core/
config.rs

1//! Inert discovery and strict declarations.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8pub use crate::types::{ArtifactName, EvalId};
9mod identities;
10pub use identities::{
11    ChildPrefix, EndpointId, ExecutionPinName, HubEpoch, LogicalPath, ModelId, MountAlias,
12    ProfileVariantName, ReferenceName, ToolName, ToolOperationName,
13};
14use serde_json::{Map, Value};
15use thiserror::Error;
16
17mod discovery;
18mod location;
19pub use discovery::read_workspace_config;
20pub(crate) use discovery::read_workspace_declarations;
21use discovery::{logical_join, read_regular_text};
22mod plain;
23mod stored;
24mod tools;
25pub(crate) mod validation;
26pub use stored::{Field, StoredPayload, StoredProfile};
27
28pub use tools::{
29    AgentTool, Builtin, BuiltinTool, CommandTool, HumanBuiltinTool, HumanCommandTool, HumanTool,
30    HumanToolKind, SessionCommand, ToolProtocol, ToolSession,
31};
32
33use location::Location;
34pub(crate) use validation::identifier;
35use validation::{path, paths, positive_integer, present, script, tags, text, timeout};
36
37/// Format version for artifactize configuration documents.
38pub const CONFIG_FORMAT_VERSION: u32 = 1;
39pub const CONFIG_FILE: &str = "index.artf";
40/// Gitignore-style folder patterns, at the workspace root, that discovery skips.
41/// Bound named variants and declared input lists to keep discovery and preparation finite.
42const MAX_DECLARED_ITEMS: usize = 64;
43pub const IGNORE_FILE: &str = ".artfignore";
44
45#[derive(Debug, Error)]
46#[error("{}: {message}", crate::platform::path_text(.path))]
47pub struct ConfigError {
48    pub path: PathBuf,
49    pub message: String,
50}
51
52impl ConfigError {
53    pub(crate) fn new(path: impl Into<PathBuf>, message: impl ToString) -> Self {
54        Self {
55            path: path.into(),
56            message: message.to_string(),
57        }
58    }
59
60    /// Position cross-Artifact validation failures at their declaration's typed key.
61    pub(crate) fn declaration(
62        path: impl Into<PathBuf>,
63        keys: &[&str],
64        message: impl ToString,
65    ) -> Self {
66        let path = path.into();
67        let message = message.to_string();
68        let message = read_regular_text(&path).map_or(message.clone(), |source| {
69            location::error_at(&source, keys, &message)
70        });
71        Self { path, message }
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
76#[serde(rename_all = "lowercase")]
77pub enum Backend {
78    // Preserve lexicographic wire-name ordering in maps that used String keys.
79    Anthropic,
80    Codex,
81    Openai,
82}
83
84/// Backends that 0.5.0 removed; a declaration naming one fails with its replacements.
85const REMOVED_BACKENDS: [&str; 2] = ["chatgpt", "claude"];
86
87impl<'de> Deserialize<'de> for Backend {
88    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
89        use serde::de::Error;
90        let name = String::deserialize(deserializer)?;
91        match name.as_str() {
92            "openai" => Ok(Self::Openai),
93            "anthropic" => Ok(Self::Anthropic),
94            "codex" => Ok(Self::Codex),
95            removed if REMOVED_BACKENDS.contains(&removed) => Err(D::Error::custom(format!(
96                r#"backend "{removed}" was removed in 0.5.0; use "openai" or "anthropic" with an API key, or "codex" with a ChatGPT/Codex sign-in"#
97            ))),
98            other => Err(D::Error::unknown_variant(
99                other,
100                &["openai", "anthropic", "codex"],
101            )),
102        }
103    }
104}
105
106impl std::fmt::Display for Backend {
107    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        formatter.write_str(match self {
109            Self::Openai => "openai",
110            Self::Anthropic => "anthropic",
111            Self::Codex => "codex",
112        })
113    }
114}
115
116impl Backend {
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Self::Anthropic => "anthropic",
120            Self::Codex => "codex",
121            Self::Openai => "openai",
122        }
123    }
124    pub fn validate_reasoning(self, reasoning: &str) -> Result<(), String> {
125        let supported = match self {
126            // Codex takes the Responses efforts as they are, as Pi's openai-codex does.
127            Self::Openai | Self::Codex => matches!(
128                reasoning,
129                "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
130            ),
131            Self::Anthropic => matches!(reasoning, "low" | "medium" | "high" | "max"),
132        };
133        if supported {
134            Ok(())
135        } else {
136            Err(format!(
137                "Unsupported reasoning {reasoning:?} for {self:?}; effort is never remapped."
138            ))
139        }
140    }
141}
142
143/// Declared effort is exact: unsupported backend efforts fail rather than remap.
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "lowercase")]
146pub enum Reasoning {
147    None,
148    Minimal,
149    Low,
150    Medium,
151    High,
152    Xhigh,
153    Max,
154}
155impl Reasoning {
156    pub fn as_str(self) -> &'static str {
157        match self {
158            Self::None => "none",
159            Self::Minimal => "minimal",
160            Self::Low => "low",
161            Self::Medium => "medium",
162            Self::High => "high",
163            Self::Xhigh => "xhigh",
164            Self::Max => "max",
165        }
166    }
167}
168impl std::fmt::Display for Reasoning {
169    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        formatter.write_str(self.as_str())
171    }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)]
176pub enum Profile {
177    Agent {
178        backend: Backend,
179        model: ModelId,
180        #[serde(default, deserialize_with = "present")]
181        reasoning: Option<Reasoning>,
182        #[serde(
183            rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
184            default,
185            deserialize_with = "timeout",
186            serialize_with = "validation::milliseconds::serialize"
187        )]
188        timeout_ms: Option<std::time::Duration>,
189        #[serde(
190            rename(serialize = "maxToolCalls", deserialize = "max_tool_calls"),
191            default,
192            deserialize_with = "positive_integer"
193        )]
194        max_tool_calls: Option<u64>,
195        #[serde(
196            rename(serialize = "maxTokens", deserialize = "max_tokens"),
197            default,
198            deserialize_with = "positive_integer"
199        )]
200        max_tokens: Option<u64>,
201    },
202    Human {},
203    Dependency {
204        #[serde(rename(serialize = "dependsOn", deserialize = "depends_on"))]
205        depends_on: Vec<ArtifactName>,
206    },
207    Runtime {
208        command: String,
209        args: Vec<crate::scope::Argument>,
210        #[serde(
211            rename(serialize = "timeoutMs", deserialize = "timeout_ms"),
212            default,
213            deserialize_with = "timeout",
214            serialize_with = "validation::milliseconds::serialize"
215        )]
216        timeout_ms: Option<std::time::Duration>,
217    },
218}
219
220impl Profile {
221    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
222        match self {
223            Self::Agent {
224                backend,
225                model,
226                reasoning,
227                ..
228            } => {
229                location
230                    .child("model")
231                    .check(text(model, "Agent profile model"))?;
232                if let Some(reasoning) = reasoning {
233                    location
234                        .child("reasoning")
235                        .check(backend.validate_reasoning(reasoning.as_str()))?;
236                }
237                Ok(())
238            }
239            Self::Human {} => Ok(()),
240            Self::Dependency { depends_on } => {
241                if depends_on.is_empty()
242                    || depends_on.len() > MAX_DECLARED_ITEMS
243                    || depends_on.iter().collect::<BTreeSet<_>>().len() != depends_on.len()
244                {
245                    return Err(location.child("depends_on").error(
246                        "Dependency profile depends_on must contain 1–64 unique Artifact names.",
247                    ));
248                }
249                for name in depends_on {
250                    location
251                        .child("depends_on")
252                        .check(identifier(name, "Dependency Artifact name"))?;
253                }
254                Ok(())
255            }
256            Self::Runtime { command, args, .. } => location.check(script(command, args)),
257        }
258    }
259
260    pub fn kind(&self) -> ProfileKind {
261        match self {
262            Self::Agent { .. } => ProfileKind::Agent,
263            Self::Human {} => ProfileKind::Human,
264            Self::Dependency { .. } => ProfileKind::Dependency,
265            Self::Runtime { .. } => ProfileKind::Runtime,
266        }
267    }
268}
269
270/// The kind of reviewer a profile names, without its execution options.
271#[derive(
272    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum,
273)]
274#[serde(rename_all = "lowercase")]
275pub enum ProfileKind {
276    /// Runtime evals: a command's exit code.
277    Runtime,
278    /// Agent evals: a model's review.
279    Agent,
280    /// Human evals: a person's sign-off.
281    Human,
282    /// Dependency evals: derived from current required Artifact evidence.
283    Dependency,
284}
285
286impl ProfileKind {
287    pub fn name(self) -> &'static str {
288        match self {
289            Self::Runtime => "runtime",
290            Self::Agent => "agent",
291            Self::Human => "human",
292            Self::Dependency => "dependency",
293        }
294    }
295}
296
297/// The required instruction is typed at the declaration and saved-request boundaries;
298/// owner-defined context remains extensible JSON with the same object representation.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct EvalPayload {
301    pub instruction: String,
302    #[serde(flatten)]
303    pub extra: Map<String, Value>,
304}
305
306/// An Eval's id within its Artifact, such as `follows-style`: the key of its
307/// `[evals.<id>]` table, validated when the declaration is read.
308#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
309#[serde(transparent)]
310pub struct LocalEvalId(String);
311
312impl<'de> Deserialize<'de> for LocalEvalId {
313    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
314        String::deserialize(deserializer)?
315            .parse()
316            .map_err(serde::de::Error::custom)
317    }
318}
319
320impl std::str::FromStr for LocalEvalId {
321    type Err = String;
322    fn from_str(value: &str) -> Result<Self, Self::Err> {
323        identifier(value, "Eval id")?;
324        Ok(Self(value.to_owned()))
325    }
326}
327
328impl LocalEvalId {
329    pub fn as_str(&self) -> &str {
330        &self.0
331    }
332}
333
334impl PartialEq<&str> for LocalEvalId {
335    fn eq(&self, other: &&str) -> bool {
336        self.0 == *other
337    }
338}
339
340impl std::ops::Deref for LocalEvalId {
341    type Target = str;
342    fn deref(&self) -> &str {
343        &self.0
344    }
345}
346
347impl std::fmt::Display for LocalEvalId {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        self.0.fmt(f)
350    }
351}
352
353/// A Human eval may explicitly retain its strict gate even in deferred Runs.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355#[serde(rename_all = "snake_case")]
356pub enum Gate {
357    Blocking,
358}
359
360#[derive(Debug, Clone, Serialize)]
361#[serde(rename_all = "camelCase")]
362pub struct EvalDeclaration {
363    pub id: LocalEvalId,
364    pub title: String,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub gate: Option<Gate>,
367    #[serde(flatten)]
368    body: EvalBody,
369}
370
371/// Dependency declarations have no payload, schemas or execution variants.
372/// Private construction parses the cross-field invariant once at the configuration edge.
373#[derive(Debug, Clone)]
374enum EvalBody {
375    Dependency {
376        profile: Profile,
377    },
378    Executable {
379        profile: Profile,
380        profile_variants: BTreeMap<ProfileVariantName, Profile>,
381        payload: EvalPayload,
382        pass_schema: Option<Map<String, Value>>,
383        fail_schema: Option<Map<String, Value>>,
384    },
385}
386
387impl EvalDeclaration {
388    pub fn profile(&self) -> &Profile {
389        match &self.body {
390            EvalBody::Dependency { profile } | EvalBody::Executable { profile, .. } => profile,
391        }
392    }
393    pub fn payload(&self) -> Option<&EvalPayload> {
394        match &self.body {
395            EvalBody::Dependency { .. } => None,
396            EvalBody::Executable { payload, .. } => Some(payload),
397        }
398    }
399    pub fn profile_variants(&self) -> &BTreeMap<ProfileVariantName, Profile> {
400        static EMPTY: std::sync::OnceLock<BTreeMap<ProfileVariantName, Profile>> =
401            std::sync::OnceLock::new();
402        match &self.body {
403            EvalBody::Dependency { .. } => EMPTY.get_or_init(BTreeMap::new),
404            EvalBody::Executable {
405                profile_variants, ..
406            } => profile_variants,
407        }
408    }
409    pub fn pass_schema(&self) -> Option<&Map<String, Value>> {
410        match &self.body {
411            EvalBody::Dependency { .. } => None,
412            EvalBody::Executable { pass_schema, .. } => pass_schema.as_ref(),
413        }
414    }
415    pub fn fail_schema(&self) -> Option<&Map<String, Value>> {
416        match &self.body {
417            EvalBody::Dependency { .. } => None,
418            EvalBody::Executable { fail_schema, .. } => fail_schema.as_ref(),
419        }
420    }
421    #[cfg(test)]
422    pub(crate) fn profile_mut(&mut self) -> &mut Profile {
423        match &mut self.body {
424            EvalBody::Dependency { profile } | EvalBody::Executable { profile, .. } => profile,
425        }
426    }
427    #[cfg(test)]
428    pub(crate) fn pass_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
429        match &mut self.body {
430            EvalBody::Executable { pass_schema, .. } => pass_schema,
431            _ => panic!("dependency schema is impossible"),
432        }
433    }
434    #[cfg(test)]
435    pub(crate) fn fail_schema_mut(&mut self) -> &mut Option<Map<String, Value>> {
436        match &mut self.body {
437            EvalBody::Executable { fail_schema, .. } => fail_schema,
438            _ => panic!("dependency schema is impossible"),
439        }
440    }
441    /// Only a declared, same-kind executable variant can replace the active profile.
442    pub(crate) fn select_profile(&mut self, name: &ProfileVariantName) -> Result<(), String> {
443        let EvalBody::Executable {
444            profile,
445            profile_variants,
446            ..
447        } = &mut self.body
448        else {
449            return Err("Dependency Evals cannot select profile variants.".into());
450        };
451        *profile = profile_variants
452            .get(name)
453            .ok_or_else(|| format!("Unknown profile variant: {name}"))?
454            .clone();
455        Ok(())
456    }
457}
458
459#[derive(Deserialize)]
460#[serde(rename_all = "snake_case", deny_unknown_fields)]
461struct EvalFields {
462    title: String,
463    #[serde(default)]
464    gate: Option<Gate>,
465    profile: Profile,
466    #[serde(default, deserialize_with = "present")]
467    profile_variants: Option<BTreeMap<ProfileVariantName, Profile>>,
468    #[serde(default, deserialize_with = "present")]
469    payload: Option<EvalPayload>,
470    #[serde(default, deserialize_with = "present")]
471    pass_schema: Option<Map<String, Value>>,
472    #[serde(default, deserialize_with = "present")]
473    fail_schema: Option<Map<String, Value>>,
474}
475
476impl EvalDeclaration {
477    /// Check the fields of the `[evals.<id>]` table that every profile needs together.
478    fn new(id: LocalEvalId, fields: EvalFields) -> Result<Self, String> {
479        if matches!(fields.profile, Profile::Dependency { .. }) {
480            if fields.payload.is_some()
481                || fields.pass_schema.is_some()
482                || fields.fail_schema.is_some()
483                || fields.profile_variants.is_some()
484            {
485                return Err(
486                    "Dependency Evals cannot declare payload, pass_schema, fail_schema or profile_variants."
487                        .into(),
488                );
489            }
490        } else if fields.payload.is_none() {
491            return Err("Eval payload is required for runtime, agent and human profiles.".into());
492        }
493        let body = if matches!(fields.profile, Profile::Dependency { .. }) {
494            EvalBody::Dependency {
495                profile: fields.profile,
496            }
497        } else {
498            EvalBody::Executable {
499                profile: fields.profile,
500                profile_variants: fields.profile_variants.unwrap_or_default(),
501                payload: fields.payload.expect("executable payload checked above"),
502                pass_schema: fields.pass_schema,
503                fail_schema: fields.fail_schema,
504            }
505        };
506        Ok(Self {
507            id,
508            title: fields.title,
509            gate: fields.gate,
510            body,
511        })
512    }
513}
514
515impl EvalDeclaration {
516    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
517        location
518            .child("title")
519            .check(text(&self.title, "Eval title"))?;
520        if let Some(payload) = self.payload() {
521            location
522                .child("payload")
523                .child("instruction")
524                .check(text(&payload.instruction, "Eval payload.instruction"))?;
525        }
526        self.profile().validate(&location.child("profile"))?;
527        for (key, schema) in [
528            ("pass_schema", self.pass_schema()),
529            ("fail_schema", self.fail_schema()),
530        ] {
531            if let Some(schema) = schema {
532                location
533                    .child(key)
534                    .check(crate::agent::verdict::validate_schema(schema))?;
535            }
536        }
537        if self.profile_variants().len() > MAX_DECLARED_ITEMS {
538            return Err(location
539                .child("profile_variants")
540                .error("profile_variants must contain at most 64 named profiles."));
541        }
542        for (name, profile) in self.profile_variants() {
543            let variant = location.child("profile_variants").child(name);
544            location
545                .child("profile_variants")
546                .key(name)
547                .check(identifier(name, "Profile variant name"))?;
548            profile.validate(&variant)?;
549            if std::mem::discriminant(profile) != std::mem::discriminant(self.profile()) {
550                return Err(
551                    variant.error("Profile variants must retain the declared reviewer kind.")
552                );
553            }
554        }
555        Ok(())
556    }
557}
558
559#[derive(Debug, Clone, Default, Serialize, Deserialize)]
560#[serde(
561    rename_all(serialize = "camelCase", deserialize = "snake_case"),
562    deny_unknown_fields
563)]
564pub struct Views {
565    #[serde(default)]
566    pub agent_sessions: BTreeMap<ToolOperationName, ToolSession>,
567    #[serde(default)]
568    pub agent_tools: BTreeMap<ToolOperationName, AgentTool>,
569    #[serde(default)]
570    pub human_tools: BTreeMap<ToolOperationName, HumanTool>,
571}
572
573impl Views {
574    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
575        for (name, session) in &self.agent_sessions {
576            let sessions = location.child("agent_sessions");
577            sessions
578                .key(name)
579                .check(identifier(name, "Tool session name"))?;
580            sessions
581                .child(name)
582                .child("start")
583                .check(session.start.validate())?;
584            sessions
585                .child(name)
586                .child("stop")
587                .check(session.stop.validate())?;
588        }
589        for (name, tool) in &self.agent_tools {
590            let tools = location.child("agent_tools");
591            if let AgentTool::Command(tool) = tool
592                && let Some(session) = &tool.session
593                && !self.agent_sessions.contains_key(session)
594            {
595                return Err(tools
596                    .child(name)
597                    .child("session")
598                    .error(format!("Unknown tool session: {session}")));
599            }
600            tools.key(name).check(identifier(name, "Agent tool name"))?;
601            tools
602                .child(name)
603                .check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
604        }
605        for (name, tool) in &self.human_tools {
606            let tools = location.child("human_tools");
607            tools.key(name).check(identifier(name, "Human tool name"))?;
608            tools
609                .child(name)
610                .check(tool.validate().map_err(|e| format!("Tool {name}: {e}")))?;
611        }
612        Ok(())
613    }
614}
615
616/// The developer's definition of what an Artifact's reviews depend on: a script's output or
617/// artifactsum, the built-in hash of the Artifact's own files. Its form is the plain
618/// object; the script form keeps its `script` wrapper.
619#[derive(Debug, Clone, Serialize, Deserialize)]
620#[serde(try_from = "Map<String, Value>", into = "Value")]
621pub enum Fingerprint {
622    /// Assigned by discovery only; it cannot be declared.
623    Graph,
624    Script {
625        command: String,
626        args: Vec<crate::scope::Argument>,
627        /// Owner-relative paths that must exist on every call; never hashed.
628        files: Vec<LogicalPath>,
629        timeout_ms: Option<std::time::Duration>,
630    },
631    Artifactsum {
632        files: Vec<LogicalPath>,
633        ignore: Vec<String>,
634    },
635}
636
637#[derive(Deserialize)]
638#[serde(deny_unknown_fields)]
639struct ScriptForm {
640    script: ScriptFields,
641}
642
643#[derive(Deserialize)]
644#[serde(rename_all = "snake_case", deny_unknown_fields)]
645struct ScriptFields {
646    command: String,
647    args: Vec<crate::scope::Argument>,
648    #[serde(default)]
649    files: Vec<LogicalPath>,
650    #[serde(
651        default,
652        deserialize_with = "timeout",
653        serialize_with = "validation::milliseconds::serialize"
654    )]
655    timeout_ms: Option<std::time::Duration>,
656}
657
658#[derive(Deserialize)]
659#[serde(deny_unknown_fields)]
660struct ArtifactsumForm {
661    #[serde(default = "owner_root")]
662    files: Vec<LogicalPath>,
663    #[serde(default)]
664    ignore: Vec<String>,
665}
666
667fn owner_root() -> Vec<LogicalPath> {
668    vec![".".parse().expect("owner root logical path")]
669}
670
671impl Default for Fingerprint {
672    fn default() -> Self {
673        Self::Artifactsum {
674            files: owner_root(),
675            ignore: Vec::new(),
676        }
677    }
678}
679
680fn default_fingerprint() -> Option<Fingerprint> {
681    Some(Fingerprint::default())
682}
683
684fn declared_fingerprint<'de, D: serde::Deserializer<'de>>(
685    deserializer: D,
686) -> Result<Option<Fingerprint>, D::Error> {
687    use serde::de::Error;
688    match Value::deserialize(deserializer)? {
689        Value::Bool(false) => Ok(None),
690        Value::Object(object) => Fingerprint::try_from(object)
691            .map(Some)
692            .map_err(D::Error::custom),
693        _ => Err(D::Error::custom(
694            "fingerprint must be false or an object declaring artifactsum (files, ignore) or a script.",
695        )),
696    }
697}
698
699impl TryFrom<Map<String, Value>> for Fingerprint {
700    type Error = serde_json::Error;
701
702    fn try_from(object: Map<String, Value>) -> Result<Self, Self::Error> {
703        Ok(if object.contains_key("script") {
704            let ScriptForm {
705                script:
706                    ScriptFields {
707                        command,
708                        args,
709                        files,
710                        timeout_ms,
711                    },
712            } = serde_json::from_value(Value::Object(object))?;
713            Self::Script {
714                command,
715                args,
716                files,
717                timeout_ms,
718            }
719        } else {
720            if object.contains_key("dependencies") {
721                return Err(serde::de::Error::custom(
722                    "fingerprint.dependencies was removed in 0.5: a fingerprint covers only its own Artifact, and an eval's reuse key adds the fingerprints of its mounts, children and referenced Artifacts. Remove the field.",
723                ));
724            }
725            let ArtifactsumForm { files, ignore } = serde_json::from_value(Value::Object(object))?;
726            Self::Artifactsum { files, ignore }
727        })
728    }
729}
730
731impl From<Fingerprint> for Value {
732    fn from(fingerprint: Fingerprint) -> Self {
733        match fingerprint {
734            Fingerprint::Graph => serde_json::json!({"graph": true}),
735            Fingerprint::Script {
736                command,
737                args,
738                files,
739                timeout_ms,
740            } => serde_json::json!({
741                "script": {
742                    "command": command,
743                    "args": args,
744                    "files": files,
745                    "timeoutMs": timeout_ms.map(|value| value.as_millis()),
746                },
747            }),
748            Fingerprint::Artifactsum { files, ignore } => {
749                serde_json::json!({"files": files, "ignore": ignore})
750            }
751        }
752    }
753}
754
755impl Fingerprint {
756    pub fn kind(&self) -> crate::types::FingerprintKind {
757        match self {
758            Self::Graph => crate::types::FingerprintKind::Graph,
759            Self::Artifactsum { .. } => crate::types::FingerprintKind::Artifactsum,
760            Self::Script { .. } => crate::types::FingerprintKind::Script,
761        }
762    }
763
764    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
765        match self {
766            Self::Graph => Ok(()),
767            Self::Script {
768                command,
769                args,
770                files,
771                ..
772            } => {
773                location.child("script").check(script(command, args))?;
774                location
775                    .child("script")
776                    .child("files")
777                    .check(paths(files, "fingerprint.script.files"))
778            }
779            Self::Artifactsum { files, ignore, .. } => {
780                if files.is_empty()
781                    || files.len() > MAX_DECLARED_ITEMS
782                    || files.iter().collect::<BTreeSet<_>>().len() != files.len()
783                {
784                    return Err(location.child("files").error(
785                        "fingerprint.files must contain 1–64 unique owner-relative paths.",
786                    ));
787                }
788                for file in files.iter().filter(|file| *file != ".") {
789                    location.child("files").check(
790                        path(file).map_err(|message| format!("fingerprint.files: {message}")),
791                    )?;
792                }
793                location
794                    .child("ignore")
795                    .check(crate::cache::ignore_patterns(ignore))
796            }
797        }
798    }
799}
800
801#[derive(Debug, Clone, Deserialize)]
802#[serde(rename_all = "snake_case", deny_unknown_fields)]
803pub struct ArtifactDeclaration {
804    pub name: ArtifactName,
805    #[serde(default)]
806    pub tags: Vec<String>,
807    #[serde(default, deserialize_with = "declared_evals")]
808    pub evals: Vec<EvalDeclaration>,
809    #[serde(default)]
810    pub views: Views,
811    #[serde(default)]
812    pub mounts: BTreeMap<MountAlias, ArtifactName>,
813    #[serde(default, deserialize_with = "present")]
814    pub basis: Option<bool>,
815    #[serde(
816        default = "default_fingerprint",
817        deserialize_with = "declared_fingerprint"
818    )]
819    pub fingerprint: Option<Fingerprint>,
820}
821
822impl ArtifactDeclaration {
823    fn validate(&self, location: &Location<'_, '_>) -> Result<(), String> {
824        location
825            .child("name")
826            .check(identifier(&self.name, "Artifact name"))?;
827        location.child("tags").check(tags(&self.tags))?;
828        for eval in &self.evals {
829            eval.validate(&location.child("evals").key(&eval.id))
830                .map_err(|message| format!("Eval {}: {message}", eval.id))?;
831        }
832        if self.basis == Some(true) && !self.evals.is_empty() {
833            return Err(location
834                .child("basis")
835                .error(format!("Basis Artifact {} cannot own Evals.", self.name)));
836        }
837        self.views.validate(&location.child("views"))?;
838        for (alias, target) in &self.mounts {
839            let mounts = location.child("mounts");
840            mounts.key(alias).check(identifier(alias, "Mount alias"))?;
841            mounts
842                .child(alias)
843                .check(identifier(target, "Mount target"))?;
844        }
845        if let Some(fingerprint) = &self.fingerprint {
846            fingerprint.validate(&location.child("fingerprint"))?;
847        }
848        Ok(())
849    }
850}
851
852/// Eval ids live in table keys, not fields; BTreeMap also fixes their discovery order.
853fn declared_evals<'de, D: serde::Deserializer<'de>>(
854    deserializer: D,
855) -> Result<Vec<EvalDeclaration>, D::Error> {
856    use serde::de::Error;
857    BTreeMap::<String, EvalFields>::deserialize(deserializer)?
858        .into_iter()
859        .map(|(id, fields)| {
860            let id = id.parse().map_err(D::Error::custom)?;
861            EvalDeclaration::new(id, fields).map_err(D::Error::custom)
862        })
863        .collect()
864}
865
866/// Validate an Artifact declaration without opening scripts or declared inputs.
867pub fn parse_declaration(source: &str) -> Result<ArtifactDeclaration, String> {
868    let table = toml::de::DeTable::parse(source).map_err(|error| error.to_string())?;
869    let span = table.span();
870    let document = toml::Spanned::new(span, toml::de::DeValue::Table(table.into_inner()));
871    let location = Location::root(source, &document);
872    for (key, value) in document.get_ref().as_table().unwrap() {
873        reject_non_json_values(value, key.get_ref().as_ref(), source)?;
874    }
875    if document.get_ref().get("family").is_some() {
876        return Err(location
877            .child("family")
878            .error("family was removed in 0.9.0; declare each instance as its own Artifact."));
879    }
880    for key in ["stale_key", "stale"] {
881        if document.get_ref().get(key).is_some() {
882            return Err(location.child(key).error(format!(
883                "{key} was renamed to fingerprint: use fingerprint = {{ files = [\".\"], ignore = [] }} or [fingerprint.script]."
884            )));
885        }
886    }
887    if let Some(evals) = document
888        .get_ref()
889        .get("evals")
890        .and_then(|value| value.get_ref().as_table())
891    {
892        for (id, eval) in evals {
893            if eval.get_ref().get("result_check").is_some() {
894                return Err(location
895                    .child("evals")
896                    .child(id.get_ref())
897                    .child("result_check")
898                    .error(format!(
899                        "Eval {}: result_check was removed in 0.6.0; remove it. The review's tool calls are in its saved session (artifactize session show).",
900                        id.get_ref()
901                    )));
902            }
903        }
904    }
905    // TOML buffers enum/table-key deserialization. Check lexical fields at their
906    // own source positions before constructing validated identities, not in discovery.
907    validate_identity_locations(&location, &document)?;
908    // These sum types use JSON maps internally; pin field errors to their original spans.
909    if let Some(fingerprint) = document.get_ref().get("fingerprint")
910        && let Some(fields) = fingerprint.get_ref().as_table()
911    {
912        let fingerprint_location = location.child("fingerprint");
913        if fields.contains_key("script") {
914            fingerprint_location.deserialize::<ScriptForm>()?;
915        } else if !fields.contains_key("dependencies") {
916            fingerprint_location.deserialize::<ArtifactsumForm>()?;
917        }
918    }
919    if let Some(tools) = document
920        .get_ref()
921        .get("views")
922        .and_then(|views| views.get_ref().get("agent_tools"))
923        .and_then(|tools| tools.get_ref().as_table())
924    {
925        for (name, tool) in tools {
926            if tool.get_ref().as_table().is_some() {
927                let tool_location = location
928                    .child("views")
929                    .child("agent_tools")
930                    .child(name.get_ref());
931                if tool.get_ref().get("builtin").is_some() {
932                    tool_location.deserialize::<BuiltinTool>()?;
933                } else {
934                    tool_location.deserialize::<CommandTool>()?;
935                }
936            }
937        }
938    }
939    if let Some(tools) = document
940        .get_ref()
941        .get("views")
942        .and_then(|views| views.get_ref().get("human_tools"))
943        .and_then(|tools| tools.get_ref().as_table())
944    {
945        for (name, _) in tools {
946            location
947                .child("views")
948                .child("human_tools")
949                .child(name.get_ref())
950                .deserialize::<HumanTool>()?;
951        }
952    }
953    if let Some(sessions) = document
954        .get_ref()
955        .get("views")
956        .and_then(|views| views.get_ref().get("agent_sessions"))
957        .and_then(|sessions| sessions.get_ref().as_table())
958    {
959        for (name, _) in sessions {
960            location
961                .child("views")
962                .child("agent_sessions")
963                .child(name.get_ref())
964                .deserialize::<ToolSession>()?;
965        }
966    }
967    let declaration =
968        ArtifactDeclaration::deserialize(toml::de::ValueDeserializer::from(document.clone()))
969            .map_err(|mut error| {
970                error.set_input(Some(source));
971                error.to_string()
972            })?;
973    declaration.validate(&location)?;
974    Ok(declaration)
975}
976
977fn validate_identity_locations(
978    location: &Location<'_, '_>,
979    document: &toml::Spanned<toml::de::DeValue<'_>>,
980) -> Result<(), String> {
981    use toml::de::DeValue;
982    if let Some(name) = document.get_ref().get("name")
983        && let DeValue::String(name) = name.get_ref()
984    {
985        location
986            .child("name")
987            .check(identifier(name, "Artifact name"))?;
988    }
989    if let Some(mounts) = document
990        .get_ref()
991        .get("mounts")
992        .and_then(|value| value.get_ref().as_table())
993    {
994        for (alias, target) in mounts {
995            location
996                .child("mounts")
997                .key(alias.get_ref())
998                .check(identifier(alias.get_ref(), "Mount alias"))?;
999            if let DeValue::String(target) = target.get_ref() {
1000                location
1001                    .child("mounts")
1002                    .child(alias.get_ref())
1003                    .check(identifier(target, "Mount target"))?;
1004            }
1005        }
1006    }
1007    if let Some(evals) = document
1008        .get_ref()
1009        .get("evals")
1010        .and_then(|value| value.get_ref().as_table())
1011    {
1012        for (id, eval) in evals {
1013            let eval_location = location.child("evals").key(id.get_ref());
1014            eval_location.check(identifier(id.get_ref(), "Eval id"))?;
1015            if let Some(model) = eval
1016                .get_ref()
1017                .get("profile")
1018                .and_then(|profile| profile.get_ref().get("model"))
1019                && let DeValue::String(model) = model.get_ref()
1020            {
1021                eval_location
1022                    .child("profile")
1023                    .child("model")
1024                    .check(text(model, "Agent profile model"))?;
1025            }
1026            if let Some(variants) = eval
1027                .get_ref()
1028                .get("profile_variants")
1029                .and_then(|value| value.get_ref().as_table())
1030            {
1031                for (name, profile) in variants {
1032                    let variant = eval_location.child("profile_variants").key(name.get_ref());
1033                    variant.check(identifier(name.get_ref(), "Profile variant name"))?;
1034                    if let Some(model) = profile.get_ref().get("model")
1035                        && let DeValue::String(model) = model.get_ref()
1036                    {
1037                        variant
1038                            .child("model")
1039                            .check(text(model, "Agent profile model"))?;
1040                    }
1041                }
1042            }
1043        }
1044    }
1045    Ok(())
1046}
1047
1048/// Reject TOML values that would become lossy or implementation-specific owner JSON.
1049fn reject_non_json_values(
1050    value: &toml::Spanned<toml::de::DeValue<'_>>,
1051    path: &str,
1052    source: &str,
1053) -> Result<(), String> {
1054    use toml::de::DeValue;
1055    let diagnostic = |message| location::positioned(source, value.span().start, path, message);
1056    match value.get_ref() {
1057        DeValue::Datetime(_) => Err(diagnostic(
1058            "TOML date/time values have no JSON equivalent; use a quoted string.",
1059        )),
1060        DeValue::Float(number)
1061            if matches!(
1062                number.as_str(),
1063                "inf" | "+inf" | "-inf" | "nan" | "+nan" | "-nan"
1064            ) =>
1065        {
1066            Err(diagnostic(
1067                "TOML non-finite floats have no JSON equivalent.",
1068            ))
1069        }
1070        DeValue::Array(items) => {
1071            for (index, item) in items.iter().enumerate() {
1072                reject_non_json_values(item, &format!("{path}[{index}]"), source)?;
1073            }
1074            Ok(())
1075        }
1076        DeValue::Table(table) => {
1077            for (key, value) in table {
1078                reject_non_json_values(value, &format!("{path}.{}", key.get_ref()), source)?;
1079            }
1080            Ok(())
1081        }
1082        _ => Ok(()),
1083    }
1084}
1085
1086pub use artifactize_tools::scope::ArtifactKind;
1087
1088/// Child paths or mount aliases with their target Artifacts as tool-side IDs. Declarations
1089/// validated both, so a failure here is a bug.
1090fn tool_ids<K: Ord, T: Ord + AsRef<str>>(
1091    targets: &BTreeMap<T, ArtifactName>,
1092    key: impl Fn(&str) -> Result<K, artifactize_tools::scope::ScopeError>,
1093) -> BTreeMap<K, artifactize_tools::scope::ArtifactId> {
1094    targets
1095        .iter()
1096        .map(|(path, name)| {
1097            (
1098                key(path.as_ref()).expect("validated child prefix or mount alias"),
1099                crate::scope::tool_id(name),
1100            )
1101        })
1102        .collect()
1103}
1104
1105#[derive(Debug, Clone, Serialize)]
1106#[serde(rename_all = "camelCase")]
1107pub struct Artifact {
1108    #[serde(with = "crate::platform::path_serde")]
1109    pub path: PathBuf,
1110    pub kind: ArtifactKind,
1111    pub children: BTreeMap<ChildPrefix, ArtifactName>,
1112    pub name: ArtifactName,
1113    pub tags: Vec<String>,
1114    pub views: Views,
1115    pub mounts: BTreeMap<MountAlias, ArtifactName>,
1116    pub basis: Option<bool>,
1117    pub fingerprint: Option<Fingerprint>,
1118}
1119
1120impl Artifact {
1121    pub(crate) fn tool_scope(&self) -> artifactize_tools::scope::Artifact {
1122        artifactize_tools::scope::Artifact {
1123            path: self.path.clone(),
1124            kind: self.kind,
1125            name: self.name.to_string(),
1126            children: tool_ids(&self.children, |path| {
1127                artifactize_tools::scope::ChildPrefix::new(path)
1128            }),
1129            mounts: tool_ids(&self.mounts, |alias| {
1130                artifactize_tools::scope::MountAlias::new(alias)
1131            }),
1132        }
1133    }
1134
1135    /// Runtime commands and relative declarations use the containing folder.
1136    pub fn folder(&self) -> &Path {
1137        match self.kind {
1138            ArtifactKind::Folder | ArtifactKind::Graph => &self.path,
1139            ArtifactKind::File => self.path.parent().unwrap_or(Path::new("")),
1140        }
1141    }
1142
1143    pub fn file_name(&self) -> Option<&str> {
1144        (self.kind == ArtifactKind::File)
1145            .then(|| self.path.file_name().and_then(|name| name.to_str()))
1146            .flatten()
1147    }
1148
1149    pub fn declaration_path(&self) -> PathBuf {
1150        match self.kind {
1151            ArtifactKind::Graph => PathBuf::from(".artf"),
1152            ArtifactKind::Folder => logical_join(&self.path, std::ffi::OsStr::new(CONFIG_FILE)),
1153            ArtifactKind::File => {
1154                let mut path = self.path.as_os_str().to_owned();
1155                path.push(".artf");
1156                PathBuf::from(path)
1157            }
1158        }
1159    }
1160}
1161
1162#[derive(Debug, Serialize)]
1163pub struct Eval {
1164    /// Workspace-qualified id; declaration.id remains the owner's local id.
1165    pub id: EvalId,
1166    pub target: ArtifactName,
1167    pub references: BTreeMap<crate::config::ReferenceName, ArtifactName>,
1168    pub deps: Vec<ArtifactName>,
1169    pub declaration: EvalDeclaration,
1170    /// The `profileVariants` entry selected for this execution, if any: an execution option,
1171    /// recorded with results but never part of the saved declaration.
1172    #[serde(skip)]
1173    pub variant: Option<ProfileVariantName>,
1174}
1175
1176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1177pub enum ReviewRequirement {
1178    Basis,
1179    Unreviewed,
1180    Evals,
1181}
1182
1183#[derive(Debug)]
1184pub struct RepoConfig {
1185    pub root: PathBuf,
1186    pub artifacts: BTreeMap<ArtifactName, Artifact>,
1187    pub evals: Vec<Eval>,
1188    pub relations: Vec<crate::scope::Relation>,
1189}
1190
1191impl RepoConfig {
1192    /// Local obligations only; a basis does not satisfy its dependency scope.
1193    pub fn review_requirement(&self, artifact_id: &str) -> Option<ReviewRequirement> {
1194        let artifact = self.artifacts.get(artifact_id)?;
1195        Some(if artifact.basis == Some(true) {
1196            ReviewRequirement::Basis
1197        } else if self.evals.iter().any(|eval| eval.target == artifact_id) {
1198            ReviewRequirement::Evals
1199        } else {
1200            ReviewRequirement::Unreviewed
1201        })
1202    }
1203}
1204
1205#[cfg(test)]
1206mod tests;
1207
1208impl Serialize for EvalBody {
1209    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1210        use serde::ser::SerializeMap;
1211        let mut map = serializer.serialize_map(None)?;
1212        map.serialize_entry("profile", self.profile())?;
1213        match self {
1214            Self::Dependency { .. } => {
1215                map.serialize_entry("passSchema", &Option::<()>::None)?;
1216                map.serialize_entry("failSchema", &Option::<()>::None)?;
1217            }
1218            Self::Executable {
1219                profile_variants,
1220                payload,
1221                pass_schema,
1222                fail_schema,
1223                ..
1224            } => {
1225                if !profile_variants.is_empty() {
1226                    map.serialize_entry("profileVariants", profile_variants)?;
1227                }
1228                map.serialize_entry("payload", payload)?;
1229                map.serialize_entry("passSchema", pass_schema)?;
1230                map.serialize_entry("failSchema", fail_schema)?;
1231            }
1232        }
1233        map.end()
1234    }
1235}
1236impl EvalBody {
1237    fn profile(&self) -> &Profile {
1238        match self {
1239            Self::Dependency { profile } | Self::Executable { profile, .. } => profile,
1240        }
1241    }
1242}