Skip to main content

warble_cli/
provenance.rs

1//! Local author origins. These are explanatory metadata, never a second prompt renderer or IR.
2use serde::Serialize;
3use std::path::Path;
4use warble::{ComponentFile, ProfileComponentMount, ProfileFile, SlotDecl};
5
6#[derive(Debug, Clone, Serialize)]
7pub struct AuthorSource {
8    pub component: Option<String>,
9    pub file: String,
10    pub field: String,
11    pub role: String,
12    pub status: String,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub slot: Option<String>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub variant: Option<String>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub default_variant: Option<String>,
19}
20
21fn source(
22    component: Option<&str>,
23    file: &Path,
24    field: &str,
25    role: &str,
26    status: &str,
27) -> AuthorSource {
28    AuthorSource {
29        component: component.map(str::to_string),
30        file: file.display().to_string(),
31        field: field.into(),
32        role: role.into(),
33        status: status.into(),
34        slot: None,
35        variant: None,
36        default_variant: None,
37    }
38}
39
40fn slots(
41    decls: &[SlotDecl],
42    component: Option<&str>,
43    base: &Path,
44    prefix: &str,
45    out: &mut Vec<AuthorSource>,
46) {
47    for slot in decls {
48        let mut variants: Vec<_> = slot.variants.iter().collect();
49        variants.sort_by_key(|(key, _)| *key);
50        for (key, reference) in variants {
51            let mut item = source(
52                component,
53                &base.join(reference),
54                &format!("{prefix}slots[{}].variants.{key}", slot.name),
55                "slot variant",
56                "unselected",
57            );
58            item.slot = Some(slot.name.clone());
59            item.variant = Some(key.clone());
60            item.default_variant = Some(slot.default.clone());
61            out.push(item);
62        }
63    }
64}
65
66pub(crate) fn profile_sources(profile: &ProfileFile, file: &Path, out: &mut Vec<AuthorSource>) {
67    if profile.system_prompt.is_some() {
68        out.push(source(
69            None,
70            file,
71            "system_prompt",
72            "common instructions",
73            "appended before effective component brief",
74        ));
75    }
76    slots(&profile.slots, None, file.parent().unwrap(), "", out);
77}
78
79#[allow(clippy::too_many_arguments)]
80pub(crate) fn component_sources(
81    profile: &ProfileFile,
82    mount: &ProfileComponentMount,
83    component: &ComponentFile,
84    profile_path: &Path,
85    component_dir: &Path,
86    inline: bool,
87    shorthand: bool,
88    out: &mut Vec<AuthorSource>,
89) {
90    let index = profile
91        .components
92        .iter()
93        .position(|m| m.use_id == mount.use_id)
94        .unwrap();
95    let mount_field = format!("components[{index}]");
96    let component_file = if inline {
97        profile_path.to_path_buf()
98    } else {
99        component_dir.join("component.yml")
100    };
101    let prefix = if inline {
102        format!("{mount_field}.")
103    } else {
104        String::new()
105    };
106    let id = Some(component.id.as_str());
107    // The full origin is useful even when compile fails for a field other than prompt text.
108    out.push(source(
109        id,
110        &component_file,
111        &prefix,
112        "component declaration",
113        "declared",
114    ));
115    if component.brief.is_some() {
116        out.push(source(
117            id,
118            &component_file,
119            &format!("{prefix}brief"),
120            "component brief",
121            if mount.brief.is_some() {
122                "replaced by mount brief; not emitted"
123            } else {
124                "effective"
125            },
126        ));
127    }
128    if mount.brief.is_some() {
129        out.push(source(
130            id,
131            profile_path,
132            &format!("{mount_field}.brief"),
133            "mount brief",
134            "replaces component brief wholesale",
135        ));
136    }
137    for (i, step) in component.llm_steps.iter().enumerate() {
138        let file = step
139            .prompt_ref
140            .as_ref()
141            .map(|p| component_dir.join(p))
142            .unwrap_or_else(|| component_file.clone());
143        let field = if shorthand {
144            format!("{prefix}prompt")
145        } else {
146            format!(
147                "{prefix}llm_steps[{i}].{}",
148                if step.prompt_ref.is_some() {
149                    "prompt_ref"
150                } else {
151                    "prompt"
152                }
153            )
154        };
155        out.push(source(
156            id,
157            &file,
158            &field,
159            "step prompt",
160            "effective; placement belongs to target",
161        ));
162    }
163    slots(&component.slots, id, component_dir, &prefix, out);
164}
165
166/// The author commands use a closed top-level vocabulary. YAML diagnostics expose location,
167/// not the parsed scalar value; credential-like unsupported values are never echoed.
168pub fn validate_profile_fields(text: &str, file: &Path) -> Result<(), String> {
169    let value: serde_yaml::Value = serde_yaml::from_str(text).map_err(|e| {
170        format!(
171            "{}: invalid YAML at {}; fix the YAML syntax (values omitted)",
172            file.display(),
173            e.location()
174                .map(|p| format!("line {}, column {}", p.line(), p.column()))
175                .unwrap_or_else(|| "unknown location".into())
176        )
177    })?;
178    let root = value
179        .as_mapping()
180        .ok_or_else(|| format!("{}: profile must be a mapping", file.display()))?;
181    for key in root.keys() {
182        if !matches!(
183            key.as_str(),
184            Some("profile" | "context" | "components" | "system_prompt" | "slots" | "config")
185        ) {
186            return Err(format!(
187                "{}: unknown profile field {}; use the documented profile fields",
188                file.display(),
189                key.as_str().unwrap_or("<non-string>")
190            ));
191        }
192    }
193    if let Some(config) = value.get("config").and_then(serde_yaml::Value::as_mapping) {
194        for key in config.keys() {
195            if key.as_str() != Some("capability_ceiling") {
196                return Err(format!(
197                    "{}#config: unknown field {}; only capability_ceiling is supported",
198                    file.display(),
199                    key.as_str().unwrap_or("<non-string>")
200                ));
201            }
202        }
203    }
204    Ok(())
205}