1use 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 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
166pub 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}