✨ feat(kebab-cli): error_classify dispatcher + wire helpers (fb-27)
`error_classify::classify` maps anyhow::Error → ErrorV1 wire record by downcasting to known typed errors (LlmError + ConfigInvalid + NotIndexed re-exported from kebab_app::error_signal, plus std::io::Error chain). Generic fallback emits `code: "generic"` with the chain in `details` when verbose. wire.rs adds wire_schema (idempotent re-tag, mirrors wire_doctor pattern since SchemaV1 carries its own schema_version field) and wire_error_v1 (simple tag_object). Tests pin both wrappers + 7 classify code paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,35 @@ pub fn wire_ingest_progress(
|
||||
Ok(tag_object(v, "ingest_progress.v1"))
|
||||
}
|
||||
|
||||
/// Wrap a [`kebab_app::SchemaV1`] as `schema.v1`.
|
||||
///
|
||||
/// Uses the idempotent re-tag pattern (mirrors `wire_doctor`) because
|
||||
/// `SchemaV1` already carries `schema_version: "schema.v1"` as a struct
|
||||
/// field. The re-tag is a defensive no-op when the field is present; it
|
||||
/// stamps the correct version if a future refactor ever drops the field.
|
||||
pub fn wire_schema(s: &kebab_app::SchemaV1) -> Value {
|
||||
let v = serde_json::to_value(s).expect("SchemaV1 serializes");
|
||||
if let Value::Object(ref map) = v {
|
||||
if matches!(
|
||||
map.get("schema_version"),
|
||||
Some(Value::String(s)) if s == "schema.v1"
|
||||
) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
tag_object(v, "schema.v1")
|
||||
}
|
||||
|
||||
/// Wrap an [`crate::error_classify::ErrorV1`] as `error.v1`.
|
||||
///
|
||||
/// Uses the simple `tag_object` pattern because `ErrorV1` is a
|
||||
/// kebab-cli-local type that does NOT carry `schema_version` itself
|
||||
/// (kebab-core convention).
|
||||
pub fn wire_error_v1(e: &crate::error_classify::ErrorV1) -> Value {
|
||||
let v = serde_json::to_value(e).expect("ErrorV1 serializes");
|
||||
tag_object(v, "error.v1")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -200,6 +229,51 @@ mod tests {
|
||||
assert_eq!(schema_of(&tagged), Some("x.v1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_wrapper_tags_schema_version() {
|
||||
use kebab_app::{Capabilities, Models, SchemaV1, Stats, WireBlock};
|
||||
let schema = SchemaV1 {
|
||||
schema_version: "schema.v1".to_string(),
|
||||
kebab_version: "0.2.1".to_string(),
|
||||
wire: WireBlock { schemas: vec!["answer.v1".to_string()] },
|
||||
capabilities: Capabilities {
|
||||
json_mode: true, ingest_progress: true, ingest_cancellation: true,
|
||||
rag_multi_turn: true, search_cache: true, incremental_ingest: true,
|
||||
streaming_ask: false, http_daemon: false, mcp_server: false,
|
||||
single_file_ingest: false,
|
||||
},
|
||||
models: Models {
|
||||
parser_version: "x".to_string(),
|
||||
chunker_version: "y".to_string(),
|
||||
embedding_version: "z".to_string(),
|
||||
prompt_template_version: "w".to_string(),
|
||||
index_version: "v".to_string(),
|
||||
corpus_revision: 7,
|
||||
},
|
||||
stats: Stats {
|
||||
doc_count: 1, chunk_count: 2, asset_count: 1,
|
||||
last_ingest_at: None,
|
||||
},
|
||||
};
|
||||
let v = wire_schema(&schema);
|
||||
assert_eq!(schema_of(&v), Some("schema.v1"));
|
||||
assert_eq!(v.get("kebab_version").and_then(Value::as_str), Some("0.2.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_wrapper_tags_schema_version_and_emits_code() {
|
||||
use crate::error_classify::ErrorV1;
|
||||
let err = ErrorV1 {
|
||||
code: "config_invalid".to_string(),
|
||||
message: "bad config".to_string(),
|
||||
details: serde_json::json!({"path": "/tmp/x"}),
|
||||
hint: Some("check the path".to_string()),
|
||||
};
|
||||
let v = wire_error_v1(&err);
|
||||
assert_eq!(schema_of(&v), Some("error.v1"));
|
||||
assert_eq!(v.get("code").and_then(Value::as_str), Some("config_invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_wrapper_tags_schema_version_and_serializes_scope() {
|
||||
let r = kebab_app::ResetReport {
|
||||
|
||||
Reference in New Issue
Block a user