1use super::adapter::{
6 list_adapters, lookup_adapter, preflight_manifest, ManifestArtifact, ModelPackManifest,
7 TrustMode, MANIFEST_SCHEMA_VERSION,
8};
9use super::conformance::{kitten_builtin_manifest, run_pack_conformance, ConformanceReport};
10use super::pack::{load_pack_dir, write_manifest, MANIFEST_FILENAME};
11use crate::error::{Result, UserError};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct InspectReport {
18 pub path: String,
19 pub ok: bool,
20 pub adapter_id: Option<String>,
21 pub model_id: Option<String>,
22 pub trust: Option<String>,
23 pub languages: Vec<String>,
24 pub voices: Vec<String>,
25 pub artifacts: Vec<String>,
26 pub license: Option<String>,
27 pub incompatibilities: Vec<String>,
28 pub notes: Vec<String>,
29}
30
31pub fn inspect_pack(path: &Path) -> InspectReport {
33 let mut report = InspectReport {
34 path: path.display().to_string(),
35 ok: false,
36 adapter_id: None,
37 model_id: None,
38 trust: None,
39 languages: vec![],
40 voices: vec![],
41 artifacts: vec![],
42 license: None,
43 incompatibilities: vec![],
44 notes: vec![
45 "No network access during inspect.".into(),
46 "Support is never inferred from filenames alone.".into(),
47 ],
48 };
49
50 if path.is_file() {
53 let name = path
54 .file_name()
55 .and_then(|n| n.to_str())
56 .unwrap_or_default();
57 if name != MANIFEST_FILENAME {
58 if path.extension().and_then(|e| e.to_str()) == Some("onnx") {
59 report.incompatibilities.push(
60 "Bare .onnx files are not supported; provide a pack directory with a manifest and known adapter id."
61 .into(),
62 );
63 } else {
64 report.incompatibilities.push(format!(
65 "expected a pack directory or {MANIFEST_FILENAME}, got file {}",
66 path.display()
67 ));
68 }
69 return report;
70 }
71 }
72
73 let pack_dir = if path.is_file() {
74 path.parent().unwrap_or(path).to_path_buf()
75 } else {
76 path.to_path_buf()
77 };
78
79 match load_pack_dir(&pack_dir, true) {
80 Ok((_root, m)) => {
81 report.adapter_id = Some(m.adapter_id.clone());
82 report.model_id = Some(m.model_id.clone());
83 report.trust = Some(m.trust.as_str().into());
84 report.languages = m.languages.clone();
85 report.voices = m.voices.iter().map(|v| v.id.clone()).collect();
86 report.artifacts = m
87 .artifacts
88 .iter()
89 .map(|a| format!("{}:{}", a.role, a.filename))
90 .collect();
91 report.license = Some(m.license.clone());
92 if let Err(e) = preflight_manifest(&m) {
93 report.incompatibilities.push(e.to_string());
94 }
95 if matches!(m.trust, TrustMode::LocalUnverified) {
96 report.notes.push(
97 "Trust=local_unverified: unsupported for production; user responsibility."
98 .into(),
99 );
100 }
101 report.ok = report.incompatibilities.is_empty();
102 }
103 Err(e) => {
104 report.incompatibilities.push(e.to_string());
105 if path.extension().and_then(|e| e.to_str()) == Some("onnx") {
106 report.incompatibilities.push(
107 "Bare .onnx files are not supported; provide a pack directory with a manifest and adapter id."
108 .into(),
109 );
110 }
111 }
112 }
113 report
114}
115
116pub fn verify_pack(path: &Path, allow_unverified: bool) -> Result<ConformanceReport> {
118 let pack_dir = if path.is_file() {
119 path.parent()
120 .ok_or_else(|| UserError::InvalidConfig {
121 reason: "invalid pack path".into(),
122 })?
123 .to_path_buf()
124 } else {
125 path.to_path_buf()
126 };
127 let _ = load_pack_dir(&pack_dir, allow_unverified)?;
128 Ok(run_pack_conformance(&pack_dir))
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct AddProposal {
134 pub dry_run: bool,
135 pub adapter_id: String,
136 pub model_id: String,
137 pub trust: String,
138 pub manifest: ModelPackManifest,
139 pub warnings: Vec<String>,
140 pub next_steps: Vec<String>,
141}
142
143pub fn propose_add_local(
145 pack_dir: &Path,
146 adapter_id: &str,
147 model_id: &str,
148 trust: TrustMode,
149) -> Result<AddProposal> {
150 let adapter = lookup_adapter(adapter_id)?;
151 if matches!(trust, TrustMode::Builtin) {
152 return Err(UserError::InvalidConfig {
153 reason: "cannot register a custom pack as trust=builtin".into(),
154 }
155 .into());
156 }
157 if model_id == super::catalogue::DEFAULT_TTS_MODEL
159 || super::catalogue::lookup_model(model_id)
160 .map(|m| m.shipped)
161 .unwrap_or(false)
162 {
163 return Err(UserError::InvalidConfig {
164 reason: format!(
165 "model id '{model_id}' is reserved for the built-in catalogue and cannot be registered as custom"
166 ),
167 }
168 .into());
169 }
170 let manifest_path = pack_dir.join(MANIFEST_FILENAME);
172 let mut warnings = vec![];
173 let mut manifest = if manifest_path.is_file() {
174 ModelPackManifest::load_path(&manifest_path)?
175 } else {
176 warnings.push(format!(
177 "no {MANIFEST_FILENAME} yet — generating a skeleton with adapter-required roles; \
178 fill digests/sizes before using trust=verified"
179 ));
180 let stub_artifacts: Vec<ManifestArtifact> = adapter
181 .required_artifact_roles
182 .iter()
183 .map(|role| ManifestArtifact {
184 role: (*role).into(),
185 filename: match *role {
186 "onnx" => "model.onnx".into(),
187 "voices" => "voices.npz".into(),
188 "config" => "config.json".into(),
189 other => format!("{other}.bin"),
190 },
191 sha256: None,
192 size_bytes: None,
193 })
194 .collect();
195 let skeleton_trust = if matches!(trust, TrustMode::Verified) {
197 warnings.push(
198 "requested trust=verified but digests are missing; skeleton uses \
199 local_unverified until pins are filled"
200 .into(),
201 );
202 TrustMode::LocalUnverified
203 } else {
204 trust
205 };
206 ModelPackManifest {
207 schema_version: MANIFEST_SCHEMA_VERSION,
208 adapter_id: adapter_id.into(),
209 adapter_version: adapter.version,
210 model_id: model_id.into(),
211 sample_rate_hz: adapter.sample_rate_hz,
212 channels: 1,
213 max_phoneme_tokens: 400,
214 languages: adapter.languages.iter().map(|s| (*s).to_string()).collect(),
215 license: "UNKNOWN — required".into(),
216 trust: skeleton_trust,
217 artifacts: stub_artifacts,
218 voices: vec![],
219 source: Some(format!("local:{}", pack_dir.display())),
220 notes: Some("generated by aurum tts add --dry-run".into()),
221 }
222 };
223 manifest.adapter_id = adapter_id.into();
224 manifest.model_id = model_id.into();
225 if matches!(trust, TrustMode::Verified)
227 && manifest
228 .artifacts
229 .iter()
230 .any(|a| a.sha256.as_ref().map(|s| s.len() != 64).unwrap_or(true))
231 {
232 if !matches!(manifest.trust, TrustMode::LocalUnverified) {
233 warnings.push(
234 "trust=verified requires sha256+size for every artifact; keeping \
235 local_unverified until pins are complete"
236 .into(),
237 );
238 }
239 manifest.trust = TrustMode::LocalUnverified;
240 } else {
241 manifest.trust = trust;
242 }
243 if manifest.license.contains("UNKNOWN") {
244 warnings.push("license field must be filled before production use".into());
245 }
246
247 Ok(AddProposal {
248 dry_run: true,
249 adapter_id: adapter_id.into(),
250 model_id: model_id.into(),
251 trust: manifest.trust.as_str().into(),
252 manifest,
253 warnings,
254 next_steps: vec![
255 "Review licenses and digests.".into(),
256 "Run: aurum tts verify <pack>".into(),
257 "On success, write manifest / add [[tts.custom_models]] in config.".into(),
258 "Custom models never become default without an explicit config change.".into(),
259 ],
260 })
261}
262
263pub fn write_add_manifest(pack_dir: &Path, proposal: &AddProposal) -> Result<PathBuf> {
265 if proposal.manifest.artifacts.is_empty() {
266 return Err(UserError::InvalidConfig {
267 reason: "cannot write manifest with zero artifacts; fill adapter roles first".into(),
268 }
269 .into());
270 }
271 if matches!(proposal.manifest.trust, TrustMode::Verified)
272 && proposal
273 .manifest
274 .artifacts
275 .iter()
276 .any(|a| a.sha256.as_ref().map(|s| s.len() != 64).unwrap_or(true))
277 {
278 return Err(UserError::InvalidConfig {
279 reason: "cannot write trust=verified manifest without sha256 for every artifact\n \
280 Hint: fill digests, or use --trust local_unverified"
281 .into(),
282 }
283 .into());
284 }
285 write_manifest(pack_dir, &proposal.manifest)
286}
287
288pub fn format_inspect(r: &InspectReport) -> String {
290 let mut out = String::new();
291 out.push_str(&format!("path: {}\n", r.path));
292 out.push_str(&format!("ok: {}\n", r.ok));
293 if let Some(a) = &r.adapter_id {
294 out.push_str(&format!("adapter: {a}\n"));
295 }
296 if let Some(m) = &r.model_id {
297 out.push_str(&format!("model: {m}\n"));
298 }
299 if let Some(t) = &r.trust {
300 out.push_str(&format!("trust: {t}\n"));
301 }
302 if let Some(l) = &r.license {
303 out.push_str(&format!("license: {l}\n"));
304 }
305 if !r.languages.is_empty() {
306 out.push_str(&format!("languages: {}\n", r.languages.join(", ")));
307 }
308 if !r.voices.is_empty() {
309 out.push_str(&format!("voices: {}\n", r.voices.join(", ")));
310 }
311 if !r.artifacts.is_empty() {
312 out.push_str("artifacts:\n");
313 for a in &r.artifacts {
314 out.push_str(&format!(" - {a}\n"));
315 }
316 }
317 if !r.incompatibilities.is_empty() {
318 out.push_str("incompatibilities:\n");
319 for i in &r.incompatibilities {
320 out.push_str(&format!(" - {i}\n"));
321 }
322 }
323 for n in &r.notes {
324 out.push_str(&format!("note: {n}\n"));
325 }
326 out
327}
328
329pub fn format_adapters() -> String {
330 let mut out = String::from("Supported TTS adapters:\n");
331 for a in list_adapters() {
332 out.push_str(&format!(
333 " {} v{} synth={} {}\n",
334 a.id, a.version, a.synthesis_supported, a.description
335 ));
336 }
337 out.push_str("\nDefault synthesis adapter: kitten-onnx-v1 (built-in catalogue).\n");
338 out.push_str("Bare ONNX paths are not supported — use a pack + manifest.\n");
339 out
340}
341
342pub fn builtin_kitten_manifest_json() -> Result<String> {
344 let m = kitten_builtin_manifest();
345 serde_json::to_string_pretty(&m)
346 .map_err(|e| crate::error::TranscriptionError::internal(format!("manifest json: {e}")))
347}
348
349#[cfg(test)]
350mod tests {
351 use super::super::pack::write_fake_sine_pack;
352 use super::*;
353 use tempfile::tempdir;
354
355 #[test]
356 fn inspect_fake_pack() {
357 let dir = tempdir().unwrap();
358 let pack = dir.path().join("p");
359 write_fake_sine_pack(&pack, "fake").unwrap();
360 let r = inspect_pack(&pack);
361 assert!(r.ok, "{:?}", r.incompatibilities);
362 assert_eq!(r.adapter_id.as_deref(), Some("fake-sine-v1"));
363 }
364
365 #[test]
366 fn add_rejects_reserved_id() {
367 let dir = tempdir().unwrap();
368 let err = propose_add_local(
369 dir.path(),
370 "fake-sine-v1",
371 super::super::catalogue::DEFAULT_TTS_MODEL,
372 TrustMode::Verified,
373 )
374 .unwrap_err();
375 assert!(err.to_string().contains("reserved"));
376 }
377
378 #[test]
379 fn bare_onnx_inspect_fails_closed() {
380 let dir = tempdir().unwrap();
381 let onnx = dir.path().join("x.onnx");
382 std::fs::write(&onnx, b"x").unwrap();
383 let r = inspect_pack(&onnx);
384 assert!(!r.ok);
385 }
386}