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